Merge branch 'main' of https://gitea.frontieralgorithmics.de/zaid.marzguioui/myeasycms-v2
This commit is contained in:
@@ -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
@@ -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));
|
||||
121
apps/web/supabase/tests/database/member-audit.test.sql
Normal file
121
apps/web/supabase/tests/database/member-audit.test.sql
Normal 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;
|
||||
186
apps/web/supabase/tests/database/member-constraints.test.sql
Normal file
186
apps/web/supabase/tests/database/member-constraints.test.sql
Normal 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;
|
||||
211
apps/web/supabase/tests/database/member-functions.test.sql
Normal file
211
apps/web/supabase/tests/database/member-functions.test.sql
Normal 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;
|
||||
105
apps/web/supabase/tests/database/member-tables.test.sql
Normal file
105
apps/web/supabase/tests/database/member-tables.test.sql
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user