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