← 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 });
  }
}