diff --git a/src/shared/db/attendees/delete.ts b/src/shared/db/attendees/delete.ts index af7f2d6c5..275f7c088 100644 --- a/src/shared/db/attendees/delete.ts +++ b/src/shared/db/attendees/delete.ts @@ -2,11 +2,11 @@ * Deletion for attendees. */ -import type { InValue } from "@libsql/client"; import { deleteByFieldStatement, executeBatch, queryAll, + type SqlStatement, } from "#shared/db/client.ts"; import { ticketCountSumExpr } from "#shared/db/migrations/schema/listing-aggregates.ts"; @@ -40,7 +40,7 @@ const attendeeListingContributions = ( const restoreListingContributions = ( contributions: ListingContribution[], -): Array<{ sql: string; args: InValue[] }> => +): SqlStatement[] => contributions.map((row) => ({ args: [row.booked_quantity, row.tickets_count, row.listing_id], sql: `UPDATE listings @@ -59,15 +59,22 @@ const DEPENDENT_ROW_TARGETS = [ { field: "servicing_attendee_id", table: "service_costs" }, ] as const; +/** Build the common dependent-row deletes for one or many attendee ids. */ +export const attendeeDependentDeleteStatements = ( + attendeeIds: SqlStatement, +): SqlStatement[] => + DEPENDENT_ROW_TARGETS.map(({ field, table }) => ({ + args: attendeeIds.args, + sql: `DELETE FROM ${table} WHERE ${field} IN (${attendeeIds.sql})`, + })); + /** Delete an attendee and all dependent data tied to the attendee record. */ const purgeAttendee = ( attendeeId: number, contributions: ListingContribution[], ): Promise => executeBatch([ - ...DEPENDENT_ROW_TARGETS.map((target) => - deleteByFieldStatement({ ...target, value: attendeeId }), - ), + ...attendeeDependentDeleteStatements({ args: [attendeeId], sql: "?" }), ...restoreListingContributions(contributions), deleteByFieldStatement({ field: "id", diff --git a/src/shared/db/orphan-attendees.ts b/src/shared/db/orphan-attendees.ts index 8c9d15e9c..55e7131d5 100644 --- a/src/shared/db/orphan-attendees.ts +++ b/src/shared/db/orphan-attendees.ts @@ -20,6 +20,7 @@ * listing" when the underlying row is gone. */ +import { attendeeDependentDeleteStatements } from "#shared/db/attendees/delete.ts"; import { executeBatchWithResults, queryOne } from "#shared/db/client.ts"; /** @@ -35,16 +36,6 @@ const ORPHAN_IDS = `SELECT attendee.id WHERE booking.attendee_id = attendee.id )`; -/** Dependent tables keyed by attendee_id, cleared before the attendees rows. - * Mirrors the canonical deleteAttendee purge set (listing_attendees is empty - * for a true orphan, but is included for exact parity and race safety). */ -const ORPHAN_DEPENDENT_TABLES = [ - "processed_payments", - "attendee_answers", - "listing_attendees", - "system_notes", -] as const; - /** Count orphaned attendees whose `created` is before `cutoffIso`. */ export const countOrphanedAttendees = async ( cutoffIso: string, @@ -67,16 +58,10 @@ export const purgeOrphanedAttendees = async ( cutoffIso: string, ): Promise => { const statements = [ - ...ORPHAN_DEPENDENT_TABLES.map((table) => ({ - args: [cutoffIso], - sql: `DELETE FROM ${table} WHERE attendee_id IN (${ORPHAN_IDS})`, - })), - // service_costs uses servicing_attendee_id (not attendee_id), so it cannot - // be in ORPHAN_DEPENDENT_TABLES; handle it separately to match deleteAttendee. - { + ...attendeeDependentDeleteStatements({ args: [cutoffIso], - sql: `DELETE FROM service_costs WHERE servicing_attendee_id IN (${ORPHAN_IDS})`, - }, + sql: ORPHAN_IDS, + }), { args: [cutoffIso], sql: `DELETE FROM attendees WHERE id IN (${ORPHAN_IDS})`, diff --git a/test/integration/servicing/purge-edge-cases.test.ts b/test/integration/servicing/purge-edge-cases.test.ts index fc7eeba64..7b22ec517 100644 --- a/test/integration/servicing/purge-edge-cases.test.ts +++ b/test/integration/servicing/purge-edge-cases.test.ts @@ -6,7 +6,7 @@ * * Behaviour under test (all shipped): * - `purgeOrphanedAttendees` deletes `system_notes` rows for swept orphans - * (`system_notes` is in `ORPHAN_DEPENDENT_TABLES`). + * (`system_notes` is a dependent row cleared by the shared attendee purge set). * - A cost-bearing servicing event purged as an orphan leaves its cost legs * as orphaned history — the transfers ledger is append-only, so the purge * never reverses them. @@ -33,7 +33,7 @@ import { // jscpd:ignore-end -/** Insert a system_notes row for an attendee (the table the purge omits). */ +/** Insert a system_notes row for an attendee (a dependent row the purge clears). */ const attachSystemNote = async (attendeeId: number): Promise => { await getDb().execute({ args: [attendeeId], @@ -85,7 +85,7 @@ describeWithEnv("servicing edge cases — purge", { db: true }, () => { await attachSystemNote(id); expect(await childRowCount("system_notes", id)).toBe(1); await purgeOrphanedAttendees(nowIso()); - // system_notes is in ORPHAN_DEPENDENT_TABLES, so the purge clears it. + // system_notes is a dependent row, so the shared purge clears it. expect(await childRowCount("system_notes", id)).toBe(0); }); diff --git a/test/shared/db/orphan-attendees.test.ts b/test/shared/db/orphan-attendees.test.ts index 01add3c58..42aaa978a 100644 --- a/test/shared/db/orphan-attendees.test.ts +++ b/test/shared/db/orphan-attendees.test.ts @@ -13,6 +13,7 @@ import { countOrphanedAttendees, purgeOrphanedAttendees, } from "#shared/db/orphan-attendees.ts"; +import { createSystemNote, getNoteRows } from "#shared/db/system-notes.ts"; import { nowIso, nowMs } from "#shared/now.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { createTestAttendeeDirect } from "#test-utils/db-helpers/attendees.ts"; @@ -125,7 +126,7 @@ describeWithEnv("db > orphan-attendees", { db: true }, () => { expect(remaining?.c).toBe(0); }); - test("removes the orphan's dependent answer and payment rows", async () => { + test("removes the orphan's dependent rows", async () => { const id = await insertOrphan(daysAgoIso(365)); await getDb().execute( insert("attendee_answers", { @@ -141,11 +142,13 @@ describeWithEnv("db > orphan-attendees", { db: true }, () => { processed_at: nowIso(), }), ); + await createSystemNote(id, "orphan note"); await purgeOrphanedAttendees(nowIso()); expect(await childCount("attendee_answers", id)).toBe(0); expect(await childCount("processed_payments", id)).toBe(0); + expect(await getNoteRows([id])).toEqual([]); }); }); });