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