← back to Norma
app/api/grants/[id]/route.ts
175 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { getOrgId } from '@/lib/orgId';
type RouteContext = { params: Promise<{ id: string }> };
/**
* GET /api/grants/[id]
* Return a single grant by ID.
*/
export async function GET(request: NextRequest, context: RouteContext) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const { id } = await context.params;
try {
const result = await query(
`SELECT * FROM grants WHERE id = $1${orgId ? ' AND org_id = $2' : ''}`,
orgId ? [id, orgId] : [id]
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
}
return NextResponse.json({ grant: result.rows[0] });
} catch (err) {
console.error('[api/grants/[id]] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch grant' }, { status: 500 });
}
}
/**
* PATCH /api/grants/[id]
* Update grant fields.
* When changing status to 'submitted', auto-set applied_at to NOW() if not already provided.
*/
export async function PATCH(request: NextRequest, context: RouteContext) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const { id } = await context.params;
try {
const body = await request.json();
const allowedFields = [
'title', 'funder', 'funder_url', 'amount_min', 'amount_max',
'description', 'eligibility', 'focus_areas', 'application_url',
'deadline', 'notification_date', 'grant_period_start', 'grant_period_end',
'cycle', 'ai_fit_score', 'ai_suggestion', 'ai_application_tips',
'status', 'priority', 'notes', 'applied_at', 'amount_requested',
'amount_awarded', 'tags', 'is_bookmarked', 'rejection_reason', 'next_steps',
];
const setClauses: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
for (const field of allowedFields) {
if (field in body) {
setClauses.push(`${field} = $${paramIndex}`);
values.push(field === 'next_steps' ? JSON.stringify(body[field]) : body[field]);
paramIndex++;
}
}
// Auto-set applied_at when status changes to 'submitted' and applied_at is not provided
if (body.status === 'submitted' && !('applied_at' in body)) {
setClauses.push(`applied_at = NOW()`);
}
// Auto-populate next_steps when status changes to 'approved'
if (body.status === 'approved' && !('next_steps' in body)) {
const defaultNextSteps = [
{ step: 'Prepare grant proposal', done: false },
{ step: 'Assign team member', done: false, assignee: null },
{ step: 'Set deadline reminder', done: false },
{ step: 'Draft cover letter', done: false },
];
setClauses.push(`next_steps = $${paramIndex}`);
values.push(JSON.stringify(defaultNextSteps));
paramIndex++;
}
// Clear next_steps and rejection_reason when switching away from approved/rejected
if (body.status && body.status !== 'approved' && body.status !== 'rejected') {
if (!('rejection_reason' in body)) {
setClauses.push(`rejection_reason = NULL`);
}
}
if (setClauses.length === 0) {
return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 });
}
values.push(id);
let whereClause = `WHERE id = $${paramIndex}`;
if (orgId) {
paramIndex++;
values.push(orgId);
whereClause += ` AND org_id = $${paramIndex}`;
}
const result = await query(
`UPDATE grants SET ${setClauses.join(', ')} ${whereClause} RETURNING *`,
values
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
}
const ip =
request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
undefined;
await auditLog(
'grant.updated',
'grant',
id,
{ fields: Object.keys(body).filter((k) => allowedFields.includes(k)) },
ip
);
return NextResponse.json({ grant: result.rows[0] });
} catch (err) {
console.error('[api/grants/[id]] PATCH error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to update grant' }, { status: 500 });
}
}
/**
* DELETE /api/grants/[id]
* Delete a grant.
*/
export async function DELETE(request: NextRequest, context: RouteContext) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const { id } = await context.params;
try {
const result = await query(
`DELETE FROM grants WHERE id = $1${orgId ? ' AND org_id = $2' : ''} RETURNING id, title, funder`,
orgId ? [id, orgId] : [id]
);
if (result.rowCount === 0) {
return NextResponse.json({ error: 'Grant not found' }, { status: 404 });
}
const ip =
request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
undefined;
await auditLog(
'grant.deleted',
'grant',
id,
{ title: result.rows[0].title, funder: result.rows[0].funder },
ip
);
return NextResponse.json({ success: true, deleted: result.rows[0] });
} catch (err) {
console.error('[api/grants/[id]] DELETE error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to delete grant' }, { status: 500 });
}
}