feat: add shared notification, communication, and export services for bookings, courses, and events; introduce btree_gist extension and new booking atomic function
This commit is contained in:
@@ -17,7 +17,11 @@ UPDATE public.members SET exit_date = entry_date
|
||||
UPDATE public.members SET entry_date = current_date
|
||||
WHERE entry_date IS NOT NULL AND entry_date > current_date;
|
||||
|
||||
-- Normalize IBANs in sepa_mandates to uppercase, strip spaces
|
||||
-- Normalize IBANs to uppercase, strip spaces (both tables)
|
||||
UPDATE public.members
|
||||
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
||||
WHERE iban IS NOT NULL AND iban != '';
|
||||
|
||||
UPDATE public.sepa_mandates
|
||||
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
||||
WHERE iban IS NOT NULL AND iban != '';
|
||||
|
||||
@@ -21,12 +21,12 @@ CREATE INDEX IF NOT EXISTS ix_event_registrations_member
|
||||
-- Backfill: match existing registrations to members by email within the same account
|
||||
UPDATE public.event_registrations er
|
||||
SET member_id = m.id
|
||||
FROM public.events e
|
||||
JOIN public.members m ON m.account_id = e.account_id
|
||||
FROM public.events e, public.members m
|
||||
WHERE e.id = er.event_id
|
||||
AND m.account_id = e.account_id
|
||||
AND lower(m.email) = lower(er.email)
|
||||
AND m.email IS NOT NULL AND m.email != ''
|
||||
AND m.status IN ('active', 'inactive', 'pending')
|
||||
WHERE e.id = er.event_id
|
||||
AND er.member_id IS NULL
|
||||
AND er.email IS NOT NULL AND er.email != '';
|
||||
|
||||
@@ -35,7 +35,7 @@ CREATE OR REPLACE FUNCTION public.transfer_member(
|
||||
p_member_id uuid,
|
||||
p_target_account_id uuid,
|
||||
p_reason text DEFAULT NULL,
|
||||
p_keep_sepa boolean DEFAULT false
|
||||
p_keep_sepa boolean DEFAULT true
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Enable btree_gist extension (required by booking overlap exclusion constraint)
|
||||
-- Separated into own migration to avoid "multiple commands in prepared statement" error
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
@@ -0,0 +1,54 @@
|
||||
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
||||
p_account_id uuid,
|
||||
p_room_id uuid,
|
||||
p_guest_id uuid DEFAULT NULL,
|
||||
p_check_in date DEFAULT NULL,
|
||||
p_check_out date DEFAULT NULL,
|
||||
p_adults integer DEFAULT 1,
|
||||
p_children integer DEFAULT 0,
|
||||
p_status text DEFAULT 'confirmed',
|
||||
p_total_price numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $fn$
|
||||
DECLARE
|
||||
v_room record;
|
||||
v_computed_price numeric(10,2);
|
||||
v_booking_id uuid;
|
||||
BEGIN
|
||||
SELECT * INTO v_room FROM public.rooms WHERE id = p_room_id FOR UPDATE;
|
||||
IF v_room IS NULL THEN
|
||||
RAISE EXCEPTION 'Room % not found', p_room_id USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
||||
RAISE EXCEPTION 'check_in and check_out dates are required' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF p_check_out <= p_check_in THEN
|
||||
RAISE EXCEPTION 'check_out must be after check_in' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF (p_adults + p_children) > v_room.capacity THEN
|
||||
RAISE EXCEPTION 'Total guests exceed room capacity' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
IF p_total_price IS NOT NULL THEN
|
||||
v_computed_price := p_total_price;
|
||||
ELSE
|
||||
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.bookings (
|
||||
account_id, room_id, guest_id, check_in, check_out,
|
||||
adults, children, status, total_price, notes
|
||||
) VALUES (
|
||||
p_account_id, p_room_id, p_guest_id, p_check_in, p_check_out,
|
||||
p_adults, p_children, p_status, v_computed_price, p_notes
|
||||
)
|
||||
RETURNING id INTO v_booking_id;
|
||||
|
||||
RETURN v_booking_id;
|
||||
END;
|
||||
$fn$;
|
||||
@@ -0,0 +1 @@
|
||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
||||
@@ -1,25 +1,4 @@
|
||||
-- =====================================================
|
||||
-- Atomic Booking Creation with Overlap Prevention
|
||||
--
|
||||
-- Problem: Creating a booking requires checking room
|
||||
-- availability, validating capacity, and inserting — all
|
||||
-- as separate queries. Race conditions can double-book
|
||||
-- a room for overlapping dates.
|
||||
--
|
||||
-- Fix:
|
||||
-- A) Enable btree_gist extension for exclusion constraints.
|
||||
-- B) Add GiST exclusion constraint to prevent overlapping
|
||||
-- bookings for the same room (non-cancelled/no_show).
|
||||
-- C) Single transactional PG function that locks the room,
|
||||
-- validates inputs, calculates price, and inserts. The
|
||||
-- exclusion constraint provides a final safety net.
|
||||
-- =====================================================
|
||||
|
||||
-- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types)
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
|
||||
-- B) Add exclusion constraint to prevent overlapping bookings (idempotent)
|
||||
DO $$
|
||||
DO $excl$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
|
||||
@@ -32,97 +11,4 @@ BEGIN
|
||||
) WHERE (status NOT IN ('cancelled', 'no_show'));
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- C) Atomic booking creation function
|
||||
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
||||
p_account_id uuid,
|
||||
p_room_id uuid,
|
||||
p_guest_id uuid DEFAULT NULL,
|
||||
p_check_in date DEFAULT NULL,
|
||||
p_check_out date DEFAULT NULL,
|
||||
p_adults integer DEFAULT 1,
|
||||
p_children integer DEFAULT 0,
|
||||
p_status text DEFAULT 'confirmed',
|
||||
p_total_price numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_room record;
|
||||
v_computed_price numeric(10,2);
|
||||
v_booking_id uuid;
|
||||
BEGIN
|
||||
-- 1. Lock the room row to serialize booking attempts
|
||||
SELECT * INTO v_room
|
||||
FROM public.rooms
|
||||
WHERE id = p_room_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- 2. Validate room exists
|
||||
IF v_room IS NULL THEN
|
||||
RAISE EXCEPTION 'Room % not found', p_room_id
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- 3. Validate check_out > check_in
|
||||
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
||||
RAISE EXCEPTION 'check_in and check_out dates are required'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
IF p_check_out <= p_check_in THEN
|
||||
RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- 4. Validate total guests do not exceed room capacity
|
||||
IF (p_adults + p_children) > v_room.capacity THEN
|
||||
RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- 5. Calculate price if not provided
|
||||
IF p_total_price IS NOT NULL THEN
|
||||
v_computed_price := p_total_price;
|
||||
ELSE
|
||||
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
||||
END IF;
|
||||
|
||||
-- 6. Insert the booking (exclusion constraint prevents double-booking)
|
||||
INSERT INTO public.bookings (
|
||||
account_id,
|
||||
room_id,
|
||||
guest_id,
|
||||
check_in,
|
||||
check_out,
|
||||
adults,
|
||||
children,
|
||||
status,
|
||||
total_price,
|
||||
notes
|
||||
) VALUES (
|
||||
p_account_id,
|
||||
p_room_id,
|
||||
p_guest_id,
|
||||
p_check_in,
|
||||
p_check_out,
|
||||
p_adults,
|
||||
p_children,
|
||||
p_status,
|
||||
v_computed_price,
|
||||
p_notes
|
||||
)
|
||||
RETURNING id INTO v_booking_id;
|
||||
|
||||
-- 7. Return the new booking id
|
||||
RETURN v_booking_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role;
|
||||
$excl$;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- =====================================================
|
||||
-- Module Notification Rules & Queue
|
||||
-- Shared notification infrastructure for courses, events, bookings.
|
||||
-- =====================================================
|
||||
|
||||
-- Notification rules: define what triggers notifications
|
||||
CREATE TABLE IF NOT EXISTS public.module_notification_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||
trigger_event text NOT NULL CHECK (trigger_event IN (
|
||||
'course.participant_enrolled', 'course.participant_waitlisted', 'course.participant_promoted',
|
||||
'course.participant_cancelled', 'course.status_changed', 'course.session_reminder',
|
||||
'event.registration_confirmed', 'event.registration_waitlisted', 'event.registration_promoted',
|
||||
'event.registration_cancelled', 'event.status_changed', 'event.reminder',
|
||||
'booking.confirmed', 'booking.check_in_reminder', 'booking.checked_in',
|
||||
'booking.checked_out', 'booking.cancelled'
|
||||
)),
|
||||
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
|
||||
recipient_type text NOT NULL DEFAULT 'admin' CHECK (recipient_type IN ('admin', 'participant', 'guest', 'instructor', 'specific_user')),
|
||||
recipient_config jsonb NOT NULL DEFAULT '{}',
|
||||
subject_template text,
|
||||
message_template text NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_module_notification_rules_lookup
|
||||
ON public.module_notification_rules(account_id, module, trigger_event)
|
||||
WHERE is_active = true;
|
||||
|
||||
ALTER TABLE public.module_notification_rules ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.module_notification_rules FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_notification_rules TO authenticated;
|
||||
GRANT ALL ON public.module_notification_rules TO service_role;
|
||||
|
||||
CREATE POLICY module_notification_rules_select ON public.module_notification_rules
|
||||
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||
|
||||
CREATE POLICY module_notification_rules_mutate ON public.module_notification_rules
|
||||
FOR ALL TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
) WITH CHECK (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
);
|
||||
|
||||
-- Pending notifications queue
|
||||
CREATE TABLE IF NOT EXISTS public.pending_module_notifications (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||
trigger_event text NOT NULL,
|
||||
entity_id uuid NOT NULL,
|
||||
context jsonb NOT NULL DEFAULT '{}',
|
||||
processed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_pending_module_notifications_unprocessed
|
||||
ON public.pending_module_notifications(created_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
ALTER TABLE public.pending_module_notifications ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.pending_module_notifications FROM authenticated, service_role;
|
||||
GRANT SELECT ON public.pending_module_notifications TO authenticated;
|
||||
GRANT ALL ON public.pending_module_notifications TO service_role;
|
||||
|
||||
CREATE POLICY pending_module_notifications_select ON public.pending_module_notifications
|
||||
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||
|
||||
-- Enqueue helper
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_module_notification(
|
||||
p_account_id uuid,
|
||||
p_module text,
|
||||
p_trigger_event text,
|
||||
p_entity_id uuid,
|
||||
p_context jsonb DEFAULT '{}'
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.pending_module_notifications
|
||||
(account_id, module, trigger_event, entity_id, context)
|
||||
VALUES
|
||||
(p_account_id, p_module, p_trigger_event, p_entity_id, p_context);
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO service_role;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* -------------------------------------------------------
|
||||
* Shared Communications Table for Courses, Events, Bookings
|
||||
* Tracks email, phone, letter, meeting, note, sms entries
|
||||
* -------------------------------------------------------
|
||||
*/
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.module_communications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||
entity_id uuid NOT NULL,
|
||||
type text NOT NULL DEFAULT 'note' CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
|
||||
direction text NOT NULL DEFAULT 'internal' CHECK (direction IN ('inbound', 'outbound', 'internal')),
|
||||
subject text,
|
||||
body text,
|
||||
email_to text,
|
||||
email_cc text,
|
||||
attachment_paths text[],
|
||||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_module_communications_entity
|
||||
ON public.module_communications(module, entity_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_module_communications_account
|
||||
ON public.module_communications(account_id, module, created_at DESC);
|
||||
|
||||
ALTER TABLE public.module_communications ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.module_communications FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_communications TO authenticated;
|
||||
GRANT ALL ON public.module_communications TO service_role;
|
||||
|
||||
CREATE POLICY module_communications_select ON public.module_communications
|
||||
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||
|
||||
CREATE POLICY module_communications_mutate ON public.module_communications
|
||||
FOR ALL TO authenticated USING (public.has_role_on_account(account_id))
|
||||
WITH CHECK (public.has_role_on_account(account_id));
|
||||
Reference in New Issue
Block a user