From 2e231eea4cad41eebdd6bd74728fbd2209a023bd Mon Sep 17 00:00:00 2001 From: Stephen Adamson Date: Sun, 21 Jun 2026 21:47:06 +0100 Subject: [PATCH] feat(db): add computed booking fields with triggers and indexes Add total_duration_minutes, total_amount, end_time computed columns to bookings table with PL/pgSQL triggers for auto-recalculation. Add indexes for new columns (end_time, status+end_time, notes trigram) and existing join columns (service_id, custom_service_id). Add trigram indexes for user name and service name search. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- init-scripts/init-script.sql | 121 ++++++++++++++++++++++++++--------- 1 file changed, 90 insertions(+), 31 deletions(-) diff --git a/init-scripts/init-script.sql b/init-scripts/init-script.sql index c626c90..342ad59 100644 --- a/init-scripts/init-script.sql +++ b/init-scripts/init-script.sql @@ -247,6 +247,9 @@ CREATE INDEX idx_users_account_role ON users (account_role); CREATE INDEX idx_users_fn_trgm ON users USING GIN (fn gin_trgm_ops); CREATE INDEX idx_users_email_trgm ON users USING GIN (email gin_trgm_ops); CREATE INDEX idx_users_phone_trgm ON users USING GIN (phone gin_trgm_ops); +CREATE INDEX idx_users_created_at ON users(created_at); +CREATE INDEX IF NOT EXISTS idx_users_n_first_name_trgm ON users USING GIN (n_first_name gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_users_n_last_name_trgm ON users USING GIN (n_last_name gin_trgm_ops); -- Enforce unique email only for registered (non-guest) users -- Guest accounts can share emails; registered accounts cannot @@ -270,6 +273,7 @@ CREATE TABLE name_history ( CREATE INDEX idx_name_history_user_id ON name_history(user_id); CREATE INDEX idx_name_history_changed_at ON name_history(changed_at); +CREATE INDEX idx_name_history_user_active ON name_history(user_id) WHERE booking_id IS NULL; -- ======================================= -- VERIFICATION CODES TABLE @@ -337,6 +341,7 @@ CREATE TABLE services ( ); CREATE INDEX idx_services_name ON services(name); +CREATE INDEX IF NOT EXISTS idx_services_name_trgm ON services USING GIN (name gin_trgm_ops); -- ======================================= -- CUSTOM SERVICES TABLE (One-off / special request services) @@ -372,6 +377,10 @@ CREATE TABLE bookings ( status booking_status NOT NULL DEFAULT 'pending', notes TEXT, deposit_required BOOLEAN NOT NULL DEFAULT FALSE, + -- Computed fields (maintained by trigger on booking_services/booking_custom_services) + total_duration_minutes INT NOT NULL DEFAULT 60, + total_amount NUMERIC(10,2) NOT NULL DEFAULT 0, + end_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Set by BEFORE INSERT trigger; recalculated by booking_services trigger created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_by CHAR(12), @@ -379,9 +388,10 @@ CREATE TABLE bookings ( ); CREATE INDEX idx_bookings_userid ON bookings(user_id); -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); +CREATE INDEX idx_bookings_end_time ON bookings(end_time); +CREATE INDEX idx_bookings_status_end_time ON bookings(status, end_time); +CREATE INDEX IF NOT EXISTS idx_bookings_notes_trgm ON bookings USING GIN (notes gin_trgm_ops); ALTER TABLE name_history ADD CONSTRAINT fk_name_history_booking_id FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE SET NULL; @@ -398,6 +408,8 @@ CREATE TABLE booking_services ( PRIMARY KEY (booking_id, service_id) ); +CREATE INDEX idx_booking_services_service_id ON booking_services(service_id); + -- ======================================= -- BOOKING CUSTOM SERVICES JUNCTION TABLE -- ======================================= @@ -410,6 +422,69 @@ CREATE TABLE booking_custom_services ( PRIMARY KEY (booking_id, custom_service_id) ); +CREATE INDEX idx_booking_custom_services_custom_service_id ON booking_custom_services(custom_service_id); + +-- ======================================= +-- TRIGGER: Auto-recalculate booking duration & total +-- ======================================= +-- WHY: Keeps bookings.total_duration_minutes, bookings.total_amount, +-- and the generated bookings.end_time in sync when services change. + +CREATE OR REPLACE FUNCTION recalc_booking_duration_and_total() +RETURNS TRIGGER AS $$ +DECLARE + bid CHAR(12); +BEGIN + bid := COALESCE(NEW.booking_id, OLD.booking_id); + UPDATE bookings b SET + total_duration_minutes = sub.dur, + total_amount = sub.amt, + end_time = b.start_time + (sub.dur * INTERVAL '1 minute') + FROM ( + SELECT + COALESCE((SELECT SUM(dur) FROM ( + SELECT COALESCE(bs.override_duration_minutes, s.duration_minutes) AS dur + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bid + UNION ALL + SELECT COALESCE(bcs.override_duration_minutes, cs.duration_minutes) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bid + ) sub2), 60) AS dur, + COALESCE((SELECT SUM(amt) FROM ( + SELECT COALESCE(bs.override_price, s.price) AS amt + FROM booking_services bs JOIN services s ON bs.service_id = s.id WHERE bs.booking_id = bid + UNION ALL + SELECT COALESCE(bcs.override_price, cs.price) + FROM booking_custom_services bcs JOIN custom_services cs ON bcs.custom_service_id = cs.id WHERE bcs.booking_id = bid + ) sub2), 0) AS amt + ) sub + WHERE b.id = bid; + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_recalc_booking_duration_total + AFTER INSERT OR UPDATE OR DELETE ON booking_services + FOR EACH ROW EXECUTE FUNCTION recalc_booking_duration_and_total(); + +CREATE TRIGGER trg_recalc_booking_duration_total_custom + AFTER INSERT OR UPDATE OR DELETE ON booking_custom_services + FOR EACH ROW EXECUTE FUNCTION recalc_booking_duration_and_total(); + +-- BEFORE INSERT trigger on bookings to set end_time from defaults +-- (the AFTER trigger on booking_services overrides this when services are added) +CREATE OR REPLACE FUNCTION set_booking_end_time() +RETURNS TRIGGER AS $$ +BEGIN + NEW.end_time := NEW.start_time + (NEW.total_duration_minutes * INTERVAL '1 minute'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_set_booking_end_time + BEFORE INSERT ON bookings + FOR EACH ROW + EXECUTE FUNCTION set_booking_end_time(); + -- ======================================= -- BOOKING EDIT REQUESTS TABLE -- ======================================= @@ -431,6 +506,7 @@ CREATE TABLE booking_edit_requests ( ); CREATE INDEX idx_booking_edit_requests_booking ON booking_edit_requests(booking_id); +CREATE INDEX idx_booking_edit_requests_requested_by ON booking_edit_requests(requested_by); CREATE TABLE user_referrals ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_referrals_id(), @@ -465,8 +541,7 @@ CREATE TABLE working_hours ( is_open BOOLEAN NOT NULL DEFAULT TRUE ); -CREATE INDEX idx_working_hours_weekday ON working_hours(weekday); - +-- weekday is already indexed via PRIMARY KEY — no separate index needed INSERT INTO working_hours VALUES (0, '00:00:00', '00:00:00', FALSE); INSERT INTO working_hours VALUES (1, '09:00:00', '17:00:00', TRUE); INSERT INTO working_hours VALUES (2, '09:00:00', '17:00:00', TRUE); @@ -526,6 +601,9 @@ CREATE TABLE time_blockers ( -- Indexes for efficient overlap queries CREATE INDEX idx_time_blockers_start_time ON time_blockers(start_time); CREATE INDEX idx_time_blockers_cron ON time_blockers(cron_expression) WHERE cron_expression IS NOT NULL; +CREATE INDEX idx_time_blockers_description ON time_blockers(description); +CREATE INDEX idx_time_blockers_created_by ON time_blockers(created_by); +CREATE INDEX IF NOT EXISTS idx_time_blockers_desc_trgm ON time_blockers USING GIN (description gin_trgm_ops); -- ======================================= -- FORGIVEN NO-SHOWS TABLE @@ -583,6 +661,8 @@ CREATE INDEX idx_payments_status ON payments(status); CREATE INDEX idx_payments_createdat ON payments(created_at); CREATE INDEX idx_payments_booking_id_status ON payments(booking_id, status); CREATE INDEX idx_payments_created_at_status ON payments(created_at, status); +CREATE INDEX idx_payments_payment_method ON payments(payment_method); +CREATE INDEX idx_payments_payment_method_created_at ON payments(payment_method, created_at); -- ======================================= -- LOYALTY REDEMPTIONS TABLE @@ -652,6 +732,7 @@ CREATE INDEX idx_booking_discounts_booking ON booking_discounts(booking_id); CREATE INDEX idx_booking_discounts_source ON booking_discounts(discount_source, source_id); CREATE INDEX idx_booking_discounts_user ON booking_discounts(user_id); CREATE INDEX idx_booking_discounts_milestone ON booking_discounts(user_id, milestone_type, source_id); +CREATE INDEX idx_booking_discounts_booking_source ON booking_discounts(booking_id, discount_source); -- ======================================= -- BUSINESS SETTINGS TABLE (FOR COMPLIANCE) @@ -727,6 +808,9 @@ CREATE TABLE admin_notifications ( created_at TIMESTAMPTZ DEFAULT NOW() ); +CREATE INDEX idx_admin_notifications_reason ON admin_notifications(reason); +CREATE INDEX idx_admin_notifications_acknowledged_at ON admin_notifications(acknowledged_at); + -- User notification preferences for future user notification system CREATE TABLE user_notification_preferences ( id CHAR(12) PRIMARY KEY DEFAULT generate_user_notification_preferences_id(), @@ -739,9 +823,6 @@ CREATE TABLE user_notification_preferences ( CREATE INDEX idx_user_notification_preferences_user_id ON user_notification_preferences(user_id); -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 CHAR(12) PRIMARY KEY DEFAULT generate_images_id(), url text not null, @@ -769,6 +850,8 @@ create index idx_images_tag_names on images using gin(tag_names); -- For ILIKE search on unnested tags at scale, normalize into a separate image_tags table -- and add: CREATE INDEX idx_image_tags_name_trgm ON image_tags USING GIN (name gin_trgm_ops); create index idx_images_created_at on images(created_at desc); +CREATE INDEX IF NOT EXISTS idx_images_url_trgm ON images USING GIN (url gin_trgm_ops); +CREATE INDEX IF NOT EXISTS idx_images_thumbnail_url_trgm ON images USING GIN (thumbnail_url gin_trgm_ops); create index idx_tags_name_trgm on tags using gin (name gin_trgm_ops); -- ======================================= @@ -2169,30 +2252,6 @@ INSERT INTO dav_calendars (principaluri, displayname, uri, description, componen SELECT 'principals/default', 'Default Calendar', 'default', 'Default calendar', 'VEVENT,VTODO', false WHERE NOT EXISTS (SELECT 1 FROM dav_calendars WHERE uri = 'default'); --- ======================================= --- PG_TRGM GIN INDEXES FOR ILIKE SEARCH WITH LEADING WILDCARDS --- These indexes enable efficient ILIKE search patterns like '%search%' --- by using trigram matching via the pg_trgm extension (already enabled). --- ======================================= - -CREATE INDEX IF NOT EXISTS idx_bookings_notes_trgm ON bookings USING GIN (notes gin_trgm_ops); - --- users: n_first_name and n_last_name for admin user search -CREATE INDEX IF NOT EXISTS idx_users_n_first_name_trgm ON users USING GIN (n_first_name gin_trgm_ops); -CREATE INDEX IF NOT EXISTS idx_users_n_last_name_trgm ON users USING GIN (n_last_name gin_trgm_ops); - --- services: name for booking search (ILIKE with leading wildcard; B-tree idx_services_name is useless for that) -CREATE INDEX IF NOT EXISTS idx_services_name_trgm ON services USING GIN (name gin_trgm_ops); - --- images: url and thumbnail_url for portfolio image lookup -CREATE INDEX IF NOT EXISTS idx_images_url_trgm ON images USING GIN (url gin_trgm_ops); -CREATE INDEX IF NOT EXISTS idx_images_thumbnail_url_trgm ON images USING GIN (thumbnail_url gin_trgm_ops); - - - --- time_blockers: description for ILIKE ANY search -CREATE INDEX IF NOT EXISTS idx_time_blockers_desc_trgm ON time_blockers USING GIN (description gin_trgm_ops); - -- Account lockout audit log CREATE TABLE IF NOT EXISTS login_audit ( id BIGSERIAL PRIMARY KEY,