← back to Norma

app/api/gmail/messages/route.ts

301 lines

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

/**
 * GET /api/gmail/messages
 *
 * List gmail messages with filtering, sorting, and pagination.
 *
 * Query params:
 *   ?sort=date|priority|from   (default: date)
 *   ?sort_dir=asc|desc         (default: desc)
 *   ?priority=high|medium|low  (filter by computed priority)
 *   ?topic=                    (filter by label/topic tag)
 *   ?from=                     (filter by sender email)
 *   ?q=                        (full-text search in subject + body_text)
 *   ?is_read=true|false
 *   ?limit=50&offset=0
 */
export async function GET(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const sp = request.nextUrl.searchParams;
  const sort = sp.get('sort') || 'date';
  const sortDir = sp.get('sort_dir') === 'asc' ? 'ASC' : 'DESC';
  const priority = sp.get('priority');
  const topic = sp.get('topic');
  const from = sp.get('from');
  const q = sp.get('q');
  const isRead = sp.get('is_read');
  const limit = Math.min(parseInt(sp.get('limit') || '50', 10), 200);
  const offset = parseInt(sp.get('offset') || '0', 10);

  // Build the priority CASE expression
  const priorityExpr = `
    CASE
      WHEN labels @> ARRAY['IMPORTANT']
        OR from_address ILIKE '%media%'
        OR from_address ILIKE '%press%'
        OR from_address ILIKE '%journalist%'
        OR from_address ILIKE '%reporter%'
        OR from_address ILIKE '%congress%'
        OR from_address ILIKE '%senate%'
        OR from_address ILIKE '%house.gov%'
      THEN 'high'
      WHEN labels @> ARRAY['CATEGORY_PROMOTIONS']
        OR from_address ILIKE '%noreply%'
        OR from_address ILIKE '%newsletter%'
      THEN 'low'
      ELSE 'medium'
    END
  `;

  const conditions: string[] = [];
  const params: unknown[] = [];
  let idx = 1;

  // Mailbox scoping:
  //   - Admin defaults to the UNION of every connected mailbox in the system
  //     (the "main account sees everything" pattern Steve asked for).
  //   - Staff / intern / pulse default to their OWN mailbox only.
  //   - ?mailbox=<id>  → specific override (anyone can pin a single mailbox)
  //   - ?mailbox=own   → force own-mailbox view even when admin
  //   - ?mailbox=all   → admin-only escape hatch (same as default for admin)
  const explicitMailbox = sp.get('mailbox');
  if (explicitMailbox === 'all' && auth.role === 'admin') {
    // intentional no-op — admin opted into the full union
  } else if (!explicitMailbox && auth.role === 'admin') {
    // Admin default: every connected mailbox
    const mbs = await query<{ id: string }>(
      `SELECT id FROM mailboxes WHERE is_active = true AND oauth_tokens IS NOT NULL`,
    );
    if (mbs.rows.length > 0) {
      const placeholders = mbs.rows.map(() => `$${idx++}`).join(',');
      conditions.push(`mailbox_id IN (${placeholders})`);
      mbs.rows.forEach((r) => params.push(r.id));
    } else {
      // No connected mailboxes yet → empty list, surface needs_connect for the UI
      return NextResponse.json({
        messages:      [],
        total:         0,
        limit,
        offset,
        needs_connect: true,
        admin_view:    true,
      });
    }
  } else if (explicitMailbox && explicitMailbox !== 'own') {
    conditions.push(`mailbox_id = $${idx++}`);
    params.push(explicitMailbox);
  } else {
    // Resolve the user's email — tier_credentials has no email column, so the
    // username IS the email when it's email-shaped.
    let userEmail: string | null = null;
    if (auth.username.includes('@')) {
      userEmail = auth.username;
    }
    if (userEmail) {
      const mb = await query<{ id: string; oauth_tokens: unknown; needs_reauth: boolean | null }>(
        `SELECT id, oauth_tokens, needs_reauth
           FROM mailboxes
          WHERE LOWER(email) = LOWER($1) AND is_active = true
          ORDER BY (oauth_tokens IS NOT NULL) DESC
          LIMIT 1`,
        [userEmail],
      );
      if (mb.rows.length === 0) {
        // No mailbox row at all → tell the UI to show Connect Gmail.
        return NextResponse.json({
          messages:      [],
          total:         0,
          limit,
          offset,
          mailbox_email: userEmail,
          needs_connect: true,
        });
      }
      const row = mb.rows[0];
      const connected = !!row.oauth_tokens && !row.needs_reauth;
      if (!connected) {
        // Row exists but never OAuth'd (or revoked) → same outcome.
        return NextResponse.json({
          messages:      [],
          total:         0,
          limit,
          offset,
          mailbox_email: userEmail,
          needs_connect: true,
          needs_reauth:  !!row.needs_reauth,
        });
      }
      conditions.push(`mailbox_id = $${idx++}`);
      params.push(row.id);
    } else {
      // No identifiable email at all — empty list (no leakage from other users).
      return NextResponse.json({ messages: [], total: 0, limit, offset, needs_connect: true });
    }
  }

  // Exclude trashed messages
  conditions.push(`NOT (labels @> ARRAY['TRASH'])`);

  // Date range filter
  const dateRange = sp.get('date_range');
  if (dateRange === 'today') {
    conditions.push(`date_sent >= CURRENT_DATE`);
  } else if (dateRange === 'week') {
    conditions.push(`date_sent >= CURRENT_DATE - INTERVAL '7 days'`);
  } else if (dateRange === 'month') {
    conditions.push(`date_sent >= CURRENT_DATE - INTERVAL '30 days'`);
  } else if (dateRange === '3months') {
    conditions.push(`date_sent >= CURRENT_DATE - INTERVAL '90 days'`);
  }

  if (priority) {
    conditions.push(`(${priorityExpr}) = $${idx++}`);
    params.push(priority);
  }

  if (topic) {
    conditions.push(`$${idx++} = ANY(labels)`);
    params.push(topic);
  }

  if (from) {
    conditions.push(`from_address ILIKE $${idx++}`);
    params.push(`%${from}%`);
  }

  if (q) {
    conditions.push(`(
      to_tsvector('english', COALESCE(subject, '')) @@ plainto_tsquery('english', $${idx})
      OR body_text ILIKE $${idx + 1}
    )`);
    params.push(q, `%${q}%`);
    idx += 2;
  }

  if (isRead === 'true') {
    conditions.push(`is_read = true`);
  } else if (isRead === 'false') {
    conditions.push(`is_read = false`);
  }

  const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

  // Determine ORDER BY
  let orderBy: string;
  switch (sort) {
    case 'priority':
      orderBy = `(${priorityExpr}) ${sortDir}, date_sent DESC`;
      break;
    case 'from':
      orderBy = `from_address ${sortDir}, date_sent DESC`;
      break;
    default:
      orderBy = `date_sent ${sortDir}`;
  }

  // Get total count
  const countRes = await query(
    `SELECT COUNT(*) AS total FROM gmail_messages ${where}`,
    params,
  );
  const total = parseInt(countRes.rows[0]?.total || '0', 10);

  // Get messages (exclude body_html from list for performance)
  const messagesRes = await query(
    `SELECT
       id, gmail_id, thread_id, subject, from_address,
       to_addresses, cc_addresses, date_sent, snippet,
       LEFT(body_text, 200) AS body_preview,
       labels, is_read, attachments, created_at,
       assigned_user_id, assigned_at,
       (${priorityExpr}) AS priority
     FROM gmail_messages
     ${where}
     ORDER BY ${orderBy}
     LIMIT $${idx++} OFFSET $${idx++}`,
    [...params, limit, offset],
  );

  // Hydrate assigned-user info + read-by list for the returned page of messages.
  // Done in a second pass so the main query keeps its existing shape + index plan.
  const messageIds: string[] = messagesRes.rows.map((r: { id: string }) => r.id);
  let assigneesById: Record<string, { id: string; username: string; name: string } | undefined> = {};
  let readsByMessageId: Record<string, Array<{ user_id: string; username: string; name: string; read_at: string }>> = {};
  if (messageIds.length > 0) {
    const assigneeIds = messagesRes.rows
      .map((r: { assigned_user_id: string | null }) => r.assigned_user_id)
      .filter((v: string | null): v is string => !!v);
    if (assigneeIds.length > 0) {
      const aRes = await query<{ id: string; username: string; name: string }>(
        `SELECT id, username, COALESCE(display_name, username) AS name
           FROM tier_credentials WHERE id = ANY($1::uuid[])`,
        [assigneeIds],
      );
      assigneesById = Object.fromEntries(aRes.rows.map(r => [r.id, r]));
    }
    const rRes = await query<{
      message_id: string;
      user_id: string;
      username: string;
      name: string;
      read_at: string;
    }>(
      `SELECT r.message_id, r.user_id, tc.username,
              COALESCE(tc.display_name, tc.username) AS name, r.read_at
         FROM gmail_message_reads r
         JOIN tier_credentials tc ON tc.id = r.user_id
        WHERE r.message_id = ANY($1::uuid[])
        ORDER BY r.read_at ASC`,
      [messageIds],
    );
    for (const row of rRes.rows) {
      (readsByMessageId[row.message_id] ||= []).push({
        user_id: row.user_id,
        username: row.username,
        name: row.name,
        read_at: row.read_at,
      });
    }
  }
  for (const m of messagesRes.rows) {
    const a = m.assigned_user_id ? assigneesById[m.assigned_user_id] : undefined;
    m.assigned_username = a?.username || null;
    m.assigned_name     = a?.name || null;
    m.read_by           = readsByMessageId[m.id] || [];
  }

  // Get unique labels/topics for filter dropdown — scoped to the same mailbox
  // we filtered messages by (if any). Pulled out of the first WHERE param.
  const mailboxClauseIdx = params.findIndex((_, i) => conditions[i]?.startsWith('mailbox_id = '));
  const mbId = mailboxClauseIdx >= 0 ? (params[mailboxClauseIdx] as string) : null;
  const topicsRes = await query(
    `SELECT DISTINCT unnest(labels) AS topic
     FROM gmail_messages
     WHERE NOT (labels @> ARRAY['TRASH'])${mbId ? ` AND mailbox_id = $1` : ''}
     ORDER BY topic`,
    mbId ? [mbId] : [],
  );

  // Get unique senders for "From" dropdown (natalia only)
  const sendersRes = await query(
    `SELECT DISTINCT from_address
     FROM gmail_messages
     WHERE from_address IS NOT NULL AND from_address != ''${mbId ? ` AND mailbox_id = $1` : ''}
     ORDER BY from_address
     LIMIT 100`,
    mbId ? [mbId] : [],
  );

  return NextResponse.json({
    messages: messagesRes.rows,
    total,
    topics: topicsRes.rows.map((r) => (r as { topic: string }).topic),
    senders: sendersRes.rows.map((r) => (r as { from_address: string }).from_address),
  });
}