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

@@ -45,40 +45,54 @@ export default async function MembersPage({ params, searchParams }: Props) {
pageSize: PAGE_SIZE,
});
// Fetch categories, departments, and tags in parallel
const [duesCategories, departments, tagsResult, tagAssignmentsResult] =
await Promise.all([
organization.listDuesCategories(acct.id),
organization.listDepartmentsWithCounts(acct.id),
(client.from as any)('member_tags')
.select('id, name, color')
.eq('account_id', acct.id)
.order('sort_order'),
(client.from as any)('member_tag_assignments')
.select('member_id, tag_id, member_tags(id, name, color)')
.in(
'member_id',
result.data.map((m: any) => m.id),
),
]);
// Fetch categories and departments (always available)
const [duesCategories, departments] = await Promise.all([
organization.listDuesCategories(acct.id),
organization.listDepartmentsWithCounts(acct.id),
]);
// Build memberTags lookup: { memberId: [{ id, name, color }] }
// Fetch tags gracefully (tables may not exist if migration hasn't run)
let accountTags: Array<{ id: string; name: string; color: string }> = [];
const memberTags: Record<
string,
Array<{ id: string; name: string; color: string }>
> = {};
for (const a of tagAssignmentsResult.data ?? []) {
const memberId = String(a.member_id);
const tag = a.member_tags;
if (!tag) continue;
try {
const memberIds = result.data.map((m: any) => m.id);
if (!memberTags[memberId]) memberTags[memberId] = [];
memberTags[memberId]!.push({
id: String(tag.id),
name: String(tag.name),
color: String(tag.color),
});
const [tagsResult, tagAssignmentsResult] = await Promise.all([
(client.from as any)('member_tags')
.select('id, name, color')
.eq('account_id', acct.id)
.order('sort_order'),
memberIds.length > 0
? (client.from as any)('member_tag_assignments')
.select('member_id, tag_id, member_tags(id, name, color)')
.in('member_id', memberIds)
: { data: [] },
]);
accountTags = (tagsResult.data ?? []).map((t: any) => ({
id: String(t.id),
name: String(t.name),
color: String(t.color),
}));
for (const a of tagAssignmentsResult.data ?? []) {
const memberId = String(a.member_id);
const tag = a.member_tags;
if (!tag) continue;
if (!memberTags[memberId]) memberTags[memberId] = [];
memberTags[memberId]!.push({
id: String(tag.id),
name: String(tag.name),
color: String(tag.color),
});
}
} catch {
// Tags tables may not exist yet — gracefully degrade
}
return (
@@ -100,11 +114,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
name: String(d.name),
memberCount: d.memberCount,
}))}
tags={(tagsResult.data ?? []).map((t: any) => ({
id: String(t.id),
name: String(t.name),
color: String(t.color),
}))}
tags={accountTags}
memberTags={memberTags}
/>
);

File diff suppressed because it is too large Load Diff

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

View File

