Files
pn-new-crm/src/lib/services/qualification.service.ts
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

492 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown> = { 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 ports 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<QualificationRow[]> {
// 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<string> = 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 · <evidence>" - 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<boolean> {
const rows = await listInterestQualifications(interestId, portId);
if (rows.length === 0) return false;
return rows.every((r) => r.confirmed);
}