← back to Norma

app/api/journalists/route.ts

179 lines

import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { requireRole } from '@/lib/require-role';

/* ═══════════════════════════════════════════════════════════════════════════
   GET /api/journalists — list journalists with stats
   Query: ?q=search&outlet=&year=2024&tag=&sort=articles|name|recent
   ═══════════════════════════════════════════════════════════════════════════ */

export async function GET(req: NextRequest) {
  const auth = requireRole(req, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const { searchParams } = new URL(req.url);
    const q = searchParams.get('q') || '';
    const outlet = searchParams.get('outlet') || '';
    const tag = searchParams.get('tag') || '';
    const year = searchParams.get('year') || '';
    const state = searchParams.get('state') || '';
    const sort = searchParams.get('sort') || 'articles';

    // Base query: journalists with article counts, year range, and org counts
    let sql = `
      SELECT j.*,
        COUNT(DISTINCT a.id) AS article_count,
        COUNT(DISTINCT o.id) AS org_count,
        MIN(a.published_date) AS first_article,
        MAX(a.published_date) AS latest_article,
        COALESCE(
          json_agg(DISTINCT jsonb_build_object('year', EXTRACT(YEAR FROM a.published_date)::int, 'count', 1))
            FILTER (WHERE a.id IS NOT NULL),
          '[]'
        ) AS year_data_raw
      FROM journalists j
      LEFT JOIN journalist_articles a ON a.journalist_id = j.id
    `;

    const conditions: string[] = [];
    const params: (string | number)[] = [];
    let idx = 1;

    if (year) {
      conditions.push(`EXTRACT(YEAR FROM a.published_date) = $${idx++}`);
      params.push(Number(year));
    }

    sql += ` LEFT JOIN journalist_orgs o ON o.journalist_id = j.id`;

    if (q) {
      conditions.push(`(j.name ILIKE $${idx} OR j.outlet ILIKE $${idx} OR j.beat ILIKE $${idx})`);
      params.push(`%${q}%`);
      idx++;
    }
    if (outlet) {
      conditions.push(`j.outlet = $${idx++}`);
      params.push(outlet);
    }
    if (tag) {
      conditions.push(`$${idx++} = ANY(j.tags)`);
      params.push(tag);
    }
    if (state) {
      conditions.push(`j.state = $${idx++}`);
      params.push(state);
    }

    if (conditions.length) sql += ` WHERE ${conditions.join(' AND ')}`;

    sql += ` GROUP BY j.id`;

    const orderMap: Record<string, string> = {
      articles: 'article_count DESC',
      name: 'j.name ASC',
      recent: 'latest_article DESC NULLS LAST',
      outlet: 'j.outlet ASC, j.name ASC',
    };
    sql += ` ORDER BY ${orderMap[sort] || orderMap.articles}`;

    const { rows: journalists } = await query(sql, params);

    // Compute year breakdowns in a single batch query (avoids N+1)
    const journalistIds = journalists.map((j: any) => j.id);
    const yearBreakdownMap = new Map<string, { year: number; count: number }[]>();

    if (journalistIds.length > 0) {
      const { rows: yearRows } = await query(
        `SELECT journalist_id, EXTRACT(YEAR FROM published_date)::int AS year, COUNT(*)::int AS count
         FROM journalist_articles
         WHERE journalist_id = ANY($1)
         GROUP BY journalist_id, year
         ORDER BY journalist_id, year`,
        [journalistIds]
      );
      for (const row of yearRows) {
        const key = String(row.journalist_id);
        if (!yearBreakdownMap.has(key)) yearBreakdownMap.set(key, []);
        yearBreakdownMap.get(key)!.push({ year: row.year, count: row.count });
      }
    }

    for (const j of journalists) {
      j.year_breakdown = yearBreakdownMap.get(String(j.id)) || [];
      delete j.year_data_raw;
    }

    // Get all outlets for filter
    const { rows: outlets } = await query(
      `SELECT DISTINCT outlet, COUNT(*)::int AS count FROM journalists GROUP BY outlet ORDER BY count DESC`
    );

    // Get all tags across journalists
    const { rows: tagRows } = await query(
      `SELECT UNNEST(tags) AS tag, COUNT(*)::int AS count FROM journalists GROUP BY tag ORDER BY count DESC LIMIT 30`
    );

    // Get all states for filter
    const { rows: stateRows } = await query(
      `SELECT state, COUNT(*)::int AS count FROM journalists WHERE state IS NOT NULL GROUP BY state ORDER BY state`
    );

    // Get year range
    const { rows: yearRange } = await query(
      `SELECT EXTRACT(YEAR FROM MIN(published_date))::int AS min_year,
              EXTRACT(YEAR FROM MAX(published_date))::int AS max_year
       FROM journalist_articles`
    );

    return NextResponse.json({
      journalists,
      outlets,
      tags: tagRows,
      states: stateRows,
      yearRange: yearRange[0] || { min_year: 2021, max_year: 2026 },
      total: journalists.length,
    });
  } catch (err: any) {
    console.error('Journalists GET error:', err.message);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   POST /api/journalists — add a new journalist
   ═══════════════════════════════════════════════════════════════════════════ */

export async function POST(req: NextRequest) {
  const auth = requireRole(req, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  try {
    const body = await req.json();
    const {
      name, outlet, beat, bio, twitter_handle, email, tags, linkedin_url,
      website, instagram_handle, facebook_url, youtube_url, substack_url,
      medium_url, podcast_url, rss_feed,
    } = body;

    if (!name || !outlet) {
      return NextResponse.json({ error: 'Name and outlet are required' }, { status: 400 });
    }

    const { rows } = await query(
      `INSERT INTO journalists (name, outlet, beat, bio, twitter_handle, email, tags, added_by,
        linkedin_url, website, instagram_handle, facebook_url, youtube_url, substack_url, medium_url, podcast_url, rss_feed)
       VALUES ($1, $2, $3, $4, $5, $6, $7, 'user', $8, $9, $10, $11, $12, $13, $14, $15, $16)
       RETURNING *`,
      [name, outlet, beat || 'General', bio || '', twitter_handle || '', email || '', tags || [],
       linkedin_url || null, website || null, instagram_handle || null, facebook_url || null,
       youtube_url || null, substack_url || null, medium_url || null, podcast_url || null, rss_feed || null]
    );

    return NextResponse.json({ journalist: rows[0] }, { status: 201 });
  } catch (err: any) {
    console.error('Journalists POST error:', err.message);
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
  }
}