From 482958584afe2fe7897927a03baef1da2dd3e93d Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sat, 20 Jun 2026 16:56:37 +0100 Subject: [PATCH] chore(db): migrate tables to CHAR(12) short ID primary keys and add new tables Add generate_*_id() functions for consistent short ID generation across all tables. - Convert user_social_logins, verification_codes, custom_services, time_blockers, forgiven_no_shows, user_referrals from BIGSERIAL to CHAR(12) - Add name_history, referral_discounts tables - Add dedicated generate_*_id() functions for each table - Add FK from name_history.booking_id to bookings(id) - Add referral_discounts index on user_id Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- init-scripts/init-script.sql | 177 ++++++++++++++++++++++++++++++----- 1 file changed, 154 insertions(+), 23 deletions(-) diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index 69d1c3f..5fccd70 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -84,6 +84,82 @@ CREATE OR REPLACE FUNCTION generate_user_patch_test_id() RETURNS CHAR(12) AS $$ SELECT generate_short_id('user_patch_tests'); $$ LANGUAGE sql; +CREATE OR REPLACE FUNCTION generate_user_referrals_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('user_referrals'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_referral_discounts_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('referral_discounts'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_user_social_logins_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('user_social_logins'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_name_history_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('name_history'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_custom_services_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('custom_services'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_verification_codes_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('verification_codes'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_time_blockers_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('time_blockers'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_forgiven_no_shows_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('forgiven_no_shows'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_loyalty_redemptions_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('loyalty_redemptions'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_discount_campaigns_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('discount_campaigns'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_booking_discounts_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('booking_discounts'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_tags_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('tags'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_admin_notifications_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('admin_notifications'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_user_notification_preferences_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('user_notification_preferences'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_images_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('images'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_gift_cards_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('gift_cards'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_gift_card_transactions_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('gift_card_transactions'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_gift_card_expired_balances_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('gift_card_expired_balances'); +$$ LANGUAGE sql; + +CREATE OR REPLACE FUNCTION generate_admin_audit_log_id() RETURNS CHAR(12) AS $$ + SELECT generate_short_id('admin_audit_log'); +$$ LANGUAGE sql; + CREATE OR REPLACE FUNCTION generate_verification_code() RETURNS CHAR(12) AS $$ SELECT substr(encode(gen_random_bytes(6), 'hex'), 1, 12); $$ LANGUAGE sql; CREATE OR REPLACE FUNCTION generate_referral_code() @@ -153,7 +229,7 @@ CREATE TABLE users ( ); CREATE TABLE user_social_logins ( - id BIGSERIAL PRIMARY KEY, + id CHAR(12) PRIMARY KEY DEFAULT generate_user_social_logins_id(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider account_type NOT NULL CHECK (provider IN ('google', 'microsoft', 'facebook')), immutable_id TEXT NOT NULL, -- the stable provider user ID @@ -176,13 +252,32 @@ CREATE INDEX idx_users_phone_trgm ON users USING GIN (phone gin_trgm_ops); -- Guest accounts can share emails; registered accounts cannot CREATE UNIQUE INDEX idx_users_email_registered ON users (email) WHERE account_role != 'guest'; +-- ======================================= +-- NAME HISTORY TABLE +-- ======================================= +-- WHY: Tracks user-initiated first/last name changes for display in booking modals +-- and GDPR export. History is used to show "(formerly [old first] [old last])" +-- on completed booking receipts and admin views. + +CREATE TABLE name_history ( + id CHAR(12) PRIMARY KEY DEFAULT generate_name_history_id(), + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + previous_first_name VARCHAR(50) NOT NULL, + previous_last_name VARCHAR(50) NOT NULL, + booking_id CHAR(12), + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_name_history_user_id ON name_history(user_id); +CREATE INDEX idx_name_history_changed_at ON name_history(changed_at); + -- ======================================= -- VERIFICATION CODES TABLE -- ======================================= CREATE TYPE verification_purpose AS ENUM ('email_verify', 'password_reset'); CREATE TABLE verification_codes ( - id BIGSERIAL PRIMARY KEY, + id CHAR(12) PRIMARY KEY DEFAULT generate_verification_codes_id(), code CHAR(12) NOT NULL UNIQUE DEFAULT generate_verification_code(), user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, purpose verification_purpose NOT NULL, @@ -248,7 +343,7 @@ CREATE INDEX idx_services_name ON services(name); -- ======================================= CREATE TABLE custom_services ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('custom_services'), + id CHAR(12) PRIMARY KEY DEFAULT generate_custom_services_id(), name VARCHAR(100) NOT NULL, description TEXT, price NUMERIC(10,2) NOT NULL, @@ -288,6 +383,9 @@ CREATE INDEX idx_bookings_starttime ON bookings(start_time); CREATE INDEX idx_bookings_status ON bookings(status); CREATE INDEX idx_bookings_userid_starttime ON bookings(user_id, start_time); +ALTER TABLE name_history ADD CONSTRAINT fk_name_history_booking_id + FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL; + -- ======================================= -- BOOKING SERVICES JUNCTION TABLE -- ======================================= @@ -335,7 +433,7 @@ CREATE TABLE booking_edit_requests ( CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_id); CREATE TABLE user_referrals ( - id BIGSERIAL PRIMARY KEY, + id CHAR(12) PRIMARY KEY DEFAULT generate_user_referrals_id(), 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(), @@ -343,6 +441,20 @@ CREATE TABLE user_referrals ( UNIQUE (referrer_id, referred_id) ); +-- ======================================= +-- REFERRAL DISCOUNTS TABLE +-- ======================================= +CREATE TABLE referral_discounts ( + id CHAR(12) PRIMARY KEY DEFAULT generate_referral_discounts_id(), + user_id CHAR(12) NOT NULL REFERENCES users(id) ON DELETE CASCADE, + referral_id CHAR(12) NOT NULL REFERENCES user_referrals(id) ON DELETE CASCADE, + discount_percent NUMERIC(5,2) NOT NULL DEFAULT 10.00, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + used_at TIMESTAMPTZ +); +CREATE INDEX idx_referral_discounts_user_id ON referral_discounts(user_id); + -- ======================================= -- DEFAULT WORKING HOURS TABLE -- ======================================= @@ -402,7 +514,7 @@ CREATE UNIQUE INDEX idx_group_application_week ON exceptional_group_applications -- Used for doctor appointments, extended lunch, training, etc. CREATE TABLE time_blockers ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('time_blockers'), + id CHAR(12) PRIMARY KEY DEFAULT generate_time_blockers_id(), start_time TIMESTAMPTZ NOT NULL, duration_minutes INT NOT NULL CHECK (duration_minutes > 0), description TEXT, @@ -420,7 +532,7 @@ CREATE INDEX idx_time_blockers_cron ON time_blockers(cron_expression) WHERE cron -- ======================================= -- Tracks which no-shows have been forgiven (by full deposit payment) CREATE TABLE forgiven_no_shows ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('forgiven_no_shows'), + id CHAR(12) PRIMARY KEY DEFAULT generate_forgiven_no_shows_id(), booking_id CHAR(12) NOT NULL UNIQUE REFERENCES bookings(id) ON DELETE CASCADE, forgiven_by CHAR(12), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() @@ -476,7 +588,7 @@ CREATE INDEX idx_payments_created_at_status ON payments(created_at, status); -- LOYALTY REDEMPTIONS TABLE -- ======================================= CREATE TABLE loyalty_redemptions ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('loyalty_redemptions'), + id CHAR(12) PRIMARY KEY DEFAULT generate_loyalty_redemptions_id(), user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, stamps_redeemed INT NOT NULL DEFAULT 10, status VARCHAR(20) NOT NULL DEFAULT 'pending', @@ -493,7 +605,7 @@ CREATE INDEX idx_loyalty_redemptions_status ON loyalty_redemptions(status); -- DISCOUNT CAMPAIGNS TABLE -- ======================================= CREATE TABLE discount_campaigns ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('discount_campaigns'), + id CHAR(12) PRIMARY KEY DEFAULT generate_discount_campaigns_id(), name VARCHAR(100) NOT NULL, description TEXT, campaign_type campaign_type NOT NULL DEFAULT 'time_based', @@ -523,7 +635,7 @@ CREATE INDEX idx_discount_campaigns_type ON discount_campaigns(campaign_type); -- BOOKING DISCOUNTS TABLE -- ======================================= CREATE TABLE booking_discounts ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('booking_discounts'), + id CHAR(12) PRIMARY KEY DEFAULT generate_booking_discounts_id(), booking_id CHAR(12) NOT NULL REFERENCES bookings(id) ON DELETE CASCADE, user_id CHAR(12) REFERENCES users(id) ON DELETE SET NULL, discount_source VARCHAR(30) NOT NULL, @@ -607,7 +719,7 @@ INSERT INTO business_settings ( CREATE TYPE admin_notification_reason AS ENUM ('pending_booking', 'cancelled_booking', 'rescheduled_booking', '1_week_no_pay', '1_month_no_pay', 'affiliate_claim', 'late_cancellation', 'deposit_paid', 'edit_request', 'edit_requested', 'new_booking', 'deposit_not_paid_by_deadline'); CREATE TABLE admin_notifications ( - id SERIAL PRIMARY KEY, + id CHAR(12) PRIMARY KEY DEFAULT generate_admin_notifications_id(), reason admin_notification_reason NOT NULL, booking_id CHAR(12) REFERENCES bookings(id), user_id CHAR(12) REFERENCES users(id), @@ -617,7 +729,7 @@ CREATE TABLE admin_notifications ( -- User notification preferences for future user notification system CREATE TABLE user_notification_preferences ( - id SERIAL PRIMARY KEY, + id CHAR(12) PRIMARY KEY DEFAULT generate_user_notification_preferences_id(), user_id CHAR(12) REFERENCES users(id) ON DELETE CASCADE, email_enabled BOOLEAN DEFAULT false, sms_enabled BOOLEAN DEFAULT false, @@ -631,7 +743,7 @@ CREATE INDEX idx_bookings_start_time_status ON bookings(start_time, status); CREATE INDEX idx_users_created_at ON users(created_at); create table images ( - id uuid primary key default gen_random_uuid(), + id CHAR(12) PRIMARY KEY DEFAULT generate_images_id(), url text not null, thumbnail_url text not null, tag_names text[] not null default '{}', @@ -647,7 +759,7 @@ create table images ( -- only for autocomplete text create table tags ( - id serial primary key, + id CHAR(12) PRIMARY KEY DEFAULT generate_tags_id(), name text not null unique ); @@ -917,6 +1029,16 @@ BEGIN ) ) ), + 'referral_discounts', ( + SELECT COALESCE(json_agg(json_build_object( + 'id', rd.id, + 'discount_percent', rd.discount_percent, + 'used', rd.used, + 'created_at', rd.created_at, + 'used_at', rd.used_at + ) ORDER BY rd.created_at DESC), '[]'::json) + FROM referral_discounts rd WHERE rd.user_id = target_user_id + ), 'notification_preferences', ( SELECT COALESCE(json_agg( json_build_object( @@ -1074,22 +1196,31 @@ BEGIN WHERE gct.user_id = target_user_id ), 'login_audit', ( - (SELECT json_agg(json_build_object( + SELECT COALESCE(json_agg(json_build_object( 'attempt_type', la.attempt_type, 'ip_address', la.ip_address::text, 'success', la.success, 'created_at', la.created_at - ) ORDER BY la.created_at DESC) - FROM login_audit la WHERE la.user_id = target_user_id) + ) ORDER BY la.created_at DESC), '[]'::json) + FROM login_audit la WHERE la.user_id = target_user_id ), 'refresh_tokens', ( - (SELECT json_agg(json_build_object( + SELECT COALESCE(json_agg(json_build_object( 'role', rt.role, 'revoked', rt.revoked, 'created_at', rt.created_at, 'expires_at', rt.expires_at - ) ORDER BY rt.created_at DESC) - FROM refresh_tokens rt WHERE rt.user_id = target_user_id) + ) ORDER BY rt.created_at DESC), '[]'::json) + FROM refresh_tokens rt WHERE rt.user_id = target_user_id + ), + 'name_history', ( + SELECT COALESCE(json_agg(json_build_object( + 'previous_first_name', nh.previous_first_name, + 'previous_last_name', nh.previous_last_name, + 'booking_id', nh.booking_id, + 'changed_at', nh.changed_at + ) ORDER BY nh.changed_at DESC), '[]'::json) + FROM name_history nh WHERE nh.user_id = target_user_id ), 'export_metadata', json_build_object( 'exported_at', NOW(), @@ -1802,7 +1933,7 @@ CREATE INDEX idx_affiliate_payouts_affiliate ON affiliate_payouts(affiliate_id); -- ======================================= CREATE TABLE gift_cards ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('gift_cards'), + id CHAR(12) PRIMARY KEY DEFAULT generate_gift_cards_id(), total_funds_added NUMERIC(12,2) NOT NULL DEFAULT 0, amount_remaining NUMERIC(12,2) NOT NULL DEFAULT 0, created_by CHAR(12) REFERENCES users(id), @@ -1845,7 +1976,7 @@ CREATE TABLE user_giftcard_balances ( -- ======================================= CREATE TABLE gift_card_transactions ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('gift_card_transactions'), + id CHAR(12) PRIMARY KEY DEFAULT generate_gift_card_transactions_id(), gift_card_id CHAR(12) NOT NULL REFERENCES gift_cards(id), transaction_type VARCHAR(20) NOT NULL, amount NUMERIC(10,2) NOT NULL, @@ -1879,7 +2010,7 @@ CREATE INDEX idx_gift_card_transactions_type ON gift_card_transactions(transacti -- ======================================= CREATE TABLE gift_card_expired_balances ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('gift_card_expired_balances'), + id CHAR(12) PRIMARY KEY DEFAULT generate_gift_card_expired_balances_id(), account_id CHAR(12) NOT NULL, original_balance NUMERIC(10,2) NOT NULL, expired_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -1901,7 +2032,7 @@ CREATE INDEX idx_gift_card_transactions_created_at ON gift_card_transactions(cre -- ======================================= CREATE TABLE admin_audit_log ( - id CHAR(12) PRIMARY KEY DEFAULT generate_short_id('admin_audit_log'), + id CHAR(12) PRIMARY KEY DEFAULT generate_admin_audit_log_id(), admin_id CHAR(12) NOT NULL REFERENCES users(id), action_type VARCHAR(30) NOT NULL, target_user_id CHAR(12) REFERENCES users(id),