← 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 });
}
}