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