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