← back to Norma

app/api/donations/route.ts

203 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/donations
 * List donations with optional filters:
 *   ?source=        — filter by source (actblue, direct, moveon, event, grant, other)
 *   ?channel=       — filter by channel (email, social, website, event, direct-mail)
 *   ?campaign_name= — filter by campaign name (exact match)
 *   ?recurring=     — filter by recurring flag (true/false)
 *   ?from=          — filter by donated_at >= date (YYYY-MM-DD)
 *   ?to=            — filter by donated_at <= date (YYYY-MM-DD)
 * Ordered by donated_at DESC.
 * Returns { donations: [...], summary: { total, count, avg, recurring_count } }
 */
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 source = searchParams.get('source');
    const channel = searchParams.get('channel');
    const campaignName = searchParams.get('campaign_name');
    const recurring = searchParams.get('recurring');
    const from = searchParams.get('from');
    const to = searchParams.get('to');

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

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

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

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

    if (from) {
      conditions.push(`donated_at >= $${paramIndex}`);
      values.push(from);
      paramIndex++;
    }

    if (to) {
      conditions.push(`donated_at <= $${paramIndex}::date + INTERVAL '1 day'`);
      values.push(to);
      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 ')}` : '';

    // Fetch donations with pagination
    const result = await query(
      `SELECT * FROM donations ${whereClause} ORDER BY donated_at DESC
       LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
      [...values, limit, offset]
    );

    // Compute summary stats with the same filters
    const summaryResult = await query(
      `SELECT
         COALESCE(SUM(amount), 0)::numeric  AS total,
         COUNT(*)::int                       AS count,
         COALESCE(AVG(amount), 0)::numeric  AS avg,
         COUNT(*) FILTER (WHERE recurring = true)::int AS recurring_count
       FROM donations
       ${whereClause}`,
      values
    );

    const summary = summaryResult.rows[0] ?? {
      total: 0,
      count: 0,
      avg: 0,
      recurring_count: 0,
    };

    // Round numeric values for cleaner JSON
    summary.total = Number(Number(summary.total).toFixed(2));
    summary.avg = Number(Number(summary.avg).toFixed(2));

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

/**
 * POST /api/donations
 * Record a new donation.
 * Body: { source, amount, donor_type?, campaign_name?, petition_id?, draft_id?,
 *         channel?, recurring?, notes?, tags?, donated_at? }
 */
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.amount == null || isNaN(Number(body.amount)) || Number(body.amount) <= 0) {
      return NextResponse.json({ error: 'amount is required and must be positive' }, { status: 400 });
    }

    const validSources = ['actblue', 'direct', 'moveon', 'event', 'grant', 'other'];
    const source = validSources.includes(body.source) ? body.source : 'other';

    const validDonorTypes = ['individual', 'foundation', 'corporate', 'anonymous'];
    const donorType = validDonorTypes.includes(body.donor_type) ? body.donor_type : null;

    const validChannels = ['email', 'social', 'website', 'event', 'direct-mail'];
    const channel = validChannels.includes(body.channel) ? body.channel : null;

    // Parse tags
    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 donations
         (source, amount, donor_type, campaign_name, petition_id, draft_id,
          channel, recurring, notes, tags, donated_at, org_id)
       VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
       RETURNING *`,
      [
        source,
        Number(body.amount),
        donorType,
        body.campaign_name || null,
        body.petition_id || null,
        body.draft_id || null,
        channel,
        body.recurring === true || body.recurring === 'true',
        body.notes || null,
        tags,
        body.donated_at || new Date().toISOString(),
        orgId,
      ]
    );

    const donation = result.rows[0];

    const ip =
      request.headers.get('x-forwarded-for') ||
      request.headers.get('x-real-ip') ||
      undefined;
    await auditLog(
      'donation.created',
      'donation',
      donation.id,
      { amount: donation.amount, source, campaign_name: donation.campaign_name },
      ip
    );

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