Fix #27: soft deletes for auditability#79
Conversation
Add deletedAt to Ride/User/Client/Volunteer (plus deletedEmail/deletedPhone on User to preserve released contact values). DELETE endpoints now archive rows instead of destroying them, releasing the unique email/phone so records can be re-added. Active read paths filter deletedAt: null; metrics endpoints deliberately do not, preserving historical totals. Soft-deleted users are blocked from logging in. Migration 20260710035425_add_soft_deletes is additive (nullable ADD COLUMN). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address code-review follow-ups: PUT clients/volunteers and the ride estimate endpoint now treat an archived record as 404 (findFirst with deletedAt: null), so soft-deleted rows can't be edited or re-surfaced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…kups (#27 review) Address independent-review HIGH findings: - Session revocation: hard delete used to cascade to the session table (auth onDelete: Cascade), logging the user out. Soft-delete now deletes the user's session rows in the same transaction (admins/clients/volunteers), so an archived user's live cookie no longer retains API access — closing the gap in the decision-#1 login block. - Filter deletedAt: null on the volunteer lookups that resolve by userId from a session (signup, unsignup, bySession status/reminders/notifications) so a stale session can't act as an archived volunteer. - Guard the soft-delete update where-clauses (clients/volunteers) with deletedAt: null so a double-delete can't overwrite deletedEmail/deletedPhone. New regression test: "revokes sessions on soft-delete" — a previously valid volunteer cookie is rejected with 401 on GET /api/get/rides after the volunteer is archived, and no session rows remain. Fails pre-fix (expected 200 to be 401). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🚩 FLAGGED for human review — schema + migration + auth risk files (feature verified correct & complete)Built per the owner's decisions (block login for soft-deleted users; solve unique-reuse now). Went through full Stacked on #76 (base = HIGH-severity gaps found and fixed:
Final gate results (post-fix):
Note (intentional, no change): ride detail/list Automated |
What was broken
DELETE endpoints for clients/volunteers/admins/rides hard-
delete()d rows, destroying historical metrics and any chance of recovery. Issue #27 asks for soft deletes: archive records instead of destroying them, hide them from active views, but keep them for historical metrics.What changed
prisma/schema/*): added nullabledeletedAt DateTime?toRide,User,Client,Volunteer, plusdeletedEmail/deletedPhoneonUser. Migration20260710035425_add_soft_deletesis purely additive — fiveALTER TABLE ... ADD COLUMN, all nullable (safe on existing prod rows). Existing@unique/@@uniquedeclarations kept as-is (no partial-index drift)..delete()replaced with an.update()settingdeletedAt. For clients/volunteers/admins we soft-delete the User and the profile row, and release the unique contact values — copyemail→deletedEmail,phone→deletedPhone, then nullemail/phone. SQLite allows many NULLs under a unique index, so the same email/phone is immediately reusable (decision Privilege Escalation via Unprotected Admin Creation API #2). Ride delete guardsdeletedAt: nullso a second delete 404s. The client-with-rides#23409 block is preserved.deletedAt: null; metrics endpoints deliberately do not (historical preservation). See the audit table below.server/utils/auth.ts— the OTP-send hook now rejects archived users (findFirst({ where: { email, deletedAt: null }})). Since soft-delete also nulls the email, both the OTP-send and sign-in steps fail for an archived user, so the login flow can never complete.deletedAt: null.broadcastNotificationandsendNotificationskip archived volunteers; the reminder scan inscheduler.tsskips archived rides and archived volunteers.Read-path audit
get/clients/index.tsdeletedAt: null)get/volunteers/index.tsget/admins/index.tsget/users/index.tsget/users/byId/[id]get/users/byEmail/[email]get/rides/index.tsget/rides/byId/[id]get/rides/estimate/[id]get/volunteers/bySession/index.tspost/rides/[id]/signup.ts(ride lookup + atomic update)post/rides/[id]/unsignup.tsput/rides/[id].tsput/clients/[id].ts,put/volunteers/[id].tsutils/scheduler.ts(reminder scan)utils/notification.ts(sendNotification,broadcastNotification)utils/auth.ts(login)get/metrics/completionRate/index.tsget/metrics/hours/index.tsget/metrics/topRiders/index.ts(rides + client name lookup)get/addresses/index.tspost/{clients,volunteers,admins}(user lookup by email)null, so lookup misses and a fresh user is created (this is what makes reuse work)Verification
Regression test:
tests/e2e/soft-deletes.test.ts(4 cases).archives a ride-less client …— client disappears fromGET /api/get/clientsbut the row still exists withdeletedAtset (direct better-sqlite3 read);emailreleased todeletedEmail.unique reuse …— a new client with the same email as a soft-deleted one is created (no 409/500). Decision Privilege Escalation via Unprotected Admin Creation API #2.blocked login …— after archiving,send-verification-otpandsign-in/email-otpboth fail. Decision Complete Lack of Authentication on Core CRUD APIs #1.metrics preserved …— a soft-deleted COMPLETED ride still counts incompletionRate/hours.Revert check (columns kept, behavioral source reverted to base): tests 1 and 4 fail pre-fix:
AssertionError: client row must still exist (soft delete): expected undefined to be truthy(base hard-deletes the row).AssertionError: soft-deleted ride still counts toward total: expected 9 to be 10(base hard-deletes the ride, dropping it from metrics).Tests 2 and 3 pass on base too — hard-delete also frees the unique email and removes the user, so those decisions trivially hold; they exist to pin that soft-delete does not regress them.
Full suite: 30 files / 115 tests green (including the 4 new).
Risk files touched (flagged for human review)
prisma/schema/*+ the new migration20260710035425_add_soft_deletes.server/utils/auth.ts(auth-config change — blocks archived users from login, decision Complete Lack of Authentication on Core CRUD APIs #1).Notes for the reviewer
fix/33-adopt-migrations, issue Prisma Migrations Are Gitignored — No Reproducible Schema History #33) for the migrations workflow — base isfix/33-adopt-migrations. Merge after Fix #33: adopt a Prisma migrations workflow #76.bySessionvolunteer PUTs (status/notifications/reminders) resolve the volunteer byuserIdviafindUnique(a unique-arg lookup that can't take adeletedAtfilter). They're left unfiltered because an archived volunteer is blocked from logging in, so there's no session to reach them — the login block is the real gate. Called out in case you'd prefer a defensivefindFirstthere too.Fixes #27