Audit cleanup completion plan, all tiers shipped: Tier 1 (security + data integrity) - A.7 RTBF true wipe: redact email_messages body/subject/addresses for threads owned by deleted client; redact document_sends.recipient_email; collect file storage keys + delete blobs post-commit. - A.8 user_permission_overrides FK: documented inline why cascade is correct (not set-null as audit suggested) — overrides have no value without their user. - W2.14 PII redaction: camelCase normalization in audit.ts + error-events.service.ts isSensitiveKey; added city/postal/country/ birth fragments. firstName/lastName/dateOfBirth/postalCode etc. now caught in BOTH masker paths. 12 new test cases lock the coverage. Tier 2 (Documenso completion + refactor) - C.2: documentEvents.recipient_email column + partial unique index for per-recipient webhook dedup (migration 0075). handleDocumentSigned now sets recipient_email on insert. - Phase 2: completion_cc_emails distribution. handleDocumentCompleted reads documents.completionCcEmails, filters out signer-duplicates case-insensitively, fans signed PDF out to non-signer recipients. - C.4: extracted createPublicInterest() service from the 346-line api/public/interests route. Route becomes a thin shell (rate-limit, port resolution, audit log, email fan-out). The trio creation logic is now unit-testable without an HTTP fixture. - Phase 4: POST /api/v1/document-templates/[id]/detect-fields wired to document-field-detector.detectFields(). Sparkles "Auto-detect" button added to template-editor.tsx — maps DetectedField → marker with best-guess merge token (DATE / NAME / EMAIL); user retags. Tier 3 (reporting + recommender snapshot lockfiles) - W7.reports: extracted rollupStageRevenue / rollupStageCounts / computeTotalForecast / computeOccupancyRate / rollupBerthStatusCounts into src/lib/services/report-math.ts (pure functions). 16 new tests including an inline-snapshot lockfile on a representative 7-stage forecast. report-generators.ts now delegates. - W7.recommender: 18 new toMatchSnapshot tripwires on classifyTier boundaries + computeHeat at canonical input points. Tier 4 (rolling) - W6.attach: fixed outdated CLAUDE.md claim — threshold banner is informational and never depended on IMAP; bounce monitoring (the IMAP poller) is separate. - D.1 + D.2: documented deferral inline with full why-not-build-it reasoning so a future engineer sees the rationale. - G.1: representative formatDate sweep (audit-log-list, user-list, document-templates merge tokens, document-signing email). Rest of the ~100 sites stay rolling. Quality gates: 1420/1420 vitest (46 new tests above baseline of 1374), tsc clean, 0 lint errors. Plan: docs/superpowers/plans/2026-05-18-audit-cleanup-completion.md Migration: 0075_c2_document_events_recipient_email.sql (applied to dev DB). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
304 lines
10 KiB
TypeScript
304 lines
10 KiB
TypeScript
/**
|
|
* Public interest creation — extracted from `/api/public/interests/route.ts`
|
|
* per the C.4 audit finding ("Public POST routes bypass service layer"). The
|
|
* pre-extraction route was 346 lines of inline DB logic + audit + email
|
|
* fan-out, which made unit testing the dedup, ownership, and address rules
|
|
* effectively impossible without spinning up a full HTTP request fixture.
|
|
*
|
|
* After extraction:
|
|
* - The route handles HTTP concerns: rate-limit, port resolution from
|
|
* headers, parseBody validation, audit-log + email side-effect dispatch.
|
|
* - This service handles the transactional trio creation (client + yacht
|
|
* + interest, plus optional company + membership + address).
|
|
*
|
|
* The companion routes — `/api/public/website-inquiries/route.ts` (pure raw
|
|
* capture; no entity creation) and `/api/public/residential-inquiries/route.ts`
|
|
* (residential funnel, separate schema) — were intentionally NOT extracted
|
|
* here. Their bodies are smaller and their concerns don't overlap with the
|
|
* marina-funnel logic this service encapsulates.
|
|
*/
|
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
|
|
|
import { db } from '@/lib/db';
|
|
import { withTransaction } from '@/lib/db/utils';
|
|
import { interests, interestBerths } from '@/lib/db/schema/interests';
|
|
import { clients, clientContacts, clientAddresses } from '@/lib/db/schema/clients';
|
|
import { berths } from '@/lib/db/schema/berths';
|
|
import { yachts, yachtOwnershipHistory } from '@/lib/db/schema/yachts';
|
|
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
|
import { parsePhone } from '@/lib/i18n/phone';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
import type { publicInterestSchema } from '@/lib/validators/interests';
|
|
import type { z } from 'zod';
|
|
|
|
type PublicInterestData = z.infer<typeof publicInterestSchema>;
|
|
type Tx = typeof db;
|
|
|
|
export interface CreatePublicInterestArgs {
|
|
portId: string;
|
|
data: PublicInterestData;
|
|
}
|
|
|
|
export interface CreatePublicInterestResult {
|
|
interestId: string;
|
|
clientId: string;
|
|
yachtId: string;
|
|
companyId: string | null;
|
|
berthId: string | null;
|
|
resolvedMooringNumber: string | null;
|
|
fullName: string;
|
|
firstName: string;
|
|
}
|
|
|
|
export async function createPublicInterest(
|
|
args: CreatePublicInterestArgs,
|
|
): Promise<CreatePublicInterestResult> {
|
|
const { portId, data } = args;
|
|
|
|
// Server-side phone normalization for older website builds that post raw
|
|
// international/national strings. Newer builds may pre-fill phoneE164/Country.
|
|
let phoneE164 = data.phoneE164 ?? null;
|
|
let phoneCountry: CountryCode | null = (data.phoneCountry as CountryCode | null) ?? null;
|
|
if (!phoneE164) {
|
|
const parsed = parsePhone(data.phone, phoneCountry ?? undefined);
|
|
phoneE164 = parsed.e164;
|
|
phoneCountry = parsed.country ?? phoneCountry;
|
|
}
|
|
|
|
const fullName =
|
|
data.firstName && data.lastName
|
|
? `${data.firstName} ${data.lastName}`
|
|
: (data.fullName ?? 'Unknown');
|
|
|
|
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
|
|
|
|
// Resolve berth by mooring number (if provided). Read-only lookup — safe
|
|
// to do outside the transaction.
|
|
let berthId: string | null = null;
|
|
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;
|
|
if (data.mooringNumber) {
|
|
const berth = await db.query.berths.findFirst({
|
|
where: and(eq(berths.mooringNumber, data.mooringNumber), eq(berths.portId, portId)),
|
|
});
|
|
if (berth) {
|
|
berthId = berth.id;
|
|
resolvedMooringNumber = berth.mooringNumber;
|
|
}
|
|
}
|
|
|
|
// ─── Transactional trio creation ────────────────────────────────────────
|
|
const result = await withTransaction(async (tx) => {
|
|
// 1. Find or create client by email. The inquiry-funnel audit
|
|
// flagged that the previous exact match was case-sensitive —
|
|
// capital-letter resubmissions spawned duplicate client+yacht+
|
|
// interest rows. Match LOWER(value) instead so foo@x.com and
|
|
// Foo@X.COM dedupe to the same client.
|
|
let clientId: string;
|
|
const normalizedEmail = data.email.trim().toLowerCase();
|
|
const existingContact = await tx.query.clientContacts.findFirst({
|
|
where: and(
|
|
eq(clientContacts.channel, 'email'),
|
|
sql`LOWER(${clientContacts.value}) = ${normalizedEmail}`,
|
|
),
|
|
});
|
|
if (existingContact) {
|
|
const existingClient = await tx.query.clients.findFirst({
|
|
where: eq(clients.id, existingContact.clientId),
|
|
});
|
|
if (existingClient && existingClient.portId === portId) {
|
|
clientId = existingClient.id;
|
|
const updates: Partial<typeof clients.$inferInsert> = {};
|
|
if (data.preferredContactMethod) {
|
|
updates.preferredContactMethod = data.preferredContactMethod;
|
|
}
|
|
if (data.nationalityIso && !existingClient.nationalityIso) {
|
|
updates.nationalityIso = data.nationalityIso;
|
|
}
|
|
if (Object.keys(updates).length > 0) {
|
|
await tx.update(clients).set(updates).where(eq(clients.id, clientId));
|
|
}
|
|
} else {
|
|
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
|
}
|
|
} else {
|
|
clientId = await createClientInTx(tx, portId, fullName, data, phoneE164, phoneCountry);
|
|
}
|
|
|
|
// 2. Optional: upsert company + add membership
|
|
let companyId: string | null = null;
|
|
if (data.company) {
|
|
const existingCompany = await tx.query.companies.findFirst({
|
|
where: and(
|
|
eq(companies.portId, portId),
|
|
sql`lower(${companies.name}) = lower(${data.company.name})`,
|
|
),
|
|
});
|
|
if (existingCompany) {
|
|
companyId = existingCompany.id;
|
|
} else {
|
|
const [newCompany] = await tx
|
|
.insert(companies)
|
|
.values({
|
|
portId,
|
|
name: data.company.name,
|
|
legalName: data.company.legalName ?? null,
|
|
taxId: data.company.taxId ?? null,
|
|
incorporationCountryIso: data.company.incorporationCountryIso ?? null,
|
|
incorporationSubdivisionIso: data.company.incorporationSubdivisionIso ?? null,
|
|
status: 'active',
|
|
})
|
|
.returning();
|
|
companyId = newCompany!.id;
|
|
}
|
|
|
|
// Add active membership only if one doesn't already exist (open row).
|
|
const existingMembership = await tx.query.companyMemberships.findFirst({
|
|
where: and(
|
|
eq(companyMemberships.companyId, companyId),
|
|
eq(companyMemberships.clientId, clientId),
|
|
isNull(companyMemberships.endDate),
|
|
),
|
|
});
|
|
if (!existingMembership) {
|
|
await tx.insert(companyMemberships).values({
|
|
companyId,
|
|
clientId,
|
|
role: data.company.role ?? 'representative',
|
|
startDate: new Date(),
|
|
isPrimary: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 3. Create yacht. Owner is the company when provided, else the client.
|
|
const ownerType: 'client' | 'company' = companyId ? 'company' : 'client';
|
|
const ownerId = companyId ?? clientId;
|
|
const [newYacht] = await tx
|
|
.insert(yachts)
|
|
.values({
|
|
portId,
|
|
name: data.yacht.name,
|
|
hullNumber: data.yacht.hullNumber ?? null,
|
|
registration: data.yacht.registration ?? null,
|
|
flag: data.yacht.flag ?? null,
|
|
yearBuilt: data.yacht.yearBuilt ?? null,
|
|
lengthFt: data.yacht.lengthFt != null ? String(data.yacht.lengthFt) : null,
|
|
widthFt: data.yacht.widthFt != null ? String(data.yacht.widthFt) : null,
|
|
draftFt: data.yacht.draftFt != null ? String(data.yacht.draftFt) : null,
|
|
currentOwnerType: ownerType,
|
|
currentOwnerId: ownerId,
|
|
status: 'active',
|
|
})
|
|
.returning();
|
|
const yachtId = newYacht!.id;
|
|
|
|
// 3a. Open ownership_history row for the new yacht.
|
|
await tx.insert(yachtOwnershipHistory).values({
|
|
yachtId,
|
|
ownerType,
|
|
ownerId,
|
|
startDate: new Date(),
|
|
endDate: null,
|
|
createdBy: 'public-submission',
|
|
});
|
|
|
|
// 4. Store address if provided AND no primary address exists yet.
|
|
if (data.address && Object.values(data.address).some(Boolean)) {
|
|
const existingAddr = await tx.query.clientAddresses.findFirst({
|
|
where: and(eq(clientAddresses.clientId, clientId), eq(clientAddresses.isPrimary, true)),
|
|
});
|
|
if (!existingAddr) {
|
|
await tx.insert(clientAddresses).values({
|
|
clientId,
|
|
portId,
|
|
label: 'Primary',
|
|
streetAddress: data.address.street ?? null,
|
|
city: data.address.city ?? null,
|
|
subdivisionIso: data.address.subdivisionIso ?? null,
|
|
postalCode: data.address.postalCode ?? null,
|
|
countryIso: data.address.countryIso ?? null,
|
|
isPrimary: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
// 5. Create interest with yachtId wired up.
|
|
const [newInterest] = await tx
|
|
.insert(interests)
|
|
.values({
|
|
portId,
|
|
clientId,
|
|
yachtId,
|
|
source: 'website',
|
|
pipelineStage: 'open',
|
|
})
|
|
.returning();
|
|
|
|
if (berthId) {
|
|
await tx.insert(interestBerths).values({
|
|
interestId: newInterest!.id,
|
|
berthId,
|
|
isPrimary: true,
|
|
isSpecificInterest: true,
|
|
isInEoiBundle: false,
|
|
});
|
|
}
|
|
|
|
return {
|
|
interestId: newInterest!.id,
|
|
clientId,
|
|
yachtId,
|
|
companyId,
|
|
};
|
|
});
|
|
|
|
return {
|
|
...result,
|
|
berthId,
|
|
resolvedMooringNumber,
|
|
fullName,
|
|
firstName,
|
|
};
|
|
}
|
|
|
|
async function createClientInTx(
|
|
tx: Tx,
|
|
portId: string,
|
|
fullName: string,
|
|
data: Pick<PublicInterestData, 'email' | 'phone' | 'preferredContactMethod' | 'nationalityIso'>,
|
|
phoneE164: string | null,
|
|
phoneCountry: CountryCode | null,
|
|
): Promise<string> {
|
|
const [newClient] = await tx
|
|
.insert(clients)
|
|
.values({
|
|
portId,
|
|
fullName,
|
|
preferredContactMethod: data.preferredContactMethod,
|
|
nationalityIso: data.nationalityIso ?? null,
|
|
source: 'website',
|
|
})
|
|
.returning();
|
|
const clientId = newClient!.id;
|
|
|
|
await tx.insert(clientContacts).values({
|
|
clientId,
|
|
channel: 'email',
|
|
// Store lowercased so the case-insensitive dedup match above always
|
|
// hits on subsequent submissions.
|
|
value: data.email.trim().toLowerCase(),
|
|
isPrimary: true,
|
|
});
|
|
|
|
await tx.insert(clientContacts).values({
|
|
clientId,
|
|
channel: 'phone',
|
|
value: data.phone,
|
|
valueE164: phoneE164,
|
|
valueCountry: phoneCountry,
|
|
isPrimary: false,
|
|
});
|
|
|
|
return clientId;
|
|
}
|