← back to Norma

app/api/pipeline/[id]/schedule/route.ts

103 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';

const VALID_PLATFORMS = ['twitter', 'reddit', 'discord', 'moveon', 'bluesky'];

/**
 * POST /api/pipeline/[id]/schedule
 * Schedule a dispatch for a future date/time.
 *
 * Body: { platforms: string[], scheduled_at: string (ISO datetime) }
 * Creates one scheduled_dispatches row per platform.
 */
export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { id } = await params;

    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
    if (!uuidRegex.test(id)) {
      return NextResponse.json({ error: 'Invalid ID format' }, { status: 400 });
    }

    const body = await request.json();

    // 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(', ')}. Valid: ${VALID_PLATFORMS.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 });
    }

    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

    // Fetch pipeline item
    const existing = await query(
      `SELECT id, status, title, body FROM petition_pipeline WHERE id = $1 AND ($2::text IS NULL OR org_id = $2)`,
      [id, orgId],
    );
    if (existing.rows.length === 0) {
      return NextResponse.json({ error: 'Pipeline item not found' }, { status: 404 });
    }

    const item = existing.rows[0];
    if (item.status !== 'approved' && item.status !== 'posting') {
      return NextResponse.json(
        { error: `Cannot schedule item with status '${item.status}'. Must be approved first.` },
        { status: 409 },
      );
    }

    // Create scheduled_dispatches rows
    const created = [];
    for (const platform of platforms) {
      const res = await query(
        `INSERT INTO scheduled_dispatches
           (pipeline_id, content_type, platform, title, body, scheduled_at, created_by)
         VALUES ($1, 'petition', $2, $3, $4, $5, $6)
         RETURNING *`,
        [id, 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('pipeline.scheduled', 'petition_pipeline', id, {
      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/pipeline/[id]/schedule] error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to schedule dispatch' }, { status: 500 });
  }
}