fix(audit): GDPR/merge — M6 (drop false merge-reversibility claims), M7 (GDPR export adds 4 PII tables), L14 (docstring), L15 (hard-delete breadcrumb note)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,15 @@
|
|||||||
* audit history is preserved without blocking the delete.
|
* audit history is preserved without blocking the delete.
|
||||||
* - non-cascade non-nullable FKs (interests, reservations, surviving
|
* - non-cascade non-nullable FKs (interests, reservations, surviving
|
||||||
* row in client_merge_log) are deleted explicitly inside the tx.
|
* row in client_merge_log) are deleted explicitly inside the tx.
|
||||||
|
* - the `clients.merged_into_client_id` self-FK is ON DELETE SET NULL
|
||||||
|
* (migration 0042). If THIS client was a merge winner, any archived
|
||||||
|
* loser whose `merged_into_client_id` points here has that pointer
|
||||||
|
* auto-NULLed by the cascade when this row is deleted. That silently
|
||||||
|
* severs the loser's redirect breadcrumb (the loser is no longer
|
||||||
|
* resolvable to a surviving record) but is benign: no FK violation,
|
||||||
|
* no orphaned/cross-tenant data, and the loser stays archived. We do
|
||||||
|
* NOT proactively re-home those pointers — the winner is gone, so
|
||||||
|
* there is nothing valid left to redirect to.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { timingSafeEqual } from 'node:crypto';
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|||||||
@@ -8,11 +8,15 @@
|
|||||||
* actually merges two pre-existing clients
|
* actually merges two pre-existing clients
|
||||||
* - the migration script's `--apply` (eventually)
|
* - the migration script's `--apply` (eventually)
|
||||||
*
|
*
|
||||||
* Reversibility: every merge writes a `client_merge_log` row containing
|
* NOT reversible: a merge is permanent. Every merge writes a
|
||||||
* the loser's full pre-merge state. Within the configured undo window
|
* `client_merge_log` row containing a snapshot of the loser's pre-merge
|
||||||
* (default 7 days, see `dedup_undo_window_days` in system_settings) a
|
* state, but this is a forensic/audit record only — there is NO
|
||||||
* follow-up `unmergeClients` call can restore the loser and detach
|
* `unmergeClients` implementation, and the child rows reattached to the
|
||||||
* everything that was reattached.
|
* winner are not restorable from the snapshot. Operators must treat
|
||||||
|
* merge as a destructive, one-way operation. (The original design called
|
||||||
|
* for a 7-day `dedup_undo_window_days` reversibility window; that undo
|
||||||
|
* pathway was never built, so the setting has no effect and the snapshot
|
||||||
|
* is retained purely as an audit trail.)
|
||||||
*
|
*
|
||||||
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §6.
|
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §6.
|
||||||
*/
|
*/
|
||||||
@@ -138,8 +142,9 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
|||||||
throw new ConflictError('Cannot merge into an archived client');
|
throw new ConflictError('Cannot merge into an archived client');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Snapshot the loser's full state before any mutation. Used by
|
// ── Snapshot the loser's full state before any mutation. Written to
|
||||||
// `unmergeClients` to restore within the undo window. ──────────────
|
// `client_merge_log.mergeDetails` as a forensic/audit record only;
|
||||||
|
// NOT used to restore — merge is one-way (no `unmergeClients`). ─────
|
||||||
const loserContacts = await tx
|
const loserContacts = await tx
|
||||||
.select()
|
.select()
|
||||||
.from(clientContacts)
|
.from(clientContacts)
|
||||||
@@ -245,8 +250,8 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
|||||||
const key = `${c.channel}::${c.value.toLowerCase()}`;
|
const key = `${c.channel}::${c.value.toLowerCase()}`;
|
||||||
if (winnerContactKeys.has(key)) {
|
if (winnerContactKeys.has(key)) {
|
||||||
// Winner already has this contact - drop loser's row (cascade
|
// Winner already has this contact - drop loser's row (cascade
|
||||||
// will clean up when loser is archived). But we keep snapshot
|
// will clean up when loser is archived). The snapshot records it
|
||||||
// so undo restores it.
|
// for audit, but this drop is not reversible (merge is one-way).
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await tx
|
await tx
|
||||||
@@ -393,9 +398,11 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
|||||||
.returning({ id: invoices.id })
|
.returning({ id: invoices.id })
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
// ── Archive the loser. Row stays in DB for the undo window;
|
// ── Archive the loser. The row stays in the DB (soft-archived, not
|
||||||
// `mergedIntoClientId` is the redirect pointer for any stragglers
|
// deleted) so `mergedIntoClientId` can act as the redirect pointer
|
||||||
// (links / direct queries / saved views). ──────────────────────────
|
// for any stragglers (links / direct queries / saved views). This
|
||||||
|
// is a permanent redirect — the loser is never un-archived by a
|
||||||
|
// reverse-merge, as no unmerge pathway exists. ─────────────────────
|
||||||
await tx
|
await tx
|
||||||
.update(clients)
|
.update(clients)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ import { emailThreads, emailMessages } from '@/lib/db/schema/email';
|
|||||||
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
import { reminders, interestContactLog } from '@/lib/db/schema/operations';
|
||||||
import { documentSends } from '@/lib/db/schema/brochures';
|
import { documentSends } from '@/lib/db/schema/brochures';
|
||||||
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
import { websiteSubmissions } from '@/lib/db/schema/website-submissions';
|
||||||
|
import { payments } from '@/lib/db/schema/pipeline';
|
||||||
|
import { berthWaitingList } from '@/lib/db/schema/berths';
|
||||||
|
import { supplementalFormTokens } from '@/lib/db/schema/supplemental-forms';
|
||||||
|
import { interestFieldHistory } from '@/lib/db/schema/interest-field-history';
|
||||||
|
|
||||||
export interface GdprBundle {
|
export interface GdprBundle {
|
||||||
/** Bundle metadata for traceability. */
|
/** Bundle metadata for traceability. */
|
||||||
@@ -56,12 +60,16 @@ export interface GdprBundle {
|
|||||||
company: Record<string, unknown>;
|
company: Record<string, unknown>;
|
||||||
}>;
|
}>;
|
||||||
interests: Record<string, unknown>[];
|
interests: Record<string, unknown>[];
|
||||||
|
interestFieldHistory: Record<string, unknown>[];
|
||||||
contactLog: Record<string, unknown>[];
|
contactLog: Record<string, unknown>[];
|
||||||
tenancies: Record<string, unknown>[];
|
tenancies: Record<string, unknown>[];
|
||||||
|
berthWaitingList: Record<string, unknown>[];
|
||||||
|
payments: Record<string, unknown>[];
|
||||||
invoices: Record<string, unknown>[];
|
invoices: Record<string, unknown>[];
|
||||||
documents: Record<string, unknown>[];
|
documents: Record<string, unknown>[];
|
||||||
files: Record<string, unknown>[];
|
files: Record<string, unknown>[];
|
||||||
formSubmissions: Record<string, unknown>[];
|
formSubmissions: Record<string, unknown>[];
|
||||||
|
supplementalFormTokens: Record<string, unknown>[];
|
||||||
websiteSubmissions: Record<string, unknown>[];
|
websiteSubmissions: Record<string, unknown>[];
|
||||||
documentSends: Record<string, unknown>[];
|
documentSends: Record<string, unknown>[];
|
||||||
emailThreads: Array<{
|
emailThreads: Array<{
|
||||||
@@ -77,8 +85,16 @@ export interface GdprBundle {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads every row that references this client across all tenant-scoped
|
* Loads every row that references this client across all tenant-scoped
|
||||||
* tables. Every query is filtered by `portId` as well, so a stale FK
|
* tables.
|
||||||
* to another tenant never leaks across.
|
*
|
||||||
|
* Port-scoping: queries on tables that carry a `port_id` column add a
|
||||||
|
* redundant `eq(..., portId)` predicate as defense-in-depth, so a stale
|
||||||
|
* cross-tenant FK never leaks across. Tables WITHOUT a `port_id` column
|
||||||
|
* (clientContacts, clientAddresses, clientRelationships, clientNotes,
|
||||||
|
* clientTags, formSubmissions, scratchpadNotes, portalUsers,
|
||||||
|
* berthWaitingList) are scoped by `clientId` ONLY — which is safe because
|
||||||
|
* `clientId` is a globally-unique UUID and the client itself was already
|
||||||
|
* validated against `portId` at the top of `buildClientBundle`.
|
||||||
*/
|
*/
|
||||||
export async function buildClientBundle(clientId: string, portId: string): Promise<GdprBundle> {
|
export async function buildClientBundle(clientId: string, portId: string): Promise<GdprBundle> {
|
||||||
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
|
const client = await db.query.clients.findFirst({ where: eq(clients.id, clientId) });
|
||||||
@@ -95,11 +111,15 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
|||||||
ownedYachts,
|
ownedYachts,
|
||||||
membershipRows,
|
membershipRows,
|
||||||
interestRows,
|
interestRows,
|
||||||
|
fieldHistoryRows,
|
||||||
tenancyRows,
|
tenancyRows,
|
||||||
|
waitingListRows,
|
||||||
|
paymentRows,
|
||||||
invoiceRows,
|
invoiceRows,
|
||||||
documentRows,
|
documentRows,
|
||||||
fileRows,
|
fileRows,
|
||||||
formSubmissionRows,
|
formSubmissionRows,
|
||||||
|
supplementalTokenRows,
|
||||||
documentSendRows,
|
documentSendRows,
|
||||||
threadRows,
|
threadRows,
|
||||||
reminderRows,
|
reminderRows,
|
||||||
@@ -141,9 +161,26 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
|||||||
db.query.interests.findMany({
|
db.query.interests.findMany({
|
||||||
where: and(eq(interests.clientId, clientId), eq(interests.portId, portId)),
|
where: and(eq(interests.clientId, clientId), eq(interests.portId, portId)),
|
||||||
}),
|
}),
|
||||||
|
// Field-level override history carries the denormalized clientId for
|
||||||
|
// direct-edit and supplemental-form overrides alike. Port-scoped too,
|
||||||
|
// since interest_field_history has a notNull port_id column.
|
||||||
|
db.query.interestFieldHistory.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(interestFieldHistory.portId, portId),
|
||||||
|
eq(interestFieldHistory.clientId, clientId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
db.query.berthTenancies.findMany({
|
db.query.berthTenancies.findMany({
|
||||||
where: and(eq(berthTenancies.clientId, clientId), eq(berthTenancies.portId, portId)),
|
where: and(eq(berthTenancies.clientId, clientId), eq(berthTenancies.portId, portId)),
|
||||||
}),
|
}),
|
||||||
|
// berth_waiting_list has no port_id column — scope by clientId only
|
||||||
|
// (clientId is a global UUID, client already validated against portId).
|
||||||
|
db.query.berthWaitingList.findMany({
|
||||||
|
where: eq(berthWaitingList.clientId, clientId),
|
||||||
|
}),
|
||||||
|
db.query.payments.findMany({
|
||||||
|
where: and(eq(payments.portId, portId), eq(payments.clientId, clientId)),
|
||||||
|
}),
|
||||||
db.query.invoices.findMany({
|
db.query.invoices.findMany({
|
||||||
where: and(
|
where: and(
|
||||||
eq(invoices.portId, portId),
|
eq(invoices.portId, portId),
|
||||||
@@ -160,6 +197,12 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
|||||||
db.query.formSubmissions.findMany({
|
db.query.formSubmissions.findMany({
|
||||||
where: eq(formSubmissions.clientId, clientId),
|
where: eq(formSubmissions.clientId, clientId),
|
||||||
}),
|
}),
|
||||||
|
db.query.supplementalFormTokens.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(supplementalFormTokens.portId, portId),
|
||||||
|
eq(supplementalFormTokens.clientId, clientId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
db.query.documentSends.findMany({
|
db.query.documentSends.findMany({
|
||||||
where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)),
|
where: and(eq(documentSends.portId, portId), eq(documentSends.clientId, clientId)),
|
||||||
}),
|
}),
|
||||||
@@ -267,12 +310,16 @@ export async function buildClientBundle(clientId: string, portId: string): Promi
|
|||||||
company: toJsonRow(row.company),
|
company: toJsonRow(row.company),
|
||||||
})),
|
})),
|
||||||
interests: interestRows.map(toJsonRow),
|
interests: interestRows.map(toJsonRow),
|
||||||
|
interestFieldHistory: fieldHistoryRows.map(toJsonRow),
|
||||||
contactLog: contactLogRows.map(toJsonRow),
|
contactLog: contactLogRows.map(toJsonRow),
|
||||||
tenancies: tenancyRows.map(toJsonRow),
|
tenancies: tenancyRows.map(toJsonRow),
|
||||||
|
berthWaitingList: waitingListRows.map(toJsonRow),
|
||||||
|
payments: paymentRows.map(toJsonRow),
|
||||||
invoices: invoiceRows.map(toJsonRow),
|
invoices: invoiceRows.map(toJsonRow),
|
||||||
documents: documentRows.map(toJsonRow),
|
documents: documentRows.map(toJsonRow),
|
||||||
files: fileRows.map(toJsonRow),
|
files: fileRows.map(toJsonRow),
|
||||||
formSubmissions: formSubmissionRows.map(toJsonRow),
|
formSubmissions: formSubmissionRows.map(toJsonRow),
|
||||||
|
supplementalFormTokens: supplementalTokenRows.map(toJsonRow),
|
||||||
websiteSubmissions: websiteSubmissionRows.map(toJsonRow),
|
websiteSubmissions: websiteSubmissionRows.map(toJsonRow),
|
||||||
documentSends: documentSendRows.map(toJsonRow),
|
documentSends: documentSendRows.map(toJsonRow),
|
||||||
emailThreads: emailThreadBundle,
|
emailThreads: emailThreadBundle,
|
||||||
@@ -360,12 +407,16 @@ export function renderBundleHtml(bundle: GdprBundle): string {
|
|||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
tableSection('Interests', bundle.interests),
|
tableSection('Interests', bundle.interests),
|
||||||
|
tableSection('Interest field history (overrides)', bundle.interestFieldHistory),
|
||||||
tableSection('Contact log', bundle.contactLog),
|
tableSection('Contact log', bundle.contactLog),
|
||||||
tableSection('Tenancies', bundle.tenancies),
|
tableSection('Tenancies', bundle.tenancies),
|
||||||
|
tableSection('Berth waiting list', bundle.berthWaitingList),
|
||||||
|
tableSection('Payments (deposits / balances / refunds)', bundle.payments),
|
||||||
tableSection('Invoices', bundle.invoices),
|
tableSection('Invoices', bundle.invoices),
|
||||||
tableSection('Documents', bundle.documents),
|
tableSection('Documents', bundle.documents),
|
||||||
tableSection('Files', bundle.files),
|
tableSection('Files', bundle.files),
|
||||||
tableSection('Form submissions', bundle.formSubmissions),
|
tableSection('Form submissions', bundle.formSubmissions),
|
||||||
|
tableSection('Supplemental form tokens', bundle.supplementalFormTokens),
|
||||||
tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions),
|
tableSection('Website submissions (inquiry forms)', bundle.websiteSubmissions),
|
||||||
tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends),
|
tableSection('Document sends (PDFs / brochures emailed)', bundle.documentSends),
|
||||||
tableSection(
|
tableSection(
|
||||||
|
|||||||
Reference in New Issue
Block a user