Compare commits

14 Commits

Author SHA1 Message Date
Zaid Marzguioui
9cbe6652a1 fix: close all remaining known gaps across modules
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m52s
Workflow / ⚫️ Test (push) Has been skipped
Every 'read-only placeholder' and 'missing functionality' gap from the
QA audit is now resolved:

COURSES — categories/instructors/locations can now be deleted:
- Added update/delete methods to course-reference-data.service.ts
- Added deleteCategory/deleteInstructor/deleteLocation server actions
- Created DeleteRefDataButton client component with confirmation dialog
- Wired delete buttons into all three table pages

BOOKINGS — calendar month navigation now works:
- Calendar was hardcoded to current month with disabled prev/next
- Added year/month search params for server-side month rendering
- Replaced disabled buttons with Link-based navigation
- Verified: clicking next/prev correctly renders different months

DOCUMENTS — templates page now reads from database:
- Was hardcoded empty array; now queries document_templates table
- Table exists since migration 20260414000006_shared_templates.sql

FISCHEREI — statistics page shows real data:
- Replaced dashed-border placeholder with 6 real stat cards
- Queries waters, species, stocking, catch_books, leases, permits
- Shows counts + stocking costs + pending catch books
- Falls back to helpful message when no data exists

VERBAND — statistics page shows real KPIs:
- Added server-side data fetching (clubs, members, fees)
- Passes activeClubs, totalMembers, openFees as props
- Added 4 KPI cards: Aktive Vereine, Gesamtmitglieder,
  ∅ Mitglieder/Verein, Offene Beiträge
- Kept existing trend charts below KPI cards
2026-04-03 23:52:25 +02:00
Zaid Marzguioui
ad01ecb8b9 feat: wire 10 dead buttons across 6 modules to their server actions
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m53s
Workflow / ⚫️ Test (push) Has been skipped
Every module had buttons that rendered visually but did nothing when
clicked. Server actions existed for all of them. Created client
components with dialogs/forms and wired them in.

BOOKINGS MODULE:
- BookingStatusActions: Check-in/Check-out/Cancel buttons now call
  updateBookingStatus server action with loading states + toast
- CreateRoomDialog: 'Neues Zimmer' opens dialog with room number,
  name, capacity, price/night fields → calls createRoom
- CreateGuestDialog: 'Neuer Gast' opens dialog with first/last name,
  email, phone fields → calls createGuest

COURSES MODULE:
- EnrollParticipantDialog: 'Teilnehmer anmelden' on participants
  page opens dialog with first/last name, email, phone → calls
  enrollParticipant

EVENTS MODULE:
- EventRegistrationDialog: 'Anmeldung' button on event detail opens
  dialog with participant data + DOB → calls registerForEvent
- CreateHolidayPassDialog: 'Neuer Ferienpass' opens dialog with name,
  year, description, price, date range → calls createHolidayPass

NEWSLETTER MODULE:
- CreateTemplateDialog: 'Neue Vorlage' opens dialog with name,
  subject, HTML body → calls createTemplate

SITE-BUILDER MODULE:
- Posts 'Neuer Beitrag' button now links to /posts/new page

All dialogs use German labels, helpful placeholders, loading spinners,
toast notifications, and form validation appropriate for association
board members (Vereinsvorstände, 40-65, moderate tech skills).
2026-04-03 23:33:42 +02:00
Zaid Marzguioui
7cfd88f1c3 feat(finance): add SEPA batch actions — populate, manual add, XML download
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 4m54s
Workflow / ⚫️ Test (push) Has been skipped
The SEPA batch detail page was a dead end: users could create a batch
but had no way to add payment positions or generate the XML file.

Added SepaBatchActions client component with three key workflows:

1. 'Mitglieder hinzufügen' — auto-populates batch from all active
   members who have a SEPA mandate and dues category (calls existing
   populateBatchFromMembers server action)

2. 'Einzelposition' — dialog to manually add a single debit item
   with Name, IBAN, Amount, and Verwendungszweck fields

3. 'XML herunterladen' — dialog for creditor info (Gläubiger-Name,
   IBAN, BIC, Gläubiger-ID) then generates and triggers download of
   the SEPA pain.008 XML file. Disabled when batch has 0 positions.

Also fixed: SEPA list page crashed because a Server Component had an
onClick handler on a <tr> — removed the invalid event handler.

Target demographic: German association treasurers (Kassenwarte) who
need a straightforward workflow for annual membership fee collection
via SEPA Lastschrift.
2026-04-03 22:47:35 +02:00
Zaid Marzguioui
1215e351c1 fix: UX improvements for German association users
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
- fix(member-detail): display gender in German (Männlich/Weiblich/Divers)
  instead of raw English enum values (male/female/diverse)

- fix(member-detail): display country names in German (Österreich, Deutschland)
  instead of raw ISO codes (AT, DE)

- fix(member-statistics): total member count was always 0
  getStatistics() returns per-status counts without a total key;
  now computes total by summing all status counts

- fix(i18n): add 56 breadcrumb segment translations for DE and EN
  Breadcrumbs were showing English path segments (Courses, Calendar,
  Registrations) because translation keys for URL path segments were
  missing. Added all segment-level route translations so breadcrumbs
  now display in German throughout the app.
2026-04-03 22:10:02 +02:00
Zaid Marzguioui
9f83b5cc75 Merge branch 'main' of https://gitea.frontieralgorithmics.de/zaid.marzguioui/myeasycms-v2
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m40s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-03 18:42:10 +02:00
Zaid Marzguioui
5b169a381f fix: resolve 4 QA bugs found in Docker production build
- fix(member-management): Zod v4 .partial() on refined schema crash
  Separated CreateMemberBaseSchema from superRefine so .partial()
  works for UpdateMemberSchema. Fixes members-cms page crash.

- fix(course-management): snake_case→camelCase stats normalization
  getQuickStats RPC returns snake_case keys but templates expect
  camelCase. Added normalization layer so stats cards display values.

