Files
pn-new-crm/src/lib/services/berth-rules-engine.ts
Matt Ciaccio 6e3d910c76 refactor(interests): migrate callers to interest_berths junction + drop berth_id
Phase 2b of the berth-recommender refactor (plan §3.4). Every caller of
the legacy `interests.berth_id` column now reads / writes through the
`interest_berths` junction via the helper service introduced in Phase 2a;
the column itself is dropped in a final migration.

Service-layer changes
- interests.service: filter `?berthId=X` becomes EXISTS-against-junction;
  list enrichment uses `getPrimaryBerthsForInterests`; create/update/
  linkBerth/unlinkBerth all dispatch through the junction helpers, with
  createInterest's row insert + junction write sharing a single transaction.
- clients / dashboard / report-generators / search: leftJoin chains pivot
  through `interest_berths` filtered by `is_primary=true`.
- eoi-context / document-templates / berth-rules-engine / portal /
  record-export / queue worker: read primary via `getPrimaryBerth(...)`.
- interest-scoring: berthLinked is now derived from any junction row count.
- dedup/migration-apply + public interest route: write a primary junction
  row alongside the interest insert when a berth is provided.

API contract preserved: list/detail responses still emit `berthId` and
`berthMooringNumber`, derived from the primary junction row, so frontend
consumers (interest-form, interest-detail-header) need no changes.

Schema + migration
- Drop `interestsRelations.berth` and `idx_interests_berth`.
- Replace `berthsRelations.interests` with `interestBerths`.
- Migration 0029_puzzling_romulus drops `interests.berth_id` + the index.
- Tests that previously inserted `interests.berthId` now seed a primary
  junction row alongside the interest.

Verified: vitest 995 passing (1 unrelated pre-existing flake in
maintenance-cleanup.test.ts), tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 02:41:52 +02:00

142 lines
4.7 KiB
TypeScript

import { and, eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { systemSettings } from '@/lib/db/schema/system';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { emitToRoom } from '@/lib/socket/server';
import { getPrimaryBerth } from '@/lib/services/interest-berths.service';
// ─── Types ────────────────────────────────────────────────────────────────────
export type BerthRuleTrigger =
| 'eoi_sent'
| 'eoi_signed'
| 'deposit_received'
| 'contract_signed'
| 'interest_archived'
| 'interest_completed'
| 'berth_unlinked';
export type BerthRuleMode = 'auto' | 'suggest' | 'off';
export interface BerthRuleResult {
action: 'applied' | 'suggested' | 'none';
newStatus?: string;
message?: string;
}
interface RuleConfig {
mode: BerthRuleMode;
targetStatus: string;
}
// ─── Defaults ────────────────────────────────────────────────────────────────
const DEFAULT_RULES: Record<BerthRuleTrigger, RuleConfig> = {
eoi_sent: { mode: 'suggest', targetStatus: 'under_offer' },
eoi_signed: { mode: 'auto', targetStatus: 'under_offer' },
deposit_received: { mode: 'auto', targetStatus: 'sold' },
contract_signed: { mode: 'auto', targetStatus: 'sold' },
interest_archived: { mode: 'suggest', targetStatus: 'available' },
interest_completed: { mode: 'auto', targetStatus: 'sold' },
berth_unlinked: { mode: 'off', targetStatus: 'available' },
};
// ─── Config ───────────────────────────────────────────────────────────────────
async function getRulesConfig(portId: string): Promise<Record<BerthRuleTrigger, RuleConfig>> {
const setting = await db.query.systemSettings.findFirst({
where: and(eq(systemSettings.key, 'berth_rules'), eq(systemSettings.portId, portId)),
});
if (!setting?.value) {
return { ...DEFAULT_RULES };
}
const stored = setting.value as Partial<Record<BerthRuleTrigger, RuleConfig>>;
const merged = { ...DEFAULT_RULES };
for (const trigger of Object.keys(DEFAULT_RULES) as BerthRuleTrigger[]) {
if (stored[trigger]) {
merged[trigger] = stored[trigger]!;
}
}
return merged;
}
// ─── Evaluate Rule ────────────────────────────────────────────────────────────
export async function evaluateRule(
trigger: BerthRuleTrigger,
interestId: string,
portId: string,
meta: AuditMeta,
): Promise<BerthRuleResult> {
const interest = await db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
});
if (!interest) {
return { action: 'none' };
}
// Rule evaluation targets the interest's primary berth (plan §3.4) -
// resolved via interest_berths rather than the legacy column.
const primaryBerth = await getPrimaryBerth(interestId);
const targetBerthId = primaryBerth?.berthId;
if (!targetBerthId) {
return { action: 'none' };
}
const rulesConfig = await getRulesConfig(portId);
const rule = rulesConfig[trigger];
if (rule.mode === 'off') {
return { action: 'none' };
}
if (rule.mode === 'auto') {
await db
.update(berths)
.set({
status: rule.targetStatus,
statusLastChangedBy: meta.userId,
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: targetBerthId,
newValue: { status: rule.targetStatus },
metadata: { type: 'berth_rule_auto', trigger, interestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
emitToRoom(`port:${portId}`, 'berth:statusChanged', {
berthId: targetBerthId,
newStatus: rule.targetStatus,
triggeredBy: meta.userId,
trigger,
});
return { action: 'applied', newStatus: rule.targetStatus };
}
// suggest mode
return {
action: 'suggested',
newStatus: rule.targetStatus,
message: `Suggested status change to "${rule.targetStatus}" based on trigger "${trigger}"`,
};
}