diff --git a/.dockerignore b/.dockerignore
index afbfc0dca..0dfe8e3f3 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -3,8 +3,9 @@ node_modules
.turbo
**/.turbo
.git
-*.md
.env*
+!.env.example
+!.env.local.example
.DS_Store
apps/e2e
apps/dev-tool
@@ -16,3 +17,6 @@ apps/dev-tool
.github
docs
**/*.tsbuildinfo
+**/*.md
+!**/AGENTS.md
+!**/CLAUDE.md
diff --git a/.env.local.example b/.env.local.example
new file mode 100644
index 000000000..51ac484d7
--- /dev/null
+++ b/.env.local.example
@@ -0,0 +1,19 @@
+# =====================================================
+# MyEasyCMS v2 — Local Development Environment
+# Copy to .env and run: docker compose -f docker-compose.local.yml up -d
+# =====================================================
+
+# --- Database ---
+POSTGRES_PASSWORD=postgres
+
+# --- Supabase Auth ---
+JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long
+
+# --- Supabase Keys (demo keys — safe for local dev only) ---
+SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
+SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
+
+# --- Stripe (test keys) ---
+# Get your own test keys from https://dashboard.stripe.com/test/apikeys
+NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY
+STRIPE_SECRET_KEY=sk_test_YOUR_KEY
diff --git a/AGENTS.md b/AGENTS.md
index 26ca9df84..61e71f358 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,7 +72,7 @@ After implementation, always run:
# 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.
diff --git a/CLAUDE.md b/CLAUDE.md
index f0862be7c..1ba3f4030 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -3,7 +3,7 @@
# 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.
diff --git a/Dockerfile b/Dockerfile
index 5d9a6b60f..3b65a923d 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,18 +1,15 @@
-FROM node:22-alpine AS base
+# node:22-slim (Debian/glibc) is ~2x faster for Next.js builds vs Alpine/musl
+FROM node:22-slim AS base
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
-# --- Install + Build in one stage ---
+# --- Install + Build ---
FROM base AS builder
-# CACHE_BUST: change this value to force a full rebuild (busts Docker layer cache)
-ARG CACHE_BUST=14
-RUN echo "Cache bust: ${CACHE_BUST}"
COPY . .
-RUN pnpm install --no-frozen-lockfile
+RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
+ pnpm install --no-frozen-lockfile --prefer-offline
ENV NEXT_TELEMETRY_DISABLED=1
-# NEXT_PUBLIC_* vars are baked into the Next.js build at compile time.
-# Pass them as build args so the same Dockerfile works for any environment.
ARG NEXT_PUBLIC_CI=false
ARG NEXT_PUBLIC_SITE_URL=https://myeasycms.de
ARG NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
@@ -39,18 +36,18 @@ ENV NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=${NEXT_PUBLIC_ENABLE_PERSONAL_AC
ENV NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=${NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING}
RUN pnpm --filter web build
-# --- Run ---
-FROM base AS runner
+# --- Run (slim for smaller image than full Debian) ---
+FROM node:22-slim AS runner
+RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=builder /app/ ./
-RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
-
-# Ensure Next.js cache directories are writable by the nextjs user
+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
diff --git a/apps/e2e/tests/member-lifecycle.spec.ts b/apps/e2e/tests/member-lifecycle.spec.ts
index 17cfe00a1..b329b7000 100644
--- a/apps/e2e/tests/member-lifecycle.spec.ts
+++ b/apps/e2e/tests/member-lifecycle.spec.ts
@@ -4,7 +4,9 @@
import { test, expect } from '@playwright/test';
test.describe('Member Management', () => {
- test('create member, edit, search, filter by status', async ({ page: _page }) => {
+ test('create member, edit, search, filter by status', async ({
+ page: _page,
+ }) => {
await page.goto('/auth/sign-in');
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'testpassword123');
diff --git a/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx b/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx
index ddfe84afc..0bf69eaef 100644
--- a/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx
+++ b/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx
@@ -457,10 +457,7 @@ export function FeatureCarousel() {
const [active, setActive] = useState(0);
const slide = SLIDES[active]!;
- const next = useCallback(
- () => setActive((i) => (i + 1) % SLIDES.length),
- [],
- );
+ const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []);
const prev = useCallback(
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
[],
diff --git a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx
index 2b3045dfa..8ba82c27d 100644
--- a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx
+++ b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx
@@ -8,10 +8,7 @@ import { Check, ExternalLink, X } from 'lucide-react';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
-import {
- Card,
- CardContent,
-} from '@kit/ui/card';
+import { Card, CardContent } from '@kit/ui/card';
import {
Table,
TableBody,
@@ -265,7 +262,10 @@ function PriceBar({
{available ? (
0 ? 4 : 0,
@@ -323,7 +323,10 @@ export function PricingCalculator() {
{/* ── Header ── */}
-
+
Preisvergleich
@@ -376,7 +379,7 @@ export function PricingCalculator() {
-
+
Ihr MYeasyCMS-Tarif
@@ -460,21 +463,20 @@ export function PricingCalculator() {
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
-
+
Ersparnis vs. {bestSaving.name.split(' ')[0]}
{fmt((bestSaving.p - tier.price) * 12)} €
- pro Jahr (
- {Math.round((1 - tier.price / bestSaving.p) * 100)}%
+ pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
günstiger)
-
+
Preis pro Mitglied
@@ -521,19 +523,19 @@ export function PricingCalculator() {
{USP_FEATURES.map((f, i) => (
{f.label}
- {(
- ['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
- ).map((col) => (
-
-
-
- ))}
+ {(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
+ (col) => (
+
+
+
+ ),
+ )}
))}
diff --git a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
index 80a79be04..858648cb7 100644
--- a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx
@@ -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,
diff --git a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx
index 966950c02..f4e48b288 100644
--- a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx
@@ -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 (
diff --git a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
index 9c2260269..a479ae82b 100644
--- a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx
@@ -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 (
diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx
index c3ede8b54..f58bc7f7a 100644
--- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx
@@ -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 ;
@@ -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(
diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx
index 63e0d0dd4..fad7bb8e1 100644
--- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx
@@ -25,7 +25,7 @@ export default async function EditCoursePage({ params }: PageProps) {
if (!acct) return ;
const api = createCourseManagementApi(client);
- const course = await api.getCourse(courseId);
+ const course = await api.courses.getById(courseId);
if (!course) return ;
const c = course as Record;
diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
index 83dd2ac8a..e9c4c475b 100644
--- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx
@@ -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 ;
diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx
index 13716ae69..a916d31f7 100644
--- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx
@@ -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 ;
diff --git a/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
index 06e51fc5c..e6b13e694 100644
--- a/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx
@@ -45,7 +45,7 @@ export default async function CourseCalendarPage({
if (!acct) return ;
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;
diff --git a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx
index d289131cf..a9a05d324 100644
--- a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx
@@ -29,7 +29,7 @@ export default async function CategoriesPage({ params }: PageProps) {
if (!acct) return ;
const api = createCourseManagementApi(client);
- const categories = await api.listCategories(acct.id);
+ const categories = await api.referenceData.listCategories(acct.id);
return (
diff --git a/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx b/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx
index 24a051496..7abf3db4d 100644
--- a/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx
@@ -30,7 +30,7 @@ export default async function InstructorsPage({ params }: PageProps) {
if (!acct) return ;
const api = createCourseManagementApi(client);
- const instructors = await api.listInstructors(acct.id);
+ const instructors = await api.referenceData.listInstructors(acct.id);
return (
diff --git a/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx b/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx
index c64024329..bf8cc3b07 100644
--- a/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx
@@ -29,7 +29,7 @@ export default async function LocationsPage({ params }: PageProps) {
if (!acct) return ;
const api = createCourseManagementApi(client);
- const locations = await api.listLocations(acct.id);
+ const locations = await api.referenceData.listLocations(acct.id);
return (
diff --git a/apps/web/app/[locale]/home/[account]/courses/page.tsx b/apps/web/app/[locale]/home/[account]/courses/page.tsx
index aea8e68f9..3d635ac18 100644
--- a/apps/web/app/[locale]/home/[account]/courses/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/page.tsx
@@ -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);
diff --git a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx
index 394f6e474..2d4d1f795 100644
--- a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx
@@ -33,7 +33,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
if (!acct) return ;
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 },
diff --git a/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx
index aace795d9..61df7e61f 100644
--- a/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx
@@ -25,7 +25,7 @@ export default async function EditEventPage({ params }: PageProps) {
if (!acct) return ;
const api = createEventManagementApi(client);
- const event = await api.getEvent(eventId);
+ const event = await api.events.getById(eventId);
if (!event) return ;
const e = event as Record;
diff --git a/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx b/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
index a2f0ce447..521e5b04e 100644
--- a/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx
@@ -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 {t('notFound')}
;
diff --git a/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx b/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx
index 102f7f4cb..d9f069e96 100644
--- a/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx
@@ -29,7 +29,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
if (!acct) return ;
const api = createEventManagementApi(client);
- const passes = await api.listHolidayPasses(acct.id);
+ const passes = await api.holidayPasses.list(acct.id);
return (
diff --git a/apps/web/app/[locale]/home/[account]/events/page.tsx b/apps/web/app/[locale]/home/[account]/events/page.tsx
index bc680e686..98f4d0233 100644
--- a/apps/web/app/[locale]/home/[account]/events/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/events/page.tsx
@@ -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(eventItem.id),
);
- const registrationCounts = await api.getRegistrationCounts(eventIds);
+ const registrationCounts = await api.events.getRegistrationCounts(eventIds);
// Pre-compute stats before rendering
const uniqueLocationCount = new Set(
diff --git a/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx b/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx
index 55a419d83..3b598fa91 100644
--- a/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx
@@ -36,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
if (!acct) return ;
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) => {
- 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),
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
index 138c09500..7b54971b8 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx
@@ -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 ;
- const api = createMemberManagementApi(client);
- const member = await api.getMember(memberId);
+ const { query } = createMemberServices(client);
+ const member = await query.getById(acct.id, memberId);
if (!member) return {t('detail.notFound')}
;
return (
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
index b53ad293a..8e3115116 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
@@ -1,9 +1,8 @@
-import { createMemberManagementApi } from '@kit/member-management/api';
-import { MemberDetailView } from '@kit/member-management/components';
+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';
-import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string; memberId: string }>;
@@ -19,33 +18,24 @@ export default async function MemberDetailPage({ params }: Props) {
.single();
if (!acct) return ;
- const api = createMemberManagementApi(client);
- const member = await api.getMember(memberId);
+ const { query, organization } = createMemberServices(client);
+ const member = await query.getById(acct.id, memberId);
if (!member) return ;
- // Fetch sub-entities in parallel
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),
]);
- const memberName = `${String(member.first_name)} ${String(member.last_name)}`;
-
return (
-
-
-
+ accountId={acct.id}
+ roles={roles}
+ honors={honors}
+ mandates={mandates}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx b/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx
new file mode 100644
index 000000000..33192c8a3
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx
@@ -0,0 +1,183 @@
+'use client';
+
+import type { ReactNode } from 'react';
+
+import Link from 'next/link';
+import { usePathname, useRouter } from 'next/navigation';
+
+import {
+ FileDown,
+ FileUp,
+ IdCard,
+ KeyRound,
+ LayoutList,
+ Settings,
+ Tag,
+ Users,
+} from 'lucide-react';
+
+import {
+ MemberStatsBar,
+ MemberCommandPalette,
+} from '@kit/member-management/components';
+import { Badge } from '@kit/ui/badge';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@kit/ui/dropdown-menu';
+import { PageBody } from '@kit/ui/page';
+import { cn } from '@kit/ui/utils';
+
+interface MembersCmsLayoutClientProps {
+ header: ReactNode;
+ children: ReactNode;
+ account: string;
+ accountId: string;
+ stats: {
+ total: number;
+ active: number;
+ pending: number;
+ newThisYear: number;
+ pendingApplications: number;
+ };
+}
+
+export function MembersCmsLayoutClient({
+ header,
+ children,
+ account,
+ accountId,
+ stats,
+}: MembersCmsLayoutClientProps) {
+ const pathname = usePathname();
+ const basePath = `/home/${account}/members-cms`;
+
+ const isOnMembersTab =
+ pathname.endsWith('/members-cms') ||
+ pathname.includes('/members-cms/new') ||
+ /\/members-cms\/[^/]+$/.test(pathname);
+ const isOnApplicationsTab = pathname.includes('/applications');
+ const isOnSubPage =
+ pathname.includes('/import') ||
+ pathname.includes('/edit') ||
+ (/\/members-cms\/[^/]+$/.test(pathname) &&
+ !pathname.endsWith('/members-cms'));
+
+ return (
+ <>
+ {header}
+
+
+
+ {/* Stats bar — only on main views */}
+ {!isOnSubPage &&
}
+
+ {/* Tab navigation + settings */}
+ {!isOnSubPage && (
+
+
+
+
+
+ )}
+
+ {children}
+
+
+
+
+ >
+ );
+}
+
+function TabLink({
+ href,
+ active,
+ children,
+}: {
+ href: string;
+ active: boolean;
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function SettingsMenu({ basePath }: { basePath: string }) {
+ const router = useRouter();
+
+ const navigate = (path: string) => () => router.push(path);
+
+ return (
+
+
+
+
+
+
+
+ Beitragskategorien
+
+
+
+ Abteilungen
+
+
+
+ Tags verwalten
+
+
+
+ Mitgliedsausweise
+
+
+
+ Portal-Einladungen
+
+
+
+ Import
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
index f30ac0b3b..d40218fee 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx
@@ -1,11 +1,8 @@
-import { getTranslations } from 'next-intl/server';
-
-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';
-import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
@@ -14,7 +11,7 @@ interface Props {
export default async function ApplicationsPage({ params }: Props) {
const { account } = await params;
const client = getSupabaseServerClient();
- const t = await getTranslations('members');
+
const { data: acct } = await client
.from('accounts')
.select('id')
@@ -22,20 +19,14 @@ export default async function ApplicationsPage({ params }: Props) {
.single();
if (!acct) return ;
- const api = createMemberManagementApi(client);
- const applications = await api.listApplications(acct.id);
+ const { workflow } = createMemberServices(client);
+ const applications = await workflow.listApplications(acct.id);
return (
-
-
-
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx
index f4d25a252..5c35c19e2 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx
@@ -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 ;
- 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,
});
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx
index 85f3db52b..bfcd80153 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx
@@ -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 ;
- const api = createMemberManagementApi(client);
- const departments = await api.listDepartments(acct.id);
+ const { organization } = createMemberServices(client);
+ const departments = await organization.listDepartments(acct.id);
return (
;
- const api = createMemberManagementApi(client);
- const categories = await api.listDuesCategories(acct.id);
+ const { organization } = createMemberServices(client);
+ const categories = await organization.listDuesCategories(acct.id);
return (
;
- 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
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx b/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
new file mode 100644
index 000000000..4c0e10d1d
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from 'react';
+
+import { createMemberServices } from '@kit/member-management/services';
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+
+import { AccountNotFound } from '~/components/account-not-found';
+import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
+
+import { MembersCmsLayoutClient } from './_components/members-cms-layout-client';
+
+interface Props {
+ children: ReactNode;
+ params: Promise<{ account: string }>;
+}
+
+export default async function MembersCmsLayout({ children, params }: Props) {
+ const { account } = await params;
+ const client = getSupabaseServerClient();
+
+ const { data: acct } = await client
+ .from('accounts')
+ .select('id')
+ .eq('slug', account)
+ .single();
+
+ if (!acct) return ;
+
+ const { query } = createMemberServices(client);
+ const stats = await query.getQuickStats(acct.id);
+
+ return (
+
+ }
+ account={account}
+ accountId={acct.id}
+ stats={stats}
+ >
+ {children}
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
index 4c7f49fb9..b2e298e25 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx
@@ -1,11 +1,8 @@
-import { getTranslations } from 'next-intl/server';
-
-import { createMemberManagementApi } from '@kit/member-management/api';
-import { CreateMemberForm } from '@kit/member-management/components';
+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';
-import { CmsPageShell } from '~/components/cms-page-shell';
interface Props {
params: Promise<{ account: string }>;
@@ -13,7 +10,6 @@ interface Props {
export default async function NewMemberPage({ params }: Props) {
const { account } = await params;
- const t = await getTranslations('members');
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
@@ -22,26 +18,20 @@ export default async function NewMemberPage({ params }: Props) {
.single();
if (!acct) return ;
- const api = createMemberManagementApi(client);
- const duesCategories = await api.listDuesCategories(acct.id);
+ const { organization } = createMemberServices(client);
+ const duesCategories = await organization.listDuesCategories(acct.id);
return (
-
- ) => ({
- id: String(c.id),
- name: String(c.name),
- amount: Number(c.amount ?? 0),
- }),
- )}
- />
-
+ duesCategories={(duesCategories ?? []).map(
+ (c: Record) => ({
+ id: String(c.id),
+ name: String(c.name),
+ amount: Number(c.amount ?? 0),
+ }),
+ )}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
index c8951f7e6..7b12fb12e 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx
@@ -1,11 +1,8 @@
-import { getTranslations } from 'next-intl/server';
-
-import { createMemberManagementApi } from '@kit/member-management/api';
-import { MembersDataTable } from '@kit/member-management/components';
+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';
-import { CmsPageShell } from '~/components/cms-page-shell';
const PAGE_SIZE = 25;
@@ -18,7 +15,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
const { account } = await params;
const search = await searchParams;
const client = getSupabaseServerClient();
- const t = await getTranslations('members');
+
const { data: acct } = await client
.from('accounts')
.select('id')
@@ -26,36 +23,89 @@ export default async function MembersPage({ params, searchParams }: Props) {
.single();
if (!acct) return ;
- const api = createMemberManagementApi(client);
+ const { query, organization } = createMemberServices(client);
const page = Number(search.page) || 1;
- const result = await api.listMembers(acct.id, {
+
+ // Parse multi-status filter
+ const statusParam = search.status;
+ const statusFilter = statusParam
+ ? Array.isArray(statusParam)
+ ? statusParam
+ : statusParam.split(',')
+ : undefined;
+
+ const result = await query.search({
+ accountId: acct.id,
search: search.q as string,
- status: search.status as string,
+ status: statusFilter as any,
+ duesCategoryId: search.duesCategoryId as string,
+ sortBy: (search.sortBy as string) ?? 'last_name',
+ sortDirection: (search.sortDirection as 'asc' | 'desc') ?? 'asc',
page,
pageSize: PAGE_SIZE,
});
- const duesCategories = await api.listDuesCategories(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 (
-
- ) => ({
- id: String(c.id),
- name: String(c.name),
- }),
- )}
- />
-
+ accountId={acct.id}
+ duesCategories={(duesCategories ?? []).map(
+ (c: Record) => ({
+ id: String(c.id),
+ name: String(c.name),
+ }),
+ )}
+ departments={(departments ?? []).map((d) => ({
+ id: String(d.id),
+ 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}
+ />
);
}
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx
index f9df4e0ca..94f0855ed 100644
--- a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx
@@ -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 ;
- 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 },
diff --git a/apps/web/app/[locale]/home/[account]/members-cms/tags/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/tags/page.tsx
new file mode 100644
index 000000000..9002dc805
--- /dev/null
+++ b/apps/web/app/[locale]/home/[account]/members-cms/tags/page.tsx
@@ -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 ;
+
+ // 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 (
+
+
+
+ );
+}
diff --git a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx
index 590488988..4e5486d70 100644
--- a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx
@@ -1,4 +1,3 @@
-
import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
diff --git a/apps/web/app/[locale]/home/[account]/page.tsx b/apps/web/app/[locale]/home/[account]/page.tsx
index 746c916ac..3adf1f7d9 100644
--- a/apps/web/app/[locale]/home/[account]/page.tsx
+++ b/apps/web/app/[locale]/home/[account]/page.tsx
@@ -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'
diff --git a/apps/web/app/api/club/membership-apply/route.ts b/apps/web/app/api/club/membership-apply/route.ts
index 37af843ee..202deb320 100644
--- a/apps/web/app/api/club/membership-apply/route.ts
+++ b/apps/web/app/api/club/membership-apply/route.ts
@@ -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,
diff --git a/apps/web/app/api/internal/cron/member-jobs/route.ts b/apps/web/app/api/internal/cron/member-jobs/route.ts
new file mode 100644
index 000000000..987e8d6bc
--- /dev/null
+++ b/apps/web/app/api/internal/cron/member-jobs/route.ts
@@ -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
+ */
+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 = {};
+
+ 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 },
+ );
+ }
+}
diff --git a/apps/web/config/team-account-navigation.config.tsx b/apps/web/config/team-account-navigation.config.tsx
index 8ffe909bf..f10b75835 100644
--- a/apps/web/config/team-account-navigation.config.tsx
+++ b/apps/web/config/team-account-navigation.config.tsx
@@ -5,9 +5,6 @@ import {
CreditCard,
// People (Members + Access)
UserCheck,
- UserPlus,
- IdCard,
- ClipboardList,
// Courses
GraduationCap,
CalendarDays,
@@ -69,7 +66,10 @@ import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4';
-const getRoutes = (account: string, accountFeatures?: Record) => {
+const getRoutes = (
+ account: string,
+ accountFeatures?: Record,
+) => {
const routes: Array<
| {
label: string;
@@ -110,46 +110,11 @@ const getRoutes = (account: string, accountFeatures?: Record) =
}> = [];
if (featureFlagsConfig.enableMemberManagement) {
- peopleChildren.push(
- {
- label: 'common.routes.clubMembers',
- path: createPath(pathsConfig.app.accountCmsMembers, account),
- Icon: ,
- },
- {
- label: 'common.routes.memberApplications',
- path: createPath(
- pathsConfig.app.accountCmsMembers + '/applications',
- account,
- ),
- Icon: ,
- },
- // NOTE: memberPortal page does not exist yet — nav entry commented out until built
- // {
- // label: 'common.routes.memberPortal',
- // path: createPath(
- // pathsConfig.app.accountCmsMembers + '/portal',
- // account,
- // ),
- // Icon: ,
- // },
- {
- label: 'common.routes.memberCards',
- path: createPath(
- pathsConfig.app.accountCmsMembers + '/cards',
- account,
- ),
- Icon: ,
- },
- {
- label: 'common.routes.memberDues',
- path: createPath(
- pathsConfig.app.accountCmsMembers + '/dues',
- account,
- ),
- Icon: ,
- },
- );
+ peopleChildren.push({
+ label: 'common.routes.clubMembers',
+ path: createPath(pathsConfig.app.accountCmsMembers, account),
+ Icon: ,
+ });
}
// Admin users who can log in — always visible
@@ -417,7 +382,10 @@ const getRoutes = (account: string, accountFeatures?: Record) =
}
// ── Fisheries ──
- if (featureFlagsConfig.enableFischerei && (accountFeatures?.fischerei !== false)) {
+ if (
+ featureFlagsConfig.enableFischerei &&
+ accountFeatures?.fischerei !== false
+ ) {
routes.push({
label: 'common.routes.fisheriesManagement',
collapsible: true,
@@ -473,7 +441,10 @@ const getRoutes = (account: string, accountFeatures?: Record) =
}
// ── Meeting Protocols ──
- if (featureFlagsConfig.enableMeetingProtocols && (accountFeatures?.meetings !== false)) {
+ if (
+ featureFlagsConfig.enableMeetingProtocols &&
+ accountFeatures?.meetings !== false
+ ) {
routes.push({
label: 'common.routes.meetingProtocols',
collapsible: true,
@@ -502,7 +473,10 @@ const getRoutes = (account: string, accountFeatures?: Record) =
}
// ── Association Management (Verband) ──
- if (featureFlagsConfig.enableVerbandsverwaltung && (accountFeatures?.verband !== false)) {
+ if (
+ featureFlagsConfig.enableVerbandsverwaltung &&
+ accountFeatures?.verband !== false
+ ) {
routes.push({
label: 'common.routes.associationManagement',
collapsible: true,
diff --git a/apps/web/i18n/messages/de/cms.json b/apps/web/i18n/messages/de/cms.json
index 41acb62b7..b4b528572 100644
--- a/apps/web/i18n/messages/de/cms.json
+++ b/apps/web/i18n/messages/de/cms.json
@@ -832,4 +832,4 @@
"formatExcel": "Excel"
}
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/i18n/messages/de/finance.json b/apps/web/i18n/messages/de/finance.json
index 914bbf19a..e7f78316a 100644
--- a/apps/web/i18n/messages/de/finance.json
+++ b/apps/web/i18n/messages/de/finance.json
@@ -162,4 +162,4 @@
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen"
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/i18n/messages/en/cms.json b/apps/web/i18n/messages/en/cms.json
index b45a2c202..6f4ce7b76 100644
--- a/apps/web/i18n/messages/en/cms.json
+++ b/apps/web/i18n/messages/en/cms.json
@@ -352,4 +352,4 @@
}
}
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/i18n/messages/en/finance.json b/apps/web/i18n/messages/en/finance.json
index 669a5dded..7ae90eac1 100644
--- a/apps/web/i18n/messages/en/finance.json
+++ b/apps/web/i18n/messages/en/finance.json
@@ -162,4 +162,4 @@
"completed": "Completed",
"failed": "Failed"
}
-}
\ No newline at end of file
+}
diff --git a/apps/web/lib/database.types.ts b/apps/web/lib/database.types.ts
index 8a49b1cc1..1307ff791 100644
--- a/apps/web/lib/database.types.ts
+++ b/apps/web/lib/database.types.ts
@@ -6404,6 +6404,22 @@ export type Database = {
total_upcoming_events: number
}[]
}
+ get_member_quick_stats: {
+ Args: { p_account_id: string }
+ Returns: {
+ active: number
+ inactive: number
+ new_this_year: number
+ pending: number
+ pending_applications: number
+ resigned: number
+ total: number
+ }[]
+ }
+ get_next_member_number: {
+ Args: { p_account_id: string }
+ Returns: string
+ }
get_nonce_status: { Args: { p_id: string }; Returns: Json }
get_upper_system_role: { Args: never; Returns: string }
get_user_visible_accounts: { Args: never; Returns: string[] }
diff --git a/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql b/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql
new file mode 100644
index 000000000..dc802c1a4
--- /dev/null
+++ b/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql
@@ -0,0 +1,100 @@
+-- Migration: Enhanced member search and quick stats
+-- Adds: full-text search index, quick stats RPC, next member number function
+
+-- Full-text search index (German) for faster member search
+CREATE INDEX IF NOT EXISTS ix_members_fulltext ON public.members
+ USING gin(
+ to_tsvector(
+ 'german',
+ coalesce(first_name, '') || ' ' ||
+ coalesce(last_name, '') || ' ' ||
+ coalesce(email, '') || ' ' ||
+ coalesce(member_number, '') || ' ' ||
+ coalesce(city, '')
+ )
+ );
+
+-- Trigram index on names for fuzzy / ILIKE search
+CREATE INDEX IF NOT EXISTS ix_members_name_trgm
+ ON public.members
+ USING gin ((lower(first_name || ' ' || last_name)) gin_trgm_ops);
+
+-- Quick stats RPC — returns a single row with KPI counts
+-- Includes has_role_on_account guard to prevent cross-tenant data leaks
+CREATE OR REPLACE FUNCTION public.get_member_quick_stats(p_account_id uuid)
+RETURNS TABLE(
+ total bigint,
+ active bigint,
+ inactive bigint,
+ pending bigint,
+ resigned bigint,
+ new_this_year bigint,
+ pending_applications bigint
+)
+LANGUAGE plpgsql STABLE SECURITY DEFINER
+SET search_path = ''
+AS $$
+BEGIN
+ -- Verify caller has access to this account
+ IF NOT public.has_role_on_account(p_account_id) THEN
+ RAISE EXCEPTION 'Access denied to account %', p_account_id;
+ END IF;
+
+ RETURN QUERY
+ SELECT
+ count(*)::bigint AS total,
+ count(*) FILTER (WHERE m.status = 'active')::bigint AS active,
+ count(*) FILTER (WHERE m.status = 'inactive')::bigint AS inactive,
+ count(*) FILTER (WHERE m.status = 'pending')::bigint AS pending,
+ count(*) FILTER (WHERE m.status = 'resigned')::bigint AS resigned,
+ count(*) FILTER (WHERE m.status = 'active'
+ AND m.entry_date >= date_trunc('year', current_date)::date)::bigint AS new_this_year,
+ (
+ SELECT count(*)
+ FROM public.membership_applications a
+ WHERE a.account_id = p_account_id
+ AND a.status = 'submitted'
+ )::bigint AS pending_applications
+ FROM public.members m
+ WHERE m.account_id = p_account_id;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.get_member_quick_stats(uuid) TO authenticated;
+
+-- Next member number: returns max(member_number) + 1 as text
+-- Includes has_role_on_account guard
+CREATE OR REPLACE FUNCTION public.get_next_member_number(p_account_id uuid)
+RETURNS text
+LANGUAGE plpgsql STABLE SECURITY DEFINER
+SET search_path = ''
+AS $$
+DECLARE
+ v_result text;
+BEGIN
+ -- Verify caller has access to this account
+ IF NOT public.has_role_on_account(p_account_id) THEN
+ RAISE EXCEPTION 'Access denied to account %', p_account_id;
+ END IF;
+
+ SELECT LPAD(
+ (COALESCE(
+ MAX(
+ CASE
+ WHEN member_number ~ '^\d+$' THEN member_number::integer
+ ELSE 0
+ END
+ ),
+ 0
+ ) + 1)::text,
+ 4,
+ '0'
+ ) INTO v_result
+ FROM public.members
+ WHERE account_id = p_account_id;
+
+ RETURN v_result;
+END;
+$$;
+
+GRANT EXECUTE ON FUNCTION public.get_next_member_number(uuid) TO authenticated;
diff --git a/apps/web/supabase/migrations/20260416000001_atomic_application_workflow.sql b/apps/web/supabase/migrations/20260416000001_atomic_application_workflow.sql
new file mode 100644
index 000000000..90e9c704b
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000001_atomic_application_workflow.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260416000002_sepa_deduplication.sql b/apps/web/supabase/migrations/20260416000002_sepa_deduplication.sql
new file mode 100644
index 000000000..083d5a6f2
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000002_sepa_deduplication.sql
@@ -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.
diff --git a/apps/web/supabase/migrations/20260416000003_member_delete_safety.sql b/apps/web/supabase/migrations/20260416000003_member_delete_safety.sql
new file mode 100644
index 000000000..958a766f7
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000003_member_delete_safety.sql
@@ -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
diff --git a/apps/web/supabase/migrations/20260416000004_member_constraints.sql b/apps/web/supabase/migrations/20260416000004_member_constraints.sql
new file mode 100644
index 000000000..3f37a1484
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000004_member_constraints.sql
@@ -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);
diff --git a/apps/web/supabase/migrations/20260416000005_member_versioning.sql b/apps/web/supabase/migrations/20260416000005_member_versioning.sql
new file mode 100644
index 000000000..d3b15a52e
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000005_member_versioning.sql
@@ -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();
diff --git a/apps/web/supabase/migrations/20260416000006_event_member_link.sql b/apps/web/supabase/migrations/20260416000006_event_member_link.sql
new file mode 100644
index 000000000..4af55d589
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000006_event_member_link.sql
@@ -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;
+$$;
diff --git a/apps/web/supabase/migrations/20260416000007_member_audit_log.sql b/apps/web/supabase/migrations/20260416000007_member_audit_log.sql
new file mode 100644
index 000000000..ea296c793
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000007_member_audit_log.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260416000008_member_communications.sql b/apps/web/supabase/migrations/20260416000008_member_communications.sql
new file mode 100644
index 000000000..a1a658ff5
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000008_member_communications.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260416000009_member_tags.sql b/apps/web/supabase/migrations/20260416000009_member_tags.sql
new file mode 100644
index 000000000..3fa0802db
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000009_member_tags.sql
@@ -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();
diff --git a/apps/web/supabase/migrations/20260416000010_member_merge.sql b/apps/web/supabase/migrations/20260416000010_member_merge.sql
new file mode 100644
index 000000000..f9a7508fd
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000010_member_merge.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260416000011_gdpr_retention.sql b/apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
new file mode 100644
index 000000000..e9b1f6bf0
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260416000012_reporting_functions.sql b/apps/web/supabase/migrations/20260416000012_reporting_functions.sql
new file mode 100644
index 000000000..6b3acdc38
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000012_reporting_functions.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260416000013_index_optimization.sql b/apps/web/supabase/migrations/20260416000013_index_optimization.sql
new file mode 100644
index 000000000..84f97eb01
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000013_index_optimization.sql
@@ -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);
diff --git a/apps/web/supabase/migrations/20260416000014_notification_rules_and_jobs.sql b/apps/web/supabase/migrations/20260416000014_notification_rules_and_jobs.sql
new file mode 100644
index 000000000..d11d44143
--- /dev/null
+++ b/apps/web/supabase/migrations/20260416000014_notification_rules_and_jobs.sql
@@ -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();
diff --git a/apps/web/supabase/migrations/20260417000001_course_atomic_enrollment.sql b/apps/web/supabase/migrations/20260417000001_course_atomic_enrollment.sql
new file mode 100644
index 000000000..157c5d54e
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000001_course_atomic_enrollment.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260417000002_event_atomic_registration.sql b/apps/web/supabase/migrations/20260417000002_event_atomic_registration.sql
new file mode 100644
index 000000000..b118ebf63
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000002_event_atomic_registration.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql b/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql
new file mode 100644
index 000000000..0e2cafd16
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260417000004_courses_events_bookings_constraints.sql b/apps/web/supabase/migrations/20260417000004_courses_events_bookings_constraints.sql
new file mode 100644
index 000000000..1fd9495c9
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000004_courses_events_bookings_constraints.sql
@@ -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;
+$$;
diff --git a/apps/web/supabase/migrations/20260417000005_courses_events_bookings_versioning.sql b/apps/web/supabase/migrations/20260417000005_courses_events_bookings_versioning.sql
new file mode 100644
index 000000000..ac1a73dd5
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000005_courses_events_bookings_versioning.sql
@@ -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;
+$$;
diff --git a/apps/web/supabase/migrations/20260417000006_courses_events_bookings_audit.sql b/apps/web/supabase/migrations/20260417000006_courses_events_bookings_audit.sql
new file mode 100644
index 000000000..ec4db6903
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000006_courses_events_bookings_audit.sql
@@ -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();
diff --git a/apps/web/supabase/migrations/20260417000008_audit_timeline_rpcs.sql b/apps/web/supabase/migrations/20260417000008_audit_timeline_rpcs.sql
new file mode 100644
index 000000000..dbc9ac6aa
--- /dev/null
+++ b/apps/web/supabase/migrations/20260417000008_audit_timeline_rpcs.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260418000001_waitlist_management.sql b/apps/web/supabase/migrations/20260418000001_waitlist_management.sql
new file mode 100644
index 000000000..b6f56fda5
--- /dev/null
+++ b/apps/web/supabase/migrations/20260418000001_waitlist_management.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260418000002_attendance_rollup.sql b/apps/web/supabase/migrations/20260418000002_attendance_rollup.sql
new file mode 100644
index 000000000..5a620e397
--- /dev/null
+++ b/apps/web/supabase/migrations/20260418000002_attendance_rollup.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260418000003_instructor_availability.sql b/apps/web/supabase/migrations/20260418000003_instructor_availability.sql
new file mode 100644
index 000000000..864ae605b
--- /dev/null
+++ b/apps/web/supabase/migrations/20260418000003_instructor_availability.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260418000004_module_statistics_rpcs.sql b/apps/web/supabase/migrations/20260418000004_module_statistics_rpcs.sql
new file mode 100644
index 000000000..b5170042b
--- /dev/null
+++ b/apps/web/supabase/migrations/20260418000004_module_statistics_rpcs.sql
@@ -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;
diff --git a/apps/web/supabase/migrations/20260418000005_additional_indexes.sql b/apps/web/supabase/migrations/20260418000005_additional_indexes.sql
new file mode 100644
index 000000000..3d8861a98
--- /dev/null
+++ b/apps/web/supabase/migrations/20260418000005_additional_indexes.sql
@@ -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);
diff --git a/apps/web/supabase/seed.sql b/apps/web/supabase/seed.sql
index 58eb6aa13..f94634f61 100644
--- a/apps/web/supabase/seed.sql
+++ b/apps/web/supabase/seed.sql
@@ -285,3 +285,146 @@ SELECT pg_catalog.setval('"public"."role_permissions_id_seq"', 7, true);
--
SELECT pg_catalog.setval('"supabase_functions"."hooks_id_seq"', 19, true);
+
+-- ══════════════════════════════════════════════════════════════
+-- Member Management Seed Data
+-- 30 realistic German/Austrian club members for demo/development
+-- ══════════════════════════════════════════════════════════════
+
+DO $$
+DECLARE
+ v_account_id uuid := '5deaa894-2094-4da3-b4fd-1fada0809d1c';
+ v_user_id uuid := '31a03e74-1639-45b6-bfa7-77447f1a4762';
+ v_cat_regular uuid;
+ v_cat_youth uuid;
+ v_cat_senior uuid;
+ v_dept_vorstand uuid;
+ v_dept_jugend uuid;
+ v_dept_sport uuid;
+ v_m1 uuid; v_m2 uuid; v_m3 uuid; v_m4 uuid; v_m5 uuid;
+ v_m6 uuid; v_m7 uuid; v_m8 uuid; v_m9 uuid; v_m10 uuid;
+BEGIN
+
+-- Dues Categories
+INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_default, sort_order)
+VALUES (gen_random_uuid(), v_account_id, 'Erwachsene', 'Regulärer Mitgliedsbeitrag', 120.00, 'yearly', true, 1)
+RETURNING id INTO v_cat_regular;
+
+INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_youth, sort_order)
+VALUES (gen_random_uuid(), v_account_id, 'Jugend (bis 18)', 'Ermäßigter Jugendbeitrag', 48.00, 'yearly', true, 2)
+RETURNING id INTO v_cat_youth;
+
+INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, sort_order)
+VALUES (gen_random_uuid(), v_account_id, 'Senioren (ab 65)', 'Ermäßigter Seniorenbeitrag', 72.00, 'yearly', 3)
+RETURNING id INTO v_cat_senior;
+
+-- Departments
+INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
+VALUES (gen_random_uuid(), v_account_id, 'Vorstand', 'Vereinsvorstand und Leitung', 1)
+RETURNING id INTO v_dept_vorstand;
+
+INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
+VALUES (gen_random_uuid(), v_account_id, 'Jugendabteilung', 'Kinder- und Jugendarbeit', 2)
+RETURNING id INTO v_dept_jugend;
+
+INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
+VALUES (gen_random_uuid(), v_account_id, 'Sportabteilung', 'Training und Wettkampf', 3)
+RETURNING id INTO v_dept_sport;
+
+-- Members 1-10 (with variables for relationships)
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, is_founding_member, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0001', 'Johann', 'Maier', '1968-03-15', 'male', 'Herr', 'johann.maier@example.at', '+43 512 123456', '+43 664 1234567', 'Hauptstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2005-01-15', v_cat_regular, 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', true, true, true, true, v_user_id, v_user_id)
+RETURNING id INTO v_m1;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, gdpr_consent, gdpr_newsletter, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0002', 'Maria', 'Huber', '1975-07-22', 'female', 'Frau', 'maria.huber@example.at', '+43 512 234567', '+43 660 2345678', 'Bahnhofstraße', '5a', '6020', 'Innsbruck', 'AT', 'active', '2008-03-01', v_cat_regular, 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', true, true, v_user_id, v_user_id)
+RETURNING id INTO v_m2;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0003', 'Thomas', 'Berger', '1982-11-08', 'male', 'Herr', 'thomas.berger@example.at', '+43 512 345678', 'Museumstraße', '3', '6020', 'Innsbruck', 'AT', 'active', '2010-06-15', v_cat_regular, true, v_user_id, v_user_id)
+RETURNING id INTO v_m3;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0004', 'Anna', 'Steiner', '1990-04-12', 'female', 'Frau', 'anna.steiner@example.at', '+43 676 3456789', 'Leopoldstraße', '18', '6020', 'Innsbruck', 'AT', 'active', '2012-09-01', v_cat_regular, true, true, true, v_user_id, v_user_id)
+RETURNING id INTO v_m4;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, title, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_honorary, is_founding_member, gdpr_consent, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0005', 'Franz', 'Gruber', '1945-09-03', 'male', 'Herr', 'Dr.', 'franz.gruber@example.at', '+43 512 456789', 'Rennweg', '7', '6020', 'Innsbruck', 'AT', 'active', '1998-01-01', v_cat_senior, true, true, true, v_user_id, v_user_id)
+RETURNING id INTO v_m5;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, email, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_youth, guardian_name, guardian_phone, guardian_email, gdpr_consent, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0006', 'Lukas', 'Hofer', '2010-02-28', 'male', 'lukas.hofer@example.at', 'Schillerstraße', '22', '6020', 'Innsbruck', 'AT', 'active', '2022-03-01', v_cat_youth, true, 'Stefan Hofer', '+43 664 5678901', 'stefan.hofer@example.at', true, v_user_id, v_user_id)
+RETURNING id INTO v_m6;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0007', 'Katharina', 'Wimmer', '1988-12-05', 'female', 'Frau', 'k.wimmer@example.at', '+43 512 567890', 'Maria-Theresien-Straße', '15', '6020', 'Innsbruck', 'AT', 'inactive', '2015-01-01', v_cat_regular, true, v_user_id, v_user_id)
+RETURNING id INTO v_m7;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, street, house_number, postal_code, city, country, status, entry_date, exit_date, exit_reason, dues_category_id, gdpr_consent, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0008', 'Peter', 'Moser', '1970-06-18', 'male', 'Herr', 'peter.moser@example.at', 'Anichstraße', '29', '6020', 'Innsbruck', 'AT', 'resigned', '2010-05-01', '2025-12-31', 'Umzug', v_cat_regular, false, v_user_id, v_user_id)
+RETURNING id INTO v_m8;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0009', 'Sophie', 'Eder', '1995-08-30', 'female', 'Frau', 'sophie.eder@example.at', '+43 680 6789012', 'Universitätsstraße', '8', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-15', v_cat_regular, true, v_user_id, v_user_id)
+RETURNING id INTO v_m9;
+
+INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_retiree, gdpr_consent, gdpr_print, gdpr_birthday_info, created_by, updated_by)
+VALUES (gen_random_uuid(), v_account_id, '0010', 'Helmut', 'Bauer', '1952-01-14', 'male', 'Herr', 'helmut.bauer@example.at', '+43 512 678901', 'Sillgasse', '14', '6020', 'Innsbruck', 'AT', 'active', '2001-07-01', v_cat_senior, true, true, true, true, v_user_id, v_user_id)
+RETURNING id INTO v_m10;
+
+-- Members 11-30 (bulk insert)
+INSERT INTO public.members (account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) VALUES
+ (v_account_id, '0011', 'Christina', 'Pichler', '1993-05-17', 'female', 'Frau', 'christina.pichler@example.at', '+43 664 7890123', 'Innrain', '52', '6020', 'Innsbruck', 'AT', 'active', '2019-01-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0012', 'Michael', 'Ebner', '1985-09-23', 'male', 'Herr', 'michael.ebner@example.at', '+43 660 8901234', 'Höttinger Au', '3', '6020', 'Innsbruck', 'AT', 'active', '2017-04-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0013', 'Eva', 'Schwarz', '1978-02-09', 'female', 'Frau', 'eva.schwarz@example.at', '+43 676 9012345', 'Fallmerayerstraße', '6', '6020', 'Innsbruck', 'AT', 'active', '2014-09-15', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0014', 'Stefan', 'Wallner', '1991-11-30', 'male', 'Herr', 'stefan.wallner@example.at', '+43 664 0123456', 'Reichenauer Straße', '44', '6020', 'Innsbruck', 'AT', 'active', '2020-02-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0015', 'Martina', 'Lechner', '1987-04-25', 'female', 'Frau', 'martina.lechner@example.at', '+43 680 1234567', 'Olympiastraße', '10', '6020', 'Innsbruck', 'AT', 'active', '2016-06-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0016', 'Andreas', 'Koller', '1969-08-11', 'male', 'Herr', 'andreas.koller@example.at', '+43 664 2345670', 'Pradler Straße', '72', '6020', 'Innsbruck', 'AT', 'active', '2007-01-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0017', 'Laura', 'Reiter', '2008-07-19', 'female', NULL, 'laura.reiter@example.at', '+43 660 3456701', 'Gabelsbergerstraße', '4', '6020', 'Innsbruck', 'AT', 'active', '2023-01-01', v_cat_youth, true, v_user_id, v_user_id),
+ (v_account_id, '0018', 'Markus', 'Fuchs', '1980-10-02', 'male', 'Herr', 'markus.fuchs@example.at', '+43 676 4567012', 'Egger-Lienz-Straße', '28', '6020', 'Innsbruck', 'AT', 'active', '2013-03-15', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0019', 'Lisa', 'Müller', '1996-01-07', 'female', 'Frau', 'lisa.mueller@example.at', '+43 664 5670123', 'Amraser Straße', '16', '6020', 'Innsbruck', 'AT', 'active', '2021-09-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0020', 'Georg', 'Wagner', '1973-06-14', 'male', 'Herr', 'georg.wagner@example.at', '+43 680 6701234', 'Kaiserjägerstraße', '1', '6020', 'Innsbruck', 'AT', 'active', '2009-11-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0021', 'Claudia', 'Fischer', '1984-12-20', 'female', 'Frau', 'claudia.fischer@example.at', '+43 664 7012345', 'Technikerstraße', '9', '6020', 'Innsbruck', 'AT', 'active', '2018-05-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0022', 'Daniel', 'Wolf', '1998-03-28', 'male', 'Herr', 'daniel.wolf@example.at', '+43 660 8012346', 'Schöpfstraße', '31', '6020', 'Innsbruck', 'AT', 'active', '2022-01-15', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0023', 'Sandra', 'Brunner', '1976-09-06', 'female', 'Frau', NULL, '+43 512 901234', 'Defreggerstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2011-04-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0024', 'Robert', 'Lang', '1960-11-11', 'male', 'Herr', 'robert.lang@example.at', '+43 512 012345', 'Speckbacherstraße', '21', '6020', 'Innsbruck', 'AT', 'active', '2003-01-01', v_cat_senior, true, v_user_id, v_user_id),
+ (v_account_id, '0025', 'Nina', 'Winkler', '2009-05-03', 'female', NULL, 'nina.winkler@example.at', '+43 664 1230456', 'Müllerstraße', '7', '6020', 'Innsbruck', 'AT', 'active', '2023-09-01', v_cat_youth, true, v_user_id, v_user_id),
+ (v_account_id, '0026', 'Wolfgang', 'Schmid', '1955-04-22', 'male', 'Herr', 'wolfgang.schmid@example.at', '+43 512 2340567', 'Haller Straße', '55', '6020', 'Innsbruck', 'AT', 'inactive', '2000-06-01', v_cat_senior, true, v_user_id, v_user_id),
+ (v_account_id, '0027', 'Sabrina', 'Gruber', '1994-07-15', 'female', 'Frau', 'sabrina.gruber@example.at', '+43 676 3450678', 'Grabenweg', '33', '6020', 'Innsbruck', 'AT', 'active', '2020-11-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0028', 'Patrick', 'Stockinger', '1989-10-09', 'male', 'Herr', 'patrick.stockinger@example.at', '+43 660 4560789', 'Adamgasse', '19', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0029', 'Verena', 'Neuner', '1981-01-18', 'female', 'Frau', 'verena.neuner@example.at', '+43 664 5670890', 'Amthorstraße', '2', '6020', 'Innsbruck', 'AT', 'active', '2015-08-01', v_cat_regular, true, v_user_id, v_user_id),
+ (v_account_id, '0030', 'Florian', 'Kofler', '2011-12-25', 'male', NULL, NULL, '+43 664 6780901', 'Hunoldstraße', '11', '6020', 'Innsbruck', 'AT', 'active', '2024-01-01', v_cat_youth, true, v_user_id, v_user_id);
+
+-- Department Assignments
+INSERT INTO public.member_department_assignments (member_id, department_id) VALUES
+ (v_m1, v_dept_vorstand), (v_m2, v_dept_vorstand), (v_m3, v_dept_vorstand),
+ (v_m4, v_dept_jugend), (v_m6, v_dept_jugend),
+ (v_m4, v_dept_sport), (v_m10, v_dept_sport);
+
+-- Roles
+INSERT INTO public.member_roles (account_id, member_id, role_name, from_date, until_date, is_active) VALUES
+ (v_account_id, v_m1, '1. Vorsitzender', '2015-01-01', NULL, true),
+ (v_account_id, v_m2, 'Kassierin', '2018-01-01', NULL, true),
+ (v_account_id, v_m3, 'Schriftführer', '2018-01-01', NULL, true),
+ (v_account_id, v_m4, 'Jugendleiterin', '2020-01-01', NULL, true),
+ (v_account_id, v_m1, '2. Vorsitzender', '2008-01-01', '2014-12-31', false),
+ (v_account_id, v_m5, '1. Vorsitzender', '1998-01-01', '2014-12-31', false);
+
+-- Honors
+INSERT INTO public.member_honors (account_id, member_id, honor_name, honor_date, description) VALUES
+ (v_account_id, v_m5, 'Ehrenmitglied', '2015-01-01', 'Für 17 Jahre als Vorsitzender'),
+ (v_account_id, v_m1, '20 Jahre Mitgliedschaft', '2025-01-15', 'Treueehrung'),
+ (v_account_id, v_m5, 'Goldene Ehrennadel', '2010-06-15', 'Verdienstauszeichnung');
+
+-- SEPA Mandates
+INSERT INTO public.sepa_mandates (account_id, member_id, mandate_reference, iban, bic, account_holder, mandate_date, status, sequence, is_primary) VALUES
+ (v_account_id, v_m1, 'MNDT-2020-001', 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', '2020-01-01', 'active', 'RCUR', true),
+ (v_account_id, v_m2, 'MNDT-2020-002', 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', '2020-01-01', 'active', 'RCUR', true);
+
+-- Membership Applications
+INSERT INTO public.membership_applications (account_id, first_name, last_name, email, phone, street, postal_code, city, date_of_birth, message, status) VALUES
+ (v_account_id, 'Maximilian', 'Ortner', 'max.ortner@example.at', '+43 664 9876543', 'Viaduktbogen', '6020', 'Innsbruck', '1997-08-14', 'Wurde von einem Mitglied empfohlen.', 'submitted'),
+ (v_account_id, 'Hannah', 'Troger', 'hannah.troger@example.at', '+43 680 8765432', 'Erlerstraße', '6020', 'Innsbruck', '2001-03-22', 'Möchte gerne der Jugendabteilung beitreten.', 'submitted'),
+ (v_account_id, 'Felix', 'Kirchmair', 'felix.kirchmair@example.at', '+43 660 7654321', 'Brennerstraße', '6020', 'Innsbruck', '1992-11-05', NULL, 'submitted');
+
+END $$;
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index 83ad376b1..a39069592 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -18,7 +18,7 @@ services:
image: supabase/postgres:15.8.1.060
restart: unless-stopped
ports:
- - '54322:5432'
+ - '54322:54322'
volumes:
- supabase-db-data:/var/lib/postgresql/data
- ./docker/db/zzz-role-passwords.sh:/docker-entrypoint-initdb.d/zzz-role-passwords.sh:ro
@@ -317,7 +317,6 @@ services:
context: .
dockerfile: Dockerfile
args:
- # NEXT_PUBLIC_CI=true bypasses the HTTPS check during build
NEXT_PUBLIC_CI: 'true'
NEXT_PUBLIC_SITE_URL: http://localhost:3000
NEXT_PUBLIC_SUPABASE_URL: http://localhost:8000
diff --git a/packages/features/booking-management/package.json b/packages/features/booking-management/package.json
index cf80075ee..0d1dfe35c 100644
--- a/packages/features/booking-management/package.json
+++ b/packages/features/booking-management/package.json
@@ -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",
diff --git a/packages/features/booking-management/src/lib/booking-status-machine.ts b/packages/features/booking-management/src/lib/booking-status-machine.ts
new file mode 100644
index 000000000..849607916
--- /dev/null
+++ b/packages/features/booking-management/src/lib/booking-status-machine.ts
@@ -0,0 +1,98 @@
+import type { z } from 'zod';
+
+import type { BookingStatusEnum } from '../schema/booking.schema';
+
+type BookingStatus = z.infer;
+
+/**
+ * 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>;
+};
+
+const TRANSITIONS: Record<
+ BookingStatus,
+ Partial>
+> = {
+ 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 {
+ if (from === to) return {};
+
+ const transition = TRANSITIONS[from]?.[to];
+ if (!transition?.sideEffects) return {};
+
+ const result: Record = {};
+
+ 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 {
+ 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);
+}
diff --git a/packages/features/booking-management/src/lib/errors.ts b/packages/features/booking-management/src/lib/errors.ts
new file mode 100644
index 000000000..a2d2862f3
--- /dev/null
+++ b/packages/features/booking-management/src/lib/errors.ts
@@ -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;
+
+ constructor(
+ code: BookingErrorCode,
+ message: string,
+ statusCode = 400,
+ details?: Record,
+ ) {
+ 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;
+}
diff --git a/packages/features/booking-management/src/schema/booking.schema.ts b/packages/features/booking-management/src/schema/booking.schema.ts
index 60bcbf800..67bbac3b7 100644
--- a/packages/features/booking-management/src/schema/booking.schema.ts
+++ b/packages/features/booking-management/src/schema/booking.schema.ts
@@ -20,20 +20,31 @@ export const CreateRoomSchema = z.object({
description: z.string().optional(),
});
-export const CreateBookingSchema = z.object({
- accountId: z.string().uuid(),
- roomId: z.string().uuid(),
- guestId: z.string().uuid().optional(),
- checkIn: z.string(),
- checkOut: z.string(),
- 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),
- notes: z.string().optional(),
-});
+export const CreateBookingSchema = z
+ .object({
+ accountId: z.string().uuid(),
+ roomId: z.string().uuid(),
+ guestId: z.string().uuid().optional(),
+ checkIn: z.string(),
+ checkOut: z.string(),
+ 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).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;
+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),
diff --git a/packages/features/booking-management/src/server/actions/booking-actions.ts b/packages/features/booking-management/src/server/actions/booking-actions.ts
index 8b046a253..c9acf3dbd 100644
--- a/packages/features/booking-management/src/server/actions/booking-actions.ts
+++ b/packages/features/booking-management/src/server/actions/booking-actions.ts
@@ -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);
- logger.info({ name: 'booking.create' }, 'Creating booking...');
- const result = await api.createBooking(input);
- logger.info({ name: 'booking.create' }, 'Booking created');
- return { success: true, data: result };
+ try {
+ logger.info({ name: 'booking.create' }, 'Creating booking...');
+ 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);
- logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
- return { success: true, data: result };
+ 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 };
+ } 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 };
});
diff --git a/packages/features/booking-management/src/server/api.ts b/packages/features/booking-management/src/server/api.ts
index afc9badb4..a2b7ad9a0 100644
--- a/packages/features/booking-management/src/server/api.ts
+++ b/packages/features/booking-management/src/server/api.ts
@@ -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) {
- 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),
};
}
diff --git a/packages/features/booking-management/src/server/services/booking-crud.service.ts b/packages/features/booking-management/src/server/services/booking-crud.service.ts
new file mode 100644
index 000000000..be2b7c087
--- /dev/null
+++ b/packages/features/booking-management/src/server/services/booking-crud.service.ts
@@ -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) {
+ 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)
+ .status as string;
+
+ // Validate status transition using the state machine
+ try {
+ validateTransition(
+ currentStatus as Parameters[0],
+ status as Parameters[1],
+ );
+ } catch {
+ const validTargets = getValidTransitions(
+ currentStatus as Parameters[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();
+ }
+ },
+ };
+}
diff --git a/packages/features/booking-management/src/server/services/guest.service.ts b/packages/features/booking-management/src/server/services/guest.service.ts
new file mode 100644
index 000000000..481266b8a
--- /dev/null
+++ b/packages/features/booking-management/src/server/services/guest.service.ts
@@ -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) {
+ 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 ?? [];
+ },
+ };
+}
diff --git a/packages/features/booking-management/src/server/services/index.ts b/packages/features/booking-management/src/server/services/index.ts
new file mode 100644
index 000000000..ad23c1a59
--- /dev/null
+++ b/packages/features/booking-management/src/server/services/index.ts
@@ -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) {
+ return {
+ rooms: createRoomService(client),
+ bookings: createBookingCrudService(client),
+ guests: createGuestService(client),
+ };
+}
diff --git a/packages/features/booking-management/src/server/services/room.service.ts b/packages/features/booking-management/src/server/services/room.service.ts
new file mode 100644
index 000000000..da3f8e6be
--- /dev/null
+++ b/packages/features/booking-management/src/server/services/room.service.ts
@@ -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) {
+ 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;
+ },
+ };
+}
diff --git a/packages/features/course-management/package.json b/packages/features/course-management/package.json
index 167250d8e..c44b159dd 100644
--- a/packages/features/course-management/package.json
+++ b/packages/features/course-management/package.json
@@ -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",
diff --git a/packages/features/course-management/src/lib/course-status-machine.ts b/packages/features/course-management/src/lib/course-status-machine.ts
new file mode 100644
index 000000000..4604ecdc9
--- /dev/null
+++ b/packages/features/course-management/src/lib/course-status-machine.ts
@@ -0,0 +1,97 @@
+import type { z } from 'zod';
+
+import type { CourseStatusEnum } from '../schema/course.schema';
+
+type CourseStatus = z.infer;
+
+/**
+ * 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>;
+};
+
+const TRANSITIONS: Record<
+ CourseStatus,
+ Partial>
+> = {
+ 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 {
+ if (from === to) return {};
+
+ const transition = TRANSITIONS[from]?.[to];
+ if (!transition?.sideEffects) return {};
+
+ const result: Record = {};
+
+ 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 {
+ 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);
+}
diff --git a/packages/features/course-management/src/lib/errors.ts b/packages/features/course-management/src/lib/errors.ts
new file mode 100644
index 000000000..4d67ed579
--- /dev/null
+++ b/packages/features/course-management/src/lib/errors.ts
@@ -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;
+
+ constructor(
+ code: CourseErrorCode,
+ message: string,
+ statusCode = 400,
+ details?: Record,
+ ) {
+ 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;
+}
diff --git a/packages/features/course-management/src/schema/course.schema.ts b/packages/features/course-management/src/schema/course.schema.ts
index 1e4b3e156..a7fba3714 100644
--- a/packages/features/course-management/src/schema/course.schema.ts
+++ b/packages/features/course-management/src/schema/course.schema.ts
@@ -16,29 +16,100 @@ export const CourseStatusEnum = z.enum([
'cancelled',
]);
-export const CreateCourseSchema = z.object({
- accountId: z.string().uuid(),
- courseNumber: z.string().optional(),
- name: z.string().min(1).max(256),
- 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).default(0),
- reducedFee: z.number().min(0).optional(),
- capacity: z.number().int().min(1).default(20),
- minParticipants: z.number().int().min(0).default(5),
- status: CourseStatusEnum.default('planned'),
- registrationDeadline: z.string().optional(),
- notes: z.string().optional(),
-});
+export const CreateCourseSchema = z
+ .object({
+ accountId: z.string().uuid(),
+ courseNumber: z.string().optional(),
+ name: z.string().min(1).max(256),
+ 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).default(0),
+ reducedFee: z.number().min(0).optional(),
+ capacity: z.number().int().min(1).default(20),
+ minParticipants: z.number().int().min(0).default(5),
+ 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;
-export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
- courseId: z.string().uuid(),
-});
+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;
export const EnrollParticipantSchema = z.object({
diff --git a/packages/features/course-management/src/server/actions/course-actions.ts b/packages/features/course-management/src/server/actions/course-actions.ts
index c0bcbfc81..3b532a7aa 100644
--- a/packages/features/course-management/src/server/actions/course-actions.ts
+++ b/packages/features/course-management/src/server/actions/course-actions.ts
@@ -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);
- logger.info({ name: 'course.update' }, 'Updating course...');
- const result = await api.updateCourse(input);
- logger.info({ name: 'course.update' }, 'Course updated');
- return { success: true, data: result };
+ try {
+ logger.info({ name: 'course.update' }, 'Updating course...');
+ 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);
- logger.info({ name: 'course.delete' }, 'Archiving course...');
- await api.deleteCourse(input.courseId);
- logger.info({ name: 'course.delete' }, 'Course archived');
- return { success: true };
+ try {
+ logger.info({ name: 'course.delete' }, 'Archiving course...');
+ 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);
- logger.info(
- { name: 'course.enrollParticipant' },
- 'Enrolling participant...',
- );
- const result = await api.enrollParticipant(input);
- logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
- return { success: true, data: result };
+ try {
+ logger.info(
+ { name: 'course.enrollParticipant' },
+ 'Enrolling participant...',
+ );
+ 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 };
});
diff --git a/packages/features/course-management/src/server/api.ts b/packages/features/course-management/src/server/api.ts
index e5ae2d0d3..696ed58f7 100644
--- a/packages/features/course-management/src/server/api.ts
+++ b/packages/features/course-management/src/server/api.ts
@@ -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) {
- 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 = {};
- 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),
};
}
diff --git a/packages/features/course-management/src/server/services/attendance.service.ts b/packages/features/course-management/src/server/services/attendance.service.ts
new file mode 100644
index 000000000..bd7df7b69
--- /dev/null
+++ b/packages/features/course-management/src/server/services/attendance.service.ts
@@ -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) {
+ 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;
+ },
+ };
+}
diff --git a/packages/features/course-management/src/server/services/course-crud.service.ts b/packages/features/course-management/src/server/services/course-crud.service.ts
new file mode 100644
index 000000000..a8de1b1d8
--- /dev/null
+++ b/packages/features/course-management/src/server/services/course-crud.service.ts
@@ -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) {
+ 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 = {};
+
+ 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[0],
+ input.status as Parameters[1],
+ );
+ Object.assign(update, sideEffects);
+ } catch {
+ throw new InvalidCourseStatusTransitionError(
+ currentStatus,
+ input.status,
+ getValidTransitions(
+ currentStatus as Parameters[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[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;
+ },
+ };
+}
diff --git a/packages/features/course-management/src/server/services/course-reference-data.service.ts b/packages/features/course-management/src/server/services/course-reference-data.service.ts
new file mode 100644
index 000000000..df3646605
--- /dev/null
+++ b/packages/features/course-management/src/server/services/course-reference-data.service.ts
@@ -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,
+) {
+ 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;
+ },
+ };
+}
diff --git a/packages/features/course-management/src/server/services/course-statistics.service.ts b/packages/features/course-management/src/server/services/course-statistics.service.ts
new file mode 100644
index 000000000..3e86bcc07
--- /dev/null
+++ b/packages/features/course-management/src/server/services/course-statistics.service.ts
@@ -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,
+) {
+ 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 ?? [];
+ },
+ };
+}
diff --git a/packages/features/course-management/src/server/services/enrollment.service.ts b/packages/features/course-management/src/server/services/enrollment.service.ts
new file mode 100644
index 000000000..385f25e22
--- /dev/null
+++ b/packages/features/course-management/src/server/services/enrollment.service.ts
@@ -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) {
+ 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 ?? [];
+ },
+ };
+}
diff --git a/packages/features/course-management/src/server/services/index.ts b/packages/features/course-management/src/server/services/index.ts
new file mode 100644
index 000000000..14f532b26
--- /dev/null
+++ b/packages/features/course-management/src/server/services/index.ts
@@ -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) {
+ return {
+ courses: createCourseCrudService(client),
+ enrollment: createEnrollmentService(client),
+ sessions: createSessionService(client),
+ attendance: createAttendanceService(client),
+ referenceData: createCourseReferenceDataService(client),
+ statistics: createCourseStatisticsService(client),
+ };
+}
diff --git a/packages/features/course-management/src/server/services/session.service.ts b/packages/features/course-management/src/server/services/session.service.ts
new file mode 100644
index 000000000..93a4f6f16
--- /dev/null
+++ b/packages/features/course-management/src/server/services/session.service.ts
@@ -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) {
+ 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;
+ },
+ };
+}
diff --git a/packages/features/event-management/package.json b/packages/features/event-management/package.json
index e75575157..4bfd01c45 100644
--- a/packages/features/event-management/package.json
+++ b/packages/features/event-management/package.json
@@ -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",
diff --git a/packages/features/event-management/src/lib/errors.ts b/packages/features/event-management/src/lib/errors.ts
new file mode 100644
index 000000000..e8ffad424
--- /dev/null
+++ b/packages/features/event-management/src/lib/errors.ts
@@ -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;
+
+ constructor(
+ code: EventErrorCode,
+ message: string,
+ statusCode = 400,
+ details?: Record,
+ ) {
+ 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;
+}
diff --git a/packages/features/event-management/src/lib/event-status-machine.ts b/packages/features/event-management/src/lib/event-status-machine.ts
new file mode 100644
index 000000000..b4062332d
--- /dev/null
+++ b/packages/features/event-management/src/lib/event-status-machine.ts
@@ -0,0 +1,103 @@
+import type { z } from 'zod';
+
+import type { EventStatusEnum } from '../schema/event.schema';
+
+type EventStatus = z.infer;
+
+/**
+ * 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>;
+};
+
+const TRANSITIONS: Record<
+ EventStatus,
+ Partial>
+> = {
+ 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 {
+ if (from === to) return {};
+
+ const transition = TRANSITIONS[from]?.[to];
+ if (!transition?.sideEffects) return {};
+
+ const result: Record = {};
+
+ 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 {
+ 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);
+}
diff --git a/packages/features/event-management/src/schema/event.schema.ts b/packages/features/event-management/src/schema/event.schema.ts
index dd31885cb..216357834 100644
--- a/packages/features/event-management/src/schema/event.schema.ts
+++ b/packages/features/event-management/src/schema/event.schema.ts
@@ -9,29 +9,80 @@ export const EventStatusEnum = z.enum([
'cancelled',
]);
-export const CreateEventSchema = z.object({
- accountId: z.string().uuid(),
- name: z.string().min(1).max(256),
- description: z.string().optional(),
- eventDate: z.string(),
- eventTime: z.string().optional(),
- endDate: z.string().optional(),
- location: z.string().optional(),
- capacity: z.number().int().optional(),
- minAge: z.number().int().optional(),
- maxAge: z.number().int().optional(),
- fee: z.number().min(0).default(0),
- status: EventStatusEnum.default('planned'),
- registrationDeadline: z.string().optional(),
- contactName: z.string().optional(),
- contactEmail: z.string().email().optional().or(z.literal('')),
- contactPhone: z.string().optional(),
-});
+export const CreateEventSchema = z
+ .object({
+ accountId: z.string().uuid(),
+ name: z.string().min(1).max(256),
+ description: z.string().optional(),
+ eventDate: z.string(),
+ eventTime: z.string().optional(),
+ endDate: z.string().optional(),
+ location: z.string().optional(),
+ capacity: z.number().int().optional(),
+ minAge: z.number().int().optional(),
+ maxAge: z.number().int().optional(),
+ fee: z.number().min(0).default(0),
+ status: EventStatusEnum.default('planned'),
+ 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.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;
-export const UpdateEventSchema = CreateEventSchema.partial().extend({
- eventId: z.string().uuid(),
-});
+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;
export const EventRegistrationSchema = z.object({
diff --git a/packages/features/event-management/src/server/actions/event-actions.ts b/packages/features/event-management/src/server/actions/event-actions.ts
index 479980544..32676fe5f 100644
--- a/packages/features/event-management/src/server/actions/event-actions.ts
+++ b/packages/features/event-management/src/server/actions/event-actions.ts
@@ -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);
- logger.info({ name: 'event.update' }, 'Updating event...');
- const result = await api.updateEvent(input);
- logger.info({ name: 'event.update' }, 'Event updated');
- return { success: true, data: result };
+ try {
+ logger.info({ name: 'event.update' }, 'Updating event...');
+ 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);
- logger.info({ name: 'event.delete' }, 'Cancelling event...');
- await api.deleteEvent(input.eventId);
- logger.info({ name: 'event.delete' }, 'Event cancelled');
- return { success: true };
+ try {
+ logger.info({ name: 'event.delete' }, 'Cancelling event...');
+ 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);
- logger.info({ name: 'event.register' }, 'Registering for event...');
- const result = await api.registerForEvent(input);
- logger.info({ name: 'event.register' }, 'Registered for event');
- return { success: true, data: result };
+ try {
+ logger.info({ name: 'event.register' }, 'Registering for event...');
+ 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 };
});
diff --git a/packages/features/event-management/src/server/api.ts b/packages/features/event-management/src/server/api.ts
index 351e4f989..ddbdd380a 100644
--- a/packages/features/event-management/src/server/api.ts
+++ b/packages/features/event-management/src/server/api.ts
@@ -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) {
- 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;
- 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 = {};
- for (const row of data ?? []) {
- const eid = (row as Record).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 = {};
- 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),
};
}
diff --git a/packages/features/event-management/src/server/services/event-crud.service.ts b/packages/features/event-management/src/server/services/event-crud.service.ts
new file mode 100644
index 000000000..5649acad3
--- /dev/null
+++ b/packages/features/event-management/src/server/services/event-crud.service.ts
@@ -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) {
+ 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;
+ const { data, error } = await (client.rpc as CallableFunction)(
+ 'get_event_registration_counts',
+ { p_event_ids: eventIds },
+ );
+ if (error) throw error;
+ const counts: Record = {};
+ 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 = {};
+
+ 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)
+ .status as string;
+
+ if (currentStatus !== input.status) {
+ try {
+ const sideEffects = validateTransition(
+ currentStatus as Parameters[0],
+ input.status as Parameters[1],
+ );
+ Object.assign(update, sideEffects);
+ } catch {
+ throw new InvalidEventStatusTransitionError(
+ currentStatus,
+ input.status,
+ getValidTransitions(
+ currentStatus as Parameters[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[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;
+ },
+ };
+}
diff --git a/packages/features/event-management/src/server/services/event-registration.service.ts b/packages/features/event-management/src/server/services/event-registration.service.ts
new file mode 100644
index 000000000..9cf26a339
--- /dev/null
+++ b/packages/features/event-management/src/server/services/event-registration.service.ts
@@ -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,
+) {
+ 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 ?? [];
+ },
+ };
+}
diff --git a/packages/features/event-management/src/server/services/holiday-pass.service.ts b/packages/features/event-management/src/server/services/holiday-pass.service.ts
new file mode 100644
index 000000000..c15db88b7
--- /dev/null
+++ b/packages/features/event-management/src/server/services/holiday-pass.service.ts
@@ -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) {
+ 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;
+ },
+ };
+}
diff --git a/packages/features/event-management/src/server/services/index.ts b/packages/features/event-management/src/server/services/index.ts
new file mode 100644
index 000000000..2276518f0
--- /dev/null
+++ b/packages/features/event-management/src/server/services/index.ts
@@ -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) {
+ return {
+ events: createEventCrudService(client),
+ registrations: createEventRegistrationService(client),
+ holidayPasses: createHolidayPassService(client),
+ };
+}
diff --git a/packages/features/member-management/package.json b/packages/features/member-management/package.json
index b7ba90244..e27aef37c 100644
--- a/packages/features/member-management/package.json
+++ b/packages/features/member-management/package.json
@@ -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,12 +23,15 @@
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
+ "@kit/mailers": "workspace:*",
"@kit/next": "workspace:*",
+ "@kit/notifications": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
+ "@tanstack/react-table": "catalog:",
"@types/papaparse": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
diff --git a/packages/features/member-management/src/components/index.ts b/packages/features/member-management/src/components/index.ts
index 176706564..de888f7dc 100644
--- a/packages/features/member-management/src/components/index.ts
+++ b/packages/features/member-management/src/components/index.ts
@@ -1,8 +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';
+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';
diff --git a/packages/features/member-management/src/components/member-avatar.tsx b/packages/features/member-management/src/components/member-avatar.tsx
new file mode 100644
index 000000000..4296a5d7a
--- /dev/null
+++ b/packages/features/member-management/src/components/member-avatar.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import { Avatar, AvatarFallback } from '@kit/ui/avatar';
+import { cn } from '@kit/ui/utils';
+
+interface MemberAvatarProps {
+ firstName: string;
+ lastName: string;
+ size?: 'default' | 'sm' | 'lg';
+ className?: string;
+}
+
+function getInitials(firstName: string, lastName: string): string {
+ const f = firstName.trim().charAt(0).toUpperCase();
+ const l = lastName.trim().charAt(0).toUpperCase();
+ return `${f}${l}`;
+}
+
+function getColorClass(firstName: string, lastName: string): string {
+ const name = `${firstName}${lastName}`;
+ let hash = 0;
+ for (let i = 0; i < name.length; i++) {
+ hash = name.charCodeAt(i) + ((hash << 5) - hash);
+ }
+ const colors = [
+ 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
+ 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
+ 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
+ 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
+ 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300',
+ 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300',
+ 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
+ 'bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300',
+ ];
+ return colors[Math.abs(hash) % colors.length]!;
+}
+
+export function MemberAvatar({
+ firstName,
+ lastName,
+ size = 'default',
+ className,
+}: MemberAvatarProps) {
+ const initials = getInitials(firstName, lastName);
+ const colorClass = getColorClass(firstName, lastName);
+
+ return (
+
+
+ {initials}
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/member-command-palette.tsx b/packages/features/member-management/src/components/member-command-palette.tsx
new file mode 100644
index 000000000..598a28b04
--- /dev/null
+++ b/packages/features/member-management/src/components/member-command-palette.tsx
@@ -0,0 +1,165 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { FileUp, Plus, User } from 'lucide-react';
+import { useAction } from 'next-safe-action/hooks';
+
+import { Badge } from '@kit/ui/badge';
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from '@kit/ui/command';
+
+import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
+import { quickSearchMembers } from '../server/actions/member-actions';
+import { MemberAvatar } from './member-avatar';
+
+interface MemberCommandPaletteProps {
+ account: string;
+ accountId: string;
+}
+
+export function MemberCommandPalette({
+ account,
+ accountId,
+}: MemberCommandPaletteProps) {
+ const router = useRouter();
+ const [open, setOpen] = useState(false);
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState<
+ Array<{
+ id: string;
+ first_name: string;
+ last_name: string;
+ email: string | null;
+ member_number: string | null;
+ status: string;
+ }>
+ >([]);
+
+ const { execute } = useAction(quickSearchMembers, {
+ onSuccess: ({ data }) => {
+ if (data?.data) setResults(data.data);
+ },
+ });
+
+ // Keyboard shortcut
+ useEffect(() => {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
+ e.preventDefault();
+ setOpen((v) => !v);
+ }
+ };
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, []);
+
+ // Search on query change
+ useEffect(() => {
+ if (query.length >= 2) {
+ execute({ accountId, query, limit: 8 });
+ } else {
+ setResults([]);
+ }
+ }, [query, accountId, execute]);
+
+ const handleSelect = useCallback(
+ (memberId: string) => {
+ setOpen(false);
+ setQuery('');
+ router.push(`/home/${account}/members-cms/${memberId}`);
+ },
+ [router, account],
+ );
+
+ const basePath = `/home/${account}/members-cms`;
+
+ return (
+
+
+
+ Keine Mitglieder gefunden.
+
+ {results.length > 0 && (
+
+ {results.map((m) => (
+ handleSelect(m.id)}
+ className="flex items-center gap-3"
+ >
+
+
+
+ {m.first_name} {m.last_name}
+
+ {m.member_number && (
+
+ Nr. {m.member_number}
+
+ )}
+
+
+ {STATUS_LABELS[m.status] ?? m.status}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {
+ setOpen(false);
+ router.push(`${basePath}/new`);
+ }}
+ >
+
+ Neues Mitglied erstellen
+
+ {
+ setOpen(false);
+ router.push(`${basePath}/import`);
+ }}
+ >
+
+ Import starten
+
+ {
+ setOpen(false);
+ router.push(`${basePath}/applications`);
+ }}
+ >
+
+ Aufnahmeanträge anzeigen
+
+
+
+
+ );
+}
diff --git a/packages/features/member-management/src/components/member-create-wizard.tsx b/packages/features/member-management/src/components/member-create-wizard.tsx
new file mode 100644
index 000000000..0d62fcfc3
--- /dev/null
+++ b/packages/features/member-management/src/components/member-create-wizard.tsx
@@ -0,0 +1,770 @@
+'use client';
+
+import { useState } from 'react';
+
+import { useRouter } from 'next/navigation';
+
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Check } from 'lucide-react';
+import { useForm } from 'react-hook-form';
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from '@kit/ui/alert-dialog';
+import { Button } from '@kit/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
+import { Checkbox } from '@kit/ui/checkbox';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@kit/ui/form';
+import { Input } from '@kit/ui/input';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@kit/ui/select';
+import { Textarea } from '@kit/ui/textarea';
+import { useActionWithToast } from '@kit/ui/use-action-with-toast';
+import { cn } from '@kit/ui/utils';
+
+import { CreateMemberSchema } from '../schema/member.schema';
+import { createMember } from '../server/actions/member-actions';
+
+interface Props {
+ accountId: string;
+ account: string;
+ duesCategories: Array<{ id: string; name: string; amount: number }>;
+}
+
+interface DuplicateEntry {
+ field: string;
+ message: string;
+ id?: string;
+}
+
+const STEPS = [
+ { id: 1, title: 'Basisdaten', description: 'Name und Mitgliedschaft' },
+ { id: 2, title: 'Weitere Angaben', description: 'Kontakt und Adresse' },
+ {
+ id: 3,
+ title: 'Mitgliedschaft & Finanzen',
+ description: 'Beiträge und Bankverbindung',
+ },
+] as const;
+
+export function MemberCreateWizard({
+ accountId,
+ account,
+ duesCategories,
+}: Props) {
+ const router = useRouter();
+ const [step, setStep] = useState(1);
+ const [duplicates, setDuplicates] = useState([]);
+
+ const form = useForm({
+ resolver: zodResolver(CreateMemberSchema),
+ defaultValues: {
+ accountId,
+ firstName: '',
+ lastName: '',
+ email: '',
+ phone: '',
+ mobile: '',
+ street: '',
+ houseNumber: '',
+ postalCode: '',
+ city: '',
+ country: 'DE',
+ memberNumber: '',
+ status: 'active' as const,
+ entryDate: new Date().toISOString().split('T')[0]!,
+ dateOfBirth: '',
+ gender: undefined,
+ salutation: '',
+ title: '',
+ duesCategoryId: undefined,
+ iban: '',
+ bic: '',
+ accountHolder: '',
+ gdprConsent: false,
+ gdprNewsletter: false,
+ gdprInternet: false,
+ gdprPrint: false,
+ gdprBirthdayInfo: false,
+ isHonorary: false,
+ isFoundingMember: false,
+ isYouth: false,
+ isRetiree: false,
+ isProbationary: false,
+ guardianName: '',
+ guardianPhone: '',
+ guardianEmail: '',
+ notes: '',
+ },
+ });
+
+ const { execute, isPending } = useActionWithToast(createMember, {
+ successMessage: 'Mitglied erstellt',
+ onSuccess: ({ data }: any) => {
+ if (data?.validationErrors) {
+ setDuplicates(data.validationErrors);
+ return;
+ }
+ router.push(`/home/${account}/members-cms`);
+ },
+ });
+
+ const canProceedStep1 =
+ form.watch('firstName')?.trim() && form.watch('lastName')?.trim();
+
+ const handleNext = () => {
+ if (step < 3) setStep(step + 1);
+ };
+
+ const handleBack = () => {
+ if (step > 1) setStep(step - 1);
+ };
+
+ const handleSubmit = form.handleSubmit((data) => {
+ // Clean empty strings
+ const cleanData = { ...data };
+ for (const [key, value] of Object.entries(cleanData)) {
+ if (value === '') {
+ (cleanData as any)[key] = undefined;
+ }
+ }
+ execute(cleanData);
+ });
+
+ return (
+
+ {/* Step indicator */}
+