Single coherent commit completing § 1.1 (hot-path correctness) plus
§ 1.1.4.5 (multi-berth EOI mooring fix). Numbers users see are now
self-consistent across dashboard / kanban / hot deals / PDF reports.
## Active-interest sweep (canonical predicate everywhere)
Routed every "active interest" filter through `activeInterestsWhere`
(commit b966d81 helper). The helper enforces port-scoping + archivedAt
IS NULL + outcome IS NULL — strict definition, won is closed.
Touched sites:
- src/lib/services/reminders.service.ts:digestPort — no longer fires
reminders for won/lost/cancelled deals
- src/lib/services/berths.service.ts:getLatestInterestStageByBerth
- src/lib/services/client-archive-dossier.service.ts (next-in-line
others lookup)
- src/lib/services/client-archive.service.ts (remaining-under-offer
recount before flipping berth back to available)
- src/lib/services/client-restore.service.ts (yacht-usage check)
- src/lib/services/interests.service.ts:listInterestsForBoard +
getInterestStageCounts + the "others on same berth" lookup —
kanban / board now exclude terminal deals
- src/lib/services/report-generators.ts: fetchPipelineData,
fetchRevenueData stage breakdowns, top-N interests
## Pipeline-value currency conversion
`getKpis()` now fetches the port's defaultCurrency from `ports` and
converts each berth's `priceCurrency`→port-default via
`currency.service`. Returns `pipelineValue` + `pipelineValueCurrency`
instead of the lying `pipelineValueUsd`. Missing rates fall through to
raw amount summing (so the tile still shows an approximate number) —
behind a follow-up to surface a "rates incomplete" indicator.
3 consumers updated: KpiCards, PipelineValueTile, ActiveDealsTile.
## Occupancy = sold only
Both the dashboard KPI tile and the revenue-report PDF occupancy data
now count only `berth.status='sold'`. `under_offer` is a hold, not
occupation. The analytics timeline switches from
`berth_reservations`-derived to a cumulative-won-deals derivation via
`interests.outcome='won' AND outcome_at::date <= day` — same source of
truth, historical shape preserved.
## Revenue PDF two-card layout
Added `totalForecast` + `pipelineWeights` to `RevenueData`. Summary
section now renders both:
- "Completed revenue (won)" — money in the bank
- "Forecast revenue (pipeline-weighted)" — expected pipeline value
Pipeline weights resolve from `system_settings.pipeline_weights`
(per-port admin override) and fall back to STAGE_WEIGHTS defaults. PDF
and dashboard forecast tiles reconcile.
## Multi-berth EOI mooring (4.5)
Documenso `Berth Number` form field now carries the formatBerthRange
output for BOTH single- and multi-berth EOIs. Single-berth output is
byte-identical to the legacy primary-only path
(`formatBerthRange(['A1']) === 'A1'`). Multi-berth EOIs now render
the full range ("A1-A3, B5") in the existing field instead of being
silently dropped against a nonexistent `Berth Range` field.
Dropped:
- `'Berth Range'` from the Documenso formValues payload + TS type
- `setBerthRange()` helper from fill-eoi-form.ts (now redundant)
- The "missing Berth Range AcroForm field" warning log
Updated CLAUDE.md to reflect — no Documenso admin template change
needed.
## Tests
- Updated `documenso-payload.test.ts` — new fixture asserts
formatBerthRange output flows into Berth Number; multi-berth case
added.
- Updated `analytics-service.test.ts:computeOccupancyTimeline` —
fixture creates a won interest instead of a reservation.
- Updated `alerts-engine.test.ts:interest.stale` — fixture stage
switched from dead `'in_communication'` to canonical `'qualified'`.
- Updated `report-templates.test.tsx:revenue` — fixture carries
`totalForecast` + `pipelineWeights` to match new RevenueData.
1373/1373 vitest pass. tsc + eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
404 lines
15 KiB
TypeScript
404 lines
15 KiB
TypeScript
/**
|
|
* Smart-archive mutation service.
|
|
*
|
|
* Takes a fully-resolved set of decisions from the UI (built off the
|
|
* dossier) and applies them inside a single transaction. Records every
|
|
* decision into clients.archive_metadata so the restore wizard can
|
|
* later attempt reversal.
|
|
*
|
|
* External-system cleanup (Documenso envelope void/delete, mass email
|
|
* notifications to next-in-line interests) happens AFTER the local
|
|
* commit — best-effort, queued for retry, never blocks the archive.
|
|
*/
|
|
|
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { clients } from '@/lib/db/schema/clients';
|
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
|
import { berths } from '@/lib/db/schema/berths';
|
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
|
import { invoices } from '@/lib/db/schema/financial';
|
|
import { yachts } from '@/lib/db/schema/yachts';
|
|
import { companyMemberships } from '@/lib/db/schema/companies';
|
|
import { portalUsers } from '@/lib/db/schema/portal';
|
|
import { documents } from '@/lib/db/schema/documents';
|
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
|
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
|
import {
|
|
HIGH_STAKES_STAGES,
|
|
type ClientArchiveDossier,
|
|
} from '@/lib/services/client-archive-dossier.service';
|
|
import { activeInterestsWhere } from '@/lib/services/active-interest';
|
|
|
|
// ─── Decision payload (what the UI sends to the server) ─────────────────────
|
|
|
|
/** Per-berth choice. `interestId` is the interest in the archived client
|
|
* that owns the berth link (used to remove the right interestBerths row). */
|
|
export type BerthDecision = {
|
|
berthId: string;
|
|
interestId: string;
|
|
action: 'release' | 'retain';
|
|
};
|
|
|
|
export type YachtDecision = {
|
|
yachtId: string;
|
|
action: 'transfer' | 'mark_sold_away' | 'retain';
|
|
/** Required when action='transfer' — the new owner's client/company id. */
|
|
newOwnerType?: 'client' | 'company';
|
|
newOwnerId?: string;
|
|
};
|
|
|
|
export type ReservationDecision = {
|
|
reservationId: string;
|
|
action: 'cancel' | 'transfer';
|
|
/** Required when action='transfer' — the new client id. */
|
|
transferToClientId?: string;
|
|
};
|
|
|
|
export type InvoiceDecision = {
|
|
invoiceId: string;
|
|
action: 'void' | 'write_off' | 'leave';
|
|
};
|
|
|
|
export type DocumentDecision = {
|
|
documentId: string;
|
|
/** void = call Documenso API to void the envelope. leave = no action. */
|
|
action: 'void_documenso' | 'leave';
|
|
};
|
|
|
|
export interface ArchiveDecisions {
|
|
reason: string;
|
|
/** Required acknowledgment when the dossier surfaces signed legal docs. */
|
|
acknowledgedSignedDocuments: boolean;
|
|
berthDecisions: BerthDecision[];
|
|
yachtDecisions: YachtDecision[];
|
|
reservationDecisions: ReservationDecision[];
|
|
invoiceDecisions: InvoiceDecision[];
|
|
documentDecisions: DocumentDecision[];
|
|
}
|
|
|
|
// ─── Persisted decision log (lives in clients.archive_metadata jsonb) ───────
|
|
|
|
interface PersistedDecision {
|
|
kind:
|
|
| 'berth_released'
|
|
| 'berth_retained'
|
|
| 'yacht_transferred'
|
|
| 'yacht_marked_sold_away'
|
|
| 'yacht_retained'
|
|
| 'reservation_cancelled'
|
|
| 'reservation_transferred'
|
|
| 'invoice_voided'
|
|
| 'invoice_written_off'
|
|
| 'invoice_left'
|
|
| 'documenso_voided'
|
|
| 'document_left'
|
|
| 'portal_user_revoked';
|
|
refId: string;
|
|
detail?: Record<string, unknown>;
|
|
}
|
|
|
|
export interface ArchiveMetadata {
|
|
decisions: PersistedDecision[];
|
|
decidedAt: string;
|
|
decidedBy: string;
|
|
reason: string;
|
|
}
|
|
|
|
// ─── Result shape ───────────────────────────────────────────────────────────
|
|
|
|
export interface ArchiveResult {
|
|
clientId: string;
|
|
decisionsApplied: number;
|
|
externalCleanups: Array<{
|
|
kind: 'documenso_void';
|
|
documentId: string;
|
|
documensoId: string;
|
|
}>;
|
|
releasedBerths: Array<{
|
|
berthId: string;
|
|
mooringNumber: string;
|
|
/** Other interests that should be notified about this berth becoming
|
|
* available — drives the "next in line" notification fire. */
|
|
nextInLineInterestIds: string[];
|
|
}>;
|
|
}
|
|
|
|
// ─── Implementation ──────────────────────────────────────────────────────────
|
|
|
|
export async function archiveClientWithDecisions(args: {
|
|
dossier: ClientArchiveDossier;
|
|
decisions: ArchiveDecisions;
|
|
meta: AuditMeta;
|
|
}): Promise<ArchiveResult> {
|
|
const { dossier, decisions, meta } = args;
|
|
const clientId = dossier.client.id;
|
|
const portId = dossier.client.portId;
|
|
|
|
// ─── Pre-checks (echo dossier blockers; UI can't bypass) ────────────────
|
|
if (dossier.blockers.length > 0) {
|
|
throw new ConflictError(
|
|
`Cannot archive: ${dossier.blockers.length} unresolved blocker(s). ${dossier.blockers[0]}`,
|
|
);
|
|
}
|
|
|
|
if (dossier.stakeLevel === 'high' && !decisions.reason.trim()) {
|
|
throw new ValidationError(
|
|
'A reason is required when archiving a client at deposit_10pct or later.',
|
|
);
|
|
}
|
|
|
|
const hasSignedDocs = dossier.documents.some(
|
|
(d) => d.status === 'completed' || d.status === 'signed',
|
|
);
|
|
if (hasSignedDocs && !decisions.acknowledgedSignedDocuments) {
|
|
throw new ValidationError(
|
|
'You must acknowledge that signed documents remain binding before archiving.',
|
|
);
|
|
}
|
|
|
|
const persistedDecisions: PersistedDecision[] = [];
|
|
const externalCleanups: ArchiveResult['externalCleanups'] = [];
|
|
const releasedBerths: ArchiveResult['releasedBerths'] = [];
|
|
|
|
// ─── Atomic local apply ──────────────────────────────────────────────────
|
|
await db.transaction(async (tx) => {
|
|
// Lock the client row so a concurrent archive collides cleanly.
|
|
const [locked] = await tx
|
|
.select({ id: clients.id, archivedAt: clients.archivedAt })
|
|
.from(clients)
|
|
.where(and(eq(clients.id, clientId), eq(clients.portId, portId)))
|
|
.for('update');
|
|
if (!locked) throw new NotFoundError('client');
|
|
if (locked.archivedAt) throw new ConflictError('Client is already archived');
|
|
|
|
// ─── Berth decisions ─────────────────────────────────────────────────
|
|
for (const d of decisions.berthDecisions) {
|
|
const berth = dossier.berths.find((b) => b.berthId === d.berthId);
|
|
if (!berth) continue;
|
|
if (d.action === 'release') {
|
|
// Lock the berth row so a concurrent sale can't flip the status
|
|
// between our read of dossier.berths (outside the tx) and our
|
|
// write below. Without this lock, A archives client X while B
|
|
// sells berth A1 to client Y — A's pre-tx read says
|
|
// status='under_offer', B commits status='sold', A's update
|
|
// would flip it back to 'available'.
|
|
const [locked] = await tx
|
|
.select({ status: berths.status })
|
|
.from(berths)
|
|
.where(eq(berths.id, d.berthId))
|
|
.for('update');
|
|
const lockedStatus = locked?.status ?? berth.status;
|
|
|
|
// Drop the interest_berths link for this client's interest. Other
|
|
// interests on the berth survive (so the next-in-line notification
|
|
// can fire).
|
|
await tx
|
|
.delete(interestBerths)
|
|
.where(
|
|
and(eq(interestBerths.berthId, d.berthId), eq(interestBerths.interestId, d.interestId)),
|
|
);
|
|
// If no remaining interestBerths row marks this berth as
|
|
// is_specific_interest, set the berth status back to available.
|
|
// Sold berths are immutable from this flow — also re-checked
|
|
// against the freshly-locked row, not the pre-tx dossier read.
|
|
if (lockedStatus !== 'sold') {
|
|
const [stillUnderOffer] = await tx
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(interestBerths)
|
|
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
|
|
.where(
|
|
and(
|
|
activeInterestsWhere(portId),
|
|
eq(interestBerths.berthId, d.berthId),
|
|
eq(interestBerths.isSpecificInterest, true),
|
|
),
|
|
);
|
|
if ((stillUnderOffer?.count ?? 0) === 0) {
|
|
await tx.update(berths).set({ status: 'available' }).where(eq(berths.id, d.berthId));
|
|
}
|
|
}
|
|
persistedDecisions.push({
|
|
kind: 'berth_released',
|
|
refId: d.berthId,
|
|
detail: { interestId: d.interestId, mooringNumber: berth.mooringNumber },
|
|
});
|
|
releasedBerths.push({
|
|
berthId: d.berthId,
|
|
mooringNumber: berth.mooringNumber,
|
|
nextInLineInterestIds: berth.otherInterests.map((i) => i.interestId),
|
|
});
|
|
} else {
|
|
persistedDecisions.push({
|
|
kind: 'berth_retained',
|
|
refId: d.berthId,
|
|
detail: { interestId: d.interestId, mooringNumber: berth.mooringNumber },
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Yacht decisions ─────────────────────────────────────────────────
|
|
for (const d of decisions.yachtDecisions) {
|
|
if (d.action === 'transfer') {
|
|
if (!d.newOwnerType || !d.newOwnerId) {
|
|
throw new ValidationError(
|
|
`Yacht ${d.yachtId}: transfer requires newOwnerType + newOwnerId`,
|
|
);
|
|
}
|
|
await tx
|
|
.update(yachts)
|
|
.set({ currentOwnerType: d.newOwnerType, currentOwnerId: d.newOwnerId })
|
|
.where(eq(yachts.id, d.yachtId));
|
|
persistedDecisions.push({
|
|
kind: 'yacht_transferred',
|
|
refId: d.yachtId,
|
|
detail: {
|
|
previousOwnerType: 'client',
|
|
previousOwnerId: clientId,
|
|
newOwnerType: d.newOwnerType,
|
|
newOwnerId: d.newOwnerId,
|
|
},
|
|
});
|
|
} else if (d.action === 'mark_sold_away') {
|
|
await tx.update(yachts).set({ status: 'sold_away' }).where(eq(yachts.id, d.yachtId));
|
|
persistedDecisions.push({ kind: 'yacht_marked_sold_away', refId: d.yachtId });
|
|
} else {
|
|
persistedDecisions.push({ kind: 'yacht_retained', refId: d.yachtId });
|
|
}
|
|
}
|
|
|
|
// ─── Reservation decisions ───────────────────────────────────────────
|
|
for (const d of decisions.reservationDecisions) {
|
|
if (d.action === 'cancel') {
|
|
await tx
|
|
.update(berthReservations)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(eq(berthReservations.id, d.reservationId));
|
|
persistedDecisions.push({ kind: 'reservation_cancelled', refId: d.reservationId });
|
|
} else if (d.action === 'transfer') {
|
|
if (!d.transferToClientId) {
|
|
throw new ValidationError(
|
|
`Reservation ${d.reservationId}: transfer requires transferToClientId`,
|
|
);
|
|
}
|
|
await tx
|
|
.update(berthReservations)
|
|
.set({ clientId: d.transferToClientId, updatedAt: new Date() })
|
|
.where(eq(berthReservations.id, d.reservationId));
|
|
persistedDecisions.push({
|
|
kind: 'reservation_transferred',
|
|
refId: d.reservationId,
|
|
detail: { previousClientId: clientId, newClientId: d.transferToClientId },
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Invoice decisions ───────────────────────────────────────────────
|
|
for (const d of decisions.invoiceDecisions) {
|
|
if (d.action === 'void' || d.action === 'write_off') {
|
|
await tx
|
|
.update(invoices)
|
|
.set({
|
|
status: 'cancelled',
|
|
notes: sql`coalesce(${invoices.notes}, '') || ${'\n[archive ' + new Date().toISOString() + '] ' + (d.action === 'void' ? 'voided' : 'written off') + ' as part of client archive'}`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(invoices.id, d.invoiceId));
|
|
persistedDecisions.push({
|
|
kind: d.action === 'void' ? 'invoice_voided' : 'invoice_written_off',
|
|
refId: d.invoiceId,
|
|
});
|
|
} else {
|
|
persistedDecisions.push({ kind: 'invoice_left', refId: d.invoiceId });
|
|
}
|
|
}
|
|
|
|
// ─── Document (Documenso envelope) decisions ─────────────────────────
|
|
for (const d of decisions.documentDecisions) {
|
|
const doc = dossier.documents.find((x) => x.documentId === d.documentId);
|
|
if (!doc) continue;
|
|
if (d.action === 'void_documenso' && doc.documensoEnvelopeId) {
|
|
// Local marker — actual API call queued post-commit.
|
|
await tx
|
|
.update(documents)
|
|
.set({ status: 'cancelled', updatedAt: new Date() })
|
|
.where(eq(documents.id, d.documentId));
|
|
externalCleanups.push({
|
|
kind: 'documenso_void',
|
|
documentId: d.documentId,
|
|
documensoId: doc.documensoEnvelopeId,
|
|
});
|
|
persistedDecisions.push({
|
|
kind: 'documenso_voided',
|
|
refId: d.documentId,
|
|
detail: { documensoEnvelopeId: doc.documensoEnvelopeId },
|
|
});
|
|
} else {
|
|
persistedDecisions.push({ kind: 'document_left', refId: d.documentId });
|
|
}
|
|
}
|
|
|
|
// ─── Auto-handled: portal user, company memberships ──────────────────
|
|
if (dossier.hasPortalUser) {
|
|
await tx
|
|
.update(portalUsers)
|
|
.set({ isActive: false, updatedAt: new Date() })
|
|
.where(eq(portalUsers.clientId, clientId));
|
|
persistedDecisions.push({ kind: 'portal_user_revoked', refId: clientId });
|
|
}
|
|
|
|
// Auto-end company memberships (no decision needed — preserves history
|
|
// via end_date instead of deleting the membership row).
|
|
await tx
|
|
.update(companyMemberships)
|
|
.set({ endDate: sql`now()` })
|
|
.where(and(eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate)));
|
|
|
|
// ─── Archive the client itself ────────────────────────────────────────
|
|
const archiveMetadata: ArchiveMetadata = {
|
|
decisions: persistedDecisions,
|
|
decidedAt: new Date().toISOString(),
|
|
decidedBy: meta.userId,
|
|
reason: decisions.reason,
|
|
};
|
|
await tx
|
|
.update(clients)
|
|
.set({
|
|
archivedAt: new Date(),
|
|
archivedBy: meta.userId,
|
|
archiveReason: decisions.reason || null,
|
|
archiveMetadata,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(clients.id, clientId));
|
|
});
|
|
|
|
// ─── Audit log (one parent + one per non-trivial decision) ──────────────
|
|
void createAuditLog({
|
|
portId,
|
|
userId: meta.userId,
|
|
action: 'archive',
|
|
entityType: 'client',
|
|
entityId: clientId,
|
|
metadata: {
|
|
stakeLevel: dossier.stakeLevel,
|
|
highStakesStage: dossier.highStakesStage,
|
|
reason: decisions.reason,
|
|
decisionCount: persistedDecisions.length,
|
|
},
|
|
ipAddress: meta.ipAddress,
|
|
userAgent: meta.userAgent,
|
|
});
|
|
|
|
return {
|
|
clientId,
|
|
decisionsApplied: persistedDecisions.length,
|
|
externalCleanups,
|
|
releasedBerths,
|
|
};
|
|
}
|
|
|
|
/** Re-export for convenience. */
|
|
export { HIGH_STAKES_STAGES };
|