← back to Norma
app/api/grants/calendar/route.ts
186 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { getOrgId } from '@/lib/orgId';
interface CalendarEvent {
id: string;
grant_id: string;
title: string;
funder: string;
date: string;
status: string;
priority: string;
type: 'deadline' | 'notification' | 'period_start' | 'period_end';
color: string;
}
/**
* Assign a color based on priority, status, and event type.
*/
function getEventColor(priority: string, status: string, type: string): string {
// Status-based overrides
if (status === 'awarded') return '#22c55e'; // green
if (status === 'declined') return '#9ca3af'; // gray
if (status === 'expired') return '#9ca3af'; // gray
if (status === 'submitted') return '#8b5cf6'; // purple
// Type-based for non-deadline events
if (type === 'period_start') return '#06b6d4'; // cyan
if (type === 'period_end') return '#f97316'; // orange
if (type === 'notification') return '#eab308'; // yellow
// Priority-based for deadlines
switch (priority) {
case 'high': return '#ef4444'; // red
case 'medium': return '#3b82f6'; // blue
case 'low': return '#6b7280'; // gray
default: return '#3b82f6'; // blue
}
}
/**
* GET /api/grants/calendar
* Return grants as calendar events.
* Filters: ?month= (YYYY-MM), ?status=
* Returns { events: [...] } where each event has id, title, funder, date, status,
* priority, type, color. Multiple events per grant if it has notification_date,
* grant_period_start, grant_period_end.
*/
export async function GET(request: NextRequest) {
const auth = requireRole(request, 'admin', 'staff');
if (auth instanceof NextResponse) return auth;
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
try {
const { searchParams } = new URL(request.url);
const month = searchParams.get('month'); // YYYY-MM
const status = searchParams.get('status');
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (orgId) {
conditions.push(`org_id = $${paramIndex}`);
values.push(orgId);
paramIndex++;
}
if (status) {
conditions.push(`status = $${paramIndex}`);
values.push(status);
paramIndex++;
}
if (month) {
// Parse YYYY-MM to start and end of month
if (!/^\d{4}-\d{2}$/.test(month)) {
return NextResponse.json({ error: 'Invalid month format. Use YYYY-MM.' }, { status: 400 });
}
const [year, mon] = month.split('-').map(Number);
if (!year || !mon || mon < 1 || mon > 12) {
return NextResponse.json({ error: 'Invalid month format. Use YYYY-MM.' }, { status: 400 });
}
const monthStart = `${year}-${String(mon).padStart(2, '0')}-01`;
const nextMon = mon === 12 ? 1 : mon + 1;
const nextYear = mon === 12 ? year + 1 : year;
const monthEnd = `${nextYear}-${String(nextMon).padStart(2, '0')}-01`;
// Include grants where any of their dates fall within the month
conditions.push(
`(
(deadline >= $${paramIndex} AND deadline < $${paramIndex + 1})
OR (notification_date >= $${paramIndex} AND notification_date < $${paramIndex + 1})
OR (grant_period_start >= $${paramIndex}::date AND grant_period_start < $${paramIndex + 1}::date)
OR (grant_period_end >= $${paramIndex}::date AND grant_period_end < $${paramIndex + 1}::date)
)`
);
values.push(monthStart, monthEnd);
paramIndex += 2;
} else {
// Only return grants that have at least one date set
conditions.push(
`(deadline IS NOT NULL OR notification_date IS NOT NULL OR grant_period_start IS NOT NULL OR grant_period_end IS NOT NULL)`
);
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query(
`SELECT * FROM grants ${whereClause} ORDER BY deadline ASC NULLS LAST`,
values
);
// Build calendar events -- one per date type per grant
const events: CalendarEvent[] = [];
for (const grant of result.rows) {
if (grant.deadline) {
events.push({
id: `${grant.id}-deadline`,
grant_id: grant.id,
title: `${grant.title} — Deadline`,
funder: grant.funder,
date: grant.deadline,
status: grant.status,
priority: grant.priority,
type: 'deadline',
color: getEventColor(grant.priority, grant.status, 'deadline'),
});
}
if (grant.notification_date) {
events.push({
id: `${grant.id}-notification`,
grant_id: grant.id,
title: `${grant.title} — Notification`,
funder: grant.funder,
date: grant.notification_date,
status: grant.status,
priority: grant.priority,
type: 'notification',
color: getEventColor(grant.priority, grant.status, 'notification'),
});
}
if (grant.grant_period_start) {
events.push({
id: `${grant.id}-period_start`,
grant_id: grant.id,
title: `${grant.title} — Period Start`,
funder: grant.funder,
date: grant.grant_period_start,
status: grant.status,
priority: grant.priority,
type: 'period_start',
color: getEventColor(grant.priority, grant.status, 'period_start'),
});
}
if (grant.grant_period_end) {
events.push({
id: `${grant.id}-period_end`,
grant_id: grant.id,
title: `${grant.title} — Period End`,
funder: grant.funder,
date: grant.grant_period_end,
status: grant.status,
priority: grant.priority,
type: 'period_end',
color: getEventColor(grant.priority, grant.status, 'period_end'),
});
}
}
// Sort events by date
events.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
return NextResponse.json({ events });
} catch (err) {
console.error('[api/grants/calendar] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch grant calendar events' }, { status: 500 });
}
}