← back to Norma

app/api/schedule/route.ts

116 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';

const VALID_PLATFORMS = ['twitter', 'reddit', 'discord', 'moveon', 'bluesky'];
const VALID_CONTENT_TYPES = ['petition', 'grant', 'pipeline', 'statement'];

/** Table and column mapping per content type */
const CONTENT_CONFIG: Record<string, { table: string; fkCol: string; titleCol: string; bodyCol: string; statusCheck?: { col: string; allowed: string[] } }> = {
  pipeline:  { table: 'petition_pipeline', fkCol: 'pipeline_id',  titleCol: 'title', bodyCol: 'body',        statusCheck: { col: 'status', allowed: ['approved', 'posting'] } },
  petition:  { table: 'petitions',         fkCol: 'petition_id',  titleCol: 'title', bodyCol: 'description' },
  grant:     { table: 'grants',            fkCol: 'grant_id',     titleCol: 'title', bodyCol: 'description' },
  statement: { table: 'statements',        fkCol: 'statement_id', titleCol: 'title', bodyCol: 'body' },
};

/**
 * POST /api/schedule
 * Generic schedule endpoint for all content types.
 *
 * Body: { content_type, content_id, platforms: string[], scheduled_at: string }
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const body = await request.json();

    // Validate content_type
    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 });
    }

    // Validate content_id
    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 });
    }

    // Validate platforms
    const platforms: string[] = Array.isArray(body.platforms) ? body.platforms : [body.platform].filter(Boolean);
    if (platforms.length === 0) {
      return NextResponse.json({ error: 'At least one platform is required' }, { status: 400 });
    }
    const invalid = platforms.filter(p => !VALID_PLATFORMS.includes(p));
    if (invalid.length > 0) {
      return NextResponse.json({ error: `Invalid platforms: ${invalid.join(', ')}` }, { status: 400 });
    }

    // Validate scheduled_at
    if (!body.scheduled_at) {
      return NextResponse.json({ error: 'scheduled_at is required (ISO datetime)' }, { status: 400 });
    }
    const scheduledAt = new Date(body.scheduled_at);
    if (isNaN(scheduledAt.getTime())) {
      return NextResponse.json({ error: 'scheduled_at must be a valid ISO datetime' }, { status: 400 });
    }
    if (scheduledAt.getTime() < Date.now() + 60_000) {
      return NextResponse.json({ error: 'scheduled_at must be at least 1 minute in the future' }, { status: 400 });
    }

    // Look up content from the right table
    const config = CONTENT_CONFIG[contentType];
    const existing = await query(
      `SELECT id, ${config.titleCol} AS title, ${config.bodyCol} AS body${config.statusCheck ? `, ${config.statusCheck.col} AS status` : ''} FROM ${config.table} WHERE id = $1`,
      [contentId],
    );
    if (existing.rows.length === 0) {
      return NextResponse.json({ error: `${contentType} not found` }, { status: 404 });
    }

    const item = existing.rows[0];

    // Status check (only for pipeline items)
    if (config.statusCheck) {
      if (!config.statusCheck.allowed.includes(item.status)) {
        return NextResponse.json(
          { error: `Cannot schedule ${contentType} with status '${item.status}'. Must be: ${config.statusCheck.allowed.join(', ')}` },
          { status: 409 },
        );
      }
    }

    // Create scheduled_dispatches rows
    const created = [];
    for (const platform of platforms) {
      const res = await query(
        `INSERT INTO scheduled_dispatches
           (${config.fkCol}, content_type, platform, title, body, scheduled_at, created_by)
         VALUES ($1, $2, $3, $4, $5, $6, $7)
         RETURNING *`,
        [contentId, contentType, platform, item.title, item.body || '', scheduledAt.toISOString(), auth.username],
      );
      created.push(res.rows[0]);
    }

    const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
    await auditLog(`${contentType}.scheduled`, config.table, contentId, {
      platforms,
      scheduled_at: scheduledAt.toISOString(),
      scheduled_by: auth.username,
    }, ip);

    return NextResponse.json({
      scheduled: created,
      message: `Dispatch scheduled for ${platforms.join(', ')} at ${scheduledAt.toISOString()}`,
    }, { status: 201 });
  } catch (err) {
    console.error('[api/schedule] error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to schedule dispatch' }, { status: 500 });
  }
}