refactor: remove obsolete member management API module
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-03 14:08:31 +02:00
parent 124c6a632a
commit 5c5aaabae5
132 changed files with 10107 additions and 3442 deletions

View File

@@ -72,7 +72,7 @@ After implementation, always run:
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -3,7 +3,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -41,8 +41,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/ ./
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --create-home nextjs
RUN mkdir -p /app/apps/web/.next/cache && chown -R nextjs:nodejs /app/apps/web/.next/cache
RUN mkdir -p /home/nextjs/.cache/node/corepack && chown -R nextjs:nodejs /home/nextjs/.cache
USER nextjs

View File

@@ -82,7 +82,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
const bookings = await api.listBookings(acct.id, {
const bookings = await api.bookings.list(acct.id, {
from: monthStart,
to: monthEnd,
page: 1,

View File

@@ -34,7 +34,7 @@ export default async function GuestsPage({ params }: PageProps) {
}
const api = createBookingManagementApi(client);
const guests = await api.listGuests(acct.id);
const guests = await api.guests.list(acct.id);
return (
<CmsPageShell account={account} title={t('guests.title')}>

View File

@@ -29,7 +29,7 @@ export default async function NewBookingPage({ params }: Props) {
}
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
return (
<CmsPageShell

View File

@@ -54,7 +54,7 @@ export default async function BookingsPage({
const page = Number(search.page) || 1;
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
const bookingsQuery = client

View File

@@ -36,7 +36,7 @@ export default async function RoomsPage({ params }: PageProps) {
}
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
return (
<CmsPageShell account={account} title={t('rooms.title')}>

View File

@@ -29,9 +29,9 @@ export default async function AttendancePage({
const t = await getTranslations('courses');
const [course, sessions, participants] = await Promise.all([
api.getCourse(courseId),
api.getSessions(courseId),
api.getParticipants(courseId),
api.courses.getById(courseId),
api.sessions.list(courseId),
api.enrollment.listParticipants(courseId),
]);
if (!course) return <AccountNotFound />;
@@ -43,7 +43,7 @@ export default async function AttendancePage({
: null);
const attendance = selectedSessionId
? await api.getAttendance(selectedSessionId)
? await api.attendance.getBySession(selectedSessionId)
: [];
const attendanceMap = new Map(

View File

@@ -25,7 +25,7 @@ export default async function EditCoursePage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const course = await api.getCourse(courseId);
const course = await api.courses.getById(courseId);
if (!course) return <AccountNotFound />;
const c = course as Record<string, unknown>;

View File

@@ -39,9 +39,9 @@ export default async function CourseDetailPage({ params }: PageProps) {
const t = await getTranslations('courses');
const [course, participants, sessions] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.getSessions(courseId),
api.courses.getById(courseId),
api.enrollment.listParticipants(courseId),
api.sessions.list(courseId),
]);
if (!course) return <AccountNotFound />;

View File

@@ -40,8 +40,8 @@ export default async function ParticipantsPage({ params }: PageProps) {
const t = await getTranslations('courses');
const [course, participants] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.courses.getById(courseId),
api.enrollment.listParticipants(courseId),
]);
if (!course) return <AccountNotFound />;

View File

@@ -45,7 +45,7 @@ export default async function CourseCalendarPage({
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 });
const courses = await api.courses.list(acct.id, { page: 1, pageSize: 100 });
const now = new Date();
const monthParam = search.month as string | undefined;

View File

@@ -29,7 +29,7 @@ export default async function CategoriesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const categories = await api.listCategories(acct.id);
const categories = await api.referenceData.listCategories(acct.id);
return (
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>

View File

@@ -30,7 +30,7 @@ export default async function InstructorsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const instructors = await api.listInstructors(acct.id);
const instructors = await api.referenceData.listInstructors(acct.id);
return (
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>

View File

@@ -29,7 +29,7 @@ export default async function LocationsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const locations = await api.listLocations(acct.id);
const locations = await api.referenceData.listLocations(acct.id);
return (
<CmsPageShell account={account} title={t('pages.locationsTitle')}>

View File

@@ -52,13 +52,13 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
const page = Number(search.page) || 1;
const [courses, stats] = await Promise.all([
api.listCourses(acct.id, {
api.courses.list(acct.id, {
search: search.q as string,
status: search.status as string,
page,
pageSize: PAGE_SIZE,
}),
api.getStatistics(acct.id),
api.statistics.getQuickStats(acct.id),
]);
const totalPages = Math.ceil(courses.total / PAGE_SIZE);

View File

@@ -33,7 +33,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const stats = await api.getStatistics(acct.id);
const stats = await api.statistics.getQuickStats(acct.id);
const statusChartData = [
{ name: t('stats.active'), value: stats.openCourses },

View File

@@ -25,7 +25,7 @@ export default async function EditEventPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const event = await api.getEvent(eventId);
const event = await api.events.getById(eventId);
if (!event) return <AccountNotFound />;
const e = event as Record<string, unknown>;

View File

@@ -36,8 +36,8 @@ export default async function EventDetailPage({ params }: PageProps) {
const t = await getTranslations('cms.events');
const [event, registrations] = await Promise.all([
api.getEvent(eventId),
api.getRegistrations(eventId),
api.events.getById(eventId),
api.registrations.list(eventId),
]);
if (!event) return <div>{t('notFound')}</div>;

View File

@@ -29,7 +29,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const passes = await api.listHolidayPasses(acct.id);
const passes = await api.holidayPasses.list(acct.id);
return (
<CmsPageShell account={account} title={t('holidayPasses')}>

View File

@@ -47,13 +47,13 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
const page = Number(search.page) || 1;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page });
const events = await api.events.list(acct.id, { page });
// Fetch registration counts for all events on this page
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
String(eventItem.id),
);
const registrationCounts = await api.getRegistrationCounts(eventIds);
const registrationCounts = await api.events.getRegistrationCounts(eventIds);
// Pre-compute stats before rendering
const uniqueLocationCount = new Set(

View File

@@ -36,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page: 1 });
const events = await api.events.list(acct.id, { page: 1 });
// Load registrations for each event in parallel
const eventsWithRegistrations = await Promise.all(
events.data.map(async (event: Record<string, unknown>) => {
const registrations = await api.getRegistrations(String(event.id));
const registrations = await api.registrations.list(String(event.id));
return {
id: String(event.id),
name: String(event.name),

View File

@@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { EditMemberForm } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -22,8 +22,8 @@ export default async function EditMemberPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const member = await api.getMember(acct.id, memberId);
const { query } = createMemberServices(client);
const member = await query.getById(acct.id, memberId);
if (!member) return <div>{t('detail.notFound')}</div>;
return (

View File

@@ -1,5 +1,5 @@
import { createMemberManagementApi } from '@kit/member-management/api';
import { MemberDetailTabs } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -18,14 +18,14 @@ export default async function MemberDetailPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const member = await api.getMember(acct.id, memberId);
const { query, organization } = createMemberServices(client);
const member = await query.getById(acct.id, memberId);
if (!member) return <AccountNotFound />;
const [roles, honors, mandates] = await Promise.all([
api.listMemberRoles(memberId),
api.listMemberHonors(memberId),
api.listMandates(memberId),
organization.listMemberRoles(memberId),
organization.listMemberHonors(memberId),
organization.listMandates(memberId),
]);
return (

View File

@@ -12,6 +12,7 @@ import {
KeyRound,
LayoutList,
Settings,
Tag,
Users,
} from 'lucide-react';
@@ -160,6 +161,10 @@ function SettingsMenu({ basePath }: { basePath: string }) {
<Users className="mr-2 size-4" />
Abteilungen
</DropdownMenuItem>
<DropdownMenuItem onClick={navigate(`${basePath}/tags`)}>
<Tag className="mr-2 size-4" />
Tags verwalten
</DropdownMenuItem>
<DropdownMenuItem onClick={navigate(`${basePath}/cards`)}>
<IdCard className="mr-2 size-4" />
Mitgliedsausweise

View File

@@ -1,5 +1,5 @@
import { createMemberManagementApi } from '@kit/member-management/api';
import { ApplicationWorkflow } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -19,8 +19,8 @@ export default async function ApplicationsPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const applications = await api.listApplications(acct.id);
const { workflow } = createMemberServices(client);
const applications = await workflow.listApplications(acct.id);
return (
<ApplicationWorkflow

View File

@@ -1,7 +1,7 @@
import { CreditCard } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -26,8 +26,8 @@ export default async function MemberCardsPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const result = await api.listMembers(acct.id, {
const { query } = createMemberServices(client);
const result = await query.list(acct.id, {
status: 'active',
pageSize: CARDS_PAGE_SIZE,
});

View File

@@ -1,7 +1,7 @@
import { Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -26,8 +26,8 @@ export default async function DepartmentsPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const departments = await api.listDepartments(acct.id);
const { organization } = createMemberServices(client);
const departments = await organization.listDepartments(acct.id);
return (
<CmsPageShell

View File

@@ -1,7 +1,7 @@
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { DuesCategoryManager } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -22,8 +22,8 @@ export default async function DuesPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const categories = await api.listDuesCategories(acct.id);
const { organization } = createMemberServices(client);
const categories = await organization.listDuesCategories(acct.id);
return (
<CmsPageShell

View File

@@ -1,6 +1,6 @@
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -23,8 +23,8 @@ export default async function InvitationsPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const invitations = await api.listPortalInvitations(acct.id);
const { workflow } = createMemberServices(client);
const invitations = await workflow.listPortalInvitations(acct.id);
// Fetch members for the "send invitation" dialog
const { data: members } = await client

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -25,8 +25,8 @@ export default async function MembersCmsLayout({ children, params }: Props) {
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const stats = await api.getMemberQuickStats(acct.id);
const { query } = createMemberServices(client);
const stats = await query.getQuickStats(acct.id);
return (
<MembersCmsLayoutClient

View File

@@ -1,5 +1,5 @@
import { createMemberManagementApi } from '@kit/member-management/api';
import { MemberCreateWizard } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -18,8 +18,8 @@ export default async function NewMemberPage({ params }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const duesCategories = await api.listDuesCategories(acct.id);
const { organization } = createMemberServices(client);
const duesCategories = await organization.listDuesCategories(acct.id);
return (
<MemberCreateWizard

View File

@@ -1,5 +1,5 @@
import { createMemberManagementApi } from '@kit/member-management/api';
import { MembersListView } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -23,7 +23,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const { query, organization } = createMemberServices(client);
const page = Number(search.page) || 1;
// Parse multi-status filter
@@ -34,7 +34,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
: statusParam.split(',')
: undefined;
const result = await api.searchMembers({
const result = await query.search({
accountId: acct.id,
search: search.q as string,
status: statusFilter as any,
@@ -45,11 +45,42 @@ export default async function MembersPage({ params, searchParams }: Props) {
pageSize: PAGE_SIZE,
});
const [duesCategories, departments] = await Promise.all([
api.listDuesCategories(acct.id),
api.listDepartmentsWithCounts(acct.id),
// 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),
),
]);
// Build memberTags lookup: { memberId: [{ id, name, color }] }
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;
if (!memberTags[memberId]) memberTags[memberId] = [];
memberTags[memberId]!.push({
id: String(tag.id),
name: String(tag.name),
color: String(tag.color),
});
}
return (
<MembersListView
data={result.data}
@@ -69,6 +100,12 @@ 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),
}))}
memberTags={memberTags}
/>
);
}

View File

@@ -8,7 +8,7 @@ import {
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -34,8 +34,8 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const stats = await api.getMemberStatistics(acct.id);
const { query } = createMemberServices(client);
const stats = await query.getStatistics(acct.id);
const statusChartData = [
{ name: t('status.active'), value: stats.active ?? 0 },

View File

@@ -0,0 +1,41 @@
import { getTranslations } from 'next-intl/server';
import { TagsManager } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
}
export default async function TagsPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
const t = await getTranslations('members');
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
// Fetch tags via direct query (table may not be in generated types yet)
const { data: tags } = await (client.from as any)('member_tags')
.select('*')
.eq('account_id', acct.id)
.order('sort_order');
return (
<CmsPageShell
account={account}
title="Tags verwalten"
description="Mitglieder-Tags erstellen und verwalten"
>
<TagsManager tags={tags ?? []} accountId={acct.id} />
</CmsPageShell>
);
}

View File

@@ -18,7 +18,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
import { createCourseManagementApi } from '@kit/course-management/api';
import { createEventManagementApi } from '@kit/event-management/api';
import { createFinanceApi } from '@kit/finance/api';
import { createMemberManagementApi } from '@kit/member-management/api';
import { createMemberServices } from '@kit/member-management/services';
import { createNewsletterApi } from '@kit/newsletter/api';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
@@ -64,18 +64,23 @@ export default async function TeamAccountHomePage({
bookingsResult,
eventsResult,
] = await Promise.allSettled([
createMemberManagementApi(client).getMemberStatistics(acct.id),
createCourseManagementApi(client).getStatistics(acct.id),
createMemberServices(client).query.getStatistics(acct.id),
createCourseManagementApi(client).statistics.getQuickStats(acct.id),
createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }),
createNewsletterApi(client).listNewsletters(acct.id),
createBookingManagementApi(client).listBookings(acct.id, { page: 1 }),
createEventManagementApi(client).listEvents(acct.id, { page: 1 }),
createBookingManagementApi(client).bookings.list(acct.id, { page: 1 }),
createEventManagementApi(client).events.list(acct.id, { page: 1 }),
]);
const memberStats =
memberStatsResult.status === 'fulfilled'
? memberStatsResult.value
: { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
const memberStatsRaw =
memberStatsResult.status === 'fulfilled' ? memberStatsResult.value : {};
const memberStats = {
total: Object.values(memberStatsRaw).reduce((a, b) => a + b, 0),
active: memberStatsRaw.active ?? 0,
inactive: memberStatsRaw.inactive ?? 0,
pending: memberStatsRaw.pending ?? 0,
resigned: memberStatsRaw.resigned ?? 0,
};
const courseStats =
courseStatsResult.status === 'fulfilled'

View File

@@ -6,6 +6,7 @@ import {
emailSchema,
requiredString,
} from '@kit/next/route-helpers';
import { checkRateLimit, getClientIp } from '@kit/next/routes/rate-limit';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
@@ -20,12 +21,33 @@ const MembershipApplySchema = z.object({
city: z.string().optional(),
dateOfBirth: z.string().optional(),
message: z.string().optional(),
captchaToken: z.string().optional(),
});
// Rate limits
const MAX_PER_IP = 5;
const MAX_PER_ACCOUNT = 20;
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
export async function POST(request: Request) {
const logger = await getLogger();
try {
// Rate limit by IP
const ip = getClientIp(request);
const ipLimit = checkRateLimit(
`membership-apply:ip:${ip}`,
MAX_PER_IP,
WINDOW_MS,
);
if (!ipLimit.allowed) {
return apiError(
'Zu viele Anfragen. Bitte versuchen Sie es später erneut.',
429,
);
}
const body = await request.json();
const parsed = MembershipApplySchema.safeParse(body);
@@ -44,10 +66,48 @@ export async function POST(request: Request) {
city,
dateOfBirth,
message,
captchaToken,
} = parsed.data;
// Rate limit by account
const accountLimit = checkRateLimit(
`membership-apply:account:${accountId}`,
MAX_PER_ACCOUNT,
WINDOW_MS,
);
if (!accountLimit.allowed) {
return apiError('Zu viele Bewerbungen für diese Organisation.', 429);
}
// Verify CAPTCHA when configured — token is required, not optional
if (process.env.CAPTCHA_SECRET_TOKEN) {
if (!captchaToken) {
return apiError('CAPTCHA-Überprüfung erforderlich.', 400);
}
const { verifyCaptchaToken } = await import('@kit/auth/captcha/server');
try {
await verifyCaptchaToken(captchaToken);
} catch {
return apiError('CAPTCHA-Überprüfung fehlgeschlagen.', 400);
}
}
const supabase = getSupabaseServerAdminClient();
// Validate that the account exists before inserting
const { data: account } = await supabase
.from('accounts')
.select('id')
.eq('id', accountId)
.single();
if (!account) {
return apiError('Ungültige Organisation.', 400);
}
const { error } = await supabase.from('membership_applications').insert({
account_id: accountId,
first_name: firstName,

View File

@@ -0,0 +1,87 @@
import { NextResponse } from 'next/server';
import { createMemberNotificationService } from '@kit/member-management/services';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
const CRON_SECRET = process.env.CRON_SECRET;
/**
* Internal cron endpoint for member scheduled jobs.
* Called hourly by pg_cron or external scheduler.
*
* POST /api/internal/cron/member-jobs
* Header: Authorization: Bearer <CRON_SECRET>
*/
export async function POST(request: Request) {
const logger = await getLogger();
// Verify cron secret
const authHeader = request.headers.get('authorization');
const token = authHeader?.replace('Bearer ', '');
if (!CRON_SECRET || token !== CRON_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const client = getSupabaseServerAdminClient();
const notificationService = createMemberNotificationService(client);
// 1. Process pending notification queue
const queueResult = await notificationService.processPendingNotifications();
// 2. Run scheduled jobs for all accounts with enabled jobs
const { data: accounts } = await (client.from as any)(
'scheduled_job_configs',
)
.select('account_id')
.eq('is_enabled', true)
.or(`next_run_at.is.null,next_run_at.lte.${new Date().toISOString()}`);
const uniqueAccountIds = [
...new Set((accounts ?? []).map((a: any) => a.account_id)),
] as string[];
const jobResults: Record<string, unknown> = {};
for (const accountId of uniqueAccountIds) {
try {
const result = await notificationService.runScheduledJobs(accountId);
jobResults[accountId] = result;
} catch (e) {
logger.error(
{ accountId, error: e, context: 'cron-member-jobs' },
'Failed to run jobs for account',
);
jobResults[accountId] = {
error: e instanceof Error ? e.message : 'Unknown error',
};
}
}
const summary = {
timestamp: new Date().toISOString(),
queue: queueResult,
accounts_processed: uniqueAccountIds.length,
jobs: jobResults,
};
logger.info(
{ context: 'cron-member-jobs', ...summary },
'Member cron jobs completed',
);
return NextResponse.json(summary);
} catch (err) {
logger.error(
{ error: err, context: 'cron-member-jobs' },
'Cron job failed',
);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,154 @@
-- =====================================================
-- Atomic Application Workflow
-- Replaces multi-query approve/reject in api.ts with
-- single transactional PG functions.
-- =====================================================
-- approve_application: atomically creates a member from an application
CREATE OR REPLACE FUNCTION public.approve_application(
p_application_id uuid,
p_user_id uuid
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_app record;
v_member_id uuid;
v_member_number text;
BEGIN
-- 1. Fetch and lock the application
SELECT * INTO v_app
FROM public.membership_applications
WHERE id = p_application_id
FOR UPDATE;
IF v_app IS NULL THEN
RAISE EXCEPTION 'Application % not found', p_application_id
USING ERRCODE = 'P0002';
END IF;
-- Authorization: caller must have write permission on this account
IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN
RAISE EXCEPTION 'Access denied to account %', v_app.account_id
USING ERRCODE = '42501';
END IF;
IF v_app.status NOT IN ('submitted', 'review') THEN
RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status
USING ERRCODE = 'P0001';
END IF;
-- 2. Generate next member number
SELECT LPAD(
(COALESCE(
MAX(CASE WHEN member_number ~ '^\d+$' THEN member_number::integer ELSE 0 END),
0
) + 1)::text,
4, '0'
) INTO v_member_number
FROM public.members
WHERE account_id = v_app.account_id;
-- 3. Create the member
INSERT INTO public.members (
account_id,
member_number,
first_name,
last_name,
email,
phone,
street,
postal_code,
city,
date_of_birth,
status,
entry_date,
created_by,
updated_by
) VALUES (
v_app.account_id,
v_member_number,
v_app.first_name,
v_app.last_name,
v_app.email,
v_app.phone,
v_app.street,
v_app.postal_code,
v_app.city,
v_app.date_of_birth,
'active'::public.membership_status,
current_date,
auth.uid(),
auth.uid()
)
RETURNING id INTO v_member_id;
-- 4. Mark application as approved
UPDATE public.membership_applications
SET
status = 'approved'::public.application_status,
reviewed_by = auth.uid(),
reviewed_at = now(),
member_id = v_member_id,
updated_at = now()
WHERE id = p_application_id;
RETURN v_member_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO service_role;
-- reject_application: atomically rejects an application with notes
CREATE OR REPLACE FUNCTION public.reject_application(
p_application_id uuid,
p_user_id uuid,
p_review_notes text DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_app record;
BEGIN
-- Fetch and lock the application
SELECT * INTO v_app
FROM public.membership_applications
WHERE id = p_application_id
FOR UPDATE;
IF v_app IS NULL THEN
RAISE EXCEPTION 'Application % not found', p_application_id
USING ERRCODE = 'P0002';
END IF;
-- Authorization: caller must have write permission on this account
IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN
RAISE EXCEPTION 'Access denied to account %', v_app.account_id
USING ERRCODE = '42501';
END IF;
IF v_app.status NOT IN ('submitted', 'review') THEN
RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status
USING ERRCODE = 'P0001';
END IF;
UPDATE public.membership_applications
SET
status = 'rejected'::public.application_status,
reviewed_by = auth.uid(),
reviewed_at = now(),
review_notes = p_review_notes,
updated_at = now()
WHERE id = p_application_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO service_role;

View File

@@ -0,0 +1,150 @@
-- =====================================================
-- SEPA Data Deduplication (Phase 1)
--
-- Problem: members table has inline SEPA fields (iban, bic,
-- account_holder, sepa_mandate_id, sepa_mandate_date,
-- sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name)
-- AND a separate sepa_mandates table. sepa_mandate_id is text,
-- not a FK to sepa_mandates(id) which is uuid. Data diverges.
--
-- Fix: Add proper primary_mandate_id FK, migrate inline data
-- to sepa_mandates rows, rewrite RPCs to read from sepa_mandates.
-- Inline columns are kept read-only for backward compat (phase 2 drops them).
-- =====================================================
-- Step 1: Add proper FK column pointing to the primary mandate
ALTER TABLE public.members
ADD COLUMN IF NOT EXISTS primary_mandate_id uuid
REFERENCES public.sepa_mandates(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_members_primary_mandate
ON public.members(primary_mandate_id)
WHERE primary_mandate_id IS NOT NULL;
-- Step 2: For members with inline SEPA data but no sepa_mandates row, create one
DO $$
DECLARE
r record;
v_mandate_id uuid;
BEGIN
FOR r IN
SELECT m.id AS member_id, m.account_id,
m.iban, m.bic, m.account_holder,
m.first_name, m.last_name,
m.sepa_mandate_id, m.sepa_mandate_date,
m.sepa_mandate_status, m.sepa_mandate_reference,
m.sepa_mandate_sequence, m.sepa_bank_name
FROM public.members m
WHERE m.iban IS NOT NULL AND m.iban != ''
AND NOT EXISTS (
SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id
)
LOOP
INSERT INTO public.sepa_mandates (
member_id, account_id, mandate_reference, iban, bic,
account_holder, mandate_date, status, sequence, is_primary, notes
) VALUES (
r.member_id,
r.account_id,
COALESCE(NULLIF(r.sepa_mandate_reference, ''), NULLIF(r.sepa_mandate_id, ''), 'MIGRATED-' || r.member_id::text),
r.iban,
r.bic,
COALESCE(NULLIF(r.account_holder, ''), NULLIF(TRIM(COALESCE(r.first_name, '') || ' ' || COALESCE(r.last_name, '')), ''), 'Unbekannt'),
COALESCE(r.sepa_mandate_date, current_date),
COALESCE(r.sepa_mandate_status, 'pending'::public.sepa_mandate_status),
COALESCE(NULLIF(r.sepa_mandate_sequence, ''), 'RCUR'),
true,
CASE WHEN r.sepa_bank_name IS NOT NULL AND r.sepa_bank_name != ''
THEN 'Bank: ' || r.sepa_bank_name
ELSE NULL
END
)
RETURNING id INTO v_mandate_id;
UPDATE public.members SET primary_mandate_id = v_mandate_id WHERE id = r.member_id;
END LOOP;
END $$;
-- Step 3: For members that already have sepa_mandates rows, link the primary one
UPDATE public.members m
SET primary_mandate_id = sm.id
FROM public.sepa_mandates sm
WHERE sm.member_id = m.id
AND sm.is_primary = true
AND m.primary_mandate_id IS NULL;
-- If no mandate marked as primary, pick the most recent active one
UPDATE public.members m
SET primary_mandate_id = (
SELECT sm.id FROM public.sepa_mandates sm
WHERE sm.member_id = m.id
ORDER BY
CASE WHEN sm.status = 'active' THEN 0 ELSE 1 END,
sm.created_at DESC
LIMIT 1
)
WHERE m.primary_mandate_id IS NULL
AND EXISTS (SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id);
-- Step 4: Rewrite list_hierarchy_sepa_eligible_members to read from sepa_mandates
CREATE OR REPLACE FUNCTION public.list_hierarchy_sepa_eligible_members(
root_account_id uuid,
p_account_filter uuid DEFAULT NULL
)
RETURNS TABLE (
member_id uuid,
account_id uuid,
account_name varchar,
first_name text,
last_name text,
iban text,
bic text,
account_holder text,
mandate_id text,
mandate_date date,
dues_amount numeric
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(root_account_id) THEN
RETURN;
END IF;
RETURN QUERY
SELECT
m.id AS member_id,
m.account_id,
a.name AS account_name,
m.first_name,
m.last_name,
sm.iban,
sm.bic,
sm.account_holder,
sm.mandate_reference AS mandate_id,
sm.mandate_date,
COALESCE(dc.amount, 0) AS dues_amount
FROM public.members m
JOIN public.accounts a ON a.id = m.account_id
JOIN public.sepa_mandates sm ON sm.id = m.primary_mandate_id
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
WHERE m.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
AND m.status = 'active'
AND sm.iban IS NOT NULL
AND sm.status = 'active'
AND (p_account_filter IS NULL OR m.account_id = p_account_filter)
ORDER BY a.name, m.last_name, m.first_name;
END;
$$;
-- Step 5: Add partial index for fast SEPA-eligible lookups
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_primary
ON public.sepa_mandates(member_id)
WHERE status = 'active' AND is_primary = true;
-- Note: Inline SEPA columns (iban, bic, account_holder, sepa_mandate_id,
-- sepa_mandate_date, sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name)
-- are kept for read-only backward compatibility. Phase 2 migration will drop them
-- after all code paths are migrated to use sepa_mandates via primary_mandate_id.

View File

@@ -0,0 +1,125 @@
-- =====================================================
-- Soft Delete Consistency
--
-- Problem: deleteMember does a soft delete (status='resigned'),
-- but child table FKs use ON DELETE CASCADE. A hard DELETE
-- would silently destroy roles, honors, mandates, transfers
-- with no audit trail.
--
-- Fix: Change CASCADE to RESTRICT on data-preserving tables,
-- add BEFORE DELETE audit trigger, provide safe_delete_member().
-- =====================================================
-- Step 1: Change ON DELETE CASCADE → RESTRICT on tables where
-- child data has independent value and should be preserved.
-- We must drop and recreate the FK constraints.
-- member_roles: board positions have historical value
ALTER TABLE public.member_roles
DROP CONSTRAINT IF EXISTS member_roles_member_id_fkey;
ALTER TABLE public.member_roles
ADD CONSTRAINT member_roles_member_id_fkey
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
-- member_honors: awards/medals are permanent records
ALTER TABLE public.member_honors
DROP CONSTRAINT IF EXISTS member_honors_member_id_fkey;
ALTER TABLE public.member_honors
ADD CONSTRAINT member_honors_member_id_fkey
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
-- sepa_mandates: financial records must be preserved
ALTER TABLE public.sepa_mandates
DROP CONSTRAINT IF EXISTS sepa_mandates_member_id_fkey;
ALTER TABLE public.sepa_mandates
ADD CONSTRAINT sepa_mandates_member_id_fkey
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
-- member_transfers: audit trail must survive
ALTER TABLE public.member_transfers
DROP CONSTRAINT IF EXISTS member_transfers_member_id_fkey;
ALTER TABLE public.member_transfers
ADD CONSTRAINT member_transfers_member_id_fkey
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
-- Keep CASCADE on tables where data is tightly coupled:
-- member_department_assignments (junction table, no independent value)
-- member_cards (regeneratable)
-- member_portal_invitations (transient)
-- Step 2: Audit trigger before hard delete — snapshot the full record
CREATE OR REPLACE FUNCTION public.audit_member_before_hard_delete()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
-- If an audit_log table exists, log the deletion
INSERT INTO public.audit_log (
account_id, user_id, table_name, record_id, action, old_data
)
SELECT
OLD.account_id,
COALESCE(
nullif(current_setting('app.current_user_id', true), '')::uuid,
auth.uid()
),
'members',
OLD.id::text,
'delete',
to_jsonb(OLD);
RETURN OLD;
EXCEPTION
WHEN undefined_table THEN
-- audit_log table doesn't exist yet, allow delete to proceed
RETURN OLD;
END;
$$;
CREATE TRIGGER trg_members_audit_before_delete
BEFORE DELETE ON public.members
FOR EACH ROW
EXECUTE FUNCTION public.audit_member_before_hard_delete();
-- Step 3: Safe hard-delete function for super-admin use only
-- Archives all child records first, then performs the delete.
CREATE OR REPLACE FUNCTION public.safe_delete_member(
p_member_id uuid,
p_performed_by uuid DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_member record;
BEGIN
-- Fetch member for validation
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
IF v_member IS NULL THEN
RAISE EXCEPTION 'Member % not found', p_member_id
USING ERRCODE = 'P0002';
END IF;
-- Set the user ID for the audit trigger
IF p_performed_by IS NOT NULL THEN
PERFORM set_config('app.current_user_id', p_performed_by::text, true);
END IF;
-- Delete child records that now use RESTRICT
DELETE FROM public.member_roles WHERE member_id = p_member_id;
DELETE FROM public.member_honors WHERE member_id = p_member_id;
DELETE FROM public.sepa_mandates WHERE member_id = p_member_id;
-- member_transfers: delete (the BEFORE DELETE trigger on members already snapshots everything)
DELETE FROM public.member_transfers WHERE member_id = p_member_id;
-- Now the hard delete triggers audit_member_before_hard_delete
DELETE FROM public.members WHERE id = p_member_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.safe_delete_member(uuid, uuid) TO service_role;
-- Intentionally NOT granted to authenticated — super-admin only via admin client

View File

@@ -0,0 +1,61 @@
-- =====================================================
-- Missing Database Constraints
--
-- Adds CHECK constraints for data sanity, UNIQUE index
-- for email per account, and IBAN format validation.
--
-- Fixes existing invalid data before adding constraints.
-- =====================================================
-- Fix existing invalid data before adding constraints
UPDATE public.members SET date_of_birth = NULL
WHERE date_of_birth IS NOT NULL AND date_of_birth > current_date;
UPDATE public.members SET exit_date = entry_date
WHERE exit_date IS NOT NULL AND entry_date IS NOT NULL AND 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
UPDATE public.sepa_mandates
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
WHERE iban IS NOT NULL AND iban != '';
-- Date sanity constraints
ALTER TABLE public.members
ADD CONSTRAINT chk_members_dob_not_future
CHECK (date_of_birth IS NULL OR date_of_birth <= current_date);
ALTER TABLE public.members
ADD CONSTRAINT chk_members_exit_after_entry
CHECK (exit_date IS NULL OR entry_date IS NULL OR exit_date >= entry_date);
ALTER TABLE public.members
ADD CONSTRAINT chk_members_entry_not_future
CHECK (entry_date IS NULL OR entry_date <= current_date);
-- Email uniqueness per account (partial index — allows NULLs and empty strings)
CREATE UNIQUE INDEX IF NOT EXISTS uix_members_email_per_account
ON public.members(account_id, lower(email))
WHERE email IS NOT NULL AND email != '';
-- IBAN format on sepa_mandates (2-letter country + 2 check digits + 11-30 alphanumeric)
ALTER TABLE public.sepa_mandates
ADD CONSTRAINT chk_sepa_iban_format
CHECK (iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$');
-- Mandate reference must not be empty
ALTER TABLE public.sepa_mandates
ADD CONSTRAINT chk_sepa_mandate_reference_not_empty
CHECK (mandate_reference IS NOT NULL AND mandate_reference != '');
-- Member roles: from_date should not be after until_date
ALTER TABLE public.member_roles
ADD CONSTRAINT chk_member_roles_date_range
CHECK (until_date IS NULL OR from_date IS NULL OR until_date >= from_date);
-- Dues categories: amount must be non-negative
ALTER TABLE public.dues_categories
ADD CONSTRAINT chk_dues_amount_non_negative
CHECK (amount >= 0);

View File

@@ -0,0 +1,31 @@
-- =====================================================
-- Optimistic Locking via Version Column
--
-- Problem: Two admins editing the same member silently
-- overwrite each other's changes. Last write wins.
--
-- Fix: Add version column, auto-increment on update.
-- API layer checks version match before writing.
-- =====================================================
-- Add version column
ALTER TABLE public.members
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
-- Auto-increment version on every update
CREATE OR REPLACE FUNCTION public.increment_member_version()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
NEW.version := OLD.version + 1;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_members_increment_version
BEFORE UPDATE ON public.members
FOR EACH ROW
EXECUTE FUNCTION public.increment_member_version();

View File

@@ -0,0 +1,155 @@
-- =====================================================
-- Event-Member Linkage
--
-- Problem: event_registrations links to members by email
-- only. If a member changes their email, event history is
-- lost. transfer_member matches by email — fragile.
--
-- Fix: Add member_id FK to event_registrations, backfill
-- from email matches, update transfer_member.
-- =====================================================
-- Add member_id FK column
ALTER TABLE public.event_registrations
ADD COLUMN IF NOT EXISTS member_id uuid
REFERENCES public.members(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS ix_event_registrations_member
ON public.event_registrations(member_id)
WHERE member_id IS NOT NULL;
-- 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
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 != '';
-- Update transfer_member to count active events via member_id instead of email
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
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_source_account_id uuid;
v_source_name varchar;
v_target_name varchar;
v_active_courses bigint;
v_active_events bigint;
v_cleared_data jsonb;
v_transfer_id uuid;
v_member record;
BEGIN
-- Get current member state
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
IF v_member IS NULL THEN
RAISE EXCEPTION 'Member not found';
END IF;
v_source_account_id := v_member.account_id;
-- Verify target account exists
IF NOT EXISTS (SELECT 1 FROM public.accounts WHERE id = p_target_account_id) THEN
RAISE EXCEPTION 'Target account not found';
END IF;
-- Ensure caller has access to source account
IF NOT public.has_role_on_account_or_ancestor(v_source_account_id) THEN
RAISE EXCEPTION 'Access denied to source account';
END IF;
-- Same account? No-op
IF v_source_account_id = p_target_account_id THEN
RAISE EXCEPTION 'Cannot transfer member to the same account';
END IF;
-- Ensure both accounts share a common ancestor
IF NOT EXISTS (
SELECT 1
FROM public.get_account_ancestors(v_source_account_id) sa
JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta
) THEN
RAISE EXCEPTION 'Source and target accounts do not share a common ancestor (Verband)';
END IF;
-- Get org names for the transfer note
SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id;
SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id;
-- Count active relationships (informational, for the log)
SELECT count(*) INTO v_active_courses
FROM public.course_participants cp
JOIN public.courses c ON c.id = cp.course_id
WHERE cp.member_id = p_member_id AND cp.status = 'enrolled';
-- Use member_id for event lookups instead of fragile email matching
SELECT count(*) INTO v_active_events
FROM public.event_registrations er
JOIN public.events e ON e.id = er.event_id
WHERE er.member_id = p_member_id
AND er.status IN ('confirmed', 'pending')
AND e.event_date >= current_date;
-- Perform the transfer
UPDATE public.members
SET
account_id = p_target_account_id,
-- Clear org-specific admin data
dues_category_id = NULL,
member_number = NULL,
-- Clear primary_mandate_id FK (mandate needs re-confirmation in new org)
primary_mandate_id = NULL,
-- Legacy inline SEPA fields (deprecated, kept for backward compat)
sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END,
sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END,
sepa_mandate_status = 'pending',
-- Append transfer note
notes = COALESCE(notes, '') ||
E'\n[Transfer ' || to_char(now(), 'YYYY-MM-DD') || '] ' ||
v_source_name || '' || v_target_name ||
COALESCE(' | Grund: ' || p_reason, ''),
is_transferred = true
WHERE id = p_member_id;
-- Reset SEPA mandate(s) in the mandates table
UPDATE public.sepa_mandates
SET status = 'pending'
WHERE member_id = p_member_id
AND status = 'active';
-- Build cleared data snapshot for the transfer log
v_cleared_data := jsonb_build_object(
'member_number', v_member.member_number,
'dues_category_id', v_member.dues_category_id,
'active_courses', v_active_courses,
'active_events', v_active_events
);
-- Create transfer log entry
INSERT INTO public.member_transfers (
member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data
) VALUES (
p_member_id,
v_source_account_id,
p_target_account_id,
COALESCE(nullif(current_setting('app.current_user_id', true), '')::uuid, auth.uid()),
p_reason,
v_cleared_data
)
RETURNING id INTO v_transfer_id;
RETURN v_transfer_id;
END;
$$;

View File

@@ -0,0 +1,260 @@
-- =====================================================
-- Member Audit Log
--
-- Full change history for compliance: who changed what
-- field, old value→new value, when. Plus activity timeline.
-- =====================================================
-- 1. Audit log table
CREATE TABLE IF NOT EXISTS public.member_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
action text NOT NULL CHECK (action IN (
'created', 'updated', 'status_changed', 'archived', 'unarchived',
'department_assigned', 'department_removed',
'role_assigned', 'role_removed',
'honor_awarded', 'honor_removed',
'mandate_created', 'mandate_updated', 'mandate_revoked',
'transferred', 'merged',
'application_approved', 'application_rejected',
'portal_invited', 'portal_linked',
'card_generated',
'imported', 'exported',
'gdpr_consent_changed', 'gdpr_anonymized',
'tag_added', 'tag_removed',
'communication_logged', 'note_added',
'bulk_status_changed', 'bulk_archived'
)),
changes jsonb NOT NULL DEFAULT '{}',
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.member_audit_log IS
'Immutable audit trail for all member lifecycle events';
CREATE INDEX ix_member_audit_member
ON public.member_audit_log(member_id, created_at DESC);
CREATE INDEX ix_member_audit_account
ON public.member_audit_log(account_id, created_at DESC);
CREATE INDEX ix_member_audit_user
ON public.member_audit_log(user_id)
WHERE user_id IS NOT NULL;
CREATE INDEX ix_member_audit_action
ON public.member_audit_log(account_id, action);
ALTER TABLE public.member_audit_log ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_audit_log FROM authenticated, service_role;
GRANT SELECT ON public.member_audit_log TO authenticated;
GRANT ALL ON public.member_audit_log TO service_role;
-- Read access: must have role on the account
CREATE POLICY member_audit_log_select
ON public.member_audit_log FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- No direct insert/update/delete for authenticated — only via SECURITY DEFINER functions
-- 2. Auto-audit trigger on members UPDATE
-- Computes field-by-field diff and classifies the action type.
CREATE OR REPLACE FUNCTION public.trg_member_audit_on_update()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_changes jsonb := '{}'::jsonb;
v_user_id uuid;
v_action text;
v_old jsonb;
v_new jsonb;
v_key text;
BEGIN
v_user_id := nullif(current_setting('app.current_user_id', true), '')::uuid;
v_old := to_jsonb(OLD);
v_new := to_jsonb(NEW);
-- Compare each field, skip meta columns
FOR v_key IN
SELECT jsonb_object_keys(v_new)
EXCEPT
SELECT unnest(ARRAY['updated_at', 'updated_by', 'version'])
LOOP
IF (v_old -> v_key) IS DISTINCT FROM (v_new -> v_key) THEN
v_changes := v_changes || jsonb_build_object(
v_key, jsonb_build_object('old', v_old -> v_key, 'new', v_new -> v_key)
);
END IF;
END LOOP;
-- Skip if nothing actually changed
IF v_changes = '{}'::jsonb THEN
RETURN NEW;
END IF;
-- Classify the action
IF (v_old ->> 'status') IS DISTINCT FROM (v_new ->> 'status') THEN
v_action := 'status_changed';
ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived')
AND COALESCE((v_new ->> 'is_archived'), 'false') = 'true' THEN
v_action := 'archived';
ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived') THEN
v_action := 'unarchived';
ELSE
v_action := 'updated';
END IF;
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes)
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_members_audit_on_update
AFTER UPDATE ON public.members
FOR EACH ROW
EXECUTE FUNCTION public.trg_member_audit_on_update();
-- 3. Auto-audit trigger on members INSERT
CREATE OR REPLACE FUNCTION public.trg_member_audit_on_insert()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_user_id uuid;
BEGIN
v_user_id := COALESCE(
nullif(current_setting('app.current_user_id', true), '')::uuid,
NEW.created_by
);
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
VALUES (
NEW.id, NEW.account_id, v_user_id, 'created',
jsonb_build_object(
'member_number', NEW.member_number,
'first_name', NEW.first_name,
'last_name', NEW.last_name,
'status', NEW.status
)
);
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_members_audit_on_insert
AFTER INSERT ON public.members
FOR EACH ROW
EXECUTE FUNCTION public.trg_member_audit_on_insert();
-- 4. Helper function to log explicit audit events (for related tables)
CREATE OR REPLACE FUNCTION public.log_member_audit_event(
p_member_id uuid,
p_account_id uuid,
p_action text,
p_changes jsonb DEFAULT '{}',
p_metadata jsonb DEFAULT '{}'
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
-- Verify caller has access to the account
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- Force user_id to be the actual caller
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes, metadata)
VALUES (p_member_id, p_account_id, auth.uid(), p_action, p_changes, p_metadata);
END;
$$;
GRANT EXECUTE ON FUNCTION public.log_member_audit_event(uuid, uuid, text, jsonb, jsonb)
TO authenticated, service_role;
-- 5. Activity timeline RPC (read layer on audit log)
CREATE OR REPLACE FUNCTION public.get_member_timeline(
p_member_id uuid,
p_page int DEFAULT 1,
p_page_size int DEFAULT 50,
p_action_filter text DEFAULT NULL
)
RETURNS TABLE (
id bigint,
action text,
changes jsonb,
metadata jsonb,
user_id uuid,
user_display_name text,
created_at timestamptz,
total_count bigint
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_account_id uuid;
v_total bigint;
v_offset int;
BEGIN
-- Get member's account for access check
SELECT m.account_id INTO v_account_id
FROM public.members m WHERE m.id = p_member_id;
IF v_account_id IS NULL THEN
RAISE EXCEPTION 'Member not found';
END IF;
IF NOT public.has_role_on_account(v_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
-- Clamp page size to prevent unbounded queries
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
-- Get total count
SELECT count(*) INTO v_total
FROM public.member_audit_log al
WHERE al.member_id = p_member_id
AND (p_action_filter IS NULL OR al.action = p_action_filter);
-- Return paginated results with user names
RETURN QUERY
SELECT
al.id,
al.action,
al.changes,
al.metadata,
al.user_id,
COALESCE(
u.raw_user_meta_data ->> 'display_name',
u.email,
al.user_id::text
) AS user_display_name,
al.created_at,
v_total AS total_count
FROM public.member_audit_log al
LEFT JOIN auth.users u ON u.id = al.user_id
WHERE al.member_id = p_member_id
AND (p_action_filter IS NULL OR al.action = p_action_filter)
ORDER BY al.created_at DESC
OFFSET v_offset
LIMIT p_page_size;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_timeline(uuid, int, int, text)
TO authenticated;

View File

@@ -0,0 +1,144 @@
-- =====================================================
-- Member Communications Tracking
--
-- Records all communications with/about members:
-- emails sent, phone calls, notes, letters, meetings.
-- Communications are append-only for authenticated users.
-- Only service_role (admin) can delete.
-- Integrates with audit log via triggers.
-- =====================================================
CREATE TABLE IF NOT EXISTS public.member_communications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
type text NOT NULL CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
direction text NOT NULL DEFAULT 'outbound' CHECK (direction IN ('inbound', 'outbound', 'internal')),
subject text CHECK (subject IS NULL OR length(subject) <= 500),
body text CHECK (body IS NULL OR length(body) <= 50000),
-- Email-specific fields
email_to text,
email_cc text,
email_message_id text,
-- Attachment references (Supabase Storage paths)
attachment_paths text[] CHECK (attachment_paths IS NULL OR array_length(attachment_paths, 1) <= 10),
-- Audit
created_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.member_communications IS
'Communication log per member — emails, calls, notes, letters, meetings. Append-only for regular users.';
CREATE INDEX ix_member_comms_member
ON public.member_communications(member_id, created_at DESC);
CREATE INDEX ix_member_comms_account
ON public.member_communications(account_id, created_at DESC);
CREATE INDEX ix_member_comms_type
ON public.member_communications(account_id, type);
ALTER TABLE public.member_communications ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_communications FROM authenticated, service_role;
-- Append-only: authenticated users can SELECT + INSERT, not UPDATE/DELETE
GRANT SELECT, INSERT ON public.member_communications TO authenticated;
GRANT ALL ON public.member_communications TO service_role;
-- Read: must have a role on the account
CREATE POLICY member_comms_select
ON public.member_communications FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- Insert: must have members.write permission
CREATE POLICY member_comms_insert
ON public.member_communications FOR INSERT TO authenticated
WITH CHECK (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- No UPDATE/DELETE policies for authenticated — communications are immutable
-- service_role can still delete via admin client when necessary
-- Auto-log to audit trail on communication INSERT
CREATE OR REPLACE FUNCTION public.trg_member_comm_audit_insert()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.member_audit_log (
member_id, account_id, user_id, action, metadata
) VALUES (
NEW.member_id,
NEW.account_id,
NEW.created_by,
'communication_logged',
jsonb_build_object(
'communication_id', NEW.id,
'type', NEW.type,
'direction', NEW.direction,
'subject', NEW.subject
)
);
RETURN NEW;
EXCEPTION WHEN OTHERS THEN
-- Audit failure should not block the insert
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_member_comms_audit_insert
AFTER INSERT ON public.member_communications
FOR EACH ROW
EXECUTE FUNCTION public.trg_member_comm_audit_insert();
-- Safe delete function for admin use — logs before deleting
CREATE OR REPLACE FUNCTION public.delete_member_communication(
p_communication_id uuid,
p_account_id uuid
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_comm record;
BEGIN
-- Verify caller has access
IF NOT public.has_permission(auth.uid(), p_account_id, 'members.write'::public.app_permissions) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- Fetch the communication for audit
SELECT * INTO v_comm
FROM public.member_communications
WHERE id = p_communication_id AND account_id = p_account_id;
IF v_comm IS NULL THEN
RAISE EXCEPTION 'Communication not found' USING ERRCODE = 'P0002';
END IF;
-- Log deletion to audit trail
INSERT INTO public.member_audit_log (
member_id, account_id, user_id, action, metadata
) VALUES (
v_comm.member_id,
v_comm.account_id,
auth.uid(),
'communication_logged',
jsonb_build_object(
'deleted_communication_id', v_comm.id,
'type', v_comm.type,
'direction', v_comm.direction,
'subject', v_comm.subject,
'action_detail', 'deleted'
)
);
-- Delete via service_role context (SECURITY DEFINER bypasses RLS)
DELETE FROM public.member_communications
WHERE id = p_communication_id AND account_id = p_account_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.delete_member_communication(uuid, uuid)
TO authenticated, service_role;

View File

@@ -0,0 +1,116 @@
-- =====================================================
-- Member Tags / Labels System
--
-- Flexible, colored tags for member categorization
-- beyond departments (e.g., "Vorstand-Kandidat",
-- "Beitragsrückstand", "Newsletter-Opt-Out").
-- =====================================================
-- Tag definitions (per account)
CREATE TABLE IF NOT EXISTS public.member_tags (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
name text NOT NULL,
color text NOT NULL DEFAULT '#6B7280',
description text,
sort_order int NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(account_id, name)
);
COMMENT ON TABLE public.member_tags IS
'Colored labels for flexible member categorization';
CREATE INDEX ix_member_tags_account
ON public.member_tags(account_id, sort_order);
ALTER TABLE public.member_tags ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_tags FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_tags TO authenticated;
GRANT ALL ON public.member_tags TO service_role;
CREATE POLICY member_tags_select
ON public.member_tags FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
CREATE POLICY member_tags_mutate
ON public.member_tags FOR ALL TO authenticated
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- Tag assignments (member ↔ tag junction)
CREATE TABLE IF NOT EXISTS public.member_tag_assignments (
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES public.member_tags(id) ON DELETE CASCADE,
assigned_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (member_id, tag_id)
);
COMMENT ON TABLE public.member_tag_assignments IS
'Junction table linking members to tags';
CREATE INDEX ix_member_tag_assignments_tag
ON public.member_tag_assignments(tag_id);
ALTER TABLE public.member_tag_assignments ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_tag_assignments FROM authenticated, service_role;
GRANT SELECT, INSERT, DELETE ON public.member_tag_assignments TO authenticated;
GRANT ALL ON public.member_tag_assignments TO service_role;
-- Read: via member's account
CREATE POLICY mta_select
ON public.member_tag_assignments FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.members m
WHERE m.id = member_tag_assignments.member_id
AND public.has_role_on_account(m.account_id)
));
-- Write: via member's account with write permission
CREATE POLICY mta_mutate
ON public.member_tag_assignments FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM public.members m
WHERE m.id = member_tag_assignments.member_id
AND public.has_permission(auth.uid(), m.account_id, 'members.write'::public.app_permissions)
));
-- Audit triggers for tag assignment/removal
CREATE OR REPLACE FUNCTION public.trg_tag_assignment_audit()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_account_id uuid;
v_tag_name text;
BEGIN
IF TG_OP = 'INSERT' THEN
SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = NEW.member_id;
SELECT t.name INTO v_tag_name FROM public.member_tags t WHERE t.id = NEW.tag_id;
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
VALUES (NEW.member_id, v_account_id, NEW.assigned_by, 'tag_added',
jsonb_build_object('tag_id', NEW.tag_id, 'tag_name', v_tag_name));
RETURN NEW;
ELSIF TG_OP = 'DELETE' THEN
SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = OLD.member_id;
SELECT t.name INTO v_tag_name FROM public.member_tags t WHERE t.id = OLD.tag_id;
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
VALUES (OLD.member_id, v_account_id, auth.uid(), 'tag_removed',
jsonb_build_object('tag_id', OLD.tag_id, 'tag_name', v_tag_name));
RETURN OLD;
END IF;
RETURN NULL;
EXCEPTION WHEN OTHERS THEN
-- Audit failure should not block the operation
IF TG_OP = 'INSERT' THEN RETURN NEW; ELSE RETURN OLD; END IF;
END;
$$;
CREATE TRIGGER trg_tag_assignment_audit
AFTER INSERT OR DELETE ON public.member_tag_assignments
FOR EACH ROW
EXECUTE FUNCTION public.trg_tag_assignment_audit();

View File

@@ -0,0 +1,274 @@
-- =====================================================
-- Member Merge / Deduplication
--
-- Atomic function to merge two member records:
-- picks field values, moves all references, archives secondary.
-- =====================================================
-- Merge log table for audit trail and potential undo
CREATE TABLE IF NOT EXISTS public.member_merges (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
primary_member_id uuid NOT NULL,
secondary_member_id uuid NOT NULL,
secondary_snapshot jsonb NOT NULL,
field_choices jsonb NOT NULL,
references_moved jsonb NOT NULL,
performed_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
performed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ix_member_merges_account ON public.member_merges(account_id);
ALTER TABLE public.member_merges ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_merges FROM authenticated, service_role;
GRANT SELECT ON public.member_merges TO authenticated;
GRANT ALL ON public.member_merges TO service_role;
CREATE POLICY member_merges_select
ON public.member_merges FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- Atomic merge function
CREATE OR REPLACE FUNCTION public.merge_members(
p_primary_id uuid,
p_secondary_id uuid,
p_field_choices jsonb DEFAULT '{}',
p_performed_by uuid DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_primary record;
v_secondary record;
v_account_id uuid;
v_user_id uuid;
v_refs_moved jsonb := '{}'::jsonb;
v_count int;
v_field text;
v_choice text;
v_update jsonb := '{}'::jsonb;
BEGIN
v_user_id := COALESCE(p_performed_by, auth.uid());
-- 1. Fetch both members
SELECT * INTO v_primary FROM public.members WHERE id = p_primary_id;
SELECT * INTO v_secondary FROM public.members WHERE id = p_secondary_id;
IF v_primary IS NULL THEN RAISE EXCEPTION 'Primary member not found'; END IF;
IF v_secondary IS NULL THEN RAISE EXCEPTION 'Secondary member not found'; END IF;
IF v_primary.account_id != v_secondary.account_id THEN
RAISE EXCEPTION 'Members must belong to the same account';
END IF;
v_account_id := v_primary.account_id;
-- Verify caller access
IF NOT public.has_permission(auth.uid(), v_account_id, 'members.write'::public.app_permissions) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- 2. Apply field choices: for each conflicting field, pick primary or secondary value
FOR v_field, v_choice IN SELECT * FROM jsonb_each_text(p_field_choices)
LOOP
-- Validate choice value
IF v_choice NOT IN ('primary', 'secondary') THEN
RAISE EXCEPTION 'Invalid choice "%" for field "%". Must be "primary" or "secondary"', v_choice, v_field;
END IF;
-- Whitelist of mergeable fields (no IDs, FKs, or system columns)
IF v_field NOT IN (
'first_name', 'last_name', 'email', 'phone', 'mobile', 'phone2', 'fax',
'street', 'house_number', 'street2', 'postal_code', 'city', 'country',
'date_of_birth', 'gender', 'title', 'salutation', 'birthplace', 'birth_country',
'notes', 'guardian_name', 'guardian_phone', 'guardian_email'
) THEN
RAISE EXCEPTION 'Field "%" cannot be merged', v_field;
END IF;
IF v_choice = 'secondary' THEN
v_update := v_update || jsonb_build_object(v_field, to_jsonb(v_secondary) -> v_field);
END IF;
END LOOP;
-- Apply chosen fields to primary
IF v_update != '{}'::jsonb THEN
-- Build dynamic UPDATE
EXECUTE format(
'UPDATE public.members SET %s WHERE id = $1',
(SELECT string_agg(format('%I = %L', key, value #>> '{}'), ', ')
FROM jsonb_each(v_update))
) USING p_primary_id;
END IF;
-- 3. Move references from secondary to primary
-- Department assignments
SELECT count(*) INTO v_count FROM public.member_department_assignments WHERE member_id = p_secondary_id;
INSERT INTO public.member_department_assignments (member_id, department_id)
SELECT p_primary_id, department_id
FROM public.member_department_assignments
WHERE member_id = p_secondary_id
ON CONFLICT (member_id, department_id) DO NOTHING;
DELETE FROM public.member_department_assignments WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('departments', v_count);
-- Roles
SELECT count(*) INTO v_count FROM public.member_roles WHERE member_id = p_secondary_id;
UPDATE public.member_roles SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('roles', v_count);
-- Honors
SELECT count(*) INTO v_count FROM public.member_honors WHERE member_id = p_secondary_id;
UPDATE public.member_honors SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('honors', v_count);
-- SEPA mandates
SELECT count(*) INTO v_count FROM public.sepa_mandates WHERE member_id = p_secondary_id;
UPDATE public.sepa_mandates SET member_id = p_primary_id, is_primary = false WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('mandates', v_count);
-- Member cards
SELECT count(*) INTO v_count FROM public.member_cards WHERE member_id = p_secondary_id;
UPDATE public.member_cards SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('cards', v_count);
-- Portal invitations
SELECT count(*) INTO v_count FROM public.member_portal_invitations WHERE member_id = p_secondary_id;
UPDATE public.member_portal_invitations SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('invitations', v_count);
-- Tag assignments
BEGIN
SELECT count(*) INTO v_count FROM public.member_tag_assignments WHERE member_id = p_secondary_id;
INSERT INTO public.member_tag_assignments (member_id, tag_id, assigned_by)
SELECT p_primary_id, tag_id, assigned_by
FROM public.member_tag_assignments
WHERE member_id = p_secondary_id
ON CONFLICT (member_id, tag_id) DO NOTHING;
DELETE FROM public.member_tag_assignments WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('tags', v_count);
EXCEPTION WHEN undefined_table THEN NULL; -- tags table may not exist yet
END;
-- Event registrations (if member_id column exists)
BEGIN
SELECT count(*) INTO v_count FROM public.event_registrations WHERE member_id = p_secondary_id;
UPDATE public.event_registrations SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('events', v_count);
EXCEPTION WHEN undefined_column THEN NULL;
END;
-- Communications
BEGIN
SELECT count(*) INTO v_count FROM public.member_communications WHERE member_id = p_secondary_id;
UPDATE public.member_communications SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('communications', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Course participants
BEGIN
SELECT count(*) INTO v_count FROM public.course_participants WHERE member_id = p_secondary_id;
UPDATE public.course_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('courses', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Catch books (Fischerei)
BEGIN
SELECT count(*) INTO v_count FROM public.catch_books WHERE member_id = p_secondary_id;
UPDATE public.catch_books SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('catch_books', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Catches
BEGIN
SELECT count(*) INTO v_count FROM public.catches WHERE member_id = p_secondary_id;
UPDATE public.catches SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('catches', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Water leases
BEGIN
SELECT count(*) INTO v_count FROM public.water_leases WHERE member_id = p_secondary_id;
UPDATE public.water_leases SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('water_leases', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Competition participants
BEGIN
SELECT count(*) INTO v_count FROM public.competition_participants WHERE member_id = p_secondary_id;
UPDATE public.competition_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('competitions', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Invoices
BEGIN
SELECT count(*) INTO v_count FROM public.invoices WHERE member_id = p_secondary_id;
UPDATE public.invoices SET member_id = p_primary_id WHERE member_id = p_secondary_id;
v_refs_moved := v_refs_moved || jsonb_build_object('invoices', v_count);
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Audit log entries
UPDATE public.member_audit_log SET member_id = p_primary_id WHERE member_id = p_secondary_id;
-- 4. Merge custom_data (union of keys, primary wins on conflicts)
UPDATE public.members
SET custom_data = v_secondary.custom_data || v_primary.custom_data
WHERE id = p_primary_id;
-- 5. Append merge note
UPDATE public.members
SET notes = COALESCE(notes, '') ||
E'\n[Zusammenführung ' || to_char(now(), 'YYYY-MM-DD') || '] ' ||
'Zusammengeführt mit ' || v_secondary.first_name || ' ' || v_secondary.last_name ||
COALESCE(' (Nr. ' || v_secondary.member_number || ')', '')
WHERE id = p_primary_id;
-- 6. Archive the secondary member
UPDATE public.members
SET status = 'resigned', is_archived = true,
exit_date = current_date, exit_reason = 'Zusammenführung mit Mitglied ' || p_primary_id::text,
notes = COALESCE(notes, '') || E'\n[Zusammenführung] Archiviert zugunsten von ' || v_primary.first_name || ' ' || v_primary.last_name
WHERE id = p_secondary_id;
-- 7. Create merge log entry
INSERT INTO public.member_merges (
account_id, primary_member_id, secondary_member_id,
secondary_snapshot, field_choices, references_moved, performed_by
) VALUES (
v_account_id, p_primary_id, p_secondary_id,
to_jsonb(v_secondary), p_field_choices, v_refs_moved, v_user_id
);
-- 8. Audit log
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
VALUES (p_primary_id, v_account_id, v_user_id, 'merged',
jsonb_build_object(
'secondary_member_id', p_secondary_id,
'secondary_name', v_secondary.first_name || ' ' || v_secondary.last_name,
'references_moved', v_refs_moved,
'field_choices', p_field_choices
)
);
RETURN jsonb_build_object(
'primary_id', p_primary_id,
'secondary_id', p_secondary_id,
'references_moved', v_refs_moved
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.merge_members(uuid, uuid, jsonb, uuid)
TO authenticated, service_role;

View File

@@ -0,0 +1,170 @@
-- =====================================================
-- GDPR Data Retention Automation
--
-- Configurable retention policies per account.
-- Automatic anonymization of resigned/excluded/deceased
-- members after retention period expires.
-- =====================================================
-- Retention policy configuration per account
CREATE TABLE IF NOT EXISTS public.gdpr_retention_policies (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
policy_name text NOT NULL DEFAULT 'Standard',
retention_days int NOT NULL DEFAULT 1095, -- 3 years
auto_anonymize boolean NOT NULL DEFAULT false,
applies_to_status text[] NOT NULL DEFAULT ARRAY['resigned', 'excluded', 'deceased'],
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(account_id)
);
ALTER TABLE public.gdpr_retention_policies ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.gdpr_retention_policies FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE ON public.gdpr_retention_policies TO authenticated;
GRANT ALL ON public.gdpr_retention_policies TO service_role;
CREATE POLICY gdpr_retention_select
ON public.gdpr_retention_policies FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
CREATE POLICY gdpr_retention_mutate
ON public.gdpr_retention_policies FOR ALL TO authenticated
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- Anonymize a single member (replaces all PII with placeholder)
CREATE OR REPLACE FUNCTION public.anonymize_member(
p_member_id uuid,
p_performed_by uuid DEFAULT NULL
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_member record;
v_user_id uuid;
BEGIN
v_user_id := COALESCE(p_performed_by, auth.uid());
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
IF v_member IS NULL THEN
RAISE EXCEPTION 'Member not found';
END IF;
-- Verify caller access
IF v_user_id IS NOT NULL AND NOT public.has_permission(v_user_id, v_member.account_id, 'members.write'::public.app_permissions) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- Snapshot full record to audit log before anonymization
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
VALUES (
p_member_id, v_member.account_id, v_user_id, 'gdpr_anonymized',
jsonb_build_object(
'original_first_name', v_member.first_name,
'original_last_name', v_member.last_name,
'original_email', v_member.email,
'reason', 'GDPR retention policy'
)
);
-- Replace all PII with anonymized placeholders
UPDATE public.members SET
first_name = 'ANONYMISIERT',
last_name = 'ANONYMISIERT',
email = NULL,
phone = NULL,
mobile = NULL,
phone2 = NULL,
fax = NULL,
street = NULL,
house_number = NULL,
street2 = NULL,
postal_code = NULL,
city = NULL,
date_of_birth = NULL,
birthplace = NULL,
birth_country = NULL,
iban = NULL,
bic = NULL,
account_holder = NULL,
sepa_mandate_reference = NULL,
sepa_mandate_id = NULL,
primary_mandate_id = NULL,
guardian_name = NULL,
guardian_phone = NULL,
guardian_email = NULL,
notes = '[GDPR anonymisiert am ' || to_char(now(), 'YYYY-MM-DD') || ']',
custom_data = '{}'::jsonb,
online_access_key = NULL,
online_access_blocked = true,
gdpr_consent = false,
gdpr_newsletter = false,
gdpr_internet = false,
gdpr_print = false,
gdpr_birthday_info = false,
is_archived = true,
updated_by = v_user_id
WHERE id = p_member_id;
-- Anonymize SEPA mandates (can't DELETE due to ON DELETE RESTRICT from Phase 1)
-- primary_mandate_id already cleared above in the members UPDATE
-- Anonymize SEPA PII fields (keep row for audit, revoke mandate)
UPDATE public.sepa_mandates
SET iban = 'DE00ANON0000000000000', bic = NULL, account_holder = 'ANONYMISIERT',
mandate_reference = 'ANON-' || id::text, status = 'revoked',
notes = '[GDPR anonymisiert]'
WHERE member_id = p_member_id;
-- Remove communications (may contain PII)
BEGIN
DELETE FROM public.member_communications WHERE member_id = p_member_id;
EXCEPTION WHEN undefined_table THEN NULL;
END;
-- Remove portal invitations
DELETE FROM public.member_portal_invitations WHERE member_id = p_member_id;
END;
$$;
GRANT EXECUTE ON FUNCTION public.anonymize_member(uuid, uuid)
TO authenticated, service_role;
-- Batch enforcement: find and anonymize members matching retention criteria
CREATE OR REPLACE FUNCTION public.enforce_gdpr_retention_policies()
RETURNS int
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_policy record;
v_member record;
v_count int := 0;
BEGIN
FOR v_policy IN
SELECT * FROM public.gdpr_retention_policies
WHERE auto_anonymize = true
LOOP
FOR v_member IN
SELECT m.id
FROM public.members m
WHERE m.account_id = v_policy.account_id
AND m.status = ANY(v_policy.applies_to_status::public.membership_status[])
AND m.first_name != 'ANONYMISIERT' -- not already anonymized
AND m.exit_date IS NOT NULL -- only retain based on actual exit date
AND m.exit_date + (v_policy.retention_days || ' days')::interval <= current_date
LOOP
PERFORM public.anonymize_member(v_member.id, NULL);
v_count := v_count + 1;
END LOOP;
END LOOP;
RETURN v_count;
END;
$$;
GRANT EXECUTE ON FUNCTION public.enforce_gdpr_retention_policies()
TO service_role;

View File

@@ -0,0 +1,295 @@
-- =====================================================
-- Reporting & Analytics RPC Functions
--
-- Enterprise-grade reporting: demographics, retention,
-- geographic distribution, dues collection, membership
-- duration analysis.
-- =====================================================
-- 1. Age demographics by gender
CREATE OR REPLACE FUNCTION public.get_member_demographics(p_account_id uuid)
RETURNS TABLE (
age_group text,
male_count bigint,
female_count bigint,
diverse_count bigint,
unknown_count bigint,
total bigint
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
SELECT
CASE
WHEN age < 18 THEN 'Unter 18'
WHEN age BETWEEN 18 AND 30 THEN '18-30'
WHEN age BETWEEN 31 AND 50 THEN '31-50'
WHEN age BETWEEN 51 AND 65 THEN '51-65'
WHEN age > 65 THEN 'Über 65'
ELSE 'Unbekannt'
END AS age_group,
count(*) FILTER (WHERE m.gender = 'male') AS male_count,
count(*) FILTER (WHERE m.gender = 'female') AS female_count,
count(*) FILTER (WHERE m.gender = 'diverse') AS diverse_count,
count(*) FILTER (WHERE m.gender IS NULL OR m.gender NOT IN ('male', 'female', 'diverse')) AS unknown_count,
count(*) AS total
FROM public.members m
LEFT JOIN LATERAL (
SELECT CASE
WHEN m.date_of_birth IS NOT NULL THEN
extract(year FROM age(current_date, m.date_of_birth))::int
ELSE NULL
END AS age
) ages ON true
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
GROUP BY age_group
ORDER BY
CASE age_group
WHEN 'Unter 18' THEN 1
WHEN '18-30' THEN 2
WHEN '31-50' THEN 3
WHEN '51-65' THEN 4
WHEN 'Über 65' THEN 5
ELSE 6
END;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_demographics(uuid) TO authenticated;
-- 2. Year-over-year membership retention
CREATE OR REPLACE FUNCTION public.get_member_retention(
p_account_id uuid,
p_years int DEFAULT 5
)
RETURNS TABLE (
year int,
members_start bigint,
new_members bigint,
resigned_members bigint,
members_end bigint,
retention_rate numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
WITH years AS (
SELECT generate_series(
extract(year FROM current_date)::int - p_years + 1,
extract(year FROM current_date)::int
) AS yr
),
stats AS (
SELECT
y.yr,
count(*) FILTER (WHERE m.entry_date < make_date(y.yr, 1, 1)
AND (m.exit_date IS NULL OR m.exit_date >= make_date(y.yr, 1, 1))) AS members_start,
count(*) FILTER (WHERE extract(year FROM m.entry_date) = y.yr) AS new_members,
count(*) FILTER (WHERE extract(year FROM m.exit_date) = y.yr) AS resigned_members,
count(*) FILTER (WHERE m.entry_date <= make_date(y.yr, 12, 31)
AND (m.exit_date IS NULL OR m.exit_date > make_date(y.yr, 12, 31))) AS members_end
FROM years y
CROSS JOIN public.members m
WHERE m.account_id = p_account_id AND m.is_archived = false
GROUP BY y.yr
)
SELECT
s.yr AS year,
s.members_start,
s.new_members,
s.resigned_members,
s.members_end,
CASE WHEN s.members_start > 0
THEN round((s.members_end::numeric / s.members_start) * 100, 1)
ELSE 0
END AS retention_rate
FROM stats s
ORDER BY s.yr;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_retention(uuid, int) TO authenticated;
-- 3. Geographic distribution by postal code prefix
CREATE OR REPLACE FUNCTION public.get_member_geographic_distribution(p_account_id uuid)
RETURNS TABLE (
postal_prefix text,
city text,
member_count bigint
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
SELECT
CASE
WHEN m.postal_code IS NULL OR m.postal_code = '' THEN 'Keine Angabe'
ELSE left(m.postal_code, 2)
END AS postal_prefix,
COALESCE(NULLIF(m.city, ''), 'Keine Angabe') AS city,
count(*) AS member_count
FROM public.members m
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
GROUP BY postal_prefix, m.city
ORDER BY member_count DESC;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_member_geographic_distribution(uuid) TO authenticated;
-- 4. Dues collection rates by category
CREATE OR REPLACE FUNCTION public.get_dues_collection_report(p_account_id uuid)
RETURNS TABLE (
category_name text,
member_count bigint,
expected_amount numeric,
paid_count bigint,
collection_rate numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
RETURN QUERY
SELECT
COALESCE(dc.name, 'Keine Kategorie') AS category_name,
count(m.id) AS member_count,
COALESCE(sum(dc.amount), 0) AS expected_amount,
count(*) FILTER (WHERE m.dues_paid = true) AS paid_count,
CASE WHEN count(m.id) > 0
THEN round((count(*) FILTER (WHERE m.dues_paid = true)::numeric / count(m.id)) * 100, 1)
ELSE 0
END AS collection_rate
FROM public.members m
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
GROUP BY dc.name, dc.sort_order
ORDER BY dc.sort_order NULLS LAST;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_dues_collection_report(uuid) TO authenticated;
-- 5. Membership duration analysis
CREATE OR REPLACE FUNCTION public.get_membership_duration_analysis(p_account_id uuid)
RETURNS TABLE (
duration_bucket text,
member_count bigint,
percentage numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_total bigint;
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
SELECT count(*) INTO v_total
FROM public.members
WHERE account_id = p_account_id AND status = 'active' AND is_archived = false;
RETURN QUERY
SELECT
CASE
WHEN years < 1 THEN 'Unter 1 Jahr'
WHEN years BETWEEN 1 AND 5 THEN '1-5 Jahre'
WHEN years BETWEEN 6 AND 10 THEN '6-10 Jahre'
WHEN years BETWEEN 11 AND 25 THEN '11-25 Jahre'
WHEN years > 25 THEN 'Über 25 Jahre'
ELSE 'Unbekannt'
END AS duration_bucket,
count(*) AS member_count,
CASE WHEN v_total > 0
THEN round((count(*)::numeric / v_total) * 100, 1)
ELSE 0
END AS percentage
FROM (
SELECT
CASE WHEN m.entry_date IS NOT NULL
THEN extract(year FROM age(current_date, m.entry_date))::int
ELSE NULL
END AS years
FROM public.members m
WHERE m.account_id = p_account_id
AND m.status = 'active'
AND m.is_archived = false
) sub
GROUP BY duration_bucket
ORDER BY
CASE duration_bucket
WHEN 'Unter 1 Jahr' THEN 1
WHEN '1-5 Jahre' THEN 2
WHEN '6-10 Jahre' THEN 3
WHEN '11-25 Jahre' THEN 4
WHEN 'Über 25 Jahre' THEN 5
ELSE 6
END;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_membership_duration_analysis(uuid) TO authenticated;
-- 6. Department distribution
CREATE OR REPLACE FUNCTION public.get_department_distribution(p_account_id uuid)
RETURNS TABLE (
department_name text,
member_count bigint,
percentage numeric
)
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
AS $$
DECLARE
v_total bigint;
BEGIN
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied';
END IF;
SELECT count(DISTINCT m.id) INTO v_total
FROM public.members m
WHERE m.account_id = p_account_id AND m.status = 'active' AND m.is_archived = false;
RETURN QUERY
SELECT
d.name AS department_name,
count(DISTINCT mda.member_id) AS member_count,
CASE WHEN v_total > 0
THEN round((count(DISTINCT mda.member_id)::numeric / v_total) * 100, 1)
ELSE 0
END AS percentage
FROM public.member_departments d
LEFT JOIN public.member_department_assignments mda ON mda.department_id = d.id
LEFT JOIN public.members m ON m.id = mda.member_id
AND m.status = 'active' AND m.is_archived = false
WHERE d.account_id = p_account_id
GROUP BY d.name, d.sort_order
ORDER BY member_count DESC;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_department_distribution(uuid) TO authenticated;

View File

@@ -0,0 +1,77 @@
-- =====================================================
-- Index Optimization
--
-- Adds partial indexes for common query patterns,
-- covers the advanced search filter combinations,
-- and optimizes reporting queries.
-- =====================================================
-- 1. Active members composite index (most common query pattern)
-- Covers: listMembers, searchMembers, all reporting functions
CREATE INDEX IF NOT EXISTS ix_members_active_account_status
ON public.members(account_id, status, last_name, first_name)
WHERE is_archived = false;
-- 2. Entry date range queries (searchMembers with date filters)
CREATE INDEX IF NOT EXISTS ix_members_entry_date
ON public.members(account_id, entry_date)
WHERE entry_date IS NOT NULL;
-- 3. Dues category filter (searchMembers)
CREATE INDEX IF NOT EXISTS ix_members_dues_category
ON public.members(account_id, dues_category_id)
WHERE dues_category_id IS NOT NULL;
-- 4. Boolean flag filters (searchMembers flag queries)
-- Partial indexes only store rows where the flag is true (very compact)
CREATE INDEX IF NOT EXISTS ix_members_honorary
ON public.members(account_id) WHERE is_honorary = true;
CREATE INDEX IF NOT EXISTS ix_members_youth
ON public.members(account_id) WHERE is_youth = true;
CREATE INDEX IF NOT EXISTS ix_members_founding
ON public.members(account_id) WHERE is_founding_member = true;
CREATE INDEX IF NOT EXISTS ix_members_retiree
ON public.members(account_id) WHERE is_retiree = true;
-- 5. Active SEPA mandates lookup (finance integration)
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_lookup
ON public.sepa_mandates(member_id, status)
WHERE status = 'active' AND is_primary = true;
-- 6. Communications per member (timeline queries)
CREATE INDEX IF NOT EXISTS ix_member_comms_member_date
ON public.member_communications(member_id, created_at DESC);
-- 7. Audit log: action-type filtering (timeline with action filter)
CREATE INDEX IF NOT EXISTS ix_member_audit_member_action
ON public.member_audit_log(member_id, action, created_at DESC);
-- 8. Tag assignments: member lookup (for search filter + detail view)
CREATE INDEX IF NOT EXISTS ix_tag_assignments_member
ON public.member_tag_assignments(member_id);
-- 9. Reporting: active members for retention/duration CROSS JOIN
-- Column order: account_id first (equality), then date columns (range scans)
-- is_archived excluded from key since it's in WHERE clause
CREATE INDEX IF NOT EXISTS ix_members_active_reporting
ON public.members(account_id, entry_date, exit_date, status)
WHERE is_archived = false;
-- 10. Member merge log: primary member lookup
CREATE INDEX IF NOT EXISTS ix_member_merges_primary
ON public.member_merges(primary_member_id);
-- 11. GDPR: candidates for anonymization (batch enforcement query)
-- status excluded from key since enforcement query uses dynamic ANY(array)
-- Covers: WHERE account_id = ? AND exit_date IS NOT NULL AND exit_date + interval <= current_date
CREATE INDEX IF NOT EXISTS ix_members_gdpr_candidates
ON public.members(account_id, exit_date)
WHERE exit_date IS NOT NULL AND is_archived = false AND first_name != 'ANONYMISIERT';
-- 12. Portal invitations: account listing (listPortalInvitations query)
CREATE INDEX IF NOT EXISTS ix_portal_invitations_account_date
ON public.member_portal_invitations(account_id, created_at DESC);
-- 13. Department assignments by department (searchMembers department filter subquery)
CREATE INDEX IF NOT EXISTS ix_dept_assignments_department
ON public.member_department_assignments(department_id, member_id);

View File

@@ -0,0 +1,209 @@
-- =====================================================
-- Notification Rules + Scheduled Jobs
--
-- Configurable notification triggers per account.
-- Scheduled job runner with tracking.
-- Pending notifications queue for async dispatch.
-- =====================================================
-- 1. Notification rules — configurable triggers per account
CREATE TABLE IF NOT EXISTS public.member_notification_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
trigger_event text NOT NULL CHECK (trigger_event IN (
'application.submitted', 'application.approved', 'application.rejected',
'member.created', 'member.status_changed',
'member.birthday', 'member.anniversary',
'dues.unpaid', 'mandate.revoked'
)),
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
recipient_type text NOT NULL CHECK (recipient_type IN (
'admin', 'member', 'specific_user', 'role_holder'
)),
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 ix_notification_rules_account
ON public.member_notification_rules(account_id, trigger_event)
WHERE is_active = true;
ALTER TABLE public.member_notification_rules ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.member_notification_rules FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_notification_rules TO authenticated;
GRANT ALL ON public.member_notification_rules TO service_role;
CREATE POLICY notification_rules_select
ON public.member_notification_rules FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
CREATE POLICY notification_rules_mutate
ON public.member_notification_rules FOR ALL TO authenticated
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- 2. Scheduled job configuration per account
CREATE TABLE IF NOT EXISTS public.scheduled_job_configs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
job_type text NOT NULL CHECK (job_type IN (
'birthday_notification', 'anniversary_notification',
'dues_reminder', 'data_quality_check', 'gdpr_retention_check'
)),
is_enabled boolean NOT NULL DEFAULT true,
config jsonb NOT NULL DEFAULT '{}',
last_run_at timestamptz,
next_run_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(account_id, job_type)
);
ALTER TABLE public.scheduled_job_configs ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.scheduled_job_configs FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE ON public.scheduled_job_configs TO authenticated;
GRANT ALL ON public.scheduled_job_configs TO service_role;
CREATE POLICY scheduled_jobs_select
ON public.scheduled_job_configs FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
CREATE POLICY scheduled_jobs_mutate
ON public.scheduled_job_configs FOR ALL TO authenticated
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
-- 3. Job run history
CREATE TABLE IF NOT EXISTS public.scheduled_job_runs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
job_config_id uuid NOT NULL REFERENCES public.scheduled_job_configs(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')),
result jsonb,
started_at timestamptz NOT NULL DEFAULT now(),
completed_at timestamptz
);
CREATE INDEX ix_job_runs_config
ON public.scheduled_job_runs(job_config_id, started_at DESC);
ALTER TABLE public.scheduled_job_runs ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.scheduled_job_runs FROM authenticated, service_role;
GRANT SELECT ON public.scheduled_job_runs TO authenticated;
GRANT ALL ON public.scheduled_job_runs TO service_role;
CREATE POLICY job_runs_select
ON public.scheduled_job_runs FOR SELECT TO authenticated
USING (EXISTS (
SELECT 1 FROM public.scheduled_job_configs jc
WHERE jc.id = scheduled_job_runs.job_config_id
AND public.has_role_on_account(jc.account_id)
));
-- 4. Pending notifications queue (lightweight, processed by cron)
CREATE TABLE IF NOT EXISTS public.pending_member_notifications (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id uuid NOT NULL,
trigger_event text NOT NULL,
member_id uuid,
context jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX ix_pending_notifications_unprocessed
ON public.pending_member_notifications(created_at)
WHERE processed_at IS NULL;
-- No RLS — only service_role accesses this table
REVOKE ALL ON public.pending_member_notifications FROM authenticated;
GRANT ALL ON public.pending_member_notifications TO service_role;
-- 5. Trigger: queue notifications when audit events fire
CREATE OR REPLACE FUNCTION public.queue_notification_on_audit()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_event text;
BEGIN
-- Map audit action to notification trigger event
v_event := CASE NEW.action
WHEN 'created' THEN 'member.created'
WHEN 'status_changed' THEN 'member.status_changed'
WHEN 'application_approved' THEN 'application.approved'
WHEN 'application_rejected' THEN 'application.rejected'
ELSE NULL
END;
IF v_event IS NULL THEN
RETURN NEW;
END IF;
-- Only queue if there are active rules for this event
IF EXISTS (
SELECT 1 FROM public.member_notification_rules
WHERE account_id = NEW.account_id
AND trigger_event = v_event
AND is_active = true
) THEN
INSERT INTO public.pending_member_notifications (account_id, trigger_event, member_id, context)
VALUES (
NEW.account_id,
v_event,
NEW.member_id,
jsonb_build_object(
'audit_action', NEW.action,
'changes', NEW.changes,
'metadata', NEW.metadata,
'user_id', NEW.user_id
)
);
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_audit_queue_notifications
AFTER INSERT ON public.member_audit_log
FOR EACH ROW
EXECUTE FUNCTION public.queue_notification_on_audit();
-- 6. Queue trigger for application submissions (from membership_applications, not audit log)
CREATE OR REPLACE FUNCTION public.queue_notification_on_application()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
IF NEW.status = 'submitted' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM NEW.status) THEN
IF EXISTS (
SELECT 1 FROM public.member_notification_rules
WHERE account_id = NEW.account_id
AND trigger_event = 'application.submitted'
AND is_active = true
) THEN
INSERT INTO public.pending_member_notifications (account_id, trigger_event, context)
VALUES (
NEW.account_id,
'application.submitted',
jsonb_build_object(
'application_id', NEW.id,
'first_name', NEW.first_name,
'last_name', NEW.last_name,
'email', NEW.email
)
);
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_application_queue_notifications
AFTER INSERT OR UPDATE OF status ON public.membership_applications
FOR EACH ROW
EXECUTE FUNCTION public.queue_notification_on_application();

View File

@@ -0,0 +1,109 @@
-- =====================================================
-- Atomic Course Enrollment
--
-- Problem: Enrolling a participant in a course requires
-- multiple queries (check capacity, count enrolled, insert).
-- Race conditions can over-enroll a course.
--
-- Fix: Single transactional PG function that locks the
-- course row, validates capacity, and inserts with the
-- correct status (enrolled vs waitlisted).
-- =====================================================
CREATE OR REPLACE FUNCTION public.enroll_course_participant(
p_course_id uuid,
p_member_id uuid DEFAULT NULL,
p_first_name text DEFAULT NULL,
p_last_name text DEFAULT NULL,
p_email text DEFAULT NULL,
p_phone text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_course record;
v_enrolled_count bigint;
v_status public.enrollment_status;
v_waitlist_position bigint;
v_participant_id uuid;
BEGIN
-- 1. Lock the course row to prevent concurrent enrollment races
SELECT * INTO v_course
FROM public.courses
WHERE id = p_course_id
FOR UPDATE;
IF v_course IS NULL THEN
RAISE EXCEPTION 'Course % not found', p_course_id
USING ERRCODE = 'P0002';
END IF;
-- 2. Validate course status is open for enrollment
IF v_course.status != 'open' THEN
RAISE EXCEPTION 'Course is not open for enrollment (current status: %)', v_course.status
USING ERRCODE = 'P0001';
END IF;
-- 3. Check registration deadline hasn't passed
IF v_course.registration_deadline IS NOT NULL AND v_course.registration_deadline < current_date THEN
RAISE EXCEPTION 'Registration deadline (%) has passed', v_course.registration_deadline
USING ERRCODE = 'P0001';
END IF;
-- 4. Count currently enrolled participants
SELECT count(*) INTO v_enrolled_count
FROM public.course_participants
WHERE course_id = p_course_id
AND status = 'enrolled';
-- 5. Determine status based on capacity
IF v_enrolled_count >= v_course.capacity THEN
v_status := 'waitlisted';
ELSE
v_status := 'enrolled';
END IF;
-- 6. Insert the participant
INSERT INTO public.course_participants (
course_id,
member_id,
first_name,
last_name,
email,
phone,
status,
enrolled_at
) VALUES (
p_course_id,
p_member_id,
p_first_name,
p_last_name,
p_email,
p_phone,
v_status,
now()
)
RETURNING id INTO v_participant_id;
-- 7. Calculate waitlist position if waitlisted
IF v_status = 'waitlisted' THEN
SELECT count(*) INTO v_waitlist_position
FROM public.course_participants
WHERE course_id = p_course_id
AND status = 'waitlisted';
END IF;
-- 8. Return result
RETURN jsonb_build_object(
'participant_id', v_participant_id,
'status', v_status::text,
'waitlist_position', CASE WHEN v_status = 'waitlisted' THEN v_waitlist_position ELSE NULL END
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO service_role;

View File

@@ -0,0 +1,140 @@
-- =====================================================
-- Atomic Event Registration
--
-- Problem: Registering for an event requires multiple
-- queries (check capacity, validate age, count registrations,
-- insert). Race conditions can over-register an event.
--
-- Fix:
-- A) Ensure member_id FK column exists on event_registrations
-- (idempotent — may already exist from 20260416000006).
-- B) Single transactional PG function that locks the event
-- row, validates capacity/age, and inserts with the
-- correct status (confirmed vs waitlisted).
-- =====================================================
-- A) Add member_id column if not already present
ALTER TABLE public.event_registrations
ADD COLUMN IF NOT EXISTS member_id uuid
REFERENCES public.members(id) ON DELETE SET NULL;
-- Ensure index exists (idempotent)
CREATE INDEX IF NOT EXISTS ix_event_registrations_member
ON public.event_registrations(member_id)
WHERE member_id IS NOT NULL;
-- The status CHECK constraint already includes 'waitlisted' in the
-- original schema: check (status in ('pending','confirmed','waitlisted','cancelled'))
-- No constraint modification needed.
-- B) Atomic registration function
CREATE OR REPLACE FUNCTION public.register_for_event(
p_event_id uuid,
p_member_id uuid DEFAULT NULL,
p_first_name text DEFAULT NULL,
p_last_name text DEFAULT NULL,
p_email text DEFAULT NULL,
p_phone text DEFAULT NULL,
p_date_of_birth date DEFAULT NULL,
p_parent_name text DEFAULT NULL,
p_parent_phone text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_event record;
v_reg_count bigint;
v_status text;
v_age integer;
v_registration_id uuid;
BEGIN
-- 1. Lock the event row to prevent concurrent registration races
SELECT * INTO v_event
FROM public.events
WHERE id = p_event_id
FOR UPDATE;
IF v_event IS NULL THEN
RAISE EXCEPTION 'Event % not found', p_event_id
USING ERRCODE = 'P0002';
END IF;
-- 2. Validate event status is open for registration
IF v_event.status != 'open' THEN
RAISE EXCEPTION 'Event is not open for registration (current status: %)', v_event.status
USING ERRCODE = 'P0001';
END IF;
-- 3. Check registration deadline hasn't passed
IF v_event.registration_deadline IS NOT NULL AND v_event.registration_deadline < current_date THEN
RAISE EXCEPTION 'Registration deadline (%) has passed', v_event.registration_deadline
USING ERRCODE = 'P0001';
END IF;
-- 4. Age validation: calculate age at event_date if date_of_birth provided
IF p_date_of_birth IS NOT NULL THEN
v_age := extract(year FROM age(v_event.event_date, p_date_of_birth))::integer;
IF v_event.min_age IS NOT NULL AND v_age < v_event.min_age THEN
RAISE EXCEPTION 'Participant age (%) is below the minimum age (%) for this event', v_age, v_event.min_age
USING ERRCODE = 'P0001';
END IF;
IF v_event.max_age IS NOT NULL AND v_age > v_event.max_age THEN
RAISE EXCEPTION 'Participant age (%) exceeds the maximum age (%) for this event', v_age, v_event.max_age
USING ERRCODE = 'P0001';
END IF;
END IF;
-- 5. Count confirmed + pending registrations
SELECT count(*) INTO v_reg_count
FROM public.event_registrations
WHERE event_id = p_event_id
AND status IN ('confirmed', 'pending');
-- 6. Determine status based on capacity
IF v_event.capacity IS NOT NULL AND v_reg_count >= v_event.capacity THEN
v_status := 'waitlisted';
ELSE
v_status := 'confirmed';
END IF;
-- 7. Insert the registration
INSERT INTO public.event_registrations (
event_id,
member_id,
first_name,
last_name,
email,
phone,
date_of_birth,
parent_name,
parent_phone,
status
) VALUES (
p_event_id,
p_member_id,
p_first_name,
p_last_name,
p_email,
p_phone,
p_date_of_birth,
p_parent_name,
p_parent_phone,
v_status
)
RETURNING id INTO v_registration_id;
-- 8. Return result
RETURN jsonb_build_object(
'registration_id', v_registration_id,
'status', v_status
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.register_for_event(uuid, uuid, text, text, text, text, date, text, text) TO authenticated;
GRANT EXECUTE ON FUNCTION public.register_for_event(uuid, uuid, text, text, text, text, date, text, text) TO service_role;

View File

@@ -0,0 +1,128 @@
-- =====================================================
-- 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 $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
) THEN
ALTER TABLE public.bookings
ADD CONSTRAINT excl_booking_room_dates
EXCLUDE USING gist (
room_id WITH =,
daterange(check_in, check_out) WITH &&
) 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;

View File

@@ -0,0 +1,143 @@
-- =====================================================
-- Data Integrity Constraints for Courses, Events, Bookings
--
-- Adds CHECK constraints and partial unique indexes to
-- enforce business rules at the database level.
--
-- All constraint additions are idempotent — wrapped in
-- DO blocks that check pg_constraint before adding.
-- =====================================================
-- -------------------------------------------------------
-- COURSES
-- -------------------------------------------------------
-- reduced_fee must not exceed fee
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_reduced_fee_lte_fee'
) THEN
ALTER TABLE public.courses
ADD CONSTRAINT chk_courses_reduced_fee_lte_fee
CHECK (reduced_fee IS NULL OR reduced_fee <= fee);
END IF;
END;
$$;
-- min_participants must not exceed capacity
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_min_lte_capacity'
) THEN
ALTER TABLE public.courses
ADD CONSTRAINT chk_courses_min_lte_capacity
CHECK (min_participants IS NULL OR min_participants <= capacity);
END IF;
END;
$$;
-- end_date must be on or after start_date
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_date_range'
) THEN
ALTER TABLE public.courses
ADD CONSTRAINT chk_courses_date_range
CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date);
END IF;
END;
$$;
-- registration_deadline must be on or before start_date
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_deadline_before_start'
) THEN
ALTER TABLE public.courses
ADD CONSTRAINT chk_courses_deadline_before_start
CHECK (registration_deadline IS NULL OR start_date IS NULL OR registration_deadline <= start_date);
END IF;
END;
$$;
-- Unique course_number per account (partial index — allows NULLs and empty strings)
CREATE UNIQUE INDEX IF NOT EXISTS uix_courses_number_per_account
ON public.courses(account_id, course_number)
WHERE course_number IS NOT NULL AND course_number != '';
-- -------------------------------------------------------
-- EVENTS
-- -------------------------------------------------------
-- min_age must not exceed max_age
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_age_range'
) THEN
ALTER TABLE public.events
ADD CONSTRAINT chk_events_age_range
CHECK (min_age IS NULL OR max_age IS NULL OR min_age <= max_age);
END IF;
END;
$$;
-- end_date must be on or after event_date
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_date_range'
) THEN
ALTER TABLE public.events
ADD CONSTRAINT chk_events_date_range
CHECK (end_date IS NULL OR end_date >= event_date);
END IF;
END;
$$;
-- registration_deadline must be on or before event_date
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_deadline_before_event'
) THEN
ALTER TABLE public.events
ADD CONSTRAINT chk_events_deadline_before_event
CHECK (registration_deadline IS NULL OR registration_deadline <= event_date);
END IF;
END;
$$;
-- -------------------------------------------------------
-- BOOKINGS
-- -------------------------------------------------------
-- At least 1 adult required
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_bookings_min_adults'
) THEN
ALTER TABLE public.bookings
ADD CONSTRAINT chk_bookings_min_adults
CHECK (adults >= 1);
END IF;
END;
$$;
-- total_price must be non-negative
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'chk_bookings_price_non_negative'
) THEN
ALTER TABLE public.bookings
ADD CONSTRAINT chk_bookings_price_non_negative
CHECK (total_price >= 0);
END IF;
END;
$$;

View File

@@ -0,0 +1,84 @@
-- =====================================================
-- Optimistic Locking for Courses, Events, Bookings
--
-- Problem: Concurrent edits to courses, events, or bookings
-- can silently overwrite each other (last write wins).
--
-- Fix: Add version column to each table with an auto-
-- increment trigger on update. API layer checks version
-- match before writing, preventing silent overwrites.
--
-- Reuses the same trigger function pattern established
-- in 20260416000005_member_versioning.sql but creates a
-- shared generic function instead of table-specific ones.
-- =====================================================
-- Shared version increment function (CREATE OR REPLACE is idempotent)
CREATE OR REPLACE FUNCTION public.increment_version()
RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.version := OLD.version + 1;
RETURN NEW;
END;
$$;
-- -------------------------------------------------------
-- COURSES
-- -------------------------------------------------------
ALTER TABLE public.courses
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_courses_increment_version'
) THEN
CREATE TRIGGER trg_courses_increment_version
BEFORE UPDATE ON public.courses
FOR EACH ROW
EXECUTE FUNCTION public.increment_version();
END IF;
END;
$$;
-- -------------------------------------------------------
-- EVENTS
-- -------------------------------------------------------
ALTER TABLE public.events
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_increment_version'
) THEN
CREATE TRIGGER trg_events_increment_version
BEFORE UPDATE ON public.events
FOR EACH ROW
EXECUTE FUNCTION public.increment_version();
END IF;
END;
$$;
-- -------------------------------------------------------
-- BOOKINGS
-- -------------------------------------------------------
ALTER TABLE public.bookings
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bookings_increment_version'
) THEN
CREATE TRIGGER trg_bookings_increment_version
BEFORE UPDATE ON public.bookings
FOR EACH ROW
EXECUTE FUNCTION public.increment_version();
END IF;
END;
$$;

View File

@@ -0,0 +1,496 @@
-- =====================================================
-- Audit Logging for Courses, Events, Bookings
--
-- Full change history for compliance: who changed what
-- field, old value -> new value, when. Mirrors the
-- member_audit_log pattern from 20260416000007.
-- =====================================================
-- -------------------------------------------------------
-- A) Add created_by / updated_by to main tables
-- -------------------------------------------------------
ALTER TABLE public.courses
ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE public.events
ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
ALTER TABLE public.bookings
ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
-- -------------------------------------------------------
-- B) Audit log tables
-- -------------------------------------------------------
-- B.1 Course audit log
CREATE TABLE IF NOT EXISTS public.course_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
course_id uuid NOT NULL REFERENCES public.courses(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
action text NOT NULL CHECK (action IN (
'created', 'updated', 'status_changed', 'cancelled',
'participant_enrolled', 'participant_cancelled',
'participant_waitlisted', 'participant_promoted',
'session_created', 'session_cancelled',
'attendance_marked', 'instructor_changed', 'location_changed'
)),
changes jsonb NOT NULL DEFAULT '{}',
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.course_audit_log IS
'Immutable audit trail for all course lifecycle events';
CREATE INDEX IF NOT EXISTS ix_course_audit_course
ON public.course_audit_log(course_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_course_audit_account
ON public.course_audit_log(account_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_course_audit_action
ON public.course_audit_log(account_id, action);
ALTER TABLE public.course_audit_log ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.course_audit_log FROM authenticated, service_role;
GRANT SELECT ON public.course_audit_log TO authenticated;
GRANT INSERT, SELECT ON public.course_audit_log TO service_role;
CREATE POLICY course_audit_log_select
ON public.course_audit_log FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- B.2 Event audit log
CREATE TABLE IF NOT EXISTS public.event_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
action text NOT NULL CHECK (action IN (
'created', 'updated', 'status_changed', 'cancelled',
'registration_confirmed', 'registration_waitlisted',
'registration_cancelled', 'registration_promoted'
)),
changes jsonb NOT NULL DEFAULT '{}',
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.event_audit_log IS
'Immutable audit trail for all event lifecycle events';
CREATE INDEX IF NOT EXISTS ix_event_audit_event
ON public.event_audit_log(event_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_event_audit_account
ON public.event_audit_log(account_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_event_audit_action
ON public.event_audit_log(account_id, action);
ALTER TABLE public.event_audit_log ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.event_audit_log FROM authenticated, service_role;
GRANT SELECT ON public.event_audit_log TO authenticated;
GRANT INSERT, SELECT ON public.event_audit_log TO service_role;
CREATE POLICY event_audit_log_select
ON public.event_audit_log FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- B.3 Booking audit log
CREATE TABLE IF NOT EXISTS public.booking_audit_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
booking_id uuid NOT NULL REFERENCES public.bookings(id) ON DELETE CASCADE,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
action text NOT NULL CHECK (action IN (
'created', 'updated', 'status_changed',
'checked_in', 'checked_out', 'cancelled',
'no_show', 'price_changed'
)),
changes jsonb NOT NULL DEFAULT '{}',
metadata jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE public.booking_audit_log IS
'Immutable audit trail for all booking lifecycle events';
CREATE INDEX IF NOT EXISTS ix_booking_audit_booking
ON public.booking_audit_log(booking_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_booking_audit_account
ON public.booking_audit_log(account_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_booking_audit_action
ON public.booking_audit_log(account_id, action);
ALTER TABLE public.booking_audit_log ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.booking_audit_log FROM authenticated, service_role;
GRANT SELECT ON public.booking_audit_log TO authenticated;
GRANT INSERT, SELECT ON public.booking_audit_log TO service_role;
CREATE POLICY booking_audit_log_select
ON public.booking_audit_log FOR SELECT TO authenticated
USING (public.has_role_on_account(account_id));
-- -------------------------------------------------------
-- C) Auto-audit triggers for UPDATE
-- -------------------------------------------------------
-- C.1 Courses UPDATE trigger
CREATE OR REPLACE FUNCTION public.trg_course_audit_on_update()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_changes jsonb := '{}'::jsonb;
v_action text := 'updated';
v_user_id uuid;
BEGIN
-- Build changes diff (field by field)
IF OLD.name IS DISTINCT FROM NEW.name THEN
v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name));
END IF;
IF OLD.description IS DISTINCT FROM NEW.description THEN
v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description));
END IF;
IF OLD.course_number IS DISTINCT FROM NEW.course_number THEN
v_changes := v_changes || jsonb_build_object('course_number', jsonb_build_object('old', OLD.course_number, 'new', NEW.course_number));
END IF;
IF OLD.category_id IS DISTINCT FROM NEW.category_id THEN
v_changes := v_changes || jsonb_build_object('category_id', jsonb_build_object('old', OLD.category_id, 'new', NEW.category_id));
END IF;
IF OLD.instructor_id IS DISTINCT FROM NEW.instructor_id THEN
v_changes := v_changes || jsonb_build_object('instructor_id', jsonb_build_object('old', OLD.instructor_id, 'new', NEW.instructor_id));
END IF;
IF OLD.location_id IS DISTINCT FROM NEW.location_id THEN
v_changes := v_changes || jsonb_build_object('location_id', jsonb_build_object('old', OLD.location_id, 'new', NEW.location_id));
END IF;
IF OLD.start_date IS DISTINCT FROM NEW.start_date THEN
v_changes := v_changes || jsonb_build_object('start_date', jsonb_build_object('old', OLD.start_date, 'new', NEW.start_date));
END IF;
IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN
v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date));
END IF;
IF OLD.fee IS DISTINCT FROM NEW.fee THEN
v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee));
END IF;
IF OLD.reduced_fee IS DISTINCT FROM NEW.reduced_fee THEN
v_changes := v_changes || jsonb_build_object('reduced_fee', jsonb_build_object('old', OLD.reduced_fee, 'new', NEW.reduced_fee));
END IF;
IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN
v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity));
END IF;
IF OLD.min_participants IS DISTINCT FROM NEW.min_participants THEN
v_changes := v_changes || jsonb_build_object('min_participants', jsonb_build_object('old', OLD.min_participants, 'new', NEW.min_participants));
END IF;
IF OLD.status IS DISTINCT FROM NEW.status THEN
v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status));
END IF;
IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN
v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline));
END IF;
IF OLD.notes IS DISTINCT FROM NEW.notes THEN
v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes));
END IF;
-- Skip if nothing actually changed
IF v_changes = '{}'::jsonb THEN
RETURN NULL;
END IF;
-- Classify the action
IF OLD.status IS DISTINCT FROM NEW.status THEN
v_action := 'status_changed';
END IF;
v_user_id := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
auth.uid()
);
INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, changes)
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
RETURN NULL;
END;
$$;
CREATE OR REPLACE TRIGGER trg_courses_audit_on_update
AFTER UPDATE ON public.courses
FOR EACH ROW
EXECUTE FUNCTION public.trg_course_audit_on_update();
-- C.2 Events UPDATE trigger
CREATE OR REPLACE FUNCTION public.trg_event_audit_on_update()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_changes jsonb := '{}'::jsonb;
v_action text := 'updated';
v_user_id uuid;
BEGIN
-- Build changes diff (field by field)
IF OLD.name IS DISTINCT FROM NEW.name THEN
v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name));
END IF;
IF OLD.description IS DISTINCT FROM NEW.description THEN
v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description));
END IF;
IF OLD.event_date IS DISTINCT FROM NEW.event_date THEN
v_changes := v_changes || jsonb_build_object('event_date', jsonb_build_object('old', OLD.event_date, 'new', NEW.event_date));
END IF;
IF OLD.event_time IS DISTINCT FROM NEW.event_time THEN
v_changes := v_changes || jsonb_build_object('event_time', jsonb_build_object('old', OLD.event_time, 'new', NEW.event_time));
END IF;
IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN
v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date));
END IF;
IF OLD.location IS DISTINCT FROM NEW.location THEN
v_changes := v_changes || jsonb_build_object('location', jsonb_build_object('old', OLD.location, 'new', NEW.location));
END IF;
IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN
v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity));
END IF;
IF OLD.min_age IS DISTINCT FROM NEW.min_age THEN
v_changes := v_changes || jsonb_build_object('min_age', jsonb_build_object('old', OLD.min_age, 'new', NEW.min_age));
END IF;
IF OLD.max_age IS DISTINCT FROM NEW.max_age THEN
v_changes := v_changes || jsonb_build_object('max_age', jsonb_build_object('old', OLD.max_age, 'new', NEW.max_age));
END IF;
IF OLD.fee IS DISTINCT FROM NEW.fee THEN
v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee));
END IF;
IF OLD.status IS DISTINCT FROM NEW.status THEN
v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status));
END IF;
IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN
v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline));
END IF;
IF OLD.contact_name IS DISTINCT FROM NEW.contact_name THEN
v_changes := v_changes || jsonb_build_object('contact_name', jsonb_build_object('old', OLD.contact_name, 'new', NEW.contact_name));
END IF;
IF OLD.contact_email IS DISTINCT FROM NEW.contact_email THEN
v_changes := v_changes || jsonb_build_object('contact_email', jsonb_build_object('old', OLD.contact_email, 'new', NEW.contact_email));
END IF;
IF OLD.contact_phone IS DISTINCT FROM NEW.contact_phone THEN
v_changes := v_changes || jsonb_build_object('contact_phone', jsonb_build_object('old', OLD.contact_phone, 'new', NEW.contact_phone));
END IF;
-- Skip if nothing actually changed
IF v_changes = '{}'::jsonb THEN
RETURN NULL;
END IF;
-- Classify the action
IF OLD.status IS DISTINCT FROM NEW.status THEN
v_action := 'status_changed';
END IF;
v_user_id := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
auth.uid()
);
INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, changes)
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
RETURN NULL;
END;
$$;
CREATE OR REPLACE TRIGGER trg_events_audit_on_update
AFTER UPDATE ON public.events
FOR EACH ROW
EXECUTE FUNCTION public.trg_event_audit_on_update();
-- C.3 Bookings UPDATE trigger
CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_update()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_changes jsonb := '{}'::jsonb;
v_action text := 'updated';
v_user_id uuid;
BEGIN
-- Build changes diff (field by field)
IF OLD.room_id IS DISTINCT FROM NEW.room_id THEN
v_changes := v_changes || jsonb_build_object('room_id', jsonb_build_object('old', OLD.room_id, 'new', NEW.room_id));
END IF;
IF OLD.guest_id IS DISTINCT FROM NEW.guest_id THEN
v_changes := v_changes || jsonb_build_object('guest_id', jsonb_build_object('old', OLD.guest_id, 'new', NEW.guest_id));
END IF;
IF OLD.check_in IS DISTINCT FROM NEW.check_in THEN
v_changes := v_changes || jsonb_build_object('check_in', jsonb_build_object('old', OLD.check_in, 'new', NEW.check_in));
END IF;
IF OLD.check_out IS DISTINCT FROM NEW.check_out THEN
v_changes := v_changes || jsonb_build_object('check_out', jsonb_build_object('old', OLD.check_out, 'new', NEW.check_out));
END IF;
IF OLD.adults IS DISTINCT FROM NEW.adults THEN
v_changes := v_changes || jsonb_build_object('adults', jsonb_build_object('old', OLD.adults, 'new', NEW.adults));
END IF;
IF OLD.children IS DISTINCT FROM NEW.children THEN
v_changes := v_changes || jsonb_build_object('children', jsonb_build_object('old', OLD.children, 'new', NEW.children));
END IF;
IF OLD.status IS DISTINCT FROM NEW.status THEN
v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status));
END IF;
IF OLD.total_price IS DISTINCT FROM NEW.total_price THEN
v_changes := v_changes || jsonb_build_object('total_price', jsonb_build_object('old', OLD.total_price, 'new', NEW.total_price));
END IF;
IF OLD.notes IS DISTINCT FROM NEW.notes THEN
v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes));
END IF;
-- Skip if nothing actually changed
IF v_changes = '{}'::jsonb THEN
RETURN NULL;
END IF;
-- Classify the action
IF OLD.status IS DISTINCT FROM NEW.status THEN
v_action := 'status_changed';
END IF;
v_user_id := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
auth.uid()
);
INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, changes)
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
RETURN NULL;
END;
$$;
CREATE OR REPLACE TRIGGER trg_bookings_audit_on_update
AFTER UPDATE ON public.bookings
FOR EACH ROW
EXECUTE FUNCTION public.trg_booking_audit_on_update();
-- -------------------------------------------------------
-- D) Auto-audit triggers for INSERT
-- -------------------------------------------------------
-- D.1 Courses INSERT trigger
CREATE OR REPLACE FUNCTION public.trg_course_audit_on_insert()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_user_id uuid;
BEGIN
v_user_id := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
NEW.created_by
);
INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, metadata)
VALUES (
NEW.id, NEW.account_id, v_user_id, 'created',
jsonb_build_object(
'course_number', NEW.course_number,
'name', NEW.name,
'status', NEW.status,
'fee', NEW.fee,
'capacity', NEW.capacity,
'start_date', NEW.start_date,
'end_date', NEW.end_date
)
);
RETURN NEW;
END;
$$;
CREATE OR REPLACE TRIGGER trg_courses_audit_on_insert
AFTER INSERT ON public.courses
FOR EACH ROW
EXECUTE FUNCTION public.trg_course_audit_on_insert();
-- D.2 Events INSERT trigger
CREATE OR REPLACE FUNCTION public.trg_event_audit_on_insert()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_user_id uuid;
BEGIN
v_user_id := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
NEW.created_by
);
INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, metadata)
VALUES (
NEW.id, NEW.account_id, v_user_id, 'created',
jsonb_build_object(
'name', NEW.name,
'status', NEW.status,
'event_date', NEW.event_date,
'location', NEW.location,
'capacity', NEW.capacity,
'fee', NEW.fee
)
);
RETURN NEW;
END;
$$;
CREATE OR REPLACE TRIGGER trg_events_audit_on_insert
AFTER INSERT ON public.events
FOR EACH ROW
EXECUTE FUNCTION public.trg_event_audit_on_insert();
-- D.3 Bookings INSERT trigger
CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_insert()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_user_id uuid;
BEGIN
v_user_id := COALESCE(
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
NEW.created_by
);
INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, metadata)
VALUES (
NEW.id, NEW.account_id, v_user_id, 'created',
jsonb_build_object(
'room_id', NEW.room_id,
'guest_id', NEW.guest_id,
'check_in', NEW.check_in,
'check_out', NEW.check_out,
'status', NEW.status,
'total_price', NEW.total_price,
'adults', NEW.adults,
'children', NEW.children
)
);
RETURN NEW;
END;
$$;
CREATE OR REPLACE TRIGGER trg_bookings_audit_on_insert
AFTER INSERT ON public.bookings
FOR EACH ROW
EXECUTE FUNCTION public.trg_booking_audit_on_insert();

