← back to Grant
app/api/collaborations/route.ts
169 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { verifyAuthWithOrg, resolveOrgId } from '@/lib/auth';
/* ─── GET /api/collaborations ─────────────────────────────────────────────── */
export async function GET(request: NextRequest) {
const session = verifyAuthWithOrg(request);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const url = new URL(request.url);
const collabType = url.searchParams.get('collab_type');
const status = url.searchParams.get('status');
const search = url.searchParams.get('search');
const orgId = await resolveOrgId(session);
if (!orgId) return NextResponse.json({ rows: [], typeCounts: {} });
let sql = 'SELECT id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at FROM collaborations WHERE org_id = $1';
const params: unknown[] = [orgId];
let paramIdx = 2;
if (collabType && collabType !== 'all') {
sql += ` AND collab_type = $${paramIdx}`;
params.push(collabType);
paramIdx++;
}
if (status && status !== 'all') {
sql += ` AND status = $${paramIdx}`;
params.push(status);
paramIdx++;
}
if (search) {
sql += ` AND (name ILIKE $${paramIdx} OR organization ILIKE $${paramIdx} OR ai_reason ILIKE $${paramIdx})`;
params.push(`%${search}%`);
paramIdx++;
}
sql += ' ORDER BY ai_relevance DESC NULLS LAST, created_at DESC';
const result = await query(sql, params);
// Type counts (always unfiltered)
const countsRes = await query(
`SELECT collab_type, COUNT(*)::int as count FROM collaborations WHERE org_id = $1 GROUP BY collab_type`,
[orgId],
);
const typeCounts: Record<string, number> = {};
let total = 0;
for (const row of countsRes.rows) {
typeCounts[row.collab_type] = row.count;
total += row.count;
}
typeCounts['all'] = total;
return NextResponse.json({ rows: result.rows, typeCounts });
} catch (err) {
console.error('[collaborations GET]', err);
return NextResponse.json({ error: 'Failed to fetch collaborations' }, { status: 500 });
}
}
/* ─── POST /api/collaborations ────────────────────────────────────────────── */
export async function POST(request: NextRequest) {
const session = verifyAuthWithOrg(request);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await request.json();
const orgId = await resolveOrgId(session);
if (!orgId) return NextResponse.json({ error: 'No organization found' }, { status: 400 });
const result = await query(
`INSERT INTO collaborations (org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`,
[
orgId,
body.collab_type || 'nonprofit',
body.name,
body.title || null,
body.organization || null,
body.website_url || null,
body.email || null,
body.phone || null,
body.district || null,
body.state || null,
body.ai_reason || null,
body.ai_relevance || null,
body.ai_talking_points || [],
body.status || 'suggested',
body.notes || null,
],
);
return NextResponse.json(result.rows[0], { status: 201 });
} catch (err) {
console.error('[collaborations POST]', err);
return NextResponse.json({ error: 'Failed to create collaboration' }, { status: 500 });
}
}
/* ─── PATCH /api/collaborations ───────────────────────────────────────────── */
export async function PATCH(request: NextRequest) {
const session = verifyAuthWithOrg(request);
if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
try {
const body = await request.json();
const { id, ...fields } = body;
if (!id) {
return NextResponse.json({ error: 'id is required' }, { status: 400 });
}
// 2026-05-30 (audit P1-7): map allowed field → literal SQL identifier
// instead of interpolating the user-supplied key.
const COLUMN_MAP: Record<string, string> = {
status: '"status"', notes: '"notes"', last_contacted: '"last_contacted"',
email: '"email"', phone: '"phone"', website_url: '"website_url"',
title: '"title"', organization: '"organization"', district: '"district"',
state: '"state"',
};
const setClauses: string[] = [];
const params: unknown[] = [];
let paramIdx = 1;
for (const key of Object.keys(fields)) {
const col = COLUMN_MAP[key];
if (col) {
setClauses.push(`${col} = $${paramIdx}`);
params.push(fields[key]);
paramIdx++;
}
}
if (setClauses.length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
}
// 2026-05-04 (code-reviewer P0-3): tenant isolation on PATCH.
// 2026-05-05 (tick 19+20): cookie-orgId fast path with LIMIT 1 fallback.
const patchOrgId = await resolveOrgId(session);
if (!patchOrgId) {
return NextResponse.json({ error: 'No organization found' }, { status: 400 });
}
params.push(id);
const idIdx = paramIdx;
paramIdx++;
params.push(patchOrgId);
const orgIdx = paramIdx;
const sql = `UPDATE collaborations SET ${setClauses.join(', ')} WHERE id = $${idIdx} AND org_id = $${orgIdx} RETURNING id, org_id, collab_type, name, title, organization, website_url, email, phone, district, state, ai_reason, ai_relevance, ai_talking_points, status, notes, last_contacted, created_at, updated_at`;
const result = await query(sql, params);
if (result.rows.length === 0) {
return NextResponse.json({ error: 'Collaboration not found' }, { status: 404 });
}
return NextResponse.json(result.rows[0]);
} catch (err) {
console.error('[collaborations PATCH]', err);
return NextResponse.json({ error: 'Failed to update collaboration' }, { status: 500 });
}
}