@@ -0,0 +1,121 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- Audit Trigger & Version Tests
-- Verifies triggers fire correctly on member changes
-- =====================================================
-- Setup
select tests.create_supabase_user('audit_owner', 'audit_owner@test.com');
select makerkit.set_identifier('audit_owner', 'audit_owner@test.com');
set local role service_role;
select public.create_team_account('Audit Verein', tests.get_supabase_uid('audit_owner'));
set local role postgres;
insert into public.role_permissions (role, permission)
values ('owner', 'members.write')
on conflict do nothing;
-- Get account ID
select makerkit.authenticate_as('audit_owner');
-- Insert a member (triggers audit INSERT)
set local role service_role;
insert into public.members (
account_id, first_name, last_name, status, entry_date, member_number,
created_by, updated_by
) values (
(select id from public.accounts where slug = 'audit-verein' limit 1),
'Audit', 'Test', 'active', current_date, '0001',
tests.get_supabase_uid('audit_owner'),
tests.get_supabase_uid('audit_owner')
);
-- -------------------------------------------------------
-- Test: INSERT creates audit entry
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'Audit' limit 1)
and action = 'created' $$,
'Member INSERT creates audit entry with action=created'
);
-- -------------------------------------------------------
-- Test: Version starts at 1
-- -------------------------------------------------------
select is(
(select version from public.members where first_name = 'Audit' limit 1),
1,
'Initial version is 1'
);
-- -------------------------------------------------------
-- Test: UPDATE increments version
-- -------------------------------------------------------
update public.members
set first_name = 'AuditUpdated'
where first_name = 'Audit';
select is(
(select version from public.members where first_name = 'AuditUpdated' limit 1),
2,
'Version incremented to 2 after update'
);
-- -------------------------------------------------------
-- Test: UPDATE creates audit entry with field diff
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
and action = 'updated'
and changes ? 'first_name' $$,
'Member UPDATE creates audit entry with first_name change diff'
);
-- -------------------------------------------------------
-- Test: Status change creates status_changed audit entry
-- -------------------------------------------------------
update public.members
set status = 'inactive'
where first_name = 'AuditUpdated';
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
and action = 'status_changed' $$,
'Status change creates audit entry with action=status_changed'
);
-- -------------------------------------------------------
-- Test: Archive creates archived audit entry
-- -------------------------------------------------------
update public.members
set is_archived = true
where first_name = 'AuditUpdated';
select isnt_empty(
$$ select * from public.member_audit_log
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
and action = 'archived' $$,
'Archive creates audit entry with action=archived'
);
-- -------------------------------------------------------
-- Test: Multiple updates increment version correctly
-- -------------------------------------------------------
select is(
(select version from public.members where first_name = 'AuditUpdated' limit 1),
4,
'Version is 4 after 3 updates (initial insert + 3 updates)'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,186 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- CHECK Constraint Tests
-- =====================================================
-- Setup
select tests.create_supabase_user('constraint_owner', 'constraint_owner@test.com');
select makerkit.set_identifier('constraint_owner', 'constraint_owner@test.com');
set local role service_role;
select public.create_team_account('Constraint Verein', tests.get_supabase_uid('constraint_owner'));
set local role postgres;
insert into public.role_permissions (role, permission)
values ('owner', 'members.write')
on conflict do nothing;
-- -------------------------------------------------------
-- Test: DOB in future rejected
-- -------------------------------------------------------
set local role service_role;
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, date_of_birth, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Future', 'Baby', current_date + interval '1 day', 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'new row for relation "members" violates check constraint "chk_members_dob_not_future"',
'Future date of birth is rejected'
);
-- -------------------------------------------------------
-- Test: Exit date before entry date rejected
-- -------------------------------------------------------
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, status, entry_date, exit_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Wrong', 'Dates', 'resigned', '2024-06-01', '2024-01-01',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'new row for relation "members" violates check constraint "chk_members_exit_after_entry"',
'Exit date before entry date is rejected'
);
-- -------------------------------------------------------
-- Test: Entry date in future rejected
-- -------------------------------------------------------
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Future', 'Entry', 'active', current_date + interval '2 days',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'new row for relation "members" violates check constraint "chk_members_entry_not_future"',
'Future entry date is rejected'
);
-- -------------------------------------------------------
-- Test: Valid member insert succeeds
-- -------------------------------------------------------
select lives_ok(
$test$ insert into public.members (
account_id, first_name, last_name, status, entry_date,
date_of_birth, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Valid', 'Member', 'active', '2024-01-15', '1990-05-20',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'Valid member with correct dates succeeds'
);
-- -------------------------------------------------------
-- Test: Duplicate email in same account rejected
-- -------------------------------------------------------
insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'First', 'Email', 'duplicate@test.com', 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
);
select throws_ok(
$test$ insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Second', 'Email', 'duplicate@test.com', 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'duplicate key value violates unique constraint "uix_members_email_per_account"',
'Duplicate email in same account is rejected'
);
-- -------------------------------------------------------
-- Test: NULL emails allowed (multiple)
-- -------------------------------------------------------
select lives_ok(
$test$ insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'No', 'Email1', null, 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'NULL email is allowed'
);
select lives_ok(
$test$ insert into public.members (
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'No', 'Email2', null, 'active', current_date,
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
) $test$,
'Multiple NULL emails allowed'
);
-- -------------------------------------------------------
-- Test: Invalid IBAN rejected on sepa_mandates
-- -------------------------------------------------------
insert into public.members (
account_id, first_name, last_name, status, entry_date, member_number, created_by, updated_by
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'SEPA', 'Test', 'active', current_date, 'SEPA01',
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
);
select throws_ok(
$test$ insert into public.sepa_mandates (
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
) values (
(select id from public.members where first_name = 'SEPA' limit 1),
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'MANDATE-001', 'invalid-iban', 'Test Holder', current_date, 'active'
) $test$,
'new row for relation "sepa_mandates" violates check constraint "chk_sepa_iban_format"',
'Invalid IBAN format is rejected'
);
-- -------------------------------------------------------
-- Test: Valid IBAN accepted
-- -------------------------------------------------------
select lives_ok(
$test$ insert into public.sepa_mandates (
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
) values (
(select id from public.members where first_name = 'SEPA' limit 1),
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'MANDATE-002', 'DE89370400440532013000', 'Test Holder', current_date, 'active'
) $test$,
'Valid German IBAN is accepted'
);
-- -------------------------------------------------------
-- Test: Negative dues amount rejected
-- -------------------------------------------------------
select throws_ok(
$test$ insert into public.dues_categories (
account_id, name, amount
) values (
(select id from public.accounts where slug = 'constraint-verein' limit 1),
'Negative Fee', -50
) $test$,
'new row for relation "dues_categories" violates check constraint "chk_dues_amount_non_negative"',
'Negative dues amount is rejected'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,211 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- Member Management Function Tests
-- Tests PG functions for correctness, auth, atomicity
-- =====================================================
-- Setup: create test users and account
select tests.create_supabase_user('owner', 'owner@test.com');
select tests.create_supabase_user('member_user', 'member@test.com');
select tests.create_supabase_user('outsider', 'outsider@test.com');
select makerkit.set_identifier('owner', 'owner@test.com');
select makerkit.set_identifier('member_user', 'member@test.com');
select makerkit.set_identifier('outsider', 'outsider@test.com');
-- Create a team account owned by 'owner'
set local role service_role;
select public.create_team_account('Test Verein', tests.get_supabase_uid('owner'));
-- Get account ID
select makerkit.authenticate_as('owner');
\set test_account_id '(select id from public.accounts where slug = ''test-verein'' limit 1)'
-- Grant members.write permission to owner
set local role postgres;
insert into public.role_permissions (role, permission)
values ('owner', 'members.write')
on conflict do nothing;
-- -------------------------------------------------------
-- Test: get_next_member_number
-- -------------------------------------------------------
select makerkit.authenticate_as('owner');
select is(
public.get_next_member_number(:test_account_id),
'0001',
'First member number should be 0001'
);
-- Insert a member to test incrementing
set local role service_role;
insert into public.members (account_id, first_name, last_name, member_number, status, entry_date, created_by, updated_by)
values (:test_account_id, 'Max', 'Mustermann', '0001', 'active', current_date,
tests.get_supabase_uid('owner'), tests.get_supabase_uid('owner'));
select makerkit.authenticate_as('owner');
select is(
public.get_next_member_number(:test_account_id),
'0002',
'Second member number should be 0002'
);
-- -------------------------------------------------------
-- Test: get_member_quick_stats
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.get_member_quick_stats((select id from public.accounts where slug = 'test-verein' limit 1)) $$,
'Quick stats returns data for account with members'
);
-- -------------------------------------------------------
-- Test: check_duplicate_member
-- -------------------------------------------------------
select isnt_empty(
$$ select * from public.check_duplicate_member(
(select id from public.accounts where slug = 'test-verein' limit 1),
'Max', 'Mustermann', null
) $$,
'Duplicate check finds existing member by name'
);
select is_empty(
$$ select * from public.check_duplicate_member(
(select id from public.accounts where slug = 'test-verein' limit 1),
'Nonexistent', 'Person', null
) $$,
'Duplicate check returns empty for non-matching name'
);
-- -------------------------------------------------------
-- Test: approve_application
-- -------------------------------------------------------
-- Create a test application
set local role service_role;
insert into public.membership_applications (
account_id, first_name, last_name, email, status
) values (
:test_account_id, 'Anna', 'Bewerberin', 'anna@test.com', 'submitted'
);
select makerkit.authenticate_as('owner');
-- Approve it
select lives_ok(
$$ select public.approve_application(
(select id from public.membership_applications where email = 'anna@test.com'),
tests.get_supabase_uid('owner')
) $$,
'Owner can approve application'
);
-- Verify member was created
select isnt_empty(
$$ select * from public.members where first_name = 'Anna' and last_name = 'Bewerberin' $$,
'Approved application creates a member'
);
-- Verify application status changed
select is(
(select status from public.membership_applications where email = 'anna@test.com'),
'approved'::public.application_status,
'Application status is approved'
);
-- -------------------------------------------------------
-- Test: reject_application
-- -------------------------------------------------------
set local role service_role;
insert into public.membership_applications (
account_id, first_name, last_name, email, status
) values (
:test_account_id, 'Bob', 'Abgelehnt', 'bob@test.com', 'submitted'
);
select makerkit.authenticate_as('owner');
select lives_ok(
$$ select public.reject_application(
(select id from public.membership_applications where email = 'bob@test.com'),
tests.get_supabase_uid('owner'),
'Nicht qualifiziert'
) $$,
'Owner can reject application'
);
select is(
(select status from public.membership_applications where email = 'bob@test.com'),
'rejected'::public.application_status,
'Application status is rejected'
);
-- -------------------------------------------------------
-- Test: approve_application — already approved should fail
-- -------------------------------------------------------
-- Verify the re-approval throws with status message
prepare approve_again as select public.approve_application(
(select id from public.membership_applications where email = 'anna@test.com'),
tests.get_supabase_uid('owner')
);
select throws_ok(
'approve_again',
'P0001',
'Application is not in a reviewable state (current: approved)',
'Cannot approve already-approved application'
);
-- -------------------------------------------------------
-- Test: get_member_timeline
-- -------------------------------------------------------
-- The member creation via approve_application should have generated an audit entry
select isnt_empty(
$$ select * from public.get_member_timeline(
(select id from public.members where first_name = 'Anna' limit 1),
1, 50, null
) $$,
'Member timeline has entries after creation'
);
-- -------------------------------------------------------
-- Test: log_member_audit_event
-- -------------------------------------------------------
select makerkit.authenticate_as('owner');
select lives_ok(
$$ select public.log_member_audit_event(
(select id from public.members where first_name = 'Max' limit 1),
(select id from public.accounts where slug = 'test-verein' limit 1),
'note_added',
'{"note": "Test note"}'::jsonb,
'{}'::jsonb
) $$,
'Owner can log audit event for member'
);
-- -------------------------------------------------------
-- Test: outsider cannot access functions
-- -------------------------------------------------------
select makerkit.authenticate_as('outsider');
-- Outsider should get an error when calling get_next_member_number
prepare outsider_member_number as select public.get_next_member_number(
(select id from public.accounts where slug = 'test-verein' limit 1)
);
select throws_ok(
'outsider_member_number',
'P0001',
null,
'Outsider cannot call get_next_member_number'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,105 @@
begin;
create extension "basejump-supabase_test_helpers" version '0.0.6';
select no_plan();
-- =====================================================
-- Member Management Schema Tests
-- Verifies all tables, columns, and RLS settings
-- =====================================================
-- 1. Core tables exist
select has_table('public', 'members', 'members table exists');
select has_table('public', 'dues_categories', 'dues_categories table exists');
select has_table('public', 'membership_applications', 'membership_applications table exists');
select has_table('public', 'member_cards', 'member_cards table exists');
select has_table('public', 'member_departments', 'member_departments table exists');
select has_table('public', 'member_department_assignments', 'member_department_assignments table exists');
select has_table('public', 'member_roles', 'member_roles table exists');
select has_table('public', 'member_honors', 'member_honors table exists');
select has_table('public', 'sepa_mandates', 'sepa_mandates table exists');
select has_table('public', 'member_portal_invitations', 'member_portal_invitations table exists');
select has_table('public', 'member_transfers', 'member_transfers table exists');
-- 2. New Phase 1-4 tables exist
select has_table('public', 'member_audit_log', 'member_audit_log table exists');
select has_table('public', 'member_communications', 'member_communications table exists');
select has_table('public', 'member_tags', 'member_tags table exists');
select has_table('public', 'member_tag_assignments', 'member_tag_assignments table exists');
select has_table('public', 'member_merges', 'member_merges table exists');
select has_table('public', 'gdpr_retention_policies', 'gdpr_retention_policies table exists');
select has_table('public', 'member_notification_rules', 'member_notification_rules table exists');
select has_table('public', 'scheduled_job_configs', 'scheduled_job_configs table exists');
select has_table('public', 'scheduled_job_runs', 'scheduled_job_runs table exists');
select has_table('public', 'pending_member_notifications', 'pending_member_notifications table exists');
-- 3. New columns on members table
select has_column('public', 'members', 'primary_mandate_id', 'members has primary_mandate_id column');
select has_column('public', 'members', 'version', 'members has version column');
-- 4. New column on event_registrations
select has_column('public', 'event_registrations', 'member_id', 'event_registrations has member_id FK');
-- 5. RLS enabled on all member tables
select is(
(select relrowsecurity from pg_class where relname = 'members' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on members'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_audit_log' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_audit_log'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_communications' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_communications'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_tags' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_tags'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_tag_assignments' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_tag_assignments'
);
select is(
(select relrowsecurity from pg_class where relname = 'member_notification_rules' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on member_notification_rules'
);
select is(
(select relrowsecurity from pg_class where relname = 'scheduled_job_configs' and relnamespace = 'public'::regnamespace),
true, 'RLS enabled on scheduled_job_configs'
);
-- 6. Key indexes exist
select is(
(select count(*) > 0 from pg_indexes where tablename = 'members' and indexname = 'ix_members_active_account_status'),
true, 'Active members composite index exists'
);
select is(
(select count(*) > 0 from pg_indexes where tablename = 'member_audit_log' and indexname = 'ix_member_audit_member'),
true, 'Audit log member index exists'
);
-- 7. Check constraints exist on members
select is(
(select count(*) > 0 from information_schema.check_constraints
where constraint_name = 'chk_members_dob_not_future'),
true, 'DOB not-future constraint exists'
);
select is(
(select count(*) > 0 from information_schema.check_constraints
where constraint_name = 'chk_members_exit_after_entry'),
true, 'Exit-after-entry constraint exists'
);
-- 8. Version column has correct default
select is(
(select column_default from information_schema.columns
where table_name = 'members' and column_name = 'version'),
'1', 'Version column defaults to 1'
);
select * from finish();
rollback;

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createBookingCommunicationService(
client: SupabaseClient<Database>,
) {
return new BookingCommunicationService(client);
}
class BookingCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
bookingId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'bookings')
.eq('entity_id', bookingId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
}
async create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'bookings',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'bookings');
if (error) throw error;
}
}

