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