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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-06-21 21:47:06 +01:00
co-authored by Sisyphus
parent 96dd9e191c
commit 2e231eea4c
+90 -31
View File
@@ -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,