/** * 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 { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants'; 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; /** * True when the criterion is automatically satisfied by data already on * the interest (or a linked record). Surfaced to the UI so the checklist * can render the criterion as ticked-by-the-system without needing an * explicit `interest_qualifications` row. When both `autoSatisfied` and * the explicit `confirmed` are true, either signal alone counts. * * Auto-satisfaction rules: * - `dimensions` → ticked when EITHER (a) the linked yacht has all * three dims (length/width/draft) OR (b) the interest itself has * desired-berth dims set. The "no yacht needed" case is the second * branch - a client buying a berth doesn't have to own a vessel, * they just have to know the berth size they want. */ autoSatisfied: boolean; /** * Human-readable summary of WHY a criterion is auto-satisfied (e.g. * "Desired: 60 × 25 × 6 ft"). Empty string when the criterion is not * auto-satisfied OR when no derivation rule applies. Surfaced on the * checklist row so the rep can see the evidence behind the tick - the * "why is this checked?" question came up in UAT. */ evidence: string; } /** * 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 { // Pull the interest row with the fields needed to derive auto-satisfaction - // desired-berth dims (length/width/draft) plus a linked yacht if any. Cost // is one extra column-select vs the previous columns:{id:true} probe, so // negligible. const interest = await db.query.interests.findFirst({ where: and(eq(interests.id, interestId), eq(interests.portId, portId)), columns: { id: true, yachtId: true, desiredLengthFt: true, desiredWidthFt: true, desiredDraftFt: true, pipelineStage: true, }, }); if (!interest) throw new NotFoundError('Interest'); // Pull the linked yacht's dims when one is attached. Used by the // `dimensions` criterion's auto-satisfaction rule (yacht-side branch). let yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null; } | null = null; if (interest.yachtId) { const { yachts } = await import('@/lib/db/schema/yachts'); const yacht = await db.query.yachts.findFirst({ where: eq(yachts.id, interest.yachtId), columns: { lengthFt: true, widthFt: true, draftFt: true }, }); if (yacht) yachtDims = yacht; } 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); const ctx = { yachtDims, desiredDims: { lengthFt: interest.desiredLengthFt ?? null, widthFt: interest.desiredWidthFt ?? null, draftFt: interest.desiredDraftFt ?? null, }, pipelineStage: (interest.pipelineStage ?? 'enquiry') as PipelineStage, }; const autoSatisfied = computeAutoSatisfied(c.key, ctx); const explicit = s?.confirmed ?? false; const evidence = autoSatisfied ? computeEvidence(c.key, ctx) : ''; // Derived-only criteria (e.g. `dimensions`) ignore the explicit tick // entirely - if the underlying evidence disappears, the row un-ticks. // Judgement-based criteria keep the OR semantic so a rep's explicit // confirmation survives an evidence change. const confirmed = isDerivedOnly(c.key) ? autoSatisfied : explicit || autoSatisfied; return { key: c.key, label: c.label, description: c.description, enabled: c.enabled, displayOrder: c.displayOrder, // Surface ticked state per `confirmed` above. Explicit confirmation // still gets its confirmedAt/By stamps; auto-satisfied state leaves // those null so the rep can see "this was system-derived, not an // explicit sign-off". confirmed, confirmedAt: s?.confirmedAt ?? null, confirmedBy: s?.confirmedBy ?? null, notes: s?.notes ?? null, autoSatisfied, evidence, }; }); } /** * Per-criterion derivation rules. Each key knows how to read the interest * (and any linked records) and decide whether the criterion is satisfied * without an explicit rep tick. Add new rules by branching on `key`. */ interface AutoCtx { yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null; desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null }; pipelineStage: PipelineStage; } /** * Keys whose `confirmed` state should be purely derived (no explicit * tick respected). Removing the underlying evidence un-ticks the row. * Compare with keys carrying real rep judgement (e.g. intent_confirmed * before auto-derivation kicks in), which retain explicit-vs-auto OR * semantics. */ const DERIVED_ONLY_KEYS: ReadonlySet = new Set(['dimensions']); function isDerivedOnly(key: string): boolean { return DERIVED_ONLY_KEYS.has(key); } function computeAutoSatisfied(key: string, ctx: AutoCtx): boolean { if (key === 'dimensions') { const hasYachtDims = !!ctx.yachtDims && !!ctx.yachtDims.lengthFt && !!ctx.yachtDims.widthFt && !!ctx.yachtDims.draftFt; const hasDesiredDims = !!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt; return hasYachtDims || hasDesiredDims; } if (key === 'intent_confirmed') { // Signing an EOI (or later) is the strongest signal of intent - // auto-tick once the rep has moved past Qualified. The criterion // can still be ticked manually before then. const stageIdx = PIPELINE_STAGES.indexOf(ctx.pipelineStage); const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified'); return stageIdx > qualifiedIdx; } return false; } /** * Returns a short human-readable string explaining what data drove the * auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI * can render "Auto · " - closes the "why is this ticked?" gap. */ function computeEvidence(key: string, ctx: AutoCtx): string { if (key === 'dimensions') { const hasYacht = !!ctx.yachtDims && !!ctx.yachtDims.lengthFt && !!ctx.yachtDims.widthFt && !!ctx.yachtDims.draftFt; if (hasYacht && ctx.yachtDims) { return `Yacht: ${ctx.yachtDims.lengthFt} × ${ctx.yachtDims.widthFt} × ${ctx.yachtDims.draftFt} ft`; } const hasDesired = !!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt; if (hasDesired) { return `Desired: ${ctx.desiredDims.lengthFt} × ${ctx.desiredDims.widthFt} × ${ctx.desiredDims.draftFt} ft`; } return ''; } if (key === 'intent_confirmed') { return 'Stage advanced past Qualified'; } return ''; } /** * 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); }