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