/** * Qualification-criteria service. Per-port admins configure the criteria that * a deal must satisfy to be considered "qualified" (the gate between enquiry * and the rest of the pipeline). Per-interest state is captured separately * so changing the port's criteria doesn't retroactively affect existing * deals. * * The "fully qualified" derivation drives the soft hint on the interest * detail page that an enquiry is ready to advance. */ import { and, asc, eq } from 'drizzle-orm'; import { db } from '@/lib/db'; import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import type { CreateQualificationCriterionInput, ReorderQualificationCriteriaInput, SetInterestQualificationInput, UpdateQualificationCriterionInput, } from '@/lib/validators/qualification'; // ─── Port-scoped criterion config (admin) ─────────────────────────────────── export async function listCriteriaForPort(portId: string) { return db .select() .from(qualificationCriteria) .where(eq(qualificationCriteria.portId, portId)) .orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt)); } export async function createCriterion( portId: string, data: CreateQualificationCriterionInput, meta: AuditMeta, ) { // Unique (portId, key) is enforced at DB level, but doing the check here // surfaces a friendlier 409 with the offending key. const existing = await db.query.qualificationCriteria.findFirst({ where: and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.key, data.key)), }); if (existing) { throw new ConflictError(`A criterion with key "${data.key}" already exists for this port`); } const [row] = await db .insert(qualificationCriteria) .values({ portId, key: data.key, label: data.label, description: data.description ?? null, enabled: data.enabled, displayOrder: data.displayOrder, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'qualification_criterion', entityId: row!.id, newValue: { key: data.key, label: data.label, enabled: data.enabled }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return row!; } export async function updateCriterion( id: string, portId: string, data: UpdateQualificationCriterionInput, meta: AuditMeta, ) { const existing = await db.query.qualificationCriteria.findFirst({ where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)), }); if (!existing) throw new NotFoundError('Qualification criterion'); const next: Record = { updatedAt: new Date() }; if (data.label !== undefined) next.label = data.label; if (data.description !== undefined) next.description = data.description; if (data.enabled !== undefined) next.enabled = data.enabled; if (data.displayOrder !== undefined) next.displayOrder = data.displayOrder; const [updated] = await db .update(qualificationCriteria) .set(next) .where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId))) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'qualification_criterion', entityId: id, oldValue: { label: existing.label, enabled: existing.enabled }, newValue: next, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return updated!; } export async function deleteCriterion(id: string, portId: string, meta: AuditMeta) { const existing = await db.query.qualificationCriteria.findFirst({ where: and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId)), }); if (!existing) throw new NotFoundError('Qualification criterion'); // Per-interest state rows reference the key, not the criterion id, so they // survive a criterion deletion as historical noise. UI hides rows whose key // no longer matches an active criterion. await db .delete(qualificationCriteria) .where(and(eq(qualificationCriteria.id, id), eq(qualificationCriteria.portId, portId))); void createAuditLog({ userId: meta.userId, portId, action: 'delete', entityType: 'qualification_criterion', entityId: id, oldValue: { key: existing.key, label: existing.label }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return { ok: true }; } /** * Whole-list reorder. Rewrites display_order to match the array index of * each id, inside a single transaction so partial failure can't leave the * port with a scrambled order. The ids array must cover exactly the port's * current criteria — any mismatch (extra or missing id) is rejected so the * UI can't silently drop a criterion by sending a stale list. */ export async function reorderCriteria( portId: string, data: ReorderQualificationCriteriaInput, meta: AuditMeta, ) { return db .transaction(async (tx) => { const current = await tx .select({ id: qualificationCriteria.id }) .from(qualificationCriteria) .where(eq(qualificationCriteria.portId, portId)); const currentIds = new Set(current.map((r) => r.id)); const incomingIds = new Set(data.ids); if ( currentIds.size !== incomingIds.size || [...currentIds].some((id) => !incomingIds.has(id)) ) { throw new ValidationError( 'Reorder payload must reference exactly the port’s current criteria', ); } for (let i = 0; i < data.ids.length; i++) { await tx .update(qualificationCriteria) .set({ displayOrder: i, updatedAt: new Date() }) .where( and( eq(qualificationCriteria.id, data.ids[i]!), eq(qualificationCriteria.portId, portId), ), ); } return tx .select() .from(qualificationCriteria) .where(eq(qualificationCriteria.portId, portId)) .orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt)); }) .then((rows) => { void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'qualification_criterion', entityId: 'reorder', newValue: { orderedIds: data.ids }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); return rows; }); } // ─── Per-interest state ───────────────────────────────────────────────────── export interface QualificationRow { key: string; label: string; description: string | null; enabled: boolean; displayOrder: number; confirmed: boolean; confirmedAt: Date | null; confirmedBy: string | null; notes: string | null; } /** * The qualification state for a specific interest, joined with the port's * current criterion definitions. Returns only currently-enabled criteria — * disabled ones are hidden from the rep but their state rows are preserved * in the DB for audit. */ export async function listInterestQualifications( interestId: string, portId: string, ): Promise { const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), columns: { id: true }, }); if (!interest) throw new NotFoundError('Interest'); const criteria = await db .select() .from(qualificationCriteria) .where(and(eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.enabled, true))) .orderBy(asc(qualificationCriteria.displayOrder), asc(qualificationCriteria.createdAt)); const states = await db .select() .from(interestQualifications) .where(eq(interestQualifications.interestId, interestId)); const stateByKey = new Map(states.map((s) => [s.criterionKey, s] as const)); return criteria.map((c) => { const s = stateByKey.get(c.key); return { key: c.key, label: c.label, description: c.description, enabled: c.enabled, displayOrder: c.displayOrder, confirmed: s?.confirmed ?? false, confirmedAt: s?.confirmedAt ?? null, confirmedBy: s?.confirmedBy ?? null, notes: s?.notes ?? null, }; }); } /** * Upsert a single criterion's confirmed-state for an interest. Stamping the * server-side fields (confirmedBy / confirmedAt) makes the row a proper * audit record — the caller can't backdate it. */ export async function setInterestQualification( interestId: string, portId: string, data: SetInterestQualificationInput, meta: AuditMeta, ) { const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), columns: { id: true }, }); if (!interest) throw new NotFoundError('Interest'); // Refuse keys the port doesn't have a criterion for — keeps state rows // referentially consistent with the visible config. const criterion = await db.query.qualificationCriteria.findFirst({ where: and( eq(qualificationCriteria.portId, portId), eq(qualificationCriteria.key, data.criterionKey), ), }); if (!criterion) throw new NotFoundError('Qualification criterion'); const now = new Date(); await db .insert(interestQualifications) .values({ interestId, criterionKey: data.criterionKey, confirmed: data.confirmed, confirmedAt: data.confirmed ? now : null, confirmedBy: data.confirmed ? meta.userId : null, notes: data.notes ?? null, }) .onConflictDoUpdate({ target: [interestQualifications.interestId, interestQualifications.criterionKey], set: { confirmed: data.confirmed, confirmedAt: data.confirmed ? now : null, confirmedBy: data.confirmed ? meta.userId : null, notes: data.notes ?? null, }, }); void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'interest_qualification', entityId: `${interestId}:${data.criterionKey}`, newValue: { confirmed: data.confirmed, key: data.criterionKey }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'interest:qualificationChanged', { interestId, criterionKey: data.criterionKey, confirmed: data.confirmed, }); return listInterestQualifications(interestId, portId); } /** * Returns true when every enabled criterion for the port is confirmed for * the given interest. Used by the UI to surface the "ready to qualify" hint * and by the auto-advance helper to soft-suggest moving to 'qualified'. */ export async function isInterestFullyQualified( interestId: string, portId: string, ): Promise { const rows = await listInterestQualifications(interestId, portId); if (rows.length === 0) return false; return rows.every((r) => r.confirmed); }