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
492 lines
17 KiB
TypeScript
492 lines
17 KiB
TypeScript
/**
|
||
* 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 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<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);
|
||
}
|