feat: add shared notification, communication, and export services for bookings, courses, and events; introduce btree_gist extension and new booking atomic function
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m42s
Workflow / ⚫️ Test (push) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-03 17:03:34 +02:00
parent 4d538a5668
commit 9d5fe58ee3
24 changed files with 4372 additions and 153 deletions

View File

@@ -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 != '';

View File

@@ -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

View File

@@ -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;

View File

@@ -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$;

View File

@@ -0,0 +1 @@
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;

View File

@@ -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$;

View File

@@ -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;

View File

@@ -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));