← back to Norma

app/api/ingest/gmail/route.ts

133 lines

import { NextRequest, NextResponse } from 'next/server';
import { requireRole } from '@/lib/require-role';
import { auditLog } from '@/lib/audit';
import { syncGmail, type GmailAccount, type SyncOptions, type SyncResult } from '@/lib/ingest-gmail';

/**
 * POST /api/ingest/gmail
 *
 * Trigger Gmail ingestion for one or both accounts.
 *
 * Query params:
 *   ?account=steve       — sync only the steve@ account
 *   ?account=info        — sync only the info@ account
 *   (omit account)       — sync both accounts sequentially
 *
 * Optional JSON body:
 *   {
 *     "afterDate": "2026-01-01",   // ISO date — only fetch messages after this date
 *     "query":     "label:INBOX",  // extra Gmail search terms
 *     "maxPages":  20              // max list pages per account (default 10)
 *   }
 *
 * Response 200:
 *   {
 *     "results": [
 *       { "account": "steve", "mailboxId": "...", "fetched": 50,
 *         "inserted": 12, "skipped": 38, "errors": 0, "newCursor": "2026-02-26" },
 *       { "account": "info",  ... }
 *     ],
 *     "totalInserted": 12,
 *     "totalFetched": 100,
 *     "durationMs": 4823
 *   }
 */
export async function POST(request: NextRequest) {
  const auth = requireRole(request, 'admin', 'staff');
  if (auth instanceof NextResponse) return auth;

  const startMs = Date.now();
  const ip =
    request.headers.get('x-forwarded-for') ||
    request.headers.get('x-real-ip') ||
    undefined;

  // Parse optional JSON body — gracefully ignore empty or non-JSON bodies
  let bodyOptions: SyncOptions = {};
  try {
    const text = await request.text();
    if (text.trim()) {
      const parsed = JSON.parse(text);
      bodyOptions = {
        afterDate: typeof parsed.afterDate === 'string' ? parsed.afterDate : undefined,
        query: typeof parsed.query === 'string' ? parsed.query : undefined,
        maxPages: typeof parsed.maxPages === 'number' ? parsed.maxPages : undefined,
      };
    }
  } catch {
    // Non-JSON body is fine — just use defaults
  }

  // Determine which accounts to sync
  const { searchParams } = new URL(request.url);
  const accountParam = searchParams.get('account');

  let accounts: GmailAccount[];
  if (accountParam === 'steve') {
    accounts = ['steve'];
  } else if (accountParam === 'info') {
    accounts = ['info'];
  } else if (accountParam === null) {
    accounts = ['steve', 'info'];
  } else {
    return NextResponse.json(
      { error: `Invalid account parameter "${accountParam}". Must be "steve" or "info".` },
      { status: 400 },
    );
  }

  // Run syncs sequentially so DB load stays manageable
  const results: SyncResult[] = [];

  for (const account of accounts) {
    try {
      const result = await syncGmail(account, bodyOptions);
      results.push(result);
    } catch (err) {
      const message = (err as Error).message;
      console.error(`[api/ingest/gmail] syncGmail(${account}) threw:`, message);
      // Push a partial failure result rather than aborting the whole response
      results.push({
        account,
        mailboxId: '',
        fetched: 0,
        inserted: 0,
        skipped: 0,
        errors: 1,
        newCursor: null,
      });
    }
  }

  const durationMs = Date.now() - startMs;
  const totalFetched = results.reduce((sum, r) => sum + r.fetched, 0);
  const totalInserted = results.reduce((sum, r) => sum + r.inserted, 0);
  const totalErrors = results.reduce((sum, r) => sum + r.errors, 0);

  // Audit log the completed sync
  await auditLog(
    'gmail.ingest',
    'mailbox',
    accounts.join(','),
    {
      accounts,
      totalFetched,
      totalInserted,
      totalErrors,
      durationMs,
      options: bodyOptions,
    },
    ip,
  );

  return NextResponse.json(
    {
      results,
      totalFetched,
      totalInserted,
      durationMs,
    },
    { status: 200 },
  );
}