← back to Norma
app/api/dispatch/route.ts
110 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getOrgId } from '@/lib/orgId';
import { buildInternalAuthHeader } from '@/lib/cron-auth';
const VALID_PLATFORMS = ['twitter', 'reddit', 'discord', 'moveon', 'bluesky'];
const VALID_CONTENT_TYPES = ['petition', 'grant', 'pipeline', 'statement'];
const CONTENT_CONFIG: Record<string, { table: string; titleCol: string; bodyCol: string }> = {
pipeline: { table: 'petition_pipeline', titleCol: 'title', bodyCol: 'body' },
petition: { table: 'petitions', titleCol: 'title', bodyCol: 'description' },
grant: { table: 'grants', titleCol: 'title', bodyCol: 'description' },
statement: { table: 'statements', titleCol: 'title', bodyCol: 'body' },
};
/**
* POST /api/dispatch
* Generic immediate dispatch to social platforms via Pulse agent.
*
* Body: { content_type, content_id, platform }
*/
export async function POST(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
const body = await request.json();
const contentType = body.content_type;
if (!contentType || !VALID_CONTENT_TYPES.includes(contentType)) {
return NextResponse.json({ error: `Invalid content_type. Valid: ${VALID_CONTENT_TYPES.join(', ')}` }, { status: 400 });
}
const contentId = body.content_id;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!contentId || !uuidRegex.test(contentId)) {
return NextResponse.json({ error: 'content_id must be a valid UUID' }, { status: 400 });
}
const platform = body.platform;
if (!platform || !VALID_PLATFORMS.includes(platform)) {
return NextResponse.json({ error: `Invalid platform. Valid: ${VALID_PLATFORMS.join(', ')}` }, { status: 400 });
}
// Look up content (scoped to org)
const config = CONTENT_CONFIG[contentType];
const orgClause = orgId ? ` AND org_id = $2` : '';
const orgParams = orgId ? [orgId] : [];
const existing = await query(
`SELECT id, ${config.titleCol} AS title, ${config.bodyCol} AS body FROM ${config.table} WHERE id = $1${orgClause}`,
[contentId, ...orgParams],
);
if (existing.rows.length === 0) {
return NextResponse.json({ error: `${contentType} not found` }, { status: 404 });
}
const item = existing.rows[0];
// Call Pulse agent to dispatch
let pulseResult: Record<string, unknown> = {};
let pulseStatus = 'pulse_unreachable';
try {
const pulseRes = await fetch('http://127.0.0.1:9845/api/pulse/dispatch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': buildInternalAuthHeader(),
},
body: JSON.stringify({
petition_id: contentId,
title: item.title,
body: item.body || '',
target_agents: [platform],
}),
});
pulseResult = await pulseRes.json();
const dispatched = (pulseResult as { summary?: { dispatched?: number } })?.summary?.dispatched;
pulseStatus = dispatched && dispatched > 0 ? 'dispatched' : 'agent_unavailable';
} catch (err) {
pulseResult = { error: (err as Error).message };
pulseStatus = 'pulse_unreachable';
}
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
await auditLog(`${contentType}.dispatched`, config.table, contentId, {
platform,
pulse_status: pulseStatus,
pulse_result: pulseResult,
dispatched_by: auth.username,
}, ip);
return NextResponse.json({
content_type: contentType,
content_id: contentId,
platform,
status: pulseStatus,
pulse_result: pulseResult,
});
} catch (err) {
console.error('[api/dispatch] error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to dispatch' }, { status: 500 });
}
}