← back to Norma

app/api/grants/route.ts

222 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';

/**
 * GET /api/grants
 * List grants with optional filters:
 *   ?status=        — filter by status (researching, preparing, submitted, awarded, declined, expired)
 *   ?priority=      — filter by priority (high, medium, low)
 *   ?cycle=         — filter by cycle (annual, biannual, rolling, one-time)
 *   ?is_bookmarked= — filter by bookmarked flag (true/false)
 *   ?upcoming=      — if true, only grants with deadline in the future, sorted by deadline ASC
 *   ?search=        — ILIKE search on title, funder, description
 * Default sort: deadline ASC for upcoming=true, updated_at DESC otherwise.
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { searchParams } = new URL(request.url);
    const status = searchParams.get('status');
    const priority = searchParams.get('priority');
    const cycle = searchParams.get('cycle');
    const isBookmarked = searchParams.get('is_bookmarked');
    const upcoming = searchParams.get('upcoming');
    const search = searchParams.get('search');

    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

    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 (priority) {
      conditions.push(`priority = $${paramIndex}`);
      values.push(priority);
      paramIndex++;
    }

    if (cycle) {
      conditions.push(`cycle = $${paramIndex}`);
      values.push(cycle);
      paramIndex++;
    }

    if (isBookmarked !== null && isBookmarked !== undefined && isBookmarked !== '') {
      conditions.push(`is_bookmarked = $${paramIndex}`);
      values.push(isBookmarked === 'true');
      paramIndex++;
    }

    if (upcoming === 'true') {
      conditions.push(`deadline > NOW()`);
    }

    if (search) {
      conditions.push(
        `(title ILIKE $${paramIndex} OR funder ILIKE $${paramIndex} OR description ILIKE $${paramIndex})`
      );
      values.push(`%${search}%`);
      paramIndex++;
    }

    let limit = parseInt(searchParams.get('limit') || '50', 10);
    if (isNaN(limit) || limit < 1) limit = 50;
    if (limit > 200) limit = 200;

    let offset = parseInt(searchParams.get('offset') || '0', 10);
    if (isNaN(offset) || offset < 0) offset = 0;

    const whereClause =
      conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

    const orderClause =
      upcoming === 'true'
        ? 'ORDER BY deadline ASC NULLS LAST'
        : 'ORDER BY updated_at DESC';

    const countResult = await query(
      `SELECT COUNT(*)::int AS total FROM grants ${whereClause}`,
      values
    );
    const total = countResult.rows[0]?.total ?? 0;

    const result = await query(
      `SELECT * FROM grants ${whereClause} ${orderClause}
       LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
      [...values, limit, offset]
    );

    return NextResponse.json({ grants: result.rows, total, limit, offset });
  } catch (err) {
    console.error('[api/grants] GET error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to fetch grants' }, { status: 500 });
  }
}

/**
 * POST /api/grants
 * Create a new grant.
 * Body: { 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? }
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const body = await request.json();

    if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') {
      return NextResponse.json({ error: 'title is required' }, { status: 400 });
    }

    if (!body.funder || typeof body.funder !== 'string' || body.funder.trim() === '') {
      return NextResponse.json({ error: 'funder is required' }, { status: 400 });
    }

    const validStatuses = ['researching', 'preparing', 'submitted', 'approved', 'rejected', 'awarded', 'declined', 'expired'];
    const status = validStatuses.includes(body.status) ? body.status : 'researching';

    const validPriorities = ['high', 'medium', 'low'];
    const priority = validPriorities.includes(body.priority) ? body.priority : 'medium';

    const validCycles = ['annual', 'biannual', 'rolling', 'one-time'];
    const cycle = validCycles.includes(body.cycle) ? body.cycle : null;

    // Parse arrays
    let focusAreas: string[] = [];
    if (Array.isArray(body.focus_areas)) {
      focusAreas = body.focus_areas.map((t: string) => String(t).trim()).filter(Boolean);
    } else if (typeof body.focus_areas === 'string' && body.focus_areas.trim()) {
      focusAreas = body.focus_areas.split(',').map((t: string) => t.trim()).filter(Boolean);
    }

    let tags: string[] = [];
    if (Array.isArray(body.tags)) {
      tags = body.tags.map((t: string) => String(t).trim()).filter(Boolean);
    } else if (typeof body.tags === 'string' && body.tags.trim()) {
      tags = body.tags.split(',').map((t: string) => t.trim()).filter(Boolean);
    }

    const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;

    const result = await query(
      `INSERT INTO grants
         (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, org_id)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)
       RETURNING *`,
      [
        body.title.trim(),
        body.funder.trim(),
        body.funder_url || null,
        body.amount_min != null ? Number(body.amount_min) : null,
        body.amount_max != null ? Number(body.amount_max) : null,
        body.description || null,
        body.eligibility || null,
        focusAreas,
        body.application_url || null,
        body.deadline || null,
        body.notification_date || null,
        body.grant_period_start || null,
        body.grant_period_end || null,
        cycle,
        body.ai_fit_score != null ? Number(body.ai_fit_score) : null,
        body.ai_suggestion || null,
        body.ai_application_tips || null,
        status,
        priority,
        body.notes || null,
        body.applied_at || null,
        body.amount_requested != null ? Number(body.amount_requested) : null,
        body.amount_awarded != null ? Number(body.amount_awarded) : null,
        tags,
        body.is_bookmarked === true,
        orgId || null,
      ]
    );

    const grant = result.rows[0];

    const ip =
      request.headers.get('x-forwarded-for') ||
      request.headers.get('x-real-ip') ||
      undefined;
    await auditLog(
      'grant.created',
      'grant',
      grant.id,
      { title: grant.title, funder: grant.funder, status: grant.status },
      ip
    );

    return NextResponse.json({ grant }, { status: 201 });
  } catch (err) {
    console.error('[api/grants] POST error:', (err as Error).message);
    return NextResponse.json({ error: 'Failed to create grant' }, { status: 500 });
  }
}