View File

@@ -0,0 +1,231 @@
-- =====================================================
-- Audit Timeline RPCs for Courses, Events, Bookings
--
-- Paginated, filterable read layer on the audit logs.
-- Mirrors get_member_timeline from 20260416000007.
-- =====================================================
-- -------------------------------------------------------
-- 1. Course timeline RPC
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_course_timeline(
p_course_id uuid,
p_page integer DEFAULT 1,
p_page_size integer DEFAULT 50,
p_action_filter text DEFAULT NULL
)
RETURNS TABLE (
id bigint,
action text,
changes jsonb,
metadata jsonb,
user_id uuid,
user_email text,
created_at timestamptz,
total_count bigint
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_account_id uuid;
v_total bigint;
v_offset integer;
BEGIN
-- Get course's account for access check
SELECT c.account_id INTO v_account_id
FROM public.courses c WHERE c.id = p_course_id;
IF v_account_id IS NULL THEN
RAISE EXCEPTION 'Course not found';
END IF;
IF NOT public.has_role_on_account(v_account_id) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- Clamp page size to prevent unbounded queries
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
-- Get total count
SELECT count(*) INTO v_total
FROM public.course_audit_log cal
WHERE cal.course_id = p_course_id
AND (p_action_filter IS NULL OR cal.action = p_action_filter);
-- Return paginated results with user email
RETURN QUERY
SELECT
cal.id,
cal.action,
cal.changes,
cal.metadata,
cal.user_id,
u.email::text AS user_email,
cal.created_at,
v_total AS total_count
FROM public.course_audit_log cal
LEFT JOIN auth.users u ON u.id = cal.user_id
WHERE cal.course_id = p_course_id
AND (p_action_filter IS NULL OR cal.action = p_action_filter)
ORDER BY cal.created_at DESC
LIMIT p_page_size OFFSET v_offset;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_course_timeline(uuid, integer, integer, text)
TO authenticated, service_role;
-- -------------------------------------------------------
-- 2. Event timeline RPC
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_event_timeline(
p_event_id uuid,
p_page integer DEFAULT 1,
p_page_size integer DEFAULT 50,
p_action_filter text DEFAULT NULL
)
RETURNS TABLE (
id bigint,
action text,
changes jsonb,
metadata jsonb,
user_id uuid,
user_email text,
created_at timestamptz,
total_count bigint
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_account_id uuid;
v_total bigint;
v_offset integer;
BEGIN
-- Get event's account for access check
SELECT e.account_id INTO v_account_id
FROM public.events e WHERE e.id = p_event_id;
IF v_account_id IS NULL THEN
RAISE EXCEPTION 'Event not found';
END IF;
IF NOT public.has_role_on_account(v_account_id) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- Clamp page size to prevent unbounded queries
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
-- Get total count
SELECT count(*) INTO v_total
FROM public.event_audit_log eal
WHERE eal.event_id = p_event_id
AND (p_action_filter IS NULL OR eal.action = p_action_filter);
-- Return paginated results with user email
RETURN QUERY
SELECT
eal.id,
eal.action,
eal.changes,
eal.metadata,
eal.user_id,
u.email::text AS user_email,
eal.created_at,
v_total AS total_count
FROM public.event_audit_log eal
LEFT JOIN auth.users u ON u.id = eal.user_id
WHERE eal.event_id = p_event_id
AND (p_action_filter IS NULL OR eal.action = p_action_filter)
ORDER BY eal.created_at DESC
LIMIT p_page_size OFFSET v_offset;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_event_timeline(uuid, integer, integer, text)
TO authenticated, service_role;
-- -------------------------------------------------------
-- 3. Booking timeline RPC
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_booking_timeline(
p_booking_id uuid,
p_page integer DEFAULT 1,
p_page_size integer DEFAULT 50,
p_action_filter text DEFAULT NULL
)
RETURNS TABLE (
id bigint,
action text,
changes jsonb,
metadata jsonb,
user_id uuid,
user_email text,
created_at timestamptz,
total_count bigint
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_account_id uuid;
v_total bigint;
v_offset integer;
BEGIN
-- Get booking's account for access check
SELECT b.account_id INTO v_account_id
FROM public.bookings b WHERE b.id = p_booking_id;
IF v_account_id IS NULL THEN
RAISE EXCEPTION 'Booking not found';
END IF;
IF NOT public.has_role_on_account(v_account_id) THEN
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
END IF;
-- Clamp page size to prevent unbounded queries
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
-- Get total count
SELECT count(*) INTO v_total
FROM public.booking_audit_log bal
WHERE bal.booking_id = p_booking_id
AND (p_action_filter IS NULL OR bal.action = p_action_filter);
-- Return paginated results with user email
RETURN QUERY
SELECT
bal.id,
bal.action,
bal.changes,
bal.metadata,
bal.user_id,
u.email::text AS user_email,
bal.created_at,
v_total AS total_count
FROM public.booking_audit_log bal
LEFT JOIN auth.users u ON u.id = bal.user_id
WHERE bal.booking_id = p_booking_id
AND (p_action_filter IS NULL OR bal.action = p_action_filter)
ORDER BY bal.created_at DESC
LIMIT p_page_size OFFSET v_offset;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_booking_timeline(uuid, integer, integer, text)
TO authenticated, service_role;

View File

@@ -0,0 +1,146 @@
-- =====================================================
-- Waitlist Management
--
-- A) Course cancellation with automatic waitlist promotion
-- B) Event cancellation with automatic waitlist promotion
--
-- When an enrolled/confirmed participant is cancelled,
-- the oldest waitlisted entry is atomically promoted.
-- =====================================================
-- -------------------------------------------------------
-- A) Course waitlist promotion
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.cancel_course_enrollment(p_participant_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_participant record;
v_course record;
v_promoted_id uuid;
v_promoted_name text;
BEGIN
-- Lock participant
SELECT * INTO v_participant
FROM public.course_participants
WHERE id = p_participant_id
FOR UPDATE;
IF v_participant IS NULL THEN
RAISE EXCEPTION 'Teilnehmer nicht gefunden'
USING ERRCODE = 'P0002';
END IF;
-- Lock course
SELECT * INTO v_course
FROM public.courses
WHERE id = v_participant.course_id
FOR UPDATE;
-- Cancel
UPDATE public.course_participants
SET status = 'cancelled'::public.enrollment_status,
cancelled_at = now()
WHERE id = p_participant_id;
-- If was enrolled (not already waitlisted/cancelled), promote oldest waitlisted
IF v_participant.status = 'enrolled' THEN
UPDATE public.course_participants
SET status = 'enrolled'::public.enrollment_status
WHERE id = (
SELECT id FROM public.course_participants
WHERE course_id = v_participant.course_id
AND status = 'waitlisted'
ORDER BY enrolled_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, first_name || ' ' || last_name
INTO v_promoted_id, v_promoted_name;
END IF;
RETURN jsonb_build_object(
'cancelled_id', p_participant_id,
'promoted_id', v_promoted_id,
'promoted_name', v_promoted_name
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.cancel_course_enrollment(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.cancel_course_enrollment(uuid) TO service_role;
-- -------------------------------------------------------
-- B) Event registration cancellation + waitlist promotion
-- -------------------------------------------------------
-- Add updated_at column if not present
ALTER TABLE public.event_registrations
ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
CREATE OR REPLACE FUNCTION public.cancel_event_registration(p_registration_id uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_registration record;
v_event record;
v_promoted_id uuid;
v_promoted_name text;
BEGIN
-- Lock registration
SELECT * INTO v_registration
FROM public.event_registrations
WHERE id = p_registration_id
FOR UPDATE;
IF v_registration IS NULL THEN
RAISE EXCEPTION 'Anmeldung nicht gefunden'
USING ERRCODE = 'P0002';
END IF;
-- Lock event
SELECT * INTO v_event
FROM public.events
WHERE id = v_registration.event_id
FOR UPDATE;
-- Cancel
UPDATE public.event_registrations
SET status = 'cancelled',
updated_at = now()
WHERE id = p_registration_id;
-- If was confirmed or pending, promote oldest waitlisted
IF v_registration.status IN ('confirmed', 'pending') THEN
UPDATE public.event_registrations
SET status = 'confirmed',
updated_at = now()
WHERE id = (
SELECT id FROM public.event_registrations
WHERE event_id = v_registration.event_id
AND status = 'waitlisted'
ORDER BY created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING id, first_name || ' ' || last_name
INTO v_promoted_id, v_promoted_name;
END IF;
RETURN jsonb_build_object(
'cancelled_id', p_registration_id,
'promoted_id', v_promoted_id,
'promoted_name', v_promoted_name
);
END;
$$;
GRANT EXECUTE ON FUNCTION public.cancel_event_registration(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.cancel_event_registration(uuid) TO service_role;

View File

@@ -0,0 +1,66 @@
-- =====================================================
-- Attendance Rollup
--
-- RPC that returns a per-participant attendance summary
-- for a given course: total sessions, sessions attended,
-- and attendance rate (%).
-- =====================================================
CREATE OR REPLACE FUNCTION public.get_course_attendance_summary(p_course_id uuid)
RETURNS TABLE (
participant_id uuid,
participant_name text,
enrollment_status public.enrollment_status,
total_sessions bigint,
sessions_attended bigint,
attendance_rate numeric
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
-- Access check
IF NOT public.has_role_on_account(
(SELECT account_id FROM public.courses WHERE id = p_course_id)
) THEN
RAISE EXCEPTION 'Access denied'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
WITH session_count AS (
SELECT count(*)::bigint AS cnt
FROM public.course_sessions
WHERE course_id = p_course_id
AND is_cancelled = false
)
SELECT
cp.id AS participant_id,
(cp.first_name || ' ' || cp.last_name)::text AS participant_name,
cp.status AS enrollment_status,
sc.cnt AS total_sessions,
COALESCE(count(ca.id) FILTER (WHERE ca.present = true), 0)::bigint AS sessions_attended,
CASE WHEN sc.cnt > 0 THEN
ROUND(
COALESCE(count(ca.id) FILTER (WHERE ca.present = true), 0)::numeric
/ sc.cnt * 100,
1
)
ELSE 0 END AS attendance_rate
FROM public.course_participants cp
CROSS JOIN session_count sc
LEFT JOIN public.course_attendance ca ON ca.participant_id = cp.id
LEFT JOIN public.course_sessions cs
ON cs.id = ca.session_id
AND cs.is_cancelled = false
WHERE cp.course_id = p_course_id
AND cp.status IN ('enrolled', 'completed')
GROUP BY cp.id, cp.first_name, cp.last_name, cp.status, sc.cnt
ORDER BY cp.last_name, cp.first_name;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_course_attendance_summary(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_course_attendance_summary(uuid) TO service_role;

View File

@@ -0,0 +1,36 @@
-- =====================================================
-- Instructor Availability Check
--
-- Returns TRUE if the instructor has no scheduling
-- conflicts for the requested date/time window.
-- Optionally excludes a specific session (for edits).
-- =====================================================
CREATE OR REPLACE FUNCTION public.check_instructor_availability(
p_instructor_id uuid,
p_session_date date,
p_start_time time,
p_end_time time,
p_exclude_session_id uuid DEFAULT NULL
)
RETURNS boolean
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT NOT EXISTS (
SELECT 1
FROM public.course_sessions cs
JOIN public.courses c ON c.id = cs.course_id
WHERE c.instructor_id = p_instructor_id
AND cs.session_date = p_session_date
AND cs.start_time < p_end_time
AND cs.end_time > p_start_time
AND (p_exclude_session_id IS NULL OR cs.id != p_exclude_session_id)
AND cs.is_cancelled = false
);
$$;
GRANT EXECUTE ON FUNCTION public.check_instructor_availability(uuid, date, time, time, uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.check_instructor_availability(uuid, date, time, time, uuid) TO service_role;

View File

@@ -0,0 +1,294 @@
-- =====================================================
-- Module Statistics RPCs
--
-- A) Course statistics — counts per status, participants,
-- average occupancy, total revenue
-- B) Event statistics — counts, upcoming/past, registrations,
-- average occupancy
-- C) Booking statistics — counts, revenue, avg stay,
-- occupancy rate for a date range
-- D) Event registration counts — batch lookup replacing
-- N+1 JS iteration
-- =====================================================
-- -------------------------------------------------------
-- A) Course statistics
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_course_statistics(p_account_id uuid)
RETURNS TABLE (
total_courses bigint,
open_courses bigint,
running_courses bigint,
completed_courses bigint,
cancelled_courses bigint,
total_participants bigint,
total_waitlisted bigint,
avg_occupancy_rate numeric,
total_revenue numeric
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
-- Access check
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
WITH course_stats AS (
SELECT
count(*)::bigint AS total_courses,
count(*) FILTER (WHERE c.status = 'open')::bigint AS open_courses,
count(*) FILTER (WHERE c.status = 'running')::bigint AS running_courses,
count(*) FILTER (WHERE c.status = 'completed')::bigint AS completed_courses,
count(*) FILTER (WHERE c.status = 'cancelled')::bigint AS cancelled_courses
FROM public.courses c
WHERE c.account_id = p_account_id
),
participant_stats AS (
SELECT
count(*) FILTER (WHERE cp.status = 'enrolled')::bigint AS total_participants,
count(*) FILTER (WHERE cp.status = 'waitlisted')::bigint AS total_waitlisted
FROM public.course_participants cp
JOIN public.courses c ON c.id = cp.course_id
WHERE c.account_id = p_account_id
),
occupancy_stats AS (
SELECT
ROUND(
AVG(
CASE WHEN c.capacity > 0 THEN
enrolled_ct::numeric / c.capacity * 100
ELSE 0 END
),
1
) AS avg_occupancy_rate
FROM public.courses c
LEFT JOIN LATERAL (
SELECT count(*)::numeric AS enrolled_ct
FROM public.course_participants cp
WHERE cp.course_id = c.id AND cp.status = 'enrolled'
) ec ON true
WHERE c.account_id = p_account_id
AND c.status != 'cancelled'
),
revenue_stats AS (
SELECT
COALESCE(SUM(c.fee * enrolled_ct), 0)::numeric AS total_revenue
FROM public.courses c
LEFT JOIN LATERAL (
SELECT count(*)::numeric AS enrolled_ct
FROM public.course_participants cp
WHERE cp.course_id = c.id AND cp.status IN ('enrolled', 'completed')
) ec ON true
WHERE c.account_id = p_account_id
AND c.status != 'cancelled'
)
SELECT
cs.total_courses,
cs.open_courses,
cs.running_courses,
cs.completed_courses,
cs.cancelled_courses,
ps.total_participants,
ps.total_waitlisted,
os.avg_occupancy_rate,
rs.total_revenue
FROM course_stats cs
CROSS JOIN participant_stats ps
CROSS JOIN occupancy_stats os
CROSS JOIN revenue_stats rs;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_course_statistics(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_course_statistics(uuid) TO service_role;
-- -------------------------------------------------------
-- B) Event statistics
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_event_statistics(p_account_id uuid)
RETURNS TABLE (
total_events bigint,
upcoming_events bigint,
past_events bigint,
total_registrations bigint,
avg_occupancy_rate numeric
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
-- Access check
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied'
USING ERRCODE = '42501';
END IF;
RETURN QUERY
WITH event_counts AS (
SELECT
count(*)::bigint AS total_events,
count(*) FILTER (
WHERE e.event_date >= current_date
AND e.status NOT IN ('cancelled', 'completed')
)::bigint AS upcoming_events,
count(*) FILTER (
WHERE e.event_date < current_date
OR e.status IN ('completed')
)::bigint AS past_events
FROM public.events e
WHERE e.account_id = p_account_id
),
reg_counts AS (
SELECT count(*)::bigint AS total_registrations
FROM public.event_registrations er
JOIN public.events e ON e.id = er.event_id
WHERE e.account_id = p_account_id
AND er.status IN ('confirmed', 'pending')
),
occupancy AS (
SELECT
ROUND(
AVG(
CASE WHEN e.capacity IS NOT NULL AND e.capacity > 0 THEN
reg_ct::numeric / e.capacity * 100
ELSE NULL END
),
1
) AS avg_occupancy_rate
FROM public.events e
LEFT JOIN LATERAL (
SELECT count(*)::numeric AS reg_ct
FROM public.event_registrations er
WHERE er.event_id = e.id AND er.status IN ('confirmed', 'pending')
) rc ON true
WHERE e.account_id = p_account_id
AND e.status != 'cancelled'
)
SELECT
ec.total_events,
ec.upcoming_events,
ec.past_events,
rc.total_registrations,
COALESCE(occ.avg_occupancy_rate, 0)::numeric
FROM event_counts ec
CROSS JOIN reg_counts rc
CROSS JOIN occupancy occ;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_event_statistics(uuid) TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_event_statistics(uuid) TO service_role;
-- -------------------------------------------------------
-- C) Booking statistics
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_booking_statistics(
p_account_id uuid,
p_from date DEFAULT NULL,
p_to date DEFAULT NULL
)
RETURNS TABLE (
total_bookings bigint,
active_bookings bigint,
checked_in_count bigint,
total_revenue numeric,
avg_stay_nights numeric,
occupancy_rate numeric
)
LANGUAGE plpgsql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
DECLARE
v_from date;
v_to date;
v_total_rooms bigint;
v_total_room_nights numeric;
v_booked_room_nights numeric;
BEGIN
-- Access check
IF NOT public.has_role_on_account(p_account_id) THEN
RAISE EXCEPTION 'Access denied'
USING ERRCODE = '42501';
END IF;
-- Default date range: current month
v_from := COALESCE(p_from, date_trunc('month', current_date)::date);
v_to := COALESCE(p_to, (date_trunc('month', current_date) + interval '1 month' - interval '1 day')::date);
-- Calculate total available room-nights
SELECT count(*)::bigint INTO v_total_rooms
FROM public.rooms
WHERE account_id = p_account_id
AND is_active = true;
v_total_room_nights := v_total_rooms::numeric * (v_to - v_from + 1);
-- Calculate booked room-nights in range (non-cancelled)
SELECT COALESCE(SUM(
LEAST(b.check_out, v_to + 1) - GREATEST(b.check_in, v_from)
), 0)::numeric
INTO v_booked_room_nights
FROM public.bookings b
WHERE b.account_id = p_account_id
AND b.status NOT IN ('cancelled', 'no_show')
AND b.check_in <= v_to
AND b.check_out > v_from;
RETURN QUERY
SELECT
count(*)::bigint AS total_bookings,
count(*) FILTER (WHERE b.status IN ('confirmed', 'checked_in'))::bigint AS active_bookings,
count(*) FILTER (WHERE b.status = 'checked_in')::bigint AS checked_in_count,
COALESCE(SUM(b.total_price) FILTER (WHERE b.status != 'cancelled'), 0)::numeric AS total_revenue,
ROUND(
COALESCE(AVG((b.check_out - b.check_in)::numeric) FILTER (WHERE b.status != 'cancelled'), 0),
1
) AS avg_stay_nights,
CASE WHEN v_total_room_nights > 0 THEN
ROUND(v_booked_room_nights / v_total_room_nights * 100, 1)
ELSE 0 END AS occupancy_rate
FROM public.bookings b
WHERE b.account_id = p_account_id
AND b.check_in <= v_to
AND b.check_out > v_from;
END;
$$;
GRANT EXECUTE ON FUNCTION public.get_booking_statistics(uuid, date, date) TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_booking_statistics(uuid, date, date) TO service_role;
-- -------------------------------------------------------
-- D) Event registration counts (batch lookup)
-- -------------------------------------------------------
CREATE OR REPLACE FUNCTION public.get_event_registration_counts(p_event_ids uuid[])
RETURNS TABLE (event_id uuid, registration_count bigint)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = ''
AS $$
SELECT
er.event_id,
count(*)::bigint AS registration_count
FROM public.event_registrations er
WHERE er.event_id = ANY(p_event_ids)
AND er.status IN ('confirmed', 'pending')
GROUP BY er.event_id;
$$;
GRANT EXECUTE ON FUNCTION public.get_event_registration_counts(uuid[]) TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_event_registration_counts(uuid[]) TO service_role;

View File

@@ -0,0 +1,43 @@
-- =====================================================
-- Additional Indexes
--
-- Partial indexes for common query patterns across
-- course-management, event-management, and
-- booking-management modules.
-- =====================================================
-- Course participants: fast capacity counting
CREATE INDEX IF NOT EXISTS ix_course_participants_active_status
ON public.course_participants(course_id, status)
WHERE status IN ('enrolled', 'waitlisted');
-- Event registrations: fast registration counting
CREATE INDEX IF NOT EXISTS ix_event_registrations_active_status
ON public.event_registrations(event_id, status)
WHERE status IN ('confirmed', 'pending', 'waitlisted');
-- Bookings: active bookings for availability queries
CREATE INDEX IF NOT EXISTS ix_bookings_active_dates
ON public.bookings(room_id, check_in, check_out)
WHERE status NOT IN ('cancelled', 'no_show');
-- Bookings: guest history lookup
CREATE INDEX IF NOT EXISTS ix_bookings_guest_checkin
ON public.bookings(guest_id, check_in DESC)
WHERE guest_id IS NOT NULL;
-- Course sessions: instructor scheduling conflict checks
CREATE INDEX IF NOT EXISTS ix_course_sessions_instructor_date
ON public.course_sessions(session_date, start_time, end_time)
WHERE is_cancelled = false;
-- Audit log indexes for timeline queries
-- Safety nets in case earlier migration did not cover them
CREATE INDEX IF NOT EXISTS ix_course_audit_account_action
ON public.course_audit_log(account_id, action);
CREATE INDEX IF NOT EXISTS ix_event_audit_account_action
ON public.event_audit_log(account_id, action);
CREATE INDEX IF NOT EXISTS ix_booking_audit_account_action
ON public.booking_audit_log(account_id, action);

View File

@@ -13,7 +13,9 @@
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
"./actions/*": "./src/server/actions/*.ts",
"./services/*": "./src/server/services/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",

View File

@@ -0,0 +1,98 @@
import type { z } from 'zod';
import type { BookingStatusEnum } from '../schema/booking.schema';
type BookingStatus = z.infer<typeof BookingStatusEnum>;
/**
* Booking status state machine.
*
* Defines valid transitions between booking statuses and their
* side effects. Enforced in booking update operations.
*/
type StatusTransition = {
/** Fields to set automatically when this transition occurs */
sideEffects?: Partial<Record<string, unknown>>;
};
const TRANSITIONS: Record<
BookingStatus,
Partial<Record<BookingStatus, StatusTransition>>
> = {
pending: {
confirmed: {},
cancelled: {},
},
confirmed: {
checked_in: {},
cancelled: {},
no_show: {},
},
checked_in: {
checked_out: {},
},
// Terminal states — no transitions out
checked_out: {},
cancelled: {
pending: {},
},
no_show: {},
};
/**
* Check if a status transition is valid.
*/
export function canTransition(from: BookingStatus, to: BookingStatus): boolean {
if (from === to) return true; // no-op is always valid
return to in (TRANSITIONS[from] ?? {});
}
/**
* Get all valid target statuses from a given status.
*/
export function getValidTransitions(from: BookingStatus): BookingStatus[] {
return Object.keys(TRANSITIONS[from] ?? {}) as BookingStatus[];
}
/**
* Get the side effects for a transition.
* Returns an object of field->value pairs to apply alongside the status change.
* Function values should be called to get the actual value.
*/
export function getTransitionSideEffects(
from: BookingStatus,
to: BookingStatus,
): Record<string, unknown> {
if (from === to) return {};
const transition = TRANSITIONS[from]?.[to];
if (!transition?.sideEffects) return {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(transition.sideEffects)) {
result[key] = typeof value === 'function' ? value() : value;
}
return result;
}
/**
* Validate a status transition and return side effects.
* Throws if the transition is invalid.
*/
export function validateTransition(
from: BookingStatus,
to: BookingStatus,
): Record<string, unknown> {
if (from === to) return {};
if (!canTransition(from, to)) {
throw new Error(
`Ungültiger Statuswechsel: ${from}${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
);
}
return getTransitionSideEffects(from, to);
}

View File

@@ -0,0 +1,131 @@
/**
* Standardized error codes and domain error classes
* for the booking management module.
*/
export const BookingErrorCodes = {
BOOKING_NOT_FOUND: 'BOOKING_NOT_FOUND',
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
ROOM_NOT_AVAILABLE: 'ROOM_NOT_AVAILABLE',
ROOM_CAPACITY_EXCEEDED: 'ROOM_CAPACITY_EXCEEDED',
INVALID_STATUS_TRANSITION: 'BOOKING_INVALID_TRANSITION',
CONCURRENCY_CONFLICT: 'BOOKING_CONCURRENCY_CONFLICT',
INVALID_DATE_RANGE: 'BOOKING_INVALID_DATE_RANGE',
} as const;
export type BookingErrorCode =
(typeof BookingErrorCodes)[keyof typeof BookingErrorCodes];
/**
* Base domain error for booking management operations.
*/
export class BookingDomainError extends Error {
readonly code: BookingErrorCode;
readonly statusCode: number;
readonly details?: Record<string, unknown>;
constructor(
code: BookingErrorCode,
message: string,
statusCode = 400,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'BookingDomainError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class BookingNotFoundError extends BookingDomainError {
constructor(bookingId: string) {
super(
BookingErrorCodes.BOOKING_NOT_FOUND,
`Buchung ${bookingId} nicht gefunden`,
404,
{ bookingId },
);
this.name = 'BookingNotFoundError';
}
}
export class RoomNotFoundError extends BookingDomainError {
constructor(roomId: string) {
super(
BookingErrorCodes.ROOM_NOT_FOUND,
`Raum ${roomId} nicht gefunden`,
404,
{ roomId },
);
this.name = 'RoomNotFoundError';
}
}
export class RoomNotAvailableError extends BookingDomainError {
constructor(roomId: string, from: string, to: string) {
super(
BookingErrorCodes.ROOM_NOT_AVAILABLE,
`Raum ${roomId} ist im Zeitraum ${from} bis ${to} nicht verfügbar`,
409,
{ roomId, from, to },
);
this.name = 'RoomNotAvailableError';
}
}
export class RoomCapacityExceededError extends BookingDomainError {
constructor(roomId: string, capacity: number) {
super(
BookingErrorCodes.ROOM_CAPACITY_EXCEEDED,
`Raum ${roomId} hat die maximale Kapazität (${capacity}) erreicht`,
422,
{ roomId, capacity },
);
this.name = 'RoomCapacityExceededError';
}
}
export class InvalidBookingStatusTransitionError extends BookingDomainError {
constructor(from: string, to: string, validTargets: string[]) {
super(
BookingErrorCodes.INVALID_STATUS_TRANSITION,
`Ungültiger Statuswechsel: ${from}${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
422,
{ from, to, validTargets },
);
this.name = 'InvalidBookingStatusTransitionError';
}
}
export class BookingConcurrencyConflictError extends BookingDomainError {
constructor() {
super(
BookingErrorCodes.CONCURRENCY_CONFLICT,
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
409,
);
this.name = 'BookingConcurrencyConflictError';
}
}
export class BookingInvalidDateRangeError extends BookingDomainError {
constructor(from: string, to: string) {
super(
BookingErrorCodes.INVALID_DATE_RANGE,
`Ungültiger Zeitraum: ${from} bis ${to}. Das Enddatum muss nach dem Startdatum liegen.`,
422,
{ from, to },
);
this.name = 'BookingInvalidDateRangeError';
}
}
/**
* Check if an error is a BookingDomainError.
*/
export function isBookingDomainError(
error: unknown,
): error is BookingDomainError {
return error instanceof BookingDomainError;
}

View File

@@ -20,7 +20,8 @@ export const CreateRoomSchema = z.object({
description: z.string().optional(),
});
export const CreateBookingSchema = z.object({
export const CreateBookingSchema = z
.object({
accountId: z.string().uuid(),
roomId: z.string().uuid(),
guestId: z.string().uuid().optional(),
@@ -29,11 +30,21 @@ export const CreateBookingSchema = z.object({
adults: z.number().int().min(1).default(1),
children: z.number().int().min(0).default(0),
status: BookingStatusEnum.default('confirmed'),
totalPrice: z.number().min(0).default(0),
totalPrice: z.number().min(0).optional(),
notes: z.string().optional(),
})
.refine((d) => d.checkOut > d.checkIn, {
message: 'Abreisedatum muss nach dem Anreisedatum liegen',
path: ['checkOut'],
});
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
export const UpdateBookingStatusSchema = z.object({
bookingId: z.string().uuid(),
status: BookingStatusEnum,
version: z.number().int().optional(),
});
export const CreateGuestSchema = z.object({
accountId: z.string().uuid(),
firstName: z.string().min(1),

View File

@@ -1,71 +1,87 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isBookingDomainError } from '../../lib/errors';
import {
CreateBookingSchema,
CreateGuestSchema,
CreateRoomSchema,
UpdateBookingStatusSchema,
} from '../../schema/booking.schema';
import { createBookingManagementApi } from '../api';
export const createBooking = authActionClient
.inputSchema(CreateBookingSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
try {
logger.info({ name: 'booking.create' }, 'Creating booking...');
const result = await api.createBooking(input);
const result = await api.bookings.create(input);
logger.info({ name: 'booking.create' }, 'Booking created');
return { success: true, data: result };
} catch (e) {
if (isBookingDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const updateBookingStatus = authActionClient
.inputSchema(
z.object({
bookingId: z.string().uuid(),
status: z.string(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.inputSchema(UpdateBookingStatusSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
const result = await api.updateBookingStatus(input.bookingId, input.status);
try {
logger.info(
{ name: 'booking.updateStatus' },
'Updating booking status...',
);
await api.bookings.updateStatus(
input.bookingId,
input.status,
input.version,
);
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
return { success: true, data: result };
return { success: true };
} catch (e) {
if (isBookingDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const createRoom = authActionClient
.inputSchema(CreateRoomSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.createRoom' }, 'Creating room...');
const result = await api.createRoom(input);
const result = await api.rooms.create(input);
logger.info({ name: 'booking.createRoom' }, 'Room created');
return { success: true, data: result };
});
export const createGuest = authActionClient
.inputSchema(CreateGuestSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
const result = await api.createGuest(input);
const result = await api.guests.create(input);
logger.info({ name: 'booking.createGuest' }, 'Guest created');
return { success: true, data: result };
});

View File

@@ -1,173 +1,16 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateBookingInput } from '../schema/booking.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createBookingCrudService } from './services/booking-crud.service';
import { createGuestService } from './services/guest.service';
import { createRoomService } from './services/room.service';
export function createBookingManagementApi(client: SupabaseClient<Database>) {
const _db = client;
return {
// --- Rooms ---
async listRooms(accountId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('account_id', accountId)
.eq('is_active', true)
.order('room_number');
if (error) throw error;
return data ?? [];
},
async getRoom(roomId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('id', roomId)
.single();
if (error) throw error;
return data;
},
// --- Availability ---
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
const { count, error } = await client
.from('bookings')
.select('*', { count: 'exact', head: true })
.eq('room_id', roomId)
.not('status', 'in', '("cancelled","no_show")')
.lt('check_in', checkOut)
.gt('check_out', checkIn);
if (error) throw error;
return (count ?? 0) === 0;
},
// --- Bookings ---
async listBookings(
accountId: string,
opts?: { status?: string; from?: string; to?: string; page?: number },
) {
let query = client
.from('bookings')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.from) query = query.gte('check_in', opts.from);
if (opts?.to) query = query.lte('check_out', opts.to);
const page = opts?.page ?? 1;
query = query.range((page - 1) * 25, page * 25 - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0 };
},
async createBooking(input: CreateBookingInput) {
const available = await this.checkAvailability(
input.roomId,
input.checkIn,
input.checkOut,
);
if (!available)
throw new Error('Room is not available for the selected dates');
const { data, error } = await client
.from('bookings')
.insert({
account_id: input.accountId,
room_id: input.roomId,
guest_id: input.guestId,
check_in: input.checkIn,
check_out: input.checkOut,
adults: input.adults,
children: input.children,
status: input.status,
total_price: input.totalPrice,
notes: input.notes,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateBookingStatus(bookingId: string, status: string) {
const { error } = await client
.from('bookings')
.update({ status })
.eq('id', bookingId);
if (error) throw error;
},
// --- Guests ---
async listGuests(accountId: string, search?: string) {
let query = client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (search)
query = query.or(
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createGuest(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
city?: string;
}) {
const { data, error } = await client
.from('guests')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
city: input.city,
})
.select()
.single();
if (error) throw error;
return data;
},
async createRoom(input: {
accountId: string;
roomNumber: string;
name?: string;
roomType?: string;
capacity?: number;
floor?: number;
pricePerNight: number;
description?: string;
}) {
const { data, error } = await client
.from('rooms')
.insert({
account_id: input.accountId,
room_number: input.roomNumber,
name: input.name,
room_type: input.roomType ?? 'standard',
capacity: input.capacity ?? 2,
floor: input.floor,
price_per_night: input.pricePerNight,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},
rooms: createRoomService(client),
bookings: createBookingCrudService(client),
guests: createGuestService(client),
};
}

View File

@@ -0,0 +1,157 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import {
getValidTransitions,
validateTransition,
} from '../../lib/booking-status-machine';
import {
BookingConcurrencyConflictError,
InvalidBookingStatusTransitionError,
} from '../../lib/errors';
import type { CreateBookingInput } from '../../schema/booking.schema';
const NAMESPACE = 'booking-crud';
export function createBookingCrudService(client: SupabaseClient<Database>) {
return {
async list(
accountId: string,
opts?: {
status?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('bookings')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.from) query = query.gte('check_in', opts.from);
if (opts?.to) query = query.lte('check_out', opts.to);
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;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
},
async create(input: CreateBookingInput) {
const logger = await getLogger();
logger.info({ name: NAMESPACE }, 'Creating booking...');
const { data, error } = await (client.rpc as CallableFunction)(
'create_booking_atomic',
{
p_account_id: input.accountId,
p_room_id: input.roomId,
p_guest_id: input.guestId ?? null,
p_check_in: input.checkIn,
p_check_out: input.checkOut,
p_adults: input.adults ?? 1,
p_children: input.children ?? 0,
p_status: input.status ?? 'confirmed',
p_total_price: input.totalPrice ?? null,
p_notes: input.notes ?? null,
},
);
if (error) throw error;
// RPC returns the booking UUID; fetch the full row
const bookingId = data as unknown as string;
const { data: booking, error: fetchError } = await client
.from('bookings')
.select('*')
.eq('id', bookingId)
.single();
if (fetchError) throw fetchError;
return booking;
},
async updateStatus(
bookingId: string,
status: string,
version?: number,
userId?: string,
) {
const logger = await getLogger();
logger.info(
{ name: NAMESPACE, bookingId, status },
'Updating booking status...',
);
// Fetch current booking to get current status
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version column added via migration, not yet in generated types
const { data: current, error: fetchError } = await (
client.from('bookings').select('id, status, version') as any
)
.eq('id', bookingId)
.single();
if (fetchError) throw fetchError;
const currentStatus = (current as Record<string, unknown>)
.status as string;
// Validate status transition using the state machine
try {
validateTransition(
currentStatus as Parameters<typeof validateTransition>[0],
status as Parameters<typeof validateTransition>[1],
);
} catch {
const validTargets = getValidTransitions(
currentStatus as Parameters<typeof getValidTransitions>[0],
);
throw new InvalidBookingStatusTransitionError(
currentStatus,
status,
validTargets,
);
}
// Build the update query
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- updated_by column added via migration
let query = client
.from('bookings')
.update({
status,
...(userId ? { updated_by: userId } : {}),
} as any)
.eq('id', bookingId);
// Optimistic concurrency control via version column (added by migration, not yet in generated types)
if (version !== undefined) {
query = (query as any).eq('version', version);
}
const { data, error } = await query.select('id').single();
if (error) {
// If no rows matched, it's a concurrency conflict
if (error.code === 'PGRST116') {
throw new BookingConcurrencyConflictError();
}
throw error;
}
if (!data) {
throw new BookingConcurrencyConflictError();
}
},
};
}

View File

@@ -0,0 +1,57 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createGuestService(client: SupabaseClient<Database>) {
return {
async list(accountId: string, search?: string) {
let query = client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (search)
query = query.or(
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async create(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
city?: string;
}) {
const { data, error } = await client
.from('guests')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email || null,
phone: input.phone || null,
city: input.city || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async getHistory(guestId: string) {
const { data, error } = await client
.from('bookings')
.select('*')
.eq('guest_id', guestId)
.order('check_in', { ascending: false });
if (error) throw error;
return data ?? [];
},
};
}

View File

@@ -0,0 +1,18 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createBookingCrudService } from './booking-crud.service';
import { createGuestService } from './guest.service';
import { createRoomService } from './room.service';
export { createBookingCrudService, createGuestService, createRoomService };
export function createBookingServices(client: SupabaseClient<Database>) {
return {
rooms: createRoomService(client),
bookings: createBookingCrudService(client),
guests: createGuestService(client),
};
}

View File

@@ -0,0 +1,69 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createRoomService(client: SupabaseClient<Database>) {
return {
async list(accountId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('account_id', accountId)
.eq('is_active', true)
.order('room_number');
if (error) throw error;
return data ?? [];
},
async getById(roomId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('id', roomId)
.single();
if (error) throw error;
return data;
},
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
const { count, error } = await client
.from('bookings')
.select('*', { count: 'exact', head: true })
.eq('room_id', roomId)
.not('status', 'in', '("cancelled","no_show")')
.lt('check_in', checkOut)
.gt('check_out', checkIn);
if (error) throw error;
return (count ?? 0) === 0;
},
async create(input: {
accountId: string;
roomNumber: string;
name?: string;
roomType?: string;
capacity?: number;
floor?: number;
pricePerNight: number;
description?: string;
}) {
const { data, error } = await client
.from('rooms')
.insert({
account_id: input.accountId,
room_number: input.roomNumber,
name: input.name,
room_type: input.roomType ?? 'standard',
capacity: input.capacity ?? 2,
floor: input.floor,
price_per_night: input.pricePerNight,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},
};
}

View File

@@ -13,7 +13,9 @@
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
"./actions/*": "./src/server/actions/*.ts",
"./services/*": "./src/server/services/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",

View File

@@ -0,0 +1,97 @@
import type { z } from 'zod';
import type { CourseStatusEnum } from '../schema/course.schema';
type CourseStatus = z.infer<typeof CourseStatusEnum>;
/**
* Course status state machine.
*
* Defines valid transitions between course statuses and their
* side effects. Enforced in course update operations.
*/
type StatusTransition = {
/** Fields to set automatically when this transition occurs */
sideEffects?: Partial<Record<string, unknown>>;
};
const TRANSITIONS: Record<
CourseStatus,
Partial<Record<CourseStatus, StatusTransition>>
> = {
planned: {
open: {},
cancelled: {},
},
open: {
running: {},
cancelled: {},
},
running: {
completed: {},
cancelled: {},
},
// Terminal state — no transitions out
completed: {},
cancelled: {
planned: {},
},
};
/**
* Check if a status transition is valid.
*/
export function canTransition(from: CourseStatus, to: CourseStatus): boolean {
if (from === to) return true; // no-op is always valid
return to in (TRANSITIONS[from] ?? {});
}
/**
* Get all valid target statuses from a given status.
*/
export function getValidTransitions(from: CourseStatus): CourseStatus[] {
return Object.keys(TRANSITIONS[from] ?? {}) as CourseStatus[];
}
/**
* Get the side effects for a transition.
* Returns an object of field->value pairs to apply alongside the status change.
* Function values should be called to get the actual value.
*/
export function getTransitionSideEffects(
from: CourseStatus,
to: CourseStatus,
): Record<string, unknown> {
if (from === to) return {};
const transition = TRANSITIONS[from]?.[to];
if (!transition?.sideEffects) return {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(transition.sideEffects)) {
result[key] = typeof value === 'function' ? value() : value;
}
return result;
}
/**
* Validate a status transition and return side effects.
* Throws if the transition is invalid.
*/
export function validateTransition(
from: CourseStatus,
to: CourseStatus,
): Record<string, unknown> {
if (from === to) return {};
if (!canTransition(from, to)) {
throw new Error(
`Ungültiger Statuswechsel: ${from}${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
);
}
return getTransitionSideEffects(from, to);
}

View File

@@ -0,0 +1,128 @@
/**
* Standardized error codes and domain error classes
* for the course management module.
*/
export const CourseErrorCodes = {
NOT_FOUND: 'COURSE_NOT_FOUND',
CAPACITY_EXCEEDED: 'COURSE_CAPACITY_EXCEEDED',
INVALID_STATUS_TRANSITION: 'COURSE_INVALID_TRANSITION',
REGISTRATION_CLOSED: 'COURSE_REGISTRATION_CLOSED',
CONCURRENCY_CONFLICT: 'COURSE_CONCURRENCY_CONFLICT',
DUPLICATE_ENROLLMENT: 'COURSE_DUPLICATE_ENROLLMENT',
MIN_PARTICIPANTS_NOT_MET: 'COURSE_MIN_PARTICIPANTS_NOT_MET',
} as const;
export type CourseErrorCode =
(typeof CourseErrorCodes)[keyof typeof CourseErrorCodes];
/**
* Base domain error for course management operations.
*/
export class CourseDomainError extends Error {
readonly code: CourseErrorCode;
readonly statusCode: number;
readonly details?: Record<string, unknown>;
constructor(
code: CourseErrorCode,
message: string,
statusCode = 400,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'CourseDomainError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class CourseNotFoundError extends CourseDomainError {
constructor(courseId: string) {
super(CourseErrorCodes.NOT_FOUND, `Kurs ${courseId} nicht gefunden`, 404, {
courseId,
});
this.name = 'CourseNotFoundError';
}
}
export class CourseCapacityExceededError extends CourseDomainError {
constructor(courseId: string, capacity: number) {
super(
CourseErrorCodes.CAPACITY_EXCEEDED,
`Kurs ${courseId} hat die maximale Teilnehmerzahl (${capacity}) erreicht`,
409,
{ courseId, capacity },
);
this.name = 'CourseCapacityExceededError';
}
}
export class InvalidCourseStatusTransitionError extends CourseDomainError {
constructor(from: string, to: string, validTargets: string[]) {
super(
CourseErrorCodes.INVALID_STATUS_TRANSITION,
`Ungültiger Statuswechsel: ${from}${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
422,
{ from, to, validTargets },
);
this.name = 'InvalidCourseStatusTransitionError';
}
}
export class CourseRegistrationClosedError extends CourseDomainError {
constructor(courseId: string) {
super(
CourseErrorCodes.REGISTRATION_CLOSED,
`Anmeldung für Kurs ${courseId} ist geschlossen`,
422,
{ courseId },
);
this.name = 'CourseRegistrationClosedError';
}
}
export class CourseConcurrencyConflictError extends CourseDomainError {
constructor() {
super(
CourseErrorCodes.CONCURRENCY_CONFLICT,
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
409,
);
this.name = 'CourseConcurrencyConflictError';
}
}
export class CourseDuplicateEnrollmentError extends CourseDomainError {
constructor(courseId: string, memberId: string) {
super(
CourseErrorCodes.DUPLICATE_ENROLLMENT,
`Mitglied ${memberId} ist bereits für Kurs ${courseId} angemeldet`,
409,
{ courseId, memberId },
);
this.name = 'CourseDuplicateEnrollmentError';
}
}
export class CourseMinParticipantsError extends CourseDomainError {
constructor(courseId: string, minParticipants: number, currentCount: number) {
super(
CourseErrorCodes.MIN_PARTICIPANTS_NOT_MET,
`Kurs ${courseId} benötigt mindestens ${minParticipants} Teilnehmer (aktuell: ${currentCount})`,
422,
{ courseId, minParticipants, currentCount },
);
this.name = 'CourseMinParticipantsError';
}
}
/**
* Check if an error is a CourseDomainError.
*/
export function isCourseDomainError(
error: unknown,
): error is CourseDomainError {
return error instanceof CourseDomainError;
}

View File

@@ -16,7 +16,8 @@ export const CourseStatusEnum = z.enum([
'cancelled',
]);
export const CreateCourseSchema = z.object({
export const CreateCourseSchema = z
.object({
accountId: z.string().uuid(),
courseNumber: z.string().optional(),
name: z.string().min(1).max(256),
@@ -33,12 +34,82 @@ export const CreateCourseSchema = z.object({
status: CourseStatusEnum.default('planned'),
registrationDeadline: z.string().optional(),
notes: z.string().optional(),
});
})
.refine((d) => d.reducedFee == null || d.reducedFee <= d.fee, {
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
path: ['reducedFee'],
})
.refine((d) => d.minParticipants == null || d.minParticipants <= d.capacity, {
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
path: ['minParticipants'],
})
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
message: 'Enddatum muss nach dem Startdatum liegen',
path: ['endDate'],
})
.refine(
(d) =>
!d.registrationDeadline ||
!d.startDate ||
d.registrationDeadline <= d.startDate,
{
message: 'Anmeldefrist muss vor dem Startdatum liegen',
path: ['registrationDeadline'],
},
);
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
export const UpdateCourseSchema = z
.object({
courseId: z.string().uuid(),
});
version: z.number().int().optional(),
courseNumber: z.string().optional(),
name: z.string().min(1).max(256).optional(),
description: z.string().optional(),
categoryId: z.string().uuid().optional(),
instructorId: z.string().uuid().optional(),
locationId: z.string().uuid().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
fee: z.number().min(0).optional(),
reducedFee: z.number().min(0).optional().nullable(),
capacity: z.number().int().min(1).optional(),
minParticipants: z.number().int().min(0).optional(),
status: CourseStatusEnum.optional(),
registrationDeadline: z.string().optional(),
notes: z.string().optional(),
})
.refine(
(d) => d.reducedFee == null || d.fee == null || d.reducedFee <= d.fee,
{
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
path: ['reducedFee'],
},
)
.refine(
(d) =>
d.minParticipants == null ||
d.capacity == null ||
d.minParticipants <= d.capacity,
{
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
path: ['minParticipants'],
},
)
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
message: 'Enddatum muss nach dem Startdatum liegen',
path: ['endDate'],
})
.refine(
(d) =>
!d.registrationDeadline ||
!d.startDate ||
d.registrationDeadline <= d.startDate,
{
message: 'Anmeldefrist muss vor dem Startdatum liegen',
path: ['registrationDeadline'],
},
);
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
export const EnrollParticipantSchema = z.object({

View File

@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isCourseDomainError } from '../../lib/errors';
import {
CreateCourseSchema,
UpdateCourseSchema,
@@ -25,7 +26,7 @@ export const createCourse = authActionClient
const api = createCourseManagementApi(client);
logger.info({ name: 'course.create' }, 'Creating course...');
const result = await api.createCourse(input);
const result = await api.courses.create(input, ctx.user.id);
logger.info({ name: 'course.create' }, 'Course created');
return { success: true, data: result };
});
@@ -37,39 +38,60 @@ export const updateCourse = authActionClient
const logger = await getLogger();
const api = createCourseManagementApi(client);
try {
logger.info({ name: 'course.update' }, 'Updating course...');
const result = await api.updateCourse(input);
const result = await api.courses.update(input, ctx.user.id);
logger.info({ name: 'course.update' }, 'Course updated');
return { success: true, data: result };
} catch (e) {
if (isCourseDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const deleteCourse = authActionClient
.inputSchema(z.object({ courseId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
try {
logger.info({ name: 'course.delete' }, 'Archiving course...');
await api.deleteCourse(input.courseId);
await api.courses.softDelete(input.courseId);
logger.info({ name: 'course.delete' }, 'Course archived');
return { success: true };
} catch (e) {
if (isCourseDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const enrollParticipant = authActionClient
.inputSchema(EnrollParticipantSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
try {
logger.info(
{ name: 'course.enrollParticipant' },
'Enrolling participant...',
);
const result = await api.enrollParticipant(input);
const result = await api.enrollment.enroll(input);
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
return { success: true, data: result };
} catch (e) {
if (isCourseDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const cancelEnrollment = authActionClient
@@ -78,7 +100,7 @@ export const cancelEnrollment = authActionClient
participantId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
@@ -87,9 +109,9 @@ export const cancelEnrollment = authActionClient
{ name: 'course.cancelEnrollment' },
'Cancelling enrollment...',
);
const result = await api.cancelEnrollment(input.participantId);
await api.enrollment.cancel(input.participantId);
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
return { success: true, data: result };
return { success: true };
});
export const markAttendance = authActionClient
@@ -100,69 +122,69 @@ export const markAttendance = authActionClient
present: z.boolean(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
const result = await api.markAttendance(
await api.attendance.mark(
input.sessionId,
input.participantId,
input.present,
);
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
return { success: true, data: result };
return { success: true };
});
export const createCategory = authActionClient
.inputSchema(CreateCategorySchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createCategory' }, 'Creating category...');
const result = await api.createCategory(input);
const result = await api.referenceData.createCategory(input);
logger.info({ name: 'course.createCategory' }, 'Category created');
return { success: true, data: result };
});
export const createInstructor = authActionClient
.inputSchema(CreateInstructorSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
const result = await api.createInstructor(input);
const result = await api.referenceData.createInstructor(input);
logger.info({ name: 'course.createInstructor' }, 'Instructor created');
return { success: true, data: result };
});
export const createLocation = authActionClient
.inputSchema(CreateLocationSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createLocation' }, 'Creating location...');
const result = await api.createLocation(input);
const result = await api.referenceData.createLocation(input);
logger.info({ name: 'course.createLocation' }, 'Location created');
return { success: true, data: result };
});
export const createSession = authActionClient
.inputSchema(CreateSessionSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createSession' }, 'Creating session...');
const result = await api.createSession(input);
const result = await api.sessions.create(input);
logger.info({ name: 'course.createSession' }, 'Session created');
return { success: true, data: result };
});

View File

@@ -1,361 +1,22 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CreateCourseInput,
UpdateCourseInput,
EnrollParticipantInput,
} from '../schema/course.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createAttendanceService } from './services/attendance.service';
import { createCourseCrudService } from './services/course-crud.service';
import { createCourseReferenceDataService } from './services/course-reference-data.service';
import { createCourseStatisticsService } from './services/course-statistics.service';
import { createEnrollmentService } from './services/enrollment.service';
import { createSessionService } from './services/session.service';
export function createCourseManagementApi(client: SupabaseClient<Database>) {
const _db = client;
return {
// --- Courses ---
async listCourses(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('courses')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.search)
query = query.or(
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
);
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 getCourse(courseId: string) {
const { data, error } = await client
.from('courses')
.select('*')
.eq('id', courseId)
.single();
if (error) throw error;
return data;
},
async createCourse(input: CreateCourseInput) {
const { data, error } = await client
.from('courses')
.insert({
account_id: input.accountId,
course_number: input.courseNumber || null,
name: input.name,
description: input.description || null,
category_id: input.categoryId || null,
instructor_id: input.instructorId || null,
location_id: input.locationId || null,
start_date: input.startDate || null,
end_date: input.endDate || null,
fee: input.fee,
reduced_fee: input.reducedFee ?? null,
capacity: input.capacity,
min_participants: input.minParticipants,
status: input.status,
registration_deadline: input.registrationDeadline || null,
notes: input.notes || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateCourse(input: UpdateCourseInput) {
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.courseNumber !== undefined)
update.course_number = input.courseNumber || null;
if (input.description !== undefined)
update.description = input.description || null;
if (input.categoryId !== undefined)
update.category_id = input.categoryId || null;
if (input.instructorId !== undefined)
update.instructor_id = input.instructorId || null;
if (input.locationId !== undefined)
update.location_id = input.locationId || null;
if (input.startDate !== undefined)
update.start_date = input.startDate || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.reducedFee !== undefined)
update.reduced_fee = input.reducedFee ?? null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minParticipants !== undefined)
update.min_participants = input.minParticipants;
if (input.status !== undefined) update.status = input.status;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.notes !== undefined) update.notes = input.notes || null;
const { data, error } = await client
.from('courses')
.update(update)
.eq('id', input.courseId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteCourse(courseId: string) {
const { error } = await client
.from('courses')
.update({ status: 'cancelled' })
.eq('id', courseId);
if (error) throw error;
},
// --- Enrollment ---
async enrollParticipant(input: EnrollParticipantInput) {
// Check capacity
const { count } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true })
.eq('course_id', input.courseId)
.in('status', ['enrolled']);
const course = await this.getCourse(input.courseId);
const status =
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
const { data, error } = await client
.from('course_participants')
.insert({
course_id: input.courseId,
member_id: input.memberId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
status,
})
.select()
.single();
if (error) throw error;
return data;
},
async cancelEnrollment(participantId: string) {
const { error } = await client
.from('course_participants')
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
.eq('id', participantId);
if (error) throw error;
},
async getParticipants(courseId: string) {
const { data, error } = await client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('enrolled_at');
if (error) throw error;
return data ?? [];
},
// --- Sessions ---
async getSessions(courseId: string) {
const { data, error } = await client
.from('course_sessions')
.select('*')
.eq('course_id', courseId)
.order('session_date');
if (error) throw error;
return data ?? [];
},
async createSession(input: {
courseId: string;
sessionDate: string;
startTime: string;
endTime: string;
locationId?: string;
}) {
const { data, error } = await client
.from('course_sessions')
.insert({
course_id: input.courseId,
session_date: input.sessionDate,
start_time: input.startTime,
end_time: input.endTime,
location_id: input.locationId,
})
.select()
.single();
if (error) throw error;
return data;
},
// --- Attendance ---
async getAttendance(sessionId: string) {
const { data, error } = await client
.from('course_attendance')
.select('*')
.eq('session_id', sessionId);
if (error) throw error;
return data ?? [];
},
async markAttendance(
sessionId: string,
participantId: string,
present: boolean,
) {
const { error } = await client.from('course_attendance').upsert(
{
session_id: sessionId,
participant_id: participantId,
present,
},
{ onConflict: 'session_id,participant_id' },
);
if (error) throw error;
},
// --- Categories, Instructors, Locations ---
async listCategories(accountId: string) {
const { data, error } = await client
.from('course_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async listInstructors(accountId: string) {
const { data, error } = await client
.from('course_instructors')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
return data ?? [];
},
async listLocations(accountId: string) {
const { data, error } = await client
.from('course_locations')
.select('*')
.eq('account_id', accountId)
.order('name');
if (error) throw error;
return data ?? [];
},
// --- Statistics ---
async getStatistics(accountId: string) {
const { data: courses } = await client
.from('courses')
.select('status')
.eq('account_id', accountId);
const { count: totalParticipants } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true })
.in(
'course_id',
(courses ?? []).map((c: any) => c.id),
);
const stats = {
totalCourses: 0,
openCourses: 0,
completedCourses: 0,
totalParticipants: totalParticipants ?? 0,
};
for (const c of courses ?? []) {
stats.totalCourses++;
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
if (c.status === 'completed') stats.completedCourses++;
}
return stats;
},
// --- Create methods for CRUD ---
async createCategory(input: {
accountId: string;
name: string;
description?: string;
parentId?: string;
}) {
const { data, error } = await client
.from('course_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
parent_id: input.parentId,
})
.select()
.single();
if (error) throw error;
return data;
},
async createInstructor(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
qualifications?: string;
hourlyRate?: number;
}) {
const { data, error } = await client
.from('course_instructors')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
qualifications: input.qualifications,
hourly_rate: input.hourlyRate,
})
.select()
.single();
if (error) throw error;
return data;
},
async createLocation(input: {
accountId: string;
name: string;
address?: string;
room?: string;
capacity?: number;
}) {
const { data, error } = await client
.from('course_locations')
.insert({
account_id: input.accountId,
name: input.name,
address: input.address,
room: input.room,
capacity: input.capacity,
})
.select()
.single();
if (error) throw error;
return data;
},
courses: createCourseCrudService(client),
enrollment: createEnrollmentService(client),
sessions: createSessionService(client),
attendance: createAttendanceService(client),
referenceData: createCourseReferenceDataService(client),
statistics: createCourseStatisticsService(client),
};
}

View File

@@ -0,0 +1,29 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createAttendanceService(client: SupabaseClient<Database>) {
return {
async getBySession(sessionId: string) {
const { data, error } = await client
.from('course_attendance')
.select('*')
.eq('session_id', sessionId);
if (error) throw error;
return data ?? [];
},
async mark(sessionId: string, participantId: string, present: boolean) {
const { error } = await client.from('course_attendance').upsert(
{
session_id: sessionId,
participant_id: participantId,
present,
},
{ onConflict: 'session_id,participant_id' },
);
if (error) throw error;
},
};
}

View File

@@ -0,0 +1,227 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import {
canTransition,
validateTransition,
getValidTransitions,
} from '../../lib/course-status-machine';
import {
CourseNotFoundError,
CourseConcurrencyConflictError,
InvalidCourseStatusTransitionError,
} from '../../lib/errors';
import type {
CreateCourseInput,
UpdateCourseInput,
} from '../../schema/course.schema';
const NAMESPACE = 'course-crud';
export function createCourseCrudService(client: SupabaseClient<Database>) {
return {
async list(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('courses')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.search)
query = query.or(
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
);
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;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
},
async getById(courseId: string) {
const { data, error } = await client
.from('courses')
.select('*')
.eq('id', courseId)
.maybeSingle();
if (error) throw error;
if (!data) throw new CourseNotFoundError(courseId);
return data;
},
async create(input: CreateCourseInput, userId?: string) {
const logger = await getLogger();
logger.info({ name: NAMESPACE }, 'Creating course...');
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- created_by/updated_by added via migration, not yet in generated types
const { data, error } = await client
.from('courses')
.insert({
account_id: input.accountId,
course_number: input.courseNumber || null,
name: input.name,
description: input.description || null,
category_id: input.categoryId || null,
instructor_id: input.instructorId || null,
location_id: input.locationId || null,
start_date: input.startDate || null,
end_date: input.endDate || null,
fee: input.fee,
reduced_fee: input.reducedFee ?? null,
capacity: input.capacity,
min_participants: input.minParticipants,
status: input.status,
registration_deadline: input.registrationDeadline || null,
notes: input.notes || null,
...(userId ? { created_by: userId, updated_by: userId } : {}),
} as any)
.select()
.single();
if (error) throw error;
return data;
},
async update(input: UpdateCourseInput, userId?: string) {
const logger = await getLogger();
logger.info(
{ name: NAMESPACE, courseId: input.courseId },
'Updating course...',
);
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.courseNumber !== undefined)
update.course_number = input.courseNumber || null;
if (input.description !== undefined)
update.description = input.description || null;
if (input.categoryId !== undefined)
update.category_id = input.categoryId || null;
if (input.instructorId !== undefined)
update.instructor_id = input.instructorId || null;
if (input.locationId !== undefined)
update.location_id = input.locationId || null;
if (input.startDate !== undefined)
update.start_date = input.startDate || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.reducedFee !== undefined)
update.reduced_fee = input.reducedFee ?? null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minParticipants !== undefined)
update.min_participants = input.minParticipants;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.notes !== undefined) update.notes = input.notes || null;
// Status transition validation
if (input.status !== undefined) {
const { data: current, error: fetchError } = await client
.from('courses')
.select('status')
.eq('id', input.courseId)
.single();
if (fetchError) throw fetchError;
const currentStatus = current.status as string;
if (currentStatus !== input.status) {
try {
const sideEffects = validateTransition(
currentStatus as Parameters<typeof validateTransition>[0],
input.status as Parameters<typeof validateTransition>[1],
);
Object.assign(update, sideEffects);
} catch {
throw new InvalidCourseStatusTransitionError(
currentStatus,
input.status,
getValidTransitions(
currentStatus as Parameters<typeof getValidTransitions>[0],
),
);
}
}
update.status = input.status;
}
if (userId) {
update.updated_by = userId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version/updated_by columns added via migration, not yet in generated types
let query = client
.from('courses')
.update(update as any)
.eq('id', input.courseId);
// Optimistic locking via version column
if (input.version !== undefined) {
query = (query as any).eq('version', input.version);
}
const { data, error } = await query.select().single();
if (error) {
if (error.code === 'PGRST116' && input.version !== undefined) {
throw new CourseConcurrencyConflictError();
}
throw error;
}
return data;
},
async softDelete(courseId: string) {
const logger = await getLogger();
logger.info({ name: NAMESPACE, courseId }, 'Cancelling course...');
const { data: current, error: fetchError } = await client
.from('courses')
.select('status')
.eq('id', courseId)
.maybeSingle();
if (fetchError) throw fetchError;
if (!current) throw new CourseNotFoundError(courseId);
type CourseStatus = Parameters<typeof canTransition>[0];
if (!canTransition(current.status as CourseStatus, 'cancelled')) {
throw new InvalidCourseStatusTransitionError(
current.status,
'cancelled',
getValidTransitions(current.status as CourseStatus),
);
}
const { error } = await client
.from('courses')
.update({ status: 'cancelled' })
.eq('id', courseId);
if (error) throw error;
},
};
}

View File

@@ -0,0 +1,108 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createCourseReferenceDataService(
client: SupabaseClient<Database>,
) {
return {
async listCategories(accountId: string) {
const { data, error } = await client
.from('course_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async listInstructors(accountId: string) {
const { data, error } = await client
.from('course_instructors')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
return data ?? [];
},
async listLocations(accountId: string) {
const { data, error } = await client
.from('course_locations')
.select('*')
.eq('account_id', accountId)
.order('name');
if (error) throw error;
return data ?? [];
},
async createCategory(input: {
accountId: string;
name: string;
description?: string;
parentId?: string;
}) {
const { data, error } = await client
.from('course_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
parent_id: input.parentId,
})
.select()
.single();
if (error) throw error;
return data;
},
async createInstructor(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
qualifications?: string;
hourlyRate?: number;
}) {
const { data, error } = await client
.from('course_instructors')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
qualifications: input.qualifications,
hourly_rate: input.hourlyRate,
})
.select()
.single();
if (error) throw error;
return data;
},
async createLocation(input: {
accountId: string;
name: string;
address?: string;
room?: string;
capacity?: number;
}) {
const { data, error } = await client
.from('course_locations')
.insert({
account_id: input.accountId,
name: input.name,
address: input.address,
room: input.room,
capacity: input.capacity,
})
.select()
.single();
if (error) throw error;
return data;
},
};
}

View File

@@ -0,0 +1,42 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createCourseStatisticsService(
client: SupabaseClient<Database>,
) {
return {
async getQuickStats(accountId: string) {
const { data, error } = await (client.rpc as CallableFunction)(
'get_course_statistics',
{ p_account_id: accountId },
);
if (error) throw error;
// RPC returns a single row as an array
const stats = Array.isArray(data) ? data[0] : data;
return (
stats ?? {
total_courses: 0,
open_courses: 0,
running_courses: 0,
completed_courses: 0,
cancelled_courses: 0,
total_participants: 0,
total_waitlisted: 0,
avg_occupancy_rate: 0,
total_revenue: 0,
}
);
},
async getAttendanceSummary(courseId: string) {
const { data, error } = await (client.rpc as CallableFunction)(
'get_course_attendance_summary',
{ p_course_id: courseId },
);
if (error) throw error;
return data ?? [];
},
};
}

View File

@@ -0,0 +1,51 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { EnrollParticipantInput } from '../../schema/course.schema';
export function createEnrollmentService(client: SupabaseClient<Database>) {
return {
async enroll(input: EnrollParticipantInput) {
// Uses the enroll_course_participant RPC which handles capacity checks
// and waitlisting atomically, avoiding race conditions.
const { data, error } = await (client.rpc as CallableFunction)(
'enroll_course_participant',
{
p_course_id: input.courseId,
p_member_id: input.memberId ?? null,
p_first_name: input.firstName,
p_last_name: input.lastName,
p_email: input.email || null,
p_phone: input.phone || null,
},
);
if (error) throw error;
return data;
},
async cancel(participantId: string) {
const { data, error } = await (client.rpc as CallableFunction)(
'cancel_course_enrollment',
{ p_participant_id: participantId },
);
if (error) throw error;
return data as {
cancelled_id: string;
promoted_id: string | null;
promoted_name: string | null;
};
},
async listParticipants(courseId: string) {
const { data, error } = await client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('enrolled_at');
if (error) throw error;
return data ?? [];
},
};
}

View File

@@ -0,0 +1,31 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createAttendanceService } from './attendance.service';
import { createCourseCrudService } from './course-crud.service';
import { createCourseReferenceDataService } from './course-reference-data.service';
import { createCourseStatisticsService } from './course-statistics.service';
import { createEnrollmentService } from './enrollment.service';
import { createSessionService } from './session.service';
export {
createAttendanceService,
createCourseCrudService,
createCourseReferenceDataService,
createCourseStatisticsService,
createEnrollmentService,
createSessionService,
};
export function createCourseServices(client: SupabaseClient<Database>) {
return {
courses: createCourseCrudService(client),
enrollment: createEnrollmentService(client),
sessions: createSessionService(client),
attendance: createAttendanceService(client),
referenceData: createCourseReferenceDataService(client),
statistics: createCourseStatisticsService(client),
};
}

View File

@@ -0,0 +1,63 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createSessionService(client: SupabaseClient<Database>) {
return {
async list(courseId: string) {
const { data, error } = await client
.from('course_sessions')
.select('*')
.eq('course_id', courseId)
.order('session_date');
if (error) throw error;
return data ?? [];
},
async create(input: {
courseId: string;
sessionDate: string;
startTime: string;
endTime: string;
locationId?: string;
}) {
// Check instructor availability if course has an instructor
const { data: course, error: courseError } = await client
.from('courses')
.select('instructor_id')
.eq('id', input.courseId)
.single();
if (courseError) throw courseError;
if (course?.instructor_id) {
const { data: isAvailable, error: availError } = await (
client.rpc as CallableFunction
)('check_instructor_availability', {
p_instructor_id: course.instructor_id,
p_session_date: input.sessionDate,
p_start_time: input.startTime,
p_end_time: input.endTime,
});
if (availError) throw availError;
if (!isAvailable) {
throw new Error('Kursleiter ist zu diesem Zeitpunkt nicht verfügbar');
}
}
const { data, error } = await client
.from('course_sessions')
.insert({
course_id: input.courseId,
session_date: input.sessionDate,
start_time: input.startTime,
end_time: input.endTime,
location_id: input.locationId,
})
.select()
.single();
if (error) throw error;
return data;
},
};
}

View File

@@ -13,7 +13,9 @@
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
"./actions/*": "./src/server/actions/*.ts",
"./services/*": "./src/server/services/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",

View File

@@ -0,0 +1,123 @@
/**
* Standardized error codes and domain error classes
* for the event management module.
*/
export const EventErrorCodes = {
NOT_FOUND: 'EVENT_NOT_FOUND',
FULL: 'EVENT_FULL',
INVALID_STATUS_TRANSITION: 'EVENT_INVALID_TRANSITION',
REGISTRATION_CLOSED: 'EVENT_REGISTRATION_CLOSED',
AGE_RESTRICTION: 'EVENT_AGE_RESTRICTION',
CONCURRENCY_CONFLICT: 'EVENT_CONCURRENCY_CONFLICT',
} as const;
export type EventErrorCode =
(typeof EventErrorCodes)[keyof typeof EventErrorCodes];
/**
* Base domain error for event management operations.
*/
export class EventDomainError extends Error {
readonly code: EventErrorCode;
readonly statusCode: number;
readonly details?: Record<string, unknown>;
constructor(
code: EventErrorCode,
message: string,
statusCode = 400,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'EventDomainError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class EventNotFoundError extends EventDomainError {
constructor(eventId: string) {
super(
EventErrorCodes.NOT_FOUND,
`Veranstaltung ${eventId} nicht gefunden`,
404,
{ eventId },
);
this.name = 'EventNotFoundError';
}
}
export class EventFullError extends EventDomainError {
constructor(eventId: string, capacity: number) {
super(
EventErrorCodes.FULL,
`Veranstaltung ${eventId} ist ausgebucht (max. ${capacity} Teilnehmer)`,
409,
{ eventId, capacity },
);
this.name = 'EventFullError';
}
}
export class InvalidEventStatusTransitionError extends EventDomainError {
constructor(from: string, to: string, validTargets: string[]) {
super(
EventErrorCodes.INVALID_STATUS_TRANSITION,
`Ungültiger Statuswechsel: ${from}${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
422,
{ from, to, validTargets },
);
this.name = 'InvalidEventStatusTransitionError';
}
}
export class EventRegistrationClosedError extends EventDomainError {
constructor(eventId: string) {
super(
EventErrorCodes.REGISTRATION_CLOSED,
`Anmeldung für Veranstaltung ${eventId} ist geschlossen`,
422,
{ eventId },
);
this.name = 'EventRegistrationClosedError';
}
}
export class EventAgeRestrictionError extends EventDomainError {
constructor(eventId: string, minAge?: number, maxAge?: number) {
const ageRange =
minAge && maxAge
? `${minAge}${maxAge} Jahre`
: minAge
? `ab ${minAge} Jahre`
: `bis ${maxAge} Jahre`;
super(
EventErrorCodes.AGE_RESTRICTION,
`Altersbeschränkung für Veranstaltung ${eventId}: ${ageRange}`,
422,
{ eventId, minAge, maxAge },
);
this.name = 'EventAgeRestrictionError';
}
}
export class EventConcurrencyConflictError extends EventDomainError {
constructor() {
super(
EventErrorCodes.CONCURRENCY_CONFLICT,
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
409,
);
this.name = 'EventConcurrencyConflictError';
}
}
/**
* Check if an error is an EventDomainError.
*/
export function isEventDomainError(error: unknown): error is EventDomainError {
return error instanceof EventDomainError;
}

View File

@@ -0,0 +1,103 @@
import type { z } from 'zod';
import type { EventStatusEnum } from '../schema/event.schema';
type EventStatus = z.infer<typeof EventStatusEnum>;
/**
* Event status state machine.
*
* Defines valid transitions between event statuses and their
* side effects. Enforced in event update operations.
*/
type StatusTransition = {
/** Fields to set automatically when this transition occurs */
sideEffects?: Partial<Record<string, unknown>>;
};
const TRANSITIONS: Record<
EventStatus,
Partial<Record<EventStatus, StatusTransition>>
> = {
planned: {
open: {},
cancelled: {},
},
open: {
full: {},
running: {},
cancelled: {},
},
full: {
open: {},
running: {},
cancelled: {},
},
running: {
completed: {},
cancelled: {},
},
// Terminal state — no transitions out
completed: {},
cancelled: {
planned: {},
},
};
/**
* Check if a status transition is valid.
*/
export function canTransition(from: EventStatus, to: EventStatus): boolean {
if (from === to) return true; // no-op is always valid
return to in (TRANSITIONS[from] ?? {});
}
/**
* Get all valid target statuses from a given status.
*/
export function getValidTransitions(from: EventStatus): EventStatus[] {
return Object.keys(TRANSITIONS[from] ?? {}) as EventStatus[];
}
/**
* Get the side effects for a transition.
* Returns an object of field->value pairs to apply alongside the status change.
* Function values should be called to get the actual value.
*/
export function getTransitionSideEffects(
from: EventStatus,
to: EventStatus,
): Record<string, unknown> {
if (from === to) return {};
const transition = TRANSITIONS[from]?.[to];
if (!transition?.sideEffects) return {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(transition.sideEffects)) {
result[key] = typeof value === 'function' ? value() : value;
}
return result;
}
/**
* Validate a status transition and return side effects.
* Throws if the transition is invalid.
*/
export function validateTransition(
from: EventStatus,
to: EventStatus,
): Record<string, unknown> {
if (from === to) return {};
if (!canTransition(from, to)) {
throw new Error(
`Ungültiger Statuswechsel: ${from}${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
);
}
return getTransitionSideEffects(from, to);
}

View File

@@ -9,7 +9,8 @@ export const EventStatusEnum = z.enum([
'cancelled',
]);
export const CreateEventSchema = z.object({
export const CreateEventSchema = z
.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(256),
description: z.string().optional(),
@@ -26,12 +27,62 @@ export const CreateEventSchema = z.object({
contactName: z.string().optional(),
contactEmail: z.string().email().optional().or(z.literal('')),
contactPhone: z.string().optional(),
});
})
.refine((d) => d.minAge == null || d.maxAge == null || d.minAge <= d.maxAge, {
message: 'Mindestalter darf das Höchstalter nicht übersteigen',
path: ['minAge'],
})
.refine((d) => !d.endDate || d.endDate >= d.eventDate, {
message: 'Enddatum muss nach dem Veranstaltungsdatum liegen',
path: ['endDate'],
})
.refine(
(d) => !d.registrationDeadline || d.registrationDeadline <= d.eventDate,
{
message: 'Anmeldefrist muss vor dem Veranstaltungsdatum liegen',
path: ['registrationDeadline'],
},
);
export type CreateEventInput = z.infer<typeof CreateEventSchema>;
export const UpdateEventSchema = CreateEventSchema.partial().extend({
export const UpdateEventSchema = z
.object({
eventId: z.string().uuid(),
});
version: z.number().int().optional(),
name: z.string().min(1).max(256).optional(),
description: z.string().optional(),
eventDate: z.string().optional(),
eventTime: z.string().optional(),
endDate: z.string().optional(),
location: z.string().optional(),
capacity: z.number().int().optional(),
minAge: z.number().int().optional().nullable(),
maxAge: z.number().int().optional().nullable(),
fee: z.number().min(0).optional(),
status: EventStatusEnum.optional(),
registrationDeadline: z.string().optional(),
contactName: z.string().optional(),
contactEmail: z.string().email().optional().or(z.literal('')),
contactPhone: z.string().optional(),
})
.refine((d) => d.minAge == null || d.maxAge == null || d.minAge <= d.maxAge, {
message: 'Mindestalter darf das Höchstalter nicht übersteigen',
path: ['minAge'],
})
.refine((d) => !d.endDate || !d.eventDate || d.endDate >= d.eventDate, {
message: 'Enddatum muss nach dem Veranstaltungsdatum liegen',
path: ['endDate'],
})
.refine(
(d) =>
!d.registrationDeadline ||
!d.eventDate ||
d.registrationDeadline <= d.eventDate,
{
message: 'Anmeldefrist muss vor dem Veranstaltungsdatum liegen',
path: ['registrationDeadline'],
},
);
export type UpdateEventInput = z.infer<typeof UpdateEventSchema>;
export const EventRegistrationSchema = z.object({

View File

@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isEventDomainError } from '../../lib/errors';
import {
CreateEventSchema,
UpdateEventSchema,
@@ -22,7 +23,7 @@ export const createEvent = authActionClient
const api = createEventManagementApi(client);
logger.info({ name: 'event.create' }, 'Creating event...');
const result = await api.createEvent(input);
const result = await api.events.create(input, ctx.user.id);
logger.info({ name: 'event.create' }, 'Event created');
return { success: true, data: result };
});
@@ -34,41 +35,62 @@ export const updateEvent = authActionClient
const logger = await getLogger();
const api = createEventManagementApi(client);
try {
logger.info({ name: 'event.update' }, 'Updating event...');
const result = await api.updateEvent(input);
const result = await api.events.update(input, ctx.user.id);
logger.info({ name: 'event.update' }, 'Event updated');
return { success: true, data: result };
} catch (e) {
if (isEventDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const deleteEvent = authActionClient
.inputSchema(z.object({ eventId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
try {
logger.info({ name: 'event.delete' }, 'Cancelling event...');
await api.deleteEvent(input.eventId);
await api.events.softDelete(input.eventId);
logger.info({ name: 'event.delete' }, 'Event cancelled');
return { success: true };
} catch (e) {
if (isEventDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const registerForEvent = authActionClient
.inputSchema(EventRegistrationSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
try {
logger.info({ name: 'event.register' }, 'Registering for event...');
const result = await api.registerForEvent(input);
const result = await api.registrations.register(input);
logger.info({ name: 'event.register' }, 'Registered for event');
return { success: true, data: result };
} catch (e) {
if (isEventDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const createHolidayPass = authActionClient
.inputSchema(CreateHolidayPassSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
@@ -77,7 +99,7 @@ export const createHolidayPass = authActionClient
{ name: 'event.createHolidayPass' },
'Creating holiday pass...',
);
const result = await api.createHolidayPass(input);
const result = await api.holidayPasses.create(input);
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
return { success: true, data: result };
});

View File

@@ -1,232 +1,16 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CreateEventInput,
UpdateEventInput,
} from '../schema/event.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createEventCrudService } from './services/event-crud.service';
import { createEventRegistrationService } from './services/event-registration.service';
import { createHolidayPassService } from './services/holiday-pass.service';
export function createEventManagementApi(client: SupabaseClient<Database>) {
const PAGE_SIZE = 25;
const _db = client;
return {
async listEvents(
accountId: string,
opts?: { status?: string; page?: number },
) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
},
async getRegistrationCounts(eventIds: string[]) {
if (eventIds.length === 0) return {} as Record<string, number>;
const { data, error } = await client
.from('event_registrations')
.select('event_id', { count: 'exact', head: false })
.in('event_id', eventIds)
.in('status', ['pending', 'confirmed']);
if (error) throw error;
const counts: Record<string, number> = {};
for (const row of data ?? []) {
const eid = (row as Record<string, unknown>).event_id as string;
counts[eid] = (counts[eid] ?? 0) + 1;
}
return counts;
},
async getEvent(eventId: string) {
const { data, error } = await client
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) throw error;
return data;
},
async createEvent(input: CreateEventInput) {
const { data, error } = await client
.from('events')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description || null,
event_date: input.eventDate,
event_time: input.eventTime || null,
end_date: input.endDate || null,
location: input.location || null,
capacity: input.capacity,
min_age: input.minAge ?? null,
max_age: input.maxAge ?? null,
fee: input.fee,
status: input.status,
registration_deadline: input.registrationDeadline || null,
contact_name: input.contactName || null,
contact_email: input.contactEmail || null,
contact_phone: input.contactPhone || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateEvent(input: UpdateEventInput) {
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.description !== undefined)
update.description = input.description || null;
if (input.eventDate !== undefined)
update.event_date = input.eventDate || null;
if (input.eventTime !== undefined)
update.event_time = input.eventTime || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.location !== undefined)
update.location = input.location || null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minAge !== undefined) update.min_age = input.minAge ?? null;
if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.status !== undefined) update.status = input.status;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.contactName !== undefined)
update.contact_name = input.contactName || null;
if (input.contactEmail !== undefined)
update.contact_email = input.contactEmail || null;
if (input.contactPhone !== undefined)
update.contact_phone = input.contactPhone || null;
const { data, error } = await client
.from('events')
.update(update)
.eq('id', input.eventId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteEvent(eventId: string) {
const { error } = await client
.from('events')
.update({ status: 'cancelled' })
.eq('id', eventId);
if (error) throw error;
},
async registerForEvent(input: {
eventId: string;
firstName: string;
lastName: string;
email?: string;
parentName?: string;
}) {
// Check capacity
const event = await this.getEvent(input.eventId);
if (event.capacity) {
const { count } = await client
.from('event_registrations')
.select('*', { count: 'exact', head: true })
.eq('event_id', input.eventId)
.in('status', ['pending', 'confirmed']);
if ((count ?? 0) >= event.capacity) {
throw new Error('Event is full');
}
}
const { data, error } = await client
.from('event_registrations')
.insert({
event_id: input.eventId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
parent_name: input.parentName,
status: 'confirmed',
})
.select()
.single();
if (error) throw error;
return data;
},
async getRegistrations(eventId: string) {
const { data, error } = await client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
// Holiday passes
async listHolidayPasses(accountId: string) {
const { data, error } = await client
.from('holiday_passes')
.select('*')
.eq('account_id', accountId)
.order('year', { ascending: false });
if (error) throw error;
return data ?? [];
},
async getPassActivities(passId: string) {
const { data, error } = await client
.from('holiday_pass_activities')
.select('*')
.eq('pass_id', passId)
.order('activity_date');
if (error) throw error;
return data ?? [];
},
async createHolidayPass(input: {
accountId: string;
name: string;
year: number;
description?: string;
price?: number;
validFrom?: string;
validUntil?: string;
}) {
const { data, error } = await client
.from('holiday_passes')
.insert({
account_id: input.accountId,
name: input.name,
year: input.year,
description: input.description,
price: input.price ?? 0,
valid_from: input.validFrom,
valid_until: input.validUntil,
})
.select()
.single();
if (error) throw error;
return data;
},
events: createEventCrudService(client),
registrations: createEventRegistrationService(client),
holidayPasses: createHolidayPassService(client),
};
}

View File

@@ -0,0 +1,237 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import {
EventConcurrencyConflictError,
EventNotFoundError,
InvalidEventStatusTransitionError,
} from '../../lib/errors';
import {
canTransition,
validateTransition,
getValidTransitions,
} from '../../lib/event-status-machine';
import type {
CreateEventInput,
UpdateEventInput,
} from '../../schema/event.schema';
const PAGE_SIZE = 25;
const NAMESPACE = 'event-crud';
export function createEventCrudService(client: SupabaseClient<Database>) {
return {
async list(accountId: string, opts?: { status?: string; page?: number }) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
},
async getById(eventId: string) {
const { data, error } = await client
.from('events')
.select('*')
.eq('id', eventId)
.maybeSingle();
if (error) throw error;
if (!data) throw new EventNotFoundError(eventId);
return data;
},
async getRegistrationCounts(eventIds: string[]) {
if (eventIds.length === 0) return {} as Record<string, number>;
const { data, error } = await (client.rpc as CallableFunction)(
'get_event_registration_counts',
{ p_event_ids: eventIds },
);
if (error) throw error;
const counts: Record<string, number> = {};
for (const row of (data ?? []) as Array<{
event_id: string;
registration_count: number;
}>) {
counts[row.event_id] = Number(row.registration_count);
}
return counts;
},
async create(input: CreateEventInput, userId?: string) {
const logger = await getLogger();
logger.info({ name: NAMESPACE }, 'Creating event...');
const { data, error } = await client
.from('events')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description || null,
event_date: input.eventDate,
event_time: input.eventTime || null,
end_date: input.endDate || null,
location: input.location || null,
capacity: input.capacity,
min_age: input.minAge ?? null,
max_age: input.maxAge ?? null,
fee: input.fee,
status: input.status,
registration_deadline: input.registrationDeadline || null,
contact_name: input.contactName || null,
contact_email: input.contactEmail || null,
contact_phone: input.contactPhone || null,
...(userId ? { created_by: userId, updated_by: userId } : {}),
} as any)
.select()
.single();
if (error) throw error;
return data;
},
async update(input: UpdateEventInput, userId?: string) {
const logger = await getLogger();
logger.info(
{ name: NAMESPACE, eventId: input.eventId },
'Updating event...',
);
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.description !== undefined)
update.description = input.description || null;
if (input.eventDate !== undefined)
update.event_date = input.eventDate || null;
if (input.eventTime !== undefined)
update.event_time = input.eventTime || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.location !== undefined)
update.location = input.location || null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minAge !== undefined) update.min_age = input.minAge ?? null;
if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.contactName !== undefined)
update.contact_name = input.contactName || null;
if (input.contactEmail !== undefined)
update.contact_email = input.contactEmail || null;
if (input.contactPhone !== undefined)
update.contact_phone = input.contactPhone || null;
// Status machine validation
if (input.status !== undefined) {
// Fetch current event to validate transition
const { data: current, error: fetchError } = await client
.from('events')
.select('status')
.eq('id', input.eventId)
.single();
if (fetchError) throw fetchError;
const currentStatus = (current as Record<string, unknown>)
.status as string;
if (currentStatus !== input.status) {
try {
const sideEffects = validateTransition(
currentStatus as Parameters<typeof validateTransition>[0],
input.status as Parameters<typeof validateTransition>[1],
);
Object.assign(update, sideEffects);
} catch {
throw new InvalidEventStatusTransitionError(
currentStatus,
input.status,
getValidTransitions(
currentStatus as Parameters<typeof getValidTransitions>[0],
),
);
}
}
update.status = input.status;
}
if (userId) {
update.updated_by = userId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic fields + future version column
let query = client
.from('events')
.update(update as any)
.eq('id', input.eventId);
// Optimistic locking — version column may be added via migration
if (input.version !== undefined) {
query = (query as any).eq('version', input.version);
}
const { data, error } = await query.select().single();
// If version was provided and no rows matched, it's a concurrency conflict
if (error && input.version !== undefined) {
// PGRST116 = "JSON object requested, multiple (or no) rows returned"
if (error.code === 'PGRST116') {
throw new EventConcurrencyConflictError();
}
}
if (error) throw error;
return data;
},
async softDelete(eventId: string) {
const logger = await getLogger();
logger.info({ name: NAMESPACE, eventId }, 'Cancelling event...');
const { data: current, error: fetchError } = await client
.from('events')
.select('status')
.eq('id', eventId)
.maybeSingle();
if (fetchError) throw fetchError;
if (!current) throw new EventNotFoundError(eventId);
type EventStatus = Parameters<typeof canTransition>[0];
if (!canTransition(current.status as EventStatus, 'cancelled')) {
throw new InvalidEventStatusTransitionError(
current.status,
'cancelled',
getValidTransitions(current.status as EventStatus),
);
}
const { error } = await client
.from('events')
.update({ status: 'cancelled' })
.eq('id', eventId);
if (error) throw error;
},
};
}

View File

@@ -0,0 +1,64 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createEventRegistrationService(
client: SupabaseClient<Database>,
) {
return {
async register(input: {
eventId: string;
memberId?: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
dateOfBirth?: string;
parentName?: string;
parentPhone?: string;
}) {
// RPC function defined in migration; cast to bypass generated types
// until `pnpm supabase:web:typegen` is re-run.
const { data, error } = await (client.rpc as CallableFunction)(
'register_for_event',
{
p_event_id: input.eventId,
p_member_id: input.memberId ?? null,
p_first_name: input.firstName,
p_last_name: input.lastName,
p_email: input.email || null,
p_phone: input.phone || null,
p_date_of_birth: input.dateOfBirth ?? null,
p_parent_name: input.parentName ?? null,
p_parent_phone: input.parentPhone ?? null,
},
);
if (error) throw error;
return data;
},
async cancel(registrationId: string) {
const { data, error } = await (client.rpc as CallableFunction)(
'cancel_event_registration',
{ p_registration_id: registrationId },
);
if (error) throw error;
return data as {
cancelled_id: string;
promoted_id: string | null;
promoted_name: string | null;
};
},
async list(eventId: string) {
const { data, error } = await client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
};
}

View File

@@ -0,0 +1,54 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createHolidayPassService(client: SupabaseClient<Database>) {
return {
async list(accountId: string) {
const { data, error } = await client
.from('holiday_passes')
.select('*')
.eq('account_id', accountId)
.order('year', { ascending: false });
if (error) throw error;
return data ?? [];
},
async getActivities(passId: string) {
const { data, error } = await client
.from('holiday_pass_activities')
.select('*')
.eq('pass_id', passId)
.order('activity_date');
if (error) throw error;
return data ?? [];
},
async create(input: {
accountId: string;
name: string;
year: number;
description?: string;
price?: number;
validFrom?: string;
validUntil?: string;
}) {
const { data, error } = await client
.from('holiday_passes')
.insert({
account_id: input.accountId,
name: input.name,
year: input.year,
description: input.description,
price: input.price ?? 0,
valid_from: input.validFrom,
valid_until: input.validUntil,
})
.select()
.single();
if (error) throw error;
return data;
},
};
}

View File

@@ -0,0 +1,22 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createEventCrudService } from './event-crud.service';
import { createEventRegistrationService } from './event-registration.service';
import { createHolidayPassService } from './holiday-pass.service';
export {
createEventCrudService,
createEventRegistrationService,
createHolidayPassService,
};
export function createEventServices(client: SupabaseClient<Database>) {
return {
events: createEventCrudService(client),
registrations: createEventRegistrationService(client),
holidayPasses: createHolidayPassService(client),
};
}

View File

@@ -10,7 +10,8 @@
}
},
"exports": {
"./api": "./src/server/api.ts",
"./services": "./src/server/services/index.ts",
"./services/*": "./src/server/services/*.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts",
@@ -22,7 +23,9 @@
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/mailers": "workspace:*",
"@kit/next": "workspace:*",
"@kit/notifications": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",

View File

@@ -1,16 +1,13 @@
export { CreateMemberForm } from './create-member-form';
export { EditMemberForm } from './edit-member-form';
export { MembersDataTable } from './members-data-table';
export { MemberDetailView } from './member-detail-view';
export { ApplicationWorkflow } from './application-workflow';
export { DuesCategoryManager } from './dues-category-manager';
export { MandateManager } from './mandate-manager';
export { MemberImportWizard } from './member-import-wizard';
// New v2 components
export { MemberAvatar } from './member-avatar';
export { MemberStatsBar } from './member-stats-bar';
export { MembersListView } from './members-list-view';
export { MemberDetailTabs } from './member-detail-tabs';
export { MemberCreateWizard } from './member-create-wizard';
export { MemberCommandPalette } from './member-command-palette';
export { TagsManager } from './tags-manager';

Some files were not shown because too many files have changed in this diff Show More