← back to Norma

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

162 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
import { auditLog } from '@/lib/audit';

/**
 * GET /api/pipeline/[id]
 * Get a single pipeline item by UUID.
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { id } = await params;

    // Validate UUID format
    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 orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
    const conditions = ['id = $1'];
    const values: unknown[] = [id];
    if (orgId) {
      conditions.push('org_id = $2');
      values.push(orgId);
    }

    const result = await query(
      `SELECT * FROM petition_pipeline WHERE ${conditions.join(' AND ')}`,
      values
    );

    if (result.rows.length === 0) {
      return NextResponse.json({ error: 'Pipeline item not found' }, { status: 404 });
    }

    return NextResponse.json({ item: result.rows[0] });
  } catch (err) {
    console.error('[api/pipeline/[id]] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch pipeline item' }, { status: 500 });
  }
}

/**
 * PUT /api/pipeline/[id]
 * Update a pipeline item.
 * Body: Any subset of { title, body, target, category, platform, tone,
 *        talking_points, geo_states, status, review_notes, platform_url,
 *        signature_count, signature_goal }
 */
export async function PUT(
  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 data = await request.json();

    // Build dynamic SET clause from allowed fields
    const allowedFields: Record<string, (v: unknown) => unknown> = {
      title: (v) => typeof v === 'string' && v.trim() ? v.trim() : undefined,
      body: (v) => typeof v === 'string' && v.trim() ? v.trim() : undefined,
      target: (v) => (typeof v === 'string' ? v.trim() || null : undefined),
      category: (v) => (typeof v === 'string' ? v.trim() || null : undefined),
      platform: (v) => {
        const valid = ['moveon', 'actionnetwork', 'change_org', 'custom'];
        return valid.includes(v as string) ? v : undefined;
      },
      tone: (v) => (typeof v === 'string' ? v.trim() || null : undefined),
      talking_points: (v) => (Array.isArray(v) ? v.map((t: string) => String(t).trim()).filter(Boolean) : undefined),
      geo_states: (v) => (Array.isArray(v) ? v.map((s: string) => String(s).trim().toUpperCase()).filter(Boolean) : undefined),
      status: (v) => {
        const valid = ['draft', 'approved', 'posting', 'posted', 'rejected', 'archived'];
        return valid.includes(v as string) ? v : undefined;
      },
      review_notes: (v) => (typeof v === 'string' ? v.trim() || null : undefined),
      platform_url: (v) => (typeof v === 'string' ? v.trim() || null : undefined),
      signature_count: (v) => (v != null && !isNaN(Number(v)) ? Number(v) : undefined),
      signature_goal: (v) => (v != null && !isNaN(Number(v)) ? Number(v) : undefined),
    };

    const setClauses: string[] = [];
    const values: unknown[] = [];
    let paramIndex = 1;

    for (const [field, transform] of Object.entries(allowedFields)) {
      if (data[field] !== undefined) {
        const transformed = transform(data[field]);
        if (transformed !== undefined) {
          setClauses.push(`${field} = $${paramIndex}`);
          values.push(transformed);
          paramIndex++;
        }
      }
    }

    if (setClauses.length === 0) {
      return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
    }

    // Always update updated_at
    setClauses.push(`updated_at = NOW()`);

    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
    const conditions = [`id = $${paramIndex}`];
    values.push(id);
    paramIndex++;
    if (orgId) {
      conditions.push(`org_id = $${paramIndex}`);
      values.push(orgId);
      paramIndex++;
    }

    const result = await query(
      `UPDATE petition_pipeline
       SET ${setClauses.join(', ')}
       WHERE ${conditions.join(' AND ')}
       RETURNING *`,
      values
    );

    if (result.rows.length === 0) {
      return NextResponse.json({ error: 'Pipeline item not found' }, { status: 404 });
    }

    const item = result.rows[0];

    const ip =
      request.headers.get('x-forwarded-for') ||
      request.headers.get('x-real-ip') ||
      undefined;
    await auditLog(
      'pipeline.updated',
      'petition_pipeline',
      item.id,
      { fields: Object.keys(data), status: item.status },
      ip
    );

    return NextResponse.json({ item });
  } catch (err) {
    console.error('[api/pipeline/[id]] PUT error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to update pipeline item' }, { status: 500 });
  }
}