← back to Norma
app/api/news/route.ts
160 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/news
* List news items ordered by published_at DESC.
* Optional filters: ?tags=, ?search=, ?is_used=, ?outlet=
* Supports pagination: ?limit=50&offset=0
*/
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 tags = searchParams.get('tags');
const search = searchParams.get('search');
const isUsed = searchParams.get('is_used');
const outlet = searchParams.get('outlet');
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 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++;
}
// Date range filter
const dateRange = searchParams.get('date_range');
if (dateRange === 'today') {
conditions.push(`published_at >= CURRENT_DATE`);
} else if (dateRange === 'week') {
conditions.push(`published_at >= CURRENT_DATE - INTERVAL '7 days'`);
} else if (dateRange === 'month') {
conditions.push(`published_at >= CURRENT_DATE - INTERVAL '30 days'`);
} else if (dateRange === '3months') {
conditions.push(`published_at >= CURRENT_DATE - INTERVAL '90 days'`);
} else if (dateRange === '6months') {
conditions.push(`published_at >= CURRENT_DATE - INTERVAL '180 days'`);
}
if (tags) {
// Expect comma-separated tags; match if any tag overlaps
const tagArray = tags.split(',').map((t) => t.trim()).filter(Boolean);
conditions.push(`tags && $${paramIndex}`);
values.push(tagArray);
paramIndex++;
}
if (search) {
conditions.push(`(headline ILIKE $${paramIndex} OR summary ILIKE $${paramIndex} OR outlet ILIKE $${paramIndex} OR author_name ILIKE $${paramIndex})`);
values.push(`%${search}%`);
paramIndex++;
}
if (isUsed !== null && isUsed !== undefined && isUsed !== '') {
conditions.push(`is_used = $${paramIndex}`);
values.push(isUsed === 'true');
paramIndex++;
}
if (outlet) {
conditions.push(`outlet = $${paramIndex}`);
values.push(outlet);
paramIndex++;
}
// Filter to articles mentioning our people/org
const mentionsOnly = searchParams.get('mentions_only');
if (mentionsOnly === 'true') {
conditions.push(`mentioned_entities IS NOT NULL AND mentioned_entities != '[]'::jsonb AND jsonb_array_length(mentioned_entities) > 0`);
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const countResult = await query(
`SELECT COUNT(*)::int AS total, MAX(created_at) AS last_updated FROM news_items ${whereClause}`,
values
);
const total = countResult.rows[0]?.total ?? 0;
const lastUpdated = countResult.rows[0]?.last_updated ?? null;
const result = await query(
`SELECT * FROM news_items ${whereClause}
ORDER BY is_pinned DESC NULLS LAST, published_at DESC NULLS LAST
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
[...values, limit, offset]
);
return NextResponse.json({ news: result.rows, total, limit, offset, last_updated: lastUpdated });
} catch (err) {
console.error('[api/news] GET error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to fetch news items' }, { status: 500 });
}
}
/**
* POST /api/news
* Add a news item manually.
* Body: { headline, outlet?, url?, published_at?, summary?, tags? }
*/
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.headline || typeof body.headline !== 'string' || body.headline.trim() === '') {
return NextResponse.json({ error: 'headline is required' }, { status: 400 });
}
const orgId = auth.role === 'admin' ? getOrgId(request) : auth.orgId;
const result = await query(
`INSERT INTO news_items (headline, outlet, url, published_at, summary, tags, source_type, org_id)
VALUES ($1, $2, $3, $4, $5, $6, 'manual', $7)
RETURNING *`,
[
body.headline.trim(),
body.outlet || null,
body.url || null,
body.published_at || null,
body.summary || null,
body.tags || [],
orgId || null,
]
);
const ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || undefined;
await auditLog(
'news.created',
'news_item',
result.rows[0].id,
{ headline: body.headline },
ip
);
return NextResponse.json({ news_item: result.rows[0] }, { status: 201 });
} catch (err) {
console.error('[api/news] POST error:', (err as Error).message);
return NextResponse.json({ error: 'Failed to create news item' }, { status: 500 });
}
}