Files
pn-new-crm/src/lib/services/email-draft.service.ts
Matt Ciaccio 8699f81879
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped
chore(style): codebase em-dash sweep + minor layout polish
Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 22:57:01 +02:00

118 lines
3.9 KiB
TypeScript

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<DraftResult | null> {
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),
};
}