← back to Norma
app/api/pipeline/reorder/route.ts
85 lines
import { NextRequest, NextResponse } from 'next/server';
import { getClient } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getOrgId } from '@/lib/orgId';
/**
* PATCH /api/pipeline/reorder
* Bulk-update stage (status) and position for pipeline items after drag-and-drop.
* Body: { items: Array<{ id: string; stage: string; position: number }> }
*
* Uses a transaction so all updates are atomic.
*/
export async function PATCH(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
let data: { items?: unknown } = {};
try {
data = await request.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!Array.isArray(data.items) || data.items.length === 0) {
return NextResponse.json({ error: 'items array is required and must be non-empty' }, { status: 400 });
}
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const validStatuses = ['draft', 'reviewed', 'approved', 'scheduled', 'posting', 'posted', 'tracking'];
// Validate each item before touching the DB
const items: { id: string; stage: string; position: number }[] = [];
for (const raw of data.items) {
const item = raw as Record<string, unknown>;
if (typeof item.id !== 'string' || !uuidRegex.test(item.id)) {
return NextResponse.json({ error: `Invalid item id: ${item.id}` }, { status: 400 });
}
if (typeof item.stage !== 'string' || !validStatuses.includes(item.stage)) {
return NextResponse.json({ error: `Invalid stage "${item.stage}" for item ${item.id}` }, { status: 400 });
}
if (typeof item.position !== 'number' || !Number.isFinite(item.position) || item.position < 0) {
return NextResponse.json({ error: `Invalid position for item ${item.id}` }, { status: 400 });
}
items.push({ id: item.id, stage: item.stage, position: item.position });
}
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const client = await getClient();
try {
await client.query('BEGIN');
for (const { id, stage, position } of items) {
await client.query(
`UPDATE petition_pipeline
SET status = $1, position = $2, updated_at = NOW()
WHERE id = $3::uuid AND ($4::uuid IS NULL OR org_id = $4::uuid)`,
[stage, position, id, orgId]
);
}
await client.query('COMMIT');
const ip =
request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
undefined;
await auditLog(
'pipeline.reordered',
'petition_pipeline',
items[0].id,
{ count: items.length },
ip
);
return NextResponse.json({ ok: true, updated: items.length });
} catch (err) {
await client.query('ROLLBACK');
console.error('[api/pipeline/reorder] PATCH error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to reorder pipeline items' }, { status: 500 });
} finally {
client.release();
}
}