View File

@@ -0,0 +1,76 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createBookingExportService(client: SupabaseClient<Database>) {
return new BookingExportService(client);
}
class BookingExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportBookingsCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('bookings')
.select('*, rooms(room_number, name), guests(first_name, last_name)')
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: bookings, error } = await query;
if (error) throw error;
if (!bookings?.length) return '';
const headers = ['Zimmer', 'Gast', 'Anreise', 'Abreise', 'Status', 'Preis'];
const rows = bookings.map((b) => {
const room = (b as any).rooms;
const guest = (b as any).guests;
const roomLabel = room
? `${room.room_number}${room.name ? ` (${room.name})` : ''}`
: '';
const guestLabel = guest ? `${guest.first_name} ${guest.last_name}` : '';
return [
roomLabel,
guestLabel,
b.check_in ?? '',
b.check_out ?? '',
b.status,
b.total_price?.toString() ?? '0',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';');
});
return [headers.join(';'), ...rows].join('\n');
}
async exportGuestsCsv(accountId: string): Promise<string> {
const { data: guests, error } = await this.client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
if (!guests?.length) return '';
const headers = ['Vorname', 'Nachname', 'E-Mail', 'Telefon', 'Ort'];
const rows = guests.map((g) =>
[g.first_name, g.last_name, g.email ?? '', g.phone ?? '', g.city ?? '']
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'booking-notification';
const MODULE = 'bookings';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createBookingNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createCourseCommunicationService(
client: SupabaseClient<Database>,
) {
return new CourseCommunicationService(client);
}
class CourseCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
courseId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'courses')
.eq('entity_id', courseId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
}
async create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'courses',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'courses');
if (error) throw error;
}
}

