← back to Norma
app/api/donations/insights/route.ts
130 lines
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
/**
* GET /api/donations/insights
* List donation insights.
* Filters: ?type= (trend, recommendation, warning, milestone),
* ?is_actionable= (true/false),
* ?is_dismissed= (true/false)
* Order by confidence DESC, created_at DESC.
* Returns { insights: [...] }
*/
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 type = searchParams.get('type');
const isActionable = searchParams.get('is_actionable');
const isDismissed = searchParams.get('is_dismissed');
const conditions: string[] = [];
const values: unknown[] = [];
let paramIndex = 1;
if (type) {
conditions.push(`insight_type = $${paramIndex}`);
values.push(type);
paramIndex++;
}
if (isActionable !== null && isActionable !== undefined && isActionable !== '') {
conditions.push(`is_actionable = $${paramIndex}`);
values.push(isActionable === 'true');
paramIndex++;
}
if (isDismissed !== null && isDismissed !== undefined && isDismissed !== '') {
conditions.push(`is_dismissed = $${paramIndex}`);
values.push(isDismissed === 'true');
paramIndex++;
}
const whereClause =
conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await query(
`SELECT * FROM donation_insights ${whereClause}
ORDER BY confidence DESC NULLS LAST, created_at DESC`,
values
);
return NextResponse.json({ insights: result.rows });
} catch (err) {
console.error('[api/donations/insights] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch donation insights' }, { status: 500 });
}
}
/**
* POST /api/donations/insights
* Add a donation insight manually.
* Body: { insight_type, title, description, data_points?, confidence?, is_actionable? }
*/
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.insight_type || typeof body.insight_type !== 'string') {
return NextResponse.json({ error: 'insight_type is required' }, { status: 400 });
}
const validTypes = ['trend', 'recommendation', 'warning', 'milestone'];
if (!validTypes.includes(body.insight_type)) {
return NextResponse.json(
{ error: `insight_type must be one of: ${validTypes.join(', ')}` },
{ status: 400 }
);
}
if (!body.title || typeof body.title !== 'string' || body.title.trim() === '') {
return NextResponse.json({ error: 'title is required' }, { status: 400 });
}
if (!body.description || typeof body.description !== 'string' || body.description.trim() === '') {
return NextResponse.json({ error: 'description is required' }, { status: 400 });
}
const result = await query(
`INSERT INTO donation_insights
(insight_type, title, description, data_points, confidence, is_actionable)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[
body.insight_type,
body.title.trim(),
body.description.trim(),
body.data_points ? JSON.stringify(body.data_points) : null,
body.confidence != null ? Number(body.confidence) : null,
body.is_actionable !== false, // defaults to true
]
);
const insight = result.rows[0];
const ip =
request.headers.get('x-forwarded-for') ||
request.headers.get('x-real-ip') ||
undefined;
await auditLog(
'donation_insight.created',
'donation_insight',
insight.id,
{ insight_type: insight.insight_type, title: insight.title },
ip
);
return NextResponse.json({ insight }, { status: 201 });
} catch (err) {
console.error('[api/donations/insights] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to create donation insight' }, { status: 500 });
}
}