import { randomUUID } from 'crypto'; import { and, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interests } from '@/lib/db/schema/interests'; import { clients } from '@/lib/db/schema/clients'; import { ValidationError, ForbiddenError } from '@/lib/errors'; import { getQueue } from '@/lib/queue'; // ─── Types ──────────────────────────────────────────────────────────────────── export interface DraftRequest { interestId: string; clientId: string; portId: string; context: 'follow_up' | 'introduction' | 'stage_update' | 'general'; additionalInstructions?: string; } export interface DraftResult { subject: string; body: string; generatedAt: Date; } // ─── Request draft (enqueues job) ───────────────────────────────────────────── /** * Request an AI-generated email draft. * * Generates an opaque random jobId rather than relying on BullMQ's default * sequential ids - the jobId is the access token for polling, so it must * not be enumerable. The job payload also captures the requesting user * + port so the poll endpoint can refuse cross-tenant / cross-user reads. * * The interestId and clientId are validated against portId before enqueue * so a port-A caller cannot trigger a draft built from port-B data. */ export async function requestEmailDraft( userId: string, request: DraftRequest, ): Promise<{ jobId: string }> { const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, request.interestId), eq(interests.portId, request.portId)), }); if (!interest) { throw new ValidationError('interestId not found in this port'); } const client = await db.query.clients.findFirst({ where: and(eq(clients.id, request.clientId), eq(clients.portId, request.portId)), }); if (!client) { throw new ValidationError('clientId not found in this port'); } const aiQueue = getQueue('ai'); const jobId = randomUUID(); await aiQueue.add( 'generate-email-draft', { // No PII - only IDs and context parameters interestId: request.interestId, clientId: request.clientId, portId: request.portId, context: request.context, additionalInstructions: request.additionalInstructions, requestedBy: userId, }, { jobId }, ); return { jobId }; } // ─── Poll for result ────────────────────────────────────────────────────────── /** * Get the result of an email draft generation job. * Returns null if still processing. * * Verifies the caller (userId + portId) matches the job's original * requester before returning the drafted subject/body. A foreign caller * who happens to know the jobId (or stumbles on it) sees null, not the * drafted content. */ export async function getEmailDraftResult( jobId: string, caller: { userId: string; portId: string }, ): Promise { const aiQueue = getQueue('ai'); const job = await aiQueue.getJob(jobId); if (!job) return null; const data = job.data as { requestedBy?: string; portId?: string } | undefined | null; if (!data || data.requestedBy !== caller.userId || data.portId !== caller.portId) { throw new ForbiddenError('Email draft not accessible'); } const state = await job.getState(); if (state !== 'completed') return null; const returnValue = job.returnvalue as | { subject: string; body: string; generatedAt: string } | undefined | null; if (!returnValue) return null; return { subject: returnValue.subject, body: returnValue.body, generatedAt: new Date(returnValue.generatedAt), }; }