View File

@@ -0,0 +1,94 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createCourseExportService(client: SupabaseClient<Database>) {
return new CourseExportService(client);
}
class CourseExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportParticipantsCsv(courseId: string): Promise<string> {
const { data: participants, error } = await this.client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('last_name');
if (error) throw error;
if (!participants?.length) return '';
const headers = [
'Vorname',
'Nachname',
'E-Mail',
'Telefon',
'Status',
'Anmeldedatum',
];
const rows = participants.map((p) =>
[
p.first_name,
p.last_name,
p.email ?? '',
p.phone ?? '',
p.status,
p.enrolled_at ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportCoursesCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('courses')
.select('*')
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: courses, error } = await query;
if (error) throw error;
if (!courses?.length) return '';
const headers = [
'Kursnr.',
'Name',
'Status',
'Startdatum',
'Enddatum',
'Gebuhr',
'Kapazitat',
'Min. Teilnehmer',
];
const rows = courses.map((c) =>
[
c.course_number ?? '',
c.name,
c.status,
c.start_date ?? '',
c.end_date ?? '',
c.fee?.toString() ?? '0',
c.capacity?.toString() ?? '',
c.min_participants?.toString() ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'course-notification';
const MODULE = 'courses';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createCourseNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}

View File

@@ -0,0 +1,99 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
interface CommunicationListOptions {
type?: string;
direction?: string;
search?: string;
page?: number;
pageSize?: number;
}
interface CreateCommunicationInput {
accountId: string;
entityId: string;
type: string;
direction?: string;
subject?: string;
body?: string;
emailTo?: string;
emailCc?: string;
attachmentPaths?: string[];
}
export function createEventCommunicationService(
client: SupabaseClient<Database>,
) {
return new EventCommunicationService(client);
}
class EventCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
eventId: string,
accountId: string,
opts?: CommunicationListOptions,
) {
let query = (this.client.from as CallableFunction)('module_communications')
.select('*', { count: 'exact' })
.eq('module', 'events')
.eq('entity_id', eventId)
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (opts?.type) query = query.eq('type', opts.type);
if (opts?.direction) query = query.eq('direction', opts.direction);
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
}
async create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.insert({
account_id: input.accountId,
module: 'events',
entity_id: input.entityId,
type: input.type,
direction: input.direction ?? 'internal',
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, account_id, module, entity_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string) {
const { error } = await (this.client.from as CallableFunction)(
'module_communications',
)
.delete()
.eq('id', communicationId)
.eq('module', 'events');
if (error) throw error;
}
}

View File

@@ -0,0 +1,84 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createEventExportService(client: SupabaseClient<Database>) {
return new EventExportService(client);
}
class EventExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportRegistrationsCsv(eventId: string): Promise<string> {
const { data: registrations, error } = await this.client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('last_name');
if (error) throw error;
if (!registrations?.length) return '';
const headers = [
'Vorname',
'Nachname',
'E-Mail',
'Telefon',
'Geburtsdatum',
'Status',
'Anmeldedatum',
];
const rows = registrations.map((r) =>
[
r.first_name,
r.last_name,
r.email ?? '',
r.phone ?? '',
r.date_of_birth ?? '',
r.status,
r.created_at ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportEventsCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
let query = this.client
.from('events')
.select('*')
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data: events, error } = await query;
if (error) throw error;
if (!events?.length) return '';
const headers = ['Name', 'Status', 'Datum', 'Ort', 'Kapazitat'];
const rows = events.map((e) =>
[
e.name,
e.status,
e.event_date ?? '',
e.location ?? '',
e.capacity?.toString() ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
}

View File

@@ -0,0 +1,156 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
const NAMESPACE = 'event-notification';
const MODULE = 'events';
interface NotificationRule {
id: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
export function createEventNotificationService(
client: SupabaseClient<Database>,
) {
return {
async enqueue(
accountId: string,
triggerEvent: string,
entityId: string,
context: Record<string, unknown>,
) {
await (client.rpc as CallableFunction)('enqueue_module_notification', {
p_account_id: accountId,
p_module: MODULE,
p_trigger_event: triggerEvent,
p_entity_id: entityId,
p_context: context,
});
},
async processPending(): Promise<{ processed: number; sent: number }> {
const logger = await getLogger();
const { data: pending, error } = await (client.from as CallableFunction)(
'pending_module_notifications',
)
.select('*')
.eq('module', MODULE)
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error || !pending?.length) return { processed: 0, sent: 0 };
let sent = 0;
for (const n of pending as Array<{
id: number;
account_id: string;
trigger_event: string;
context: Record<string, unknown>;
}>) {
try {
sent += await this.dispatch(
n.account_id,
n.trigger_event,
n.context ?? {},
);
} catch (e) {
logger.error(
{ name: NAMESPACE, id: n.id, error: e },
'Dispatch failed',
);
}
await (client.from as CallableFunction)('pending_module_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', n.id);
}
logger.info(
{ name: NAMESPACE, processed: pending.length, sent },
'Batch processed',
);
return { processed: pending.length, sent };
},
async dispatch(
accountId: string,
triggerEvent: string,
context: Record<string, unknown>,
): Promise<number> {
const { data: rules } = await (client.from as CallableFunction)(
'module_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('module', MODULE)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (!rules?.length) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
const message = renderTemplate(rule.message_template, context);
if (rule.channel === 'in_app' || rule.channel === 'both') {
const { createNotificationsApi } =
await import('@kit/notifications/api');
const api = createNotificationsApi(client);
await api.createNotification({
account_id: accountId,
body: message,
type: 'info',
channel: 'in_app',
});
sent++;
}
if (rule.channel === 'email' || rule.channel === 'both') {
const subject = rule.subject_template
? renderTemplate(rule.subject_template, context)
: triggerEvent;
const email = context.email as string | undefined;
if (email) {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to: email,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family:sans-serif;max-width:600px;margin:0 auto">${message}</div>`,
});
sent++;
}
}
}
return sent;
},
};
}
function renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}

File diff suppressed because it is too large Load Diff