fix(db): update FK constraints to SET NULL and expand delete_user anonymization

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-05 15:40:43 +01:00
co-authored by Sisyphus
parent f88854e5c2
commit 5cc3e25a2c
+34 -11
View File
@@ -196,7 +196,7 @@ CREATE INDEX idx_patch_tests_name ON patch_tests(name);
CREATE TABLE user_patch_tests ( CREATE TABLE user_patch_tests (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
patch_test_id CHAR(12) NOT NULL REFERENCES patch_tests(id) ON DELETE CASCADE, patch_test_id CHAR(12) NOT NULL REFERENCES patch_tests(id) ON DELETE CASCADE,
tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, patch_test_id) UNIQUE (user_id, patch_test_id)
@@ -264,11 +264,11 @@ CREATE TABLE booking_services (
CREATE TABLE booking_edit_requests ( CREATE TABLE booking_edit_requests (
id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(), id CHAR(12) PRIMARY KEY DEFAULT generate_booking_id(),
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
requested_by CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, requested_by CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
new_start_time TIMESTAMPTZ, new_start_time TIMESTAMPTZ,
new_services CHAR(12)[] DEFAULT '{}', -- Array of service IDs to replace booking_services new_services CHAR(12)[] DEFAULT '{}',
notes TEXT, notes TEXT,
has_overrides BOOLEAN NOT NULL DEFAULT FALSE, -- If TRUE, cannot change services, use existing overrides for duration has_overrides BOOLEAN NOT NULL DEFAULT FALSE,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_at_least_one_field CHECK ( CONSTRAINT chk_at_least_one_field CHECK (
new_start_time IS NOT NULL OR new_start_time IS NOT NULL OR
@@ -280,11 +280,12 @@ CREATE TABLE booking_edit_requests (
CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_id); CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_id);
CREATE TABLE user_referrals ( CREATE TABLE user_referrals (
referrer_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, id BIGSERIAL PRIMARY KEY,
referred_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, referrer_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
referred_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), referred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, claimed_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
PRIMARY KEY (referrer_id, referred_id) UNIQUE (referrer_id, referred_id)
); );
-- ======================================= -- =======================================
@@ -419,7 +420,7 @@ CREATE INDEX idx_payments_created_at_status ON payments(created_at, status);
-- ======================================= -- =======================================
CREATE TABLE loyalty_redemptions ( CREATE TABLE loyalty_redemptions (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
stamps_redeemed INT NOT NULL DEFAULT 10, stamps_redeemed INT NOT NULL DEFAULT 10,
status VARCHAR(20) NOT NULL DEFAULT 'pending', status VARCHAR(20) NOT NULL DEFAULT 'pending',
applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL, applied_to_booking_id CHAR(12) REFERENCES bookings(id) ON DELETE SET NULL,
@@ -467,7 +468,7 @@ CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type);
CREATE TABLE booking_discounts ( CREATE TABLE booking_discounts (
id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'),
booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
discount_source VARCHAR(30) NOT NULL, discount_source VARCHAR(30) NOT NULL,
source_id CHAR(12), source_id CHAR(12),
campaign_type campaign_type, campaign_type campaign_type,
@@ -642,7 +643,7 @@ BEGIN
-- Soft-delete all saved cards and clear PCI data -- Soft-delete all saved cards and clear PCI data
UPDATE user_saved_cards UPDATE user_saved_cards
SET deleted_at = NOW(), SET deleted_at = NOW(),
retained_until = NOW(), retained_until = NOW() + INTERVAL '7 years',
last_4 = 'XXXX', last_4 = 'XXXX',
fingerprint = NULL, fingerprint = NULL,
exp_month = 1, exp_month = 1,
@@ -668,6 +669,16 @@ BEGIN
-- Clear notification preferences (no contractual basis after account closure) -- Clear notification preferences (no contractual basis after account closure)
DELETE FROM user_notification_preferences WHERE user_id = target_id; DELETE FROM user_notification_preferences WHERE user_id = target_id;
-- Anonymize patch test records (medical-adjacent PII — unlink user, preserve test history)
UPDATE user_patch_tests SET user_id = NULL WHERE user_id = target_id;
-- Anonymize referral relationships (unlink this user's side, preserve the other party's record)
UPDATE user_referrals SET referrer_id = NULL WHERE referrer_id = target_id;
UPDATE user_referrals SET referred_id = NULL WHERE referred_id = target_id;
-- Anonymize admin notification references (not customer data — just drop the user link)
UPDATE admin_notifications SET user_id = NULL WHERE user_id = target_id;
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -675,9 +686,21 @@ $$ LANGUAGE plpgsql;
-- WHY: Guest accounts have no ongoing contractual or legal basis for retention -- WHY: Guest accounts have no ongoing contractual or legal basis for retention
-- WHEN: User requests deletion or GDPR cleanup -- WHEN: User requests deletion or GDPR cleanup
-- OUTPUT: Full removal of guest account from users table -- OUTPUT: Full removal of guest account from users table
-- SAFETY: FK constraints are SET NULL on financial tables — records survive deletion.
-- Saved cards are soft-deleted with 7-year retention before unlinking.
CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12)) CREATE OR REPLACE FUNCTION delete_guest_user(target_id CHAR(12))
RETURNS VOID AS $$ RETURNS VOID AS $$
BEGIN BEGIN
UPDATE user_saved_cards
SET deleted_at = NOW(),
retained_until = NOW() + INTERVAL '7 years',
last_4 = 'XXXX',
fingerprint = NULL,
exp_month = 1,
exp_year = 2000,
user_id = NULL
WHERE user_id = target_id;
DELETE FROM users WHERE id = target_id AND account_role = 'guest'; DELETE FROM users WHERE id = target_id AND account_role = 'guest';
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
@@ -1457,7 +1480,7 @@ $$ LANGUAGE plpgsql;
CREATE TABLE user_saved_cards ( CREATE TABLE user_saved_cards (
id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(), id CHAR(12) PRIMARY KEY DEFAULT generate_user_saved_card_id(),
user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL,
square_card_id TEXT NOT NULL UNIQUE, square_card_id TEXT NOT NULL UNIQUE,
brand TEXT NOT NULL, brand TEXT NOT NULL,
last_4 TEXT NOT NULL, last_4 TEXT NOT NULL,