- fix(blog): add missing cover images for 5 German blog posts
  Posts referenced /images/posts/*.webp that didn't exist.

- fix(docker): remove non-existent catch_entries table from bootstrap
  dev-bootstrap.sh granted permissions on catch_entries which has no
  migration. Removed the stale reference.

- docs: add qa-checklist.md with full test report
2026-04-03 18:41:51 +02:00
T. Zehetbauer
9d5fe58ee3 feat: add shared notification, communication, and export services for bookings, courses, and events; introduce btree_gist extension and new booking atomic function
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m42s
Workflow / ⚫️ Test (push) Has been skipped
2026-04-03 17:03:34 +02:00
4d538a5668 Merge pull request 'feat/members-redesign' (#1) from feat/members-redesign into main
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 5m44s
Workflow / ⚫️ Test (push) Has been skipped
Reviewed-on: #1
2026-04-03 12:11:03 +00:00
T. Zehetbauer
5c5aaabae5 refactor: remove obsolete member management API module
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped
2026-04-03 14:08:31 +02:00
Zaid Marzguioui
b6092adc3e fix(billing): add billing feature flags as Dockerfile build args
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 16m1s
Workflow / ⚫️ Test (push) Has been skipped
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING and
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING must be set at build time
(ARG/ENV in Dockerfile + build args in docker-compose) because Next.js
bakes NEXT_PUBLIC_* into the bundle during 'next build'. Setting them
only as runtime environment vars has no effect.
2026-04-03 11:51:02 +02:00
T. Zehetbauer
124c6a632a feat: update Docker configuration for improved performance and add local environment example 2026-04-03 09:37:36 +02:00
T. Zehetbauer
d4acc3ba22 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx
2026-04-02 23:37:19 +02:00
Zaid Marzguioui
28188bb3a6 fix(billing): wire up Stripe checkout with real price IDs and env vars
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m3s
Workflow / ⚫️ Test (push) Has been skipped
- Replace 8 placeholder price IDs (price_starter_monthly, etc.) with real
  Stripe test-mode price IDs created via API
- Add STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, NEXT_PUBLIC_BILLING_PROVIDER,
  and NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to docker-compose.yml (build args + runtime)
- Add NEXT_PUBLIC_BILLING_PROVIDER ARG/ENV to Dockerfile
- Enable team and personal account billing (was 'false')
- Created Stripe webhook endpoint for production URL
- Created 4 Stripe products (Starter/Pro/Verband/Enterprise) with monthly+yearly prices

Checkout was crashing because:
1. STRIPE_SECRET_KEY was missing → Zod validation failed at createStripeClient()
2. STRIPE_WEBHOOK_SECRET was missing → same Zod schema rejection
3. NEXT_PUBLIC_BILLING_PROVIDER was unset → BillingProviderSchema.parse() failed
4. Price IDs were placeholders, not real Stripe price_xxx IDs
2026-04-02 23:34:30 +02:00
Zaid Marzguioui
f10a34c505 fix: comprehensive CMS QA fixes — i18n, UI labels, breadcrumbs
Some checks failed
Workflow / ʦ TypeScript (push) Failing after 6m6s
Workflow / ⚫️ Test (push) Has been skipped
- Fix i18n dotted permission keys causing INVALID_KEY console spam (en/de cms.json)
- Fix member detail breadcrumb showing UUID instead of member name
- Fix bookings stats card showing 'of' instead of 'Total'
- Fix events/registrations table 'status' column header resolving to object
- Fix finance search placeholder showing 'Show All' instead of search text
- Fix finance status filter default showing 'No data' instead of 'All'
- Fix applications page German pluralization 'Antrage' → 'Anträge'
- Add breadcrumbValues prop to CmsPageShell for UUID→name overrides
2026-04-02 22:59:42 +02:00
197 changed files with 16224 additions and 3590 deletions

View File

@@ -1 +1 @@
[]
[]

View File

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

19
.env.local.example Normal file
View File

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

View File

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

View File

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

View File

@@ -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
@@ -22,6 +19,9 @@ ARG NEXT_PUBLIC_ENABLE_FISCHEREI=true
ARG NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=true
ARG NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=true
ARG NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
ARG NEXT_PUBLIC_BILLING_PROVIDER=stripe
ARG NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=true
ARG NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true
ENV NEXT_PUBLIC_CI=${NEXT_PUBLIC_CI}
ENV NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL}
@@ -31,20 +31,23 @@ ENV NEXT_PUBLIC_ENABLE_FISCHEREI=${NEXT_PUBLIC_ENABLE_FISCHEREI}
ENV NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS=${NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS}
ENV NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG=${NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG}
ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
ENV NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=${NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING}
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

View File

@@ -4,13 +4,11 @@ import {
ArrowLeft,
BedDouble,
CalendarDays,
LogIn,
LogOut,
XCircle,
User,
} from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { BookingStatusActions } from '@kit/booking-management/components';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
@@ -288,41 +286,10 @@ export default async function BookingDetailPage({ params }: PageProps) {
<CardDescription>{t('detail.changeStatus')}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-3">
{(status === 'pending' || status === 'confirmed') && (
<Button variant="default">
<LogIn className="mr-2 h-4 w-4" />
{t('detail.checkIn')}
</Button>
)}
{status === 'checked_in' && (
<Button variant="default">
<LogOut className="mr-2 h-4 w-4" />
{t('detail.checkOut')}
</Button>
)}
{status !== 'cancelled' &&
status !== 'checked_out' &&
status !== 'no_show' && (
<Button variant="destructive">
<XCircle className="mr-2 h-4 w-4" />
{t('detail.cancel')}
</Button>
)}
{status === 'cancelled' || status === 'checked_out' ? (
<p className="text-muted-foreground py-2 text-sm">
{t('detail.noMoreActions', {
statusLabel:
status === 'cancelled'
? t('detail.cancelledStatus')
: t('detail.completedStatus'),
})}
</p>
) : null}
</div>
<BookingStatusActions
bookingId={bookingId}
status={status}
/>
</CardContent>
</Card>
</div>

View File

@@ -14,6 +14,7 @@ import { CmsPageShell } from '~/components/cms-page-shell';
interface PageProps {
params: Promise<{ account: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
const WEEKDAYS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
@@ -51,8 +52,9 @@ function isDateInRange(
return date >= checkIn && date < checkOut;
}
export default async function BookingCalendarPage({ params }: PageProps) {
export default async function BookingCalendarPage({ params, searchParams }: PageProps) {
const { account } = await params;
const search = await searchParams;
const t = await getTranslations('bookings');
const client = getSupabaseServerClient();
@@ -73,8 +75,15 @@ export default async function BookingCalendarPage({ params }: PageProps) {
const api = createBookingManagementApi(client);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const year = Number(search.year) || now.getFullYear();
const month = search.month != null ? Number(search.month) - 1 : now.getMonth();
// Compute prev/next month for navigation links
const prevMonth = month === 0 ? 12 : month;
const prevYear = month === 0 ? year - 1 : year;
const nextMonth = month === 11 ? 1 : month + 2;
const nextYear = month === 11 ? year + 1 : year;
const daysInMonth = getDaysInMonth(year, month);
const firstWeekday = getFirstWeekday(year, month);
@@ -82,7 +91,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,
@@ -160,10 +169,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Button
variant="ghost"
size="icon"
disabled
asChild
aria-label={t('calendar.previousMonth')}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
<Link href={`/home/${account}/bookings/calendar?year=${prevYear}&month=${prevMonth}`}>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
<CardTitle>
{MONTH_NAMES[month]} {year}
@@ -171,10 +182,12 @@ export default async function BookingCalendarPage({ params }: PageProps) {
<Button
variant="ghost"
size="icon"
disabled
asChild
aria-label={t('calendar.nextMonth')}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
<Link href={`/home/${account}/bookings/calendar?year=${nextYear}&month=${nextMonth}`}>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Link>
</Button>
</div>
</CardHeader>

View File

@@ -2,6 +2,7 @@ import { UserCircle, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateGuestDialog } from '@kit/booking-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -34,17 +35,14 @@ export default async function GuestsPage({ params }: PageProps) {
}
const api = createBookingManagementApi(client);
const guests = await api.listGuests(acct.id);
const guests = await api.guests.list(acct.id);
return (
<CmsPageShell account={account} title={t('guests.title')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">{t('guests.manage')}</p>
<Button data-test="guests-new-btn">
<Plus className="mr-2 h-4 w-4" />
{t('guests.newGuest')}
</Button>
<CreateGuestDialog accountId={acct.id} />
</div>
{guests.length === 0 ? (

View File

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

View File

@@ -54,7 +54,7 @@ export default async function BookingsPage({
const page = Number(search.page) || 1;
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
const bookingsQuery = client
@@ -126,7 +126,7 @@ export default async function BookingsPage({
icon={<CalendarCheck className="h-5 w-5" />}
/>
<StatsCard
title={t('common.of')}
title={t('list.total')}
value={total}
icon={<Euro className="h-5 w-5" />}
/>

View File

@@ -2,6 +2,7 @@ import { BedDouble, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createBookingManagementApi } from '@kit/booking-management/api';
import { CreateRoomDialog } from '@kit/booking-management/components';
import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
@@ -36,17 +37,14 @@ export default async function RoomsPage({ params }: PageProps) {
}
const api = createBookingManagementApi(client);
const rooms = await api.listRooms(acct.id);
const rooms = await api.rooms.list(acct.id);
return (
<CmsPageShell account={account} title={t('rooms.title')}>
<div className="flex w-full flex-col gap-6">
<div className="flex items-center justify-between">
<p className="text-muted-foreground">{t('rooms.manage')}</p>
<Button data-test="rooms-new-btn">
<Plus className="mr-2 h-4 w-4" />
{t('rooms.newRoom')}
</Button>
<CreateRoomDialog accountId={acct.id} />
</div>
{rooms.length === 0 ? (

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { Plus, Users } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { EnrollParticipantDialog } from '@kit/course-management/components';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
@@ -40,8 +41,8 @@ export default async function ParticipantsPage({ params }: PageProps) {
const t = await getTranslations('courses');
const [course, participants] = await Promise.all([
api.getCourse(courseId),
api.getParticipants(courseId),
api.courses.getById(courseId),
api.enrollment.listParticipants(courseId),
]);
if (!course) return <AccountNotFound />;
@@ -56,10 +57,7 @@ export default async function ParticipantsPage({ params }: PageProps) {
{participants.length} {t('participants.title')}
</p>
</div>
<Button data-test="participants-add-btn">
<Plus className="mr-2 h-4 w-4" />
{t('participants.add')}
</Button>
<EnrollParticipantDialog courseId={courseId} />
</div>
{participants.length === 0 ? (

View File

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

View File

@@ -2,6 +2,7 @@ import { FolderTree } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -29,7 +30,7 @@ export default async function CategoriesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const categories = await api.listCategories(acct.id);
const categories = await api.referenceData.listCategories(acct.id);
return (
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>
@@ -67,6 +68,7 @@ export default async function CategoriesPage({ params }: PageProps) {
<th scope="col" className="p-3 text-left font-medium">
{t('common.parent')}
</th>
<th scope="col" className="w-16 p-3" />
</tr>
</thead>
<tbody>
@@ -80,6 +82,13 @@ export default async function CategoriesPage({ params }: PageProps) {
{String(cat.description ?? '—')}
</td>
<td className="p-3">{String(cat.parent_id ?? '—')}</td>
<td className="p-3 text-right">
<DeleteRefDataButton
id={String(cat.id)}
type="category"
itemName={String(cat.name)}
/>
</td>
</tr>
))}
</tbody>

View File

@@ -2,6 +2,7 @@ import { GraduationCap } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -30,7 +31,7 @@ export default async function InstructorsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const instructors = await api.listInstructors(acct.id);
const instructors = await api.referenceData.listInstructors(acct.id);
return (
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>
@@ -74,6 +75,7 @@ export default async function InstructorsPage({ params }: PageProps) {
<th scope="col" className="p-3 text-right font-medium">
{t('instructors.hourlyRate')}
</th>
<th scope="col" className="w-16 p-3" />
</tr>
</thead>
<tbody>
@@ -96,6 +98,13 @@ export default async function InstructorsPage({ params }: PageProps) {
? formatCurrencyAmount(inst.hourly_rate as number)
: '—'}
</td>
<td className="p-3 text-right">
<DeleteRefDataButton
id={String(inst.id)}
type="instructor"
itemName={`${inst.first_name} ${inst.last_name}`}
/>
</td>
</tr>
))}
</tbody>

View File

@@ -2,6 +2,7 @@ import { MapPin } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createCourseManagementApi } from '@kit/course-management/api';
import { DeleteRefDataButton } from '@kit/course-management/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
@@ -29,7 +30,7 @@ export default async function LocationsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createCourseManagementApi(client);
const locations = await api.listLocations(acct.id);
const locations = await api.referenceData.listLocations(acct.id);
return (
<CmsPageShell account={account} title={t('pages.locationsTitle')}>
@@ -70,6 +71,7 @@ export default async function LocationsPage({ params }: PageProps) {
<th scope="col" className="p-3 text-right font-medium">
{t('list.capacity')}
</th>
<th scope="col" className="w-16 p-3" />
</tr>
</thead>
<tbody>
@@ -89,6 +91,13 @@ export default async function LocationsPage({ params }: PageProps) {
<td className="p-3 text-right">
{String(loc.capacity ?? '—')}
</td>
<td className="p-3 text-right">
<DeleteRefDataButton
id={String(loc.id)}
type="location"
itemName={String(loc.name)}
/>
</td>
</tr>
))}
</tbody>

View File

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

View File

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

View File

@@ -26,13 +26,19 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
// Document templates are stored locally for now — placeholder for future DB integration
const templates: Array<{
id: string;
name: string;
type: string;
description: string;
}> = [];
// Fetch document templates from DB
const { data: templates } = await client
.from('document_templates')
.select('id, name, template_type, description')
.eq('account_id', acct.id)
.order('name');
const templatesList = (templates ?? []).map((t: any) => ({
id: String(t.id),
name: String(t.name),
type: String(t.template_type ?? '—'),
description: String(t.description ?? ''),
}));
return (
<CmsPageShell account={account} title={t('templates.title')}>
@@ -50,7 +56,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
</div>
{/* Table or Empty State */}
{templates.length === 0 ? (
{templatesList.length === 0 ? (
<EmptyState
icon={<FileText className="h-8 w-8" />}
title={t('templates.noTemplates')}
@@ -61,7 +67,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
<Card>
<CardHeader>
<CardTitle>
{t('templates.allTemplates', { count: templates.length })}
{t('templates.allTemplates', { count: templatesList.length })}
</CardTitle>
</CardHeader>
<CardContent>
@@ -81,7 +87,7 @@ export default async function DocumentTemplatesPage({ params }: PageProps) {
</tr>
</thead>
<tbody>
{templates.map((template) => (
{templatesList.map((template) => (
<tr
key={template.id}
className="hover:bg-muted/30 border-b"

View File

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

View File

@@ -11,6 +11,7 @@ import {
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { EventRegistrationDialog } from '@kit/event-management/components';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
@@ -36,8 +37,8 @@ export default async function EventDetailPage({ params }: PageProps) {
const t = await getTranslations('cms.events');
const [event, registrations] = await Promise.all([
api.getEvent(eventId),
api.getRegistrations(eventId),
api.events.getById(eventId),
api.registrations.list(eventId),
]);
if (!event) return <div>{t('notFound')}</div>;
@@ -73,10 +74,7 @@ export default async function EventDetailPage({ params }: PageProps) {
)}
</Badge>
</div>
<Button>
<UserPlus className="mr-2 h-4 w-4" />
{t('register')}
</Button>
<EventRegistrationDialog eventId={eventId} eventName={String((event as any).name ?? '')} />
</div>
{/* Detail Cards */}

View File

@@ -2,6 +2,7 @@ import { Ticket, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createEventManagementApi } from '@kit/event-management/api';
import { CreateHolidayPassDialog } from '@kit/event-management/components';
import { formatDate, formatCurrencyAmount } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Button } from '@kit/ui/button';
@@ -29,7 +30,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const passes = await api.listHolidayPasses(acct.id);
const passes = await api.holidayPasses.list(acct.id);
return (
<CmsPageShell account={account} title={t('holidayPasses')}>
@@ -40,10 +41,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
{t('holidayPassesDescription')}
</p>
</div>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('newHolidayPass')}
</Button>
<CreateHolidayPassDialog accountId={acct.id} />
</div>
{passes.length === 0 ? (

View File

@@ -47,13 +47,13 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
const page = Number(search.page) || 1;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page });
const events = await api.events.list(acct.id, { page });
// Fetch registration counts for all events on this page
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
String(eventItem.id),
);
const registrationCounts = await api.getRegistrationCounts(eventIds);
const registrationCounts = await api.events.getRegistrationCounts(eventIds);
// Pre-compute stats before rendering
const uniqueLocationCount = new Set(
@@ -138,7 +138,7 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
{t('capacity')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('status')}
{t('statusLabel')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('registrations')}

View File

@@ -36,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
if (!acct) return <AccountNotFound />;
const api = createEventManagementApi(client);
const events = await api.listEvents(acct.id, { page: 1 });
const events = await api.events.list(acct.id, { page: 1 });
// Load registrations for each event in parallel
const eventsWithRegistrations = await Promise.all(
events.data.map(async (event: Record<string, unknown>) => {
const registrations = await api.getRegistrations(String(event.id));
const registrations = await api.registrations.list(String(event.id));
return {
id: String(event.id),
name: String(event.name),
@@ -116,7 +116,7 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
{t('eventDate')}
</th>
<th scope="col" className="p-3 text-left font-medium">
{t('status')}
{t('statusLabel')}
</th>
<th scope="col" className="p-3 text-right font-medium">
{t('capacity')}

View File

@@ -144,13 +144,13 @@ export default async function FinancePage({ params, searchParams }: PageProps) {
{/* Toolbar */}
<ListToolbar
searchPlaceholder={t('common.showAll')}
searchPlaceholder={t('common.searchPlaceholder')}
filters={[
{
param: 'status',
label: t('common.status'),
options: [
{ value: '', label: t('common.noData') },
{ value: '', label: t('common.all') },
{ value: 'draft', label: t('status.draft') },
{ value: 'ready', label: t('sepa.newBatch') },
{ value: 'sent', label: t('status.sent') },

View File

@@ -1,13 +1,13 @@
import Link from 'next/link';
import { ArrowLeft, Download } from 'lucide-react';
import { ArrowLeft } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createFinanceApi } from '@kit/finance/api';
import { SepaBatchActions } from '@kit/finance/components';
import { formatDate } from '@kit/shared/dates';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { AccountNotFound } from '~/components/account-not-found';
@@ -124,10 +124,12 @@ export default async function SepaBatchDetailPage({ params }: PageProps) {
</dl>
<div className="mt-6">
<Button disabled variant="outline">
<Download className="mr-2 h-4 w-4" />
{t('sepa.downloadXml')}
</Button>
<SepaBatchActions
batchId={batchId}
accountId={acct.id}
batchStatus={status}
itemCount={items.length}
/>
</div>
</CardContent>
</Card>

View File

@@ -108,7 +108,7 @@ export default async function SepaPage({ params }: PageProps) {
{batches.map((batch: Record<string, unknown>) => (
<tr
key={String(batch.id)}
className="hover:bg-muted/30 border-b"
className="hover:bg-muted/30 cursor-pointer border-b"
>
<td className="p-3">
<Badge
@@ -131,7 +131,7 @@ export default async function SepaPage({ params }: PageProps) {
<td className="p-3">
<Link
href={`/home/${account}/finance/sepa/${String(batch.id)}`}
className="hover:underline"
className="font-medium hover:underline"
>
{String(batch.description ?? '—')}
</Link>

View File

@@ -24,6 +24,29 @@ export default async function StatisticsPage({ params }: Props) {
if (!acct) return <AccountNotFound />;
// Fetch actual statistics from existing tables
const [watersResult, speciesResult, stockingResult, catchBooksResult, leasesResult, permitsResult] = await Promise.allSettled([
client.from('waters').select('id', { count: 'exact' }).eq('account_id', acct.id),
client.from('fish_species').select('id', { count: 'exact' }).eq('account_id', acct.id),
client.from('fish_stocking').select('id, quantity, cost_total', { count: 'exact' }).eq('account_id', acct.id),
client.from('catch_books').select('id, status', { count: 'exact' }).eq('account_id', acct.id),
client.from('fishing_leases').select('id', { count: 'exact' }).eq('account_id', acct.id).eq('status', 'active'),
client.from('fishing_permits').select('id', { count: 'exact' }).eq('account_id', acct.id),
]);
const waterCount = watersResult.status === 'fulfilled' ? (watersResult.value.count ?? 0) : 0;
const speciesCount = speciesResult.status === 'fulfilled' ? (speciesResult.value.count ?? 0) : 0;
const stockingData = stockingResult.status === 'fulfilled' ? (stockingResult.value.data ?? []) : [];
const stockingCount = stockingData.length;
const stockingCost = stockingData.reduce((sum: number, s: any) => sum + (Number(s.cost_total) || 0), 0);
const catchBookCount = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.count ?? 0) : 0;
const catchBookData = catchBooksResult.status === 'fulfilled' ? (catchBooksResult.value.data ?? []) : [];
const pendingCatchBooks = catchBookData.filter((cb: any) => cb.status === 'submitted').length;
const leaseCount = leasesResult.status === 'fulfilled' ? (leasesResult.value.count ?? 0) : 0;
const permitCount = permitsResult.status === 'fulfilled' ? (permitsResult.value.count ?? 0) : 0;
const formatCurrency = (v: number) => new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
return (
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<FischereiTabNavigation account={account} activeTab="statistics" />
@@ -33,22 +56,27 @@ export default async function StatisticsPage({ params }: Props) {
Fangstatistiken und Auswertungen
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Gewässer</p><p className="text-2xl font-bold">{waterCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Fischarten</p><p className="text-2xl font-bold">{speciesCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Besatzaktionen</p><p className="text-2xl font-bold">{stockingCount}</p><p className="text-muted-foreground text-xs">{formatCurrency(stockingCost)} Gesamtkosten</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Fangbücher</p><p className="text-2xl font-bold">{catchBookCount}</p>{pendingCatchBooks > 0 && <p className="text-xs text-amber-600">{pendingCatchBooks} zur Prüfung</p>}</CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Aktive Pachten</p><p className="text-2xl font-bold">{leaseCount}</p></CardContent></Card>
<Card><CardContent className="p-6"><p className="text-muted-foreground text-sm">Erlaubnisscheine</p><p className="text-2xl font-bold">{permitCount}</p></CardContent></Card>
</div>
{waterCount === 0 && speciesCount === 0 && (
<Card>
<CardHeader>
<CardTitle>Fangstatistiken</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-12 text-center">
<h3 className="text-lg font-semibold">
Noch keine Daten vorhanden
</h3>
<CardContent className="p-6">
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
<h3 className="text-lg font-semibold">Noch keine Daten vorhanden</h3>
<p className="text-muted-foreground mt-1 max-w-sm text-sm">
Sobald Fangbücher eingereicht und geprüft werden, erscheinen
hier Statistiken und Auswertungen.
Sobald Gewässer, Fischarten und Fangbücher angelegt werden, erscheinen hier detaillierte Statistiken.
</p>
</div>
</CardContent>
</Card>
)}
</div>
</CmsPageShell>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { createMemberManagementApi } from '@kit/member-management/api';
import { MembersListView } from '@kit/member-management/components';
import { createMemberServices } from '@kit/member-management/services';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
@@ -23,7 +23,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
.single();
if (!acct) return <AccountNotFound />;
const api = createMemberManagementApi(client);
const { query, organization } = createMemberServices(client);
const page = Number(search.page) || 1;
// Parse multi-status filter
@@ -34,7 +34,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
: statusParam.split(',')
: undefined;
const result = await api.searchMembers({
const result = await query.search({
accountId: acct.id,
search: search.q as string,
status: statusFilter as any,
@@ -45,11 +45,56 @@ export default async function MembersPage({ params, searchParams }: Props) {
pageSize: PAGE_SIZE,
});
// Fetch categories and departments (always available)
const [duesCategories, departments] = await Promise.all([
api.listDuesCategories(acct.id),
api.listDepartmentsWithCounts(acct.id),
organization.listDuesCategories(acct.id),
organization.listDepartmentsWithCounts(acct.id),
]);
// Fetch tags gracefully (tables may not exist if migration hasn't run)
let accountTags: Array<{ id: string; name: string; color: string }> = [];
const memberTags: Record<
string,
Array<{ id: string; name: string; color: string }>
> = {};
try {
const memberIds = result.data.map((m: any) => m.id);
const [tagsResult, tagAssignmentsResult] = await Promise.all([
(client.from as any)('member_tags')
.select('id, name, color')
.eq('account_id', acct.id)
.order('sort_order'),
memberIds.length > 0
? (client.from as any)('member_tag_assignments')
.select('member_id, tag_id, member_tags(id, name, color)')
.in('member_id', memberIds)
: { data: [] },
]);
accountTags = (tagsResult.data ?? []).map((t: any) => ({
id: String(t.id),
name: String(t.name),
color: String(t.color),
}));
for (const a of tagAssignmentsResult.data ?? []) {
const memberId = String(a.member_id);
const tag = a.member_tags;
if (!tag) continue;
if (!memberTags[memberId]) memberTags[memberId] = [];
memberTags[memberId]!.push({
id: String(tag.id),
name: String(tag.name),
color: String(tag.color),
});
}
} catch {
// Tags tables may not exist yet — gracefully degrade
}
return (
<MembersListView
data={result.data}
@@ -69,6 +114,8 @@ export default async function MembersPage({ params, searchParams }: Props) {
name: String(d.name),
memberCount: d.memberCount,
}))}
tags={accountTags}
memberTags={memberTags}
/>
);
}

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { FileText, Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
import { createNewsletterApi } from '@kit/newsletter/api';
import { CreateTemplateDialog } from '@kit/newsletter/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
@@ -41,10 +42,7 @@ export default async function NewsletterTemplatesPage({ params }: PageProps) {
<p className="text-muted-foreground">{t('templates.subtitle')}</p>
</div>
<Button data-test="newsletter-templates-new-btn">
<Plus className="mr-2 h-4 w-4" />
{t('templates.newTemplate')}
</Button>
<CreateTemplateDialog accountId={acct.id} />
</div>
{/* Table or Empty State */}

View File

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

View File

@@ -1,3 +1,5 @@
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { getTranslations } from 'next-intl/server';
@@ -45,9 +47,11 @@ export default async function PostsManagerPage({ params }: Props) {
>
<div className="space-y-6">
<div className="flex justify-end">
<Button data-test="site-new-post-btn">
<Plus className="mr-2 h-4 w-4" />
{t('posts.newPost')}
<Button data-test="site-new-post-btn" asChild>
<Link href={`/home/${account}/site-builder/posts/new`}>
<Plus className="mr-2 h-4 w-4" />
{t('posts.newPost')}
</Link>
</Button>
</div>
{posts.length === 0 ? (

View File

@@ -22,15 +22,60 @@ const PLACEHOLDER_DATA = [
{ year: '2025', vereine: 19, mitglieder: 1200 },
];
export default function StatisticsContent() {
export default function StatisticsContent({
activeClubs = 0,
totalClubs = 0,
totalMembers = 0,
openFees = 0,
}: {
activeClubs?: number;
totalClubs?: number;
totalMembers?: number;
openFees?: number;
}) {
const formatCurrency = (v: number) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v);
return (
<div className="space-y-6">
<div>
<p className="text-muted-foreground">
Entwicklung der Mitgliedsvereine und Gesamtmitglieder im Zeitverlauf
Aktuelle Kennzahlen des Verbands
</p>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Aktive Vereine</p>
<p className="text-2xl font-bold">{activeClubs}</p>
<p className="text-muted-foreground text-xs">{totalClubs} gesamt</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Gesamtmitglieder</p>
<p className="text-2xl font-bold">{totalMembers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm"> Mitglieder/Verein</p>
<p className="text-2xl font-bold">
{activeClubs > 0 ? Math.round(totalMembers / activeClubs) : 0}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-muted-foreground text-sm">Offene Beiträge</p>
<p className="text-2xl font-bold">{formatCurrency(openFees)}</p>
</CardContent>
</Card>
</div>
{/* Charts (keep existing placeholder data as trend visualization) */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Card>
<CardHeader>

View File

@@ -1,7 +1,9 @@
import { getTranslations } from 'next-intl/server';
import { VerbandTabNavigation } from '@kit/verbandsverwaltung/components';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { AccountNotFound } from '~/components/account-not-found';
import { CmsPageShell } from '~/components/cms-page-shell';
import StatisticsContent from './_components/statistics-content';
@@ -13,11 +15,50 @@ interface Props {
export default async function StatisticsPage({ params }: Props) {
const { account } = await params;
const t = await getTranslations('verband');
const client = getSupabaseServerClient();
const { data: acct } = await client
.from('accounts')
.select('id')
.eq('slug', account)
.single();
if (!acct) return <AccountNotFound />;
// Fetch real verband stats
const [clubsResult, membersResult, feesResult] = await Promise.allSettled([
client
.from('member_clubs')
.select('id, status, member_count', { count: 'exact' })
.eq('account_id', acct.id),
client
.from('members')
.select('id', { count: 'exact' })
.eq('account_id', acct.id)
.eq('status', 'active'),
(client.from as any)('club_fees')
.select('amount, status')
.eq('account_id', acct.id),
]);
const clubs = clubsResult.status === 'fulfilled' ? (clubsResult.value.data ?? []) : [];
const activeClubs = clubs.filter((c: any) => c.status !== 'archived').length;
const totalMembers = clubsResult.status === 'fulfilled'
? clubs.reduce((sum: number, c: any) => sum + (Number(c.member_count) || 0), 0)
: 0;
const directMembers = membersResult.status === 'fulfilled' ? (membersResult.value.count ?? 0) : 0;
const fees = feesResult.status === 'fulfilled' ? (feesResult.value.data ?? []) : [];
const openFees = fees.filter((f: any) => f.status !== 'paid').reduce((s: number, f: any) => s + (Number(f.amount) || 0), 0);
return (
<CmsPageShell account={account} title={t('pages.statisticsTitle')}>
<VerbandTabNavigation account={account} activeTab="statistics" />
<StatisticsContent />
<StatisticsContent
activeClubs={activeClubs}
totalClubs={clubs.length}
totalMembers={totalMembers || directMembers}
openFees={openFees}
/>
</CmsPageShell>
);
}

View File

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

View File

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

View File

@@ -9,6 +9,8 @@ interface CmsPageShellProps {
account: string;
title: string;
description?: string;
/** Override breadcrumb labels for URL path segments (e.g. UUID → name) */
breadcrumbValues?: Record<string, string>;
children: ReactNode;
}
@@ -20,6 +22,7 @@ export function CmsPageShell({
account,
title,
description,
breadcrumbValues,
children,
}: CmsPageShellProps) {
return (
@@ -28,7 +31,11 @@ export function CmsPageShell({
account={account}
title={title}
description={
description !== undefined ? description : <AppBreadcrumbs />
description !== undefined ? (
description
) : (
<AppBreadcrumbs values={breadcrumbValues} />
)
}
/>

View File

@@ -33,7 +33,7 @@ export default createBillingSchema({
interval: 'month',
lineItems: [
{
id: 'price_starter_monthly',
id: 'price_1THsqKKttnWb7SsFttMu9VzG',
name: 'Starter',
cost: 29,
type: 'flat' as const,
@@ -47,7 +47,7 @@ export default createBillingSchema({
interval: 'year',
lineItems: [
{
id: 'price_starter_yearly',
id: 'price_1THsqLKttnWb7SsFgvjsKXzs',
name: 'Starter',
cost: 290,
type: 'flat' as const,
@@ -82,7 +82,7 @@ export default createBillingSchema({
interval: 'month',
lineItems: [
{
id: 'price_pro_monthly',
id: 'price_1THsqLKttnWb7SsFlWPf5IdP',
name: 'Pro',
cost: 59,
type: 'flat' as const,
@@ -96,7 +96,7 @@ export default createBillingSchema({
interval: 'year',
lineItems: [
{
id: 'price_pro_yearly',
id: 'price_1THsqMKttnWb7SsFZq3A4QkU',
name: 'Pro',
cost: 590,
type: 'flat' as const,
@@ -130,7 +130,7 @@ export default createBillingSchema({
interval: 'month',
lineItems: [
{
id: 'price_verband_monthly',
id: 'price_1THsqNKttnWb7SsFGv7YskgJ',
name: 'Verband',
cost: 199,
type: 'flat' as const,
@@ -144,7 +144,7 @@ export default createBillingSchema({
interval: 'year',
lineItems: [
{
id: 'price_verband_yearly',
id: 'price_1THsqNKttnWb7SsFhNl2bVn8',
name: 'Verband',
cost: 1990,
type: 'flat' as const,
@@ -178,7 +178,7 @@ export default createBillingSchema({
interval: 'month',
lineItems: [
{
id: 'price_enterprise_monthly',
id: 'price_1THsqOKttnWb7SsFlLjfLw72',
name: 'Enterprise',
cost: 349,
type: 'flat' as const,
@@ -192,7 +192,7 @@ export default createBillingSchema({
interval: 'year',
lineItems: [
{
id: 'price_enterprise_yearly',
id: 'price_1THsqOKttnWb7SsF8Sr12isW',
name: 'Enterprise',
cost: 3490,
type: 'flat' as const,

View File

@@ -304,34 +304,54 @@
"paginationNext": "Weiter →"
},
"permissions": {
"modules.read": "Module lesen",
"modules.write": "Module bearbeiten",
"modules.delete": "Module löschen",
"modules.insert": "Datensätze erstellen",
"modules.lock": "Datensätze sperren",
"modules.import": "Daten importieren",
"modules.export": "Daten exportieren",
"modules.print": "Drucken",
"modules.manage": "Module verwalten",
"members.read": "Mitglieder lesen",
"members.write": "Mitglieder bearbeiten",
"courses.read": "Kurse lesen",
"courses.write": "Kurse bearbeiten",
"bookings.read": "Buchungen lesen",
"bookings.write": "Buchungen bearbeiten",
"finance.read": "Finanzen lesen",
"finance.write": "Finanzen bearbeiten",
"finance.sepa": "SEPA-Einzüge ausführen",
"documents.generate": "Dokumente generieren",
"newsletter.send": "Newsletter versenden",
"fischerei.read": "Fischerei lesen",
"fischerei.write": "Fischerei bearbeiten",
"meetings.read": "Sitzungsprotokolle lesen",
"meetings.write": "Sitzungsprotokolle bearbeiten",
"meetings.delete": "Sitzungsprotokolle löschen",
"verband.read": "Verbandsverwaltung lesen",
"verband.write": "Verbandsverwaltung bearbeiten",
"verband.delete": "Verbandsverwaltung löschen"
"modules": {
"read": "Module lesen",
"write": "Module bearbeiten",
"delete": "Module löschen",
"insert": "Datensätze erstellen",
"lock": "Datensätze sperren",
"import": "Daten importieren",
"export": "Daten exportieren",
"print": "Drucken",
"manage": "Module verwalten"
},
"members": {
"read": "Mitglieder lesen",
"write": "Mitglieder bearbeiten"
},
"courses": {
"read": "Kurse lesen",
"write": "Kurse bearbeiten"
},
"bookings": {
"read": "Buchungen lesen",
"write": "Buchungen bearbeiten"
},
"finance": {
"read": "Finanzen lesen",
"write": "Finanzen bearbeiten",
"sepa": "SEPA-Einzüge ausführen"
},
"documents": {
"generate": "Dokumente generieren"
},
"newsletter": {
"send": "Newsletter versenden"
},
"fischerei": {
"read": "Fischerei lesen",
"write": "Fischerei bearbeiten"
},
"meetings": {
"read": "Sitzungsprotokolle lesen",
"write": "Sitzungsprotokolle bearbeiten",
"delete": "Sitzungsprotokolle löschen"
},
"verband": {
"read": "Verbandsverwaltung lesen",
"write": "Verbandsverwaltung bearbeiten",
"delete": "Verbandsverwaltung löschen"
}
},
"status": {
"active": "Aktiv",

View File

@@ -139,7 +139,58 @@
"administration": "Administration",
"accountSettings": "Kontoeinstellungen",
"application": "Anwendung",
"home": "Startseite"
"home": "Startseite",
"courses": "Kurse",
"calendar": "Kalender",
"instructors": "Kursleiter",
"locations": "Standorte",
"categories": "Kategorien",
"statistics": "Statistiken",
"events": "Veranstaltungen",
"registrations": "Anmeldungen",
"holiday passes": "Ferienpässe",
"bookings": "Buchungen",
"rooms": "Zimmer",
"guests": "Gäste",
"finance": "Finanzen",
"invoices": "Rechnungen",
"sepa": "SEPA-Einzüge",
"payments": "Zahlungen",
"documents": "Dokumente",
"generate": "Generieren",
"templates": "Vorlagen",
"newsletter": "Newsletter",
"new": "Neu",
"edit": "Bearbeiten",
"members": "Mitglieder",
"members cms": "Vereinsmitglieder",
"site builder": "Website",
"posts": "Beiträge",
"fischerei": "Fischerei",
"waters": "Gewässer",
"species": "Fischarten",
"stocking": "Besatz",
"leases": "Pachten",
"catch books": "Fangbücher",
"permits": "Erlaubnisscheine",
"competitions": "Wettbewerbe",
"meetings": "Sitzungen",
"protocols": "Protokolle",
"tasks": "Aufgaben",
"verband": "Verband",
"clubs": "Vereine",
"hierarchy": "Organisationsstruktur",
"reporting": "Berichte",
"modules": "Module",
"import": "Import",
"applications": "Aufnahmeanträge",
"departments": "Abteilungen",
"dues": "Beiträge",
"tags": "Tags",
"cards": "Mitgliedsausweise",
"invitations": "Einladungen",
"attendance": "Anwesenheit",
"participants": "Teilnehmer"
},
"roles": {
"owner": {
@@ -223,4 +274,4 @@
"action": "Zum Dashboard"
},
"confirm": "Bestätigen"
}
}

View File

@@ -146,7 +146,9 @@
"next": "Weiter",
"type": "Typ",
"date": "Datum",
"description": "Beschreibung"
"description": "Beschreibung",
"searchPlaceholder": "Rechnung suchen...",
"all": "Alle"
},
"status": {
"draft": "Entwurf",

View File

@@ -295,26 +295,40 @@
"paginationNext": "Next →"
},
"permissions": {
"modules.read": "Read Modules",
"modules.write": "Edit Modules",
"modules.delete": "Delete Modules",
"modules.insert": "Create Records",
"modules.lock": "Lock Records",
"modules.import": "Import Data",
"modules.export": "Export Data",
"modules.print": "Print",
"modules.manage": "Manage Modules",
"members.read": "Read Members",
"members.write": "Edit Members",
"courses.read": "Read Courses",
"courses.write": "Edit Courses",
"bookings.read": "Read Bookings",
"bookings.write": "Edit Bookings",
"finance.read": "Read Finance",
"finance.write": "Edit Finance",
"finance.sepa": "Execute SEPA Collections",
"documents.generate": "Generate Documents",
"newsletter.send": "Send Newsletter",
"modules": {
"read": "Read Modules",
"write": "Edit Modules",
"delete": "Delete Modules",
"insert": "Create Records",
"lock": "Lock Records",
"import": "Import Data",
"export": "Export Data",
"print": "Print",
"manage": "Manage Modules"
},
"members": {
"read": "Read Members",
"write": "Edit Members"
},
"courses": {
"read": "Read Courses",
"write": "Edit Courses"
},
"bookings": {
"read": "Read Bookings",
"write": "Edit Bookings"
},
"finance": {
"read": "Read Finance",
"write": "Edit Finance",
"sepa": "Execute SEPA Collections"
},
"documents": {
"generate": "Generate Documents"
},
"newsletter": {
"send": "Send Newsletter"
},
"verband": {
"delete": "Delete Association Data"
}

View File

@@ -139,7 +139,58 @@
"associationTemplates": "Shared Templates",
"administration": "Administration",
"accountSettings": "Account Settings",
"application": "Application"
"application": "Application",
"courses": "Courses",
"calendar": "Calendar",
"instructors": "Instructors",
"locations": "Locations",
"categories": "Categories",
"statistics": "Statistics",
"events": "Events",
"registrations": "Registrations",
"holiday passes": "Holiday Passes",
"bookings": "Bookings",
"rooms": "Rooms",
"guests": "Guests",
"finance": "Finance",
"invoices": "Invoices",
"sepa": "SEPA",
"payments": "Payments",
"documents": "Documents",
"generate": "Generate",
"templates": "Templates",
"newsletter": "Newsletter",
"new": "New",
"edit": "Edit",
"members": "Members",
"members cms": "Members",
"site builder": "Site Builder",
"posts": "Posts",
"fischerei": "Fisheries",
"waters": "Waters",
"species": "Species",
"stocking": "Stocking",
"leases": "Leases",
"catch books": "Catch Books",
"permits": "Permits",
"competitions": "Competitions",
"meetings": "Meetings",
"protocols": "Protocols",
"tasks": "Tasks",
"verband": "Federation",
"clubs": "Clubs",
"hierarchy": "Hierarchy",
"reporting": "Reporting",
"modules": "Modules",
"import": "Import",
"applications": "Applications",
"departments": "Departments",
"dues": "Dues",
"tags": "Tags",
"cards": "Cards",
"invitations": "Invitations",
"attendance": "Attendance",
"participants": "Participants"
},
"roles": {
"owner": {
@@ -223,4 +274,4 @@
"action": "Go to Dashboard"
},
"confirm": "Confirm"
}
}

View File

@@ -146,7 +146,9 @@
"next": "Next",
"type": "Type",
"date": "Date",
"description": "Description"
"description": "Description",
"searchPlaceholder": "Search invoices...",
"all": "All"
},
"status": {
"draft": "Draft",

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,6 @@ const INTERNAL_PACKAGES = [
/** @type {import('next').NextConfig} */
const config = {
output: 'standalone',
reactStrictMode: true,
/** Enables hot reloading for local packages without a build step */
transpilePackages: INTERNAL_PACKAGES,

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
-- Enable btree_gist extension (required by booking overlap exclusion constraint)
-- Separated into own migration to avoid "multiple commands in prepared statement" error
CREATE EXTENSION IF NOT EXISTS btree_gist;

View File

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

View File

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

View File

@@ -0,0 +1,54 @@
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
p_account_id uuid,
p_room_id uuid,
p_guest_id uuid DEFAULT NULL,
p_check_in date DEFAULT NULL,
p_check_out date DEFAULT NULL,
p_adults integer DEFAULT 1,
p_children integer DEFAULT 0,
p_status text DEFAULT 'confirmed',
p_total_price numeric DEFAULT NULL,
p_notes text DEFAULT NULL
)
RETURNS uuid
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $fn$
DECLARE
v_room record;
v_computed_price numeric(10,2);
v_booking_id uuid;
BEGIN
SELECT * INTO v_room FROM public.rooms WHERE id = p_room_id FOR UPDATE;
IF v_room IS NULL THEN
RAISE EXCEPTION 'Room % not found', p_room_id USING ERRCODE = 'P0002';
END IF;
IF p_check_in IS NULL OR p_check_out IS NULL THEN
RAISE EXCEPTION 'check_in and check_out dates are required' USING ERRCODE = 'P0001';
END IF;
IF p_check_out <= p_check_in THEN
RAISE EXCEPTION 'check_out must be after check_in' USING ERRCODE = 'P0001';
END IF;
IF (p_adults + p_children) > v_room.capacity THEN
RAISE EXCEPTION 'Total guests exceed room capacity' USING ERRCODE = 'P0001';
END IF;
IF p_total_price IS NOT NULL THEN
v_computed_price := p_total_price;
ELSE
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
END IF;
INSERT INTO public.bookings (
account_id, room_id, guest_id, check_in, check_out,
adults, children, status, total_price, notes
) VALUES (
p_account_id, p_room_id, p_guest_id, p_check_in, p_check_out,
p_adults, p_children, p_status, v_computed_price, p_notes
)
RETURNING id INTO v_booking_id;
RETURN v_booking_id;
END;
$fn$;

View File

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

View File

@@ -0,0 +1,14 @@
DO $excl$
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;
$excl$;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
-- =====================================================
-- Module Notification Rules & Queue
-- Shared notification infrastructure for courses, events, bookings.
-- =====================================================
-- Notification rules: define what triggers notifications
CREATE TABLE IF NOT EXISTS public.module_notification_rules (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
trigger_event text NOT NULL CHECK (trigger_event IN (
'course.participant_enrolled', 'course.participant_waitlisted', 'course.participant_promoted',
'course.participant_cancelled', 'course.status_changed', 'course.session_reminder',
'event.registration_confirmed', 'event.registration_waitlisted', 'event.registration_promoted',
'event.registration_cancelled', 'event.status_changed', 'event.reminder',
'booking.confirmed', 'booking.check_in_reminder', 'booking.checked_in',
'booking.checked_out', 'booking.cancelled'
)),
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
recipient_type text NOT NULL DEFAULT 'admin' CHECK (recipient_type IN ('admin', 'participant', 'guest', 'instructor', 'specific_user')),
recipient_config jsonb NOT NULL DEFAULT '{}',
subject_template text,
message_template text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_module_notification_rules_lookup
ON public.module_notification_rules(account_id, module, trigger_event)
WHERE is_active = true;
ALTER TABLE public.module_notification_rules ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.module_notification_rules FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_notification_rules TO authenticated;
GRANT ALL ON public.module_notification_rules TO service_role;
CREATE POLICY module_notification_rules_select ON public.module_notification_rules
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
CREATE POLICY module_notification_rules_mutate ON public.module_notification_rules
FOR ALL TO authenticated USING (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
) WITH CHECK (
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
);
-- Pending notifications queue
CREATE TABLE IF NOT EXISTS public.pending_module_notifications (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
trigger_event text NOT NULL,
entity_id uuid NOT NULL,
context jsonb NOT NULL DEFAULT '{}',
processed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_pending_module_notifications_unprocessed
ON public.pending_module_notifications(created_at)
WHERE processed_at IS NULL;
ALTER TABLE public.pending_module_notifications ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.pending_module_notifications FROM authenticated, service_role;
GRANT SELECT ON public.pending_module_notifications TO authenticated;
GRANT ALL ON public.pending_module_notifications TO service_role;
CREATE POLICY pending_module_notifications_select ON public.pending_module_notifications
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
-- Enqueue helper
CREATE OR REPLACE FUNCTION public.enqueue_module_notification(
p_account_id uuid,
p_module text,
p_trigger_event text,
p_entity_id uuid,
p_context jsonb DEFAULT '{}'
)
RETURNS void
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = ''
AS $$
BEGIN
INSERT INTO public.pending_module_notifications
(account_id, module, trigger_event, entity_id, context)
VALUES
(p_account_id, p_module, p_trigger_event, p_entity_id, p_context);
END;
$$;
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO authenticated;
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO service_role;

View File

@@ -0,0 +1,40 @@
/*
* -------------------------------------------------------
* Shared Communications Table for Courses, Events, Bookings
* Tracks email, phone, letter, meeting, note, sms entries
* -------------------------------------------------------
*/
CREATE TABLE IF NOT EXISTS public.module_communications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
entity_id uuid NOT NULL,
type text NOT NULL DEFAULT 'note' CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
direction text NOT NULL DEFAULT 'internal' CHECK (direction IN ('inbound', 'outbound', 'internal')),
subject text,
body text,
email_to text,
email_cc text,
attachment_paths text[],
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS ix_module_communications_entity
ON public.module_communications(module, entity_id, created_at DESC);
CREATE INDEX IF NOT EXISTS ix_module_communications_account
ON public.module_communications(account_id, module, created_at DESC);
ALTER TABLE public.module_communications ENABLE ROW LEVEL SECURITY;
REVOKE ALL ON public.module_communications FROM authenticated, service_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_communications TO authenticated;
GRANT ALL ON public.module_communications TO service_role;
CREATE POLICY module_communications_select ON public.module_communications
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
CREATE POLICY module_communications_mutate ON public.module_communications
FOR ALL TO authenticated USING (public.has_role_on_account(account_id))
WITH CHECK (public.has_role_on_account(account_id));

View File

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

View File

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

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