From bf0571fe842c2e51266c2c5d23d37a28a6bee088 Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Fri, 5 Jun 2026 12:11:17 +0100 Subject: [PATCH] fix(db): anonymize_user NOT NULL constraint fix + export_all_user_data extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix anonymize_user() setting exp_month/exp_year to NULL on NOT NULL columns — use placeholder values (1/2000) matching pattern for other NOT NULL columns. Extend export_all_user_data() with 9 new sections: saved_cards, refunds, social_logins, loyalty_redemptions, booking_discounts, edit_requests, affiliate_payouts, verification_codes, forgiven_no_shows. Add total_price to booking objects (sums COALESCE override prices). LEFT JOIN loyalty_redemptions to bookings for booking_date/booking_services. LEFT JOIN booking_discounts to discount_campaigns for campaign_name. Simplify referrals to referrer_name/referred_name using u.fn. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- init-scripts/init-script.sql | 188 +++++++++++++++++++++++++++++------ 1 file changed, 160 insertions(+), 28 deletions(-) diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index fcb9772..a85aea4 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -635,6 +635,39 @@ BEGIN password_hash = NULL WHERE id = target_id AND account_role != 'guest'; + + -- Scrub social login identities (immutable_id is PII from OAuth providers) + DELETE FROM user_social_logins WHERE user_id = target_id; + + -- Soft-delete all saved cards and clear PCI data + UPDATE user_saved_cards + SET deleted_at = NOW(), + retained_until = NOW(), + last_4 = 'XXXX', + fingerprint = NULL, + exp_month = 1, + exp_year = 2000 + WHERE user_id = target_id; + + -- Expire all pending verification codes + UPDATE verification_codes + SET used_at = NOW() + WHERE user_id = target_id + AND used_at IS NULL; + + -- Scrub RESERVATION entries in time_blockers that reference this user + UPDATE time_blockers + SET description = NULL + WHERE created_by = target_id + AND description LIKE 'RESERVATION:user:%'; + + -- Scrub notes on booking_edit_requests made by this user (free-text PII) + UPDATE booking_edit_requests + SET notes = NULL + WHERE requested_by = target_id; + + -- Clear notification preferences (no contractual basis after account closure) + DELETE FROM user_notification_preferences WHERE user_id = target_id; END; $$ LANGUAGE plpgsql; @@ -649,21 +682,6 @@ BEGIN END; $$ LANGUAGE plpgsql; --- Update consent --- WHY: GDPR requires tracking consent changes --- WHEN: User updates privacy preferences --- OUTPUT: Updates consent flags and timestamps -CREATE OR REPLACE FUNCTION update_data_consent(target_id CHAR(12), consent BOOLEAN) -RETURNS VOID AS $$ -BEGIN - UPDATE users - SET data_retention_consent = consent, - data_consent_updated_at = NOW(), - updated_at = NOW() - WHERE id = target_id; -END; -$$ LANGUAGE plpgsql; - -- Complete Subject Access Request Export -- WHY: GDPR Article 15 - users have right to access their data -- WHEN: Customer requests "what data do you have on me?" @@ -710,6 +728,12 @@ BEGIN 'created_by', b.created_by, 'created_at', b.created_at, 'updated_at', b.updated_at, + 'total_price', ( + SELECT COALESCE(SUM(COALESCE(bsvc2.override_price, s2.price)), 0) + FROM booking_services bsvc2 + JOIN services s2 ON bsvc2.service_id = s2.id + WHERE bsvc2.booking_id = b.id + ), 'services', ( SELECT COALESCE(json_agg( json_build_object( @@ -765,14 +789,10 @@ BEGIN 'referrals', ( SELECT json_build_object( 'referred_by', ( - -- The user who referred this user (if any) - -- First name only: referrer's name is their own personal data, - -- included here only to make the SAR meaningful to the recipient. SELECT json_build_object( 'referrer_id', ur.referrer_id, - 'referrer_first_name', u.n_first_name, - 'referred_at', ur.referred_at, - 'claimed_booking_id', ur.claimed_booking_id + 'referrer_name', u.fn, + 'referred_at', ur.referred_at ) FROM user_referrals ur JOIN users u ON u.id = ur.referrer_id @@ -780,14 +800,11 @@ BEGIN LIMIT 1 ), 'referred_users', ( - -- Users this person has referred - -- First name only: same reasoning as above. SELECT COALESCE(json_agg( json_build_object( - 'referred_id', ur.referred_id, - 'referred_first_name', u.n_first_name, - 'referred_at', ur.referred_at, - 'claimed_booking_id', ur.claimed_booking_id + 'referred_id', ur.referred_id, + 'referred_name', u.fn, + 'referred_at', ur.referred_at ) ORDER BY ur.referred_at DESC), '[]'::json) FROM user_referrals ur @@ -808,11 +825,126 @@ BEGIN FROM user_notification_preferences unp WHERE unp.user_id = target_user_id ), + 'saved_cards', ( + SELECT COALESCE(json_agg(json_build_object( + 'card_id', id, + 'brand', brand, + 'last_4', last_4, + 'exp_month', exp_month, + 'exp_year', exp_year, + 'fingerprint', fingerprint, + 'is_default', is_default, + 'deleted_at', deleted_at, + 'retained_until', retained_until, + 'created_at', created_at + ) ORDER BY created_at DESC), '[]'::json) + FROM user_saved_cards WHERE user_id = target_user_id + ), + 'refunds', ( + SELECT COALESCE(json_agg(json_build_object( + 'refund_id', r.id, + 'payment_id', r.payment_id, + 'booking_id', r.booking_id, + 'amount', r.amount, + 'status', r.status, + 'reason', r.reason, + 'created_at', r.created_at + ) ORDER BY r.created_at DESC), '[]'::json) + FROM refunds r + JOIN payments p ON r.payment_id = p.id + JOIN bookings b ON p.booking_id = b.id + WHERE b.user_id = target_user_id + ), + 'social_logins', ( + SELECT COALESCE(json_agg(json_build_object( + 'provider', usl.provider, + 'created_at', usl.created_at + ) ORDER BY usl.created_at ASC), '[]'::json) + FROM user_social_logins usl WHERE usl.user_id = target_user_id + ), + 'loyalty_redemptions', ( + SELECT COALESCE(json_agg(json_build_object( + 'id', lr.id, + 'stamps_redeemed', lr.stamps_redeemed, + 'status', lr.status, + 'booking_date', b.start_time, + 'booking_services', ( + SELECT COALESCE(string_agg(s.name, ', '), '') + FROM booking_services bsvc + JOIN services s ON bsvc.service_id = s.id + WHERE bsvc.booking_id = lr.applied_to_booking_id + ), + 'redeemed_at', lr.redeemed_at, + 'applied_at', lr.applied_at, + 'expires_at', lr.expires_at + ) ORDER BY lr.redeemed_at DESC), '[]'::json) + FROM loyalty_redemptions lr + LEFT JOIN bookings b ON lr.applied_to_booking_id = b.id + WHERE lr.user_id = target_user_id + ), + 'booking_discounts', ( + SELECT COALESCE(json_agg(json_build_object( + 'id', bd.id, + 'booking_id', bd.booking_id, + 'campaign_name', COALESCE(dc.name, bd.discount_source), + 'discount_source', bd.discount_source, + 'source_id', bd.source_id, + 'campaign_type', bd.campaign_type, + 'milestone_type', bd.milestone_type, + 'discount_percent', bd.discount_percent, + 'original_total', bd.original_total, + 'discount_amount', bd.discount_amount, + 'applied_at', bd.applied_at + ) ORDER BY bd.applied_at DESC), '[]'::json) + FROM booking_discounts bd + LEFT JOIN discount_campaigns dc ON bd.source_id = dc.id AND bd.discount_source = 'campaign' + WHERE bd.user_id = target_user_id + ), + 'edit_requests', ( + SELECT COALESCE(json_agg(json_build_object( + 'id', ber.id, + 'booking_id', ber.booking_id, + 'new_start_time', ber.new_start_time, + 'new_services', ber.new_services, + 'notes', ber.notes, + 'has_overrides', ber.has_overrides, + 'updated_at', ber.updated_at + ) ORDER BY ber.updated_at DESC), '[]'::json) + FROM booking_edit_requests ber WHERE ber.requested_by = target_user_id + ), + 'affiliate_payouts', ( + SELECT COALESCE(json_agg(json_build_object( + 'id', ap.id, + 'amount', ap.amount, + 'status', ap.status, + 'created_at', ap.created_at + ) ORDER BY ap.created_at DESC), '[]'::json) + FROM affiliate_payouts ap WHERE ap.affiliate_id = target_user_id + ), + 'verification_codes', ( + SELECT COALESCE(json_agg(json_build_object( + 'purpose', vc.purpose, + 'created_at', vc.created_at, + 'used_at', vc.used_at, + 'expires_at', vc.expires_at + ) ORDER BY vc.created_at DESC), '[]'::json) + FROM verification_codes vc WHERE vc.user_id = target_user_id + ), + 'forgiven_no_shows', ( + SELECT COALESCE(json_agg(json_build_object( + 'id', fns.id, + 'booking_id', fns.booking_id, + 'created_at', fns.created_at + ) ORDER BY fns.created_at DESC), '[]'::json) + FROM forgiven_no_shows fns + JOIN bookings b ON fns.booking_id = b.id + WHERE b.user_id = target_user_id + ), 'export_metadata', json_build_object( 'exported_at', NOW(), 'exported_by', 'system', 'user_id', target_user_id, - 'format_version', '1.1' + 'format_version', '0.9' ) ) INTO result;