← back to Norma

app/api/gmail/messages/[id]/route.ts

198 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/gmail/messages/:id
 * Fetch a single message with full body_html.
 */
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id } = await params;

  const res = await query(
    `SELECT
       id, gmail_id, thread_id, subject, from_address,
       to_addresses, cc_addresses, date_sent, snippet,
       body_text, body_html, labels, headers, attachments,
       is_read, created_at,
       assigned_user_id, assigned_at,
       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 AS priority
     FROM gmail_messages
     WHERE id = $1`,
    [id],
  );

  if (!res.rows[0]) {
    return NextResponse.json({ error: 'Message not found' }, { status: 404 });
  }
  const msg = res.rows[0];

  // Hydrate assignee + read-by, mirror the list endpoint.
  if (msg.assigned_user_id) {
    const aRes = await query<{ username: string; name: string }>(
      `SELECT username, COALESCE(display_name, username) AS name
         FROM tier_credentials WHERE id = $1 LIMIT 1`,
      [msg.assigned_user_id],
    );
    msg.assigned_username = aRes.rows[0]?.username || null;
    msg.assigned_name     = aRes.rows[0]?.name || null;
  } else {
    msg.assigned_username = null;
    msg.assigned_name     = null;
  }
  const rRes = await query<{
    user_id: string; username: string; name: string; read_at: string;
  }>(
    `SELECT 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 = $1
      ORDER BY r.read_at ASC`,
    [id],
  );
  msg.read_by = rRes.rows;

  // Auto-mark as read for the CURRENT user (per-user receipt + global flag).
  try {
    const meRes = await query<{ id: string }>(
      `SELECT id FROM tier_credentials WHERE username = $1 LIMIT 1`,
      [auth.username],
    );
    const myId = meRes.rows[0]?.id;
    if (myId) {
      await query(
        `INSERT INTO gmail_message_reads (message_id, user_id)
           VALUES ($1, $2) ON CONFLICT DO NOTHING`,
        [id, myId],
      );
      // Surface the new receipt to this response (without re-querying).
      if (!msg.read_by.some((r: { user_id: string }) => r.user_id === myId)) {
        msg.read_by.push({
          user_id: myId,
          username: auth.username,
          name: auth.username,
          read_at: new Date().toISOString(),
        });
      }
    }
  } catch { /* read-receipt is best-effort; don't fail the whole request */ }

  // Keep legacy global is_read flipped on too.
  if (!msg.is_read) {
    await query(`UPDATE gmail_messages SET is_read = true WHERE id = $1`, [id]);
    msg.is_read = true;
  }

  return NextResponse.json(msg);
}

/**
 * PATCH /api/gmail/messages/:id
 * Update fields: is_read, add/remove labels.
 * Body: { is_read?: boolean, add_labels?: string[], remove_labels?: string[] }
 */
export async function PATCH(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id } = await params;
  const body = await request.json();

  const updates: string[] = [];
  const sqlParams: unknown[] = [];
  let idx = 1;

  if (typeof body.is_read === 'boolean') {
    updates.push(`is_read = $${idx++}`);
    sqlParams.push(body.is_read);
  }

  if (Array.isArray(body.add_labels) && body.add_labels.length > 0) {
    updates.push(`labels = array_cat(COALESCE(labels, ARRAY[]::text[]), $${idx++}::text[])`);
    sqlParams.push(body.add_labels);
  }

  if (Array.isArray(body.remove_labels) && body.remove_labels.length > 0) {
    // Remove each label from the array
    for (const label of body.remove_labels) {
      updates.push(`labels = array_remove(labels, $${idx++})`);
      sqlParams.push(label);
    }
  }

  if (updates.length === 0) {
    return NextResponse.json({ error: 'No update fields provided' }, { status: 400 });
  }

  sqlParams.push(id);
  const res = await query(
    `UPDATE gmail_messages SET ${updates.join(', ')} WHERE id = $${idx} RETURNING id, is_read, labels`,
    sqlParams,
  );

  if (!res.rows[0]) {
    return NextResponse.json({ error: 'Message not found' }, { status: 404 });
  }

  await auditLog('gmail.update', 'message', id, {
    updates: Object.keys(body),
  });

  return NextResponse.json(res.rows[0]);
}

/**
 * DELETE /api/gmail/messages/:id
 * Soft delete — adds 'TRASH' label to the message.
 */
export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> },
) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const { id } = await params;

  const res = await query(
    `UPDATE gmail_messages
     SET labels = array_append(COALESCE(labels, ARRAY[]::text[]), 'TRASH')
     WHERE id = $1
     RETURNING id`,
    [id],
  );

  if (!res.rows[0]) {
    return NextResponse.json({ error: 'Message not found' }, { status: 404 });
  }

  await auditLog('gmail.trash', 'message', id, {});

  return NextResponse.json({ success: true, id });
}