Compare commits
14 Commits
f43770999f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbe6652a1 | ||
|
|
ad01ecb8b9 | ||
|
|
7cfd88f1c3 | ||
|
|
1215e351c1 | ||
|
|
9f83b5cc75 | ||
|
|
5b169a381f | ||
|
|
9d5fe58ee3 | ||
| 4d538a5668 | |||
|
|
5c5aaabae5 | ||
|
|
b6092adc3e | ||
|
|
124c6a632a | ||
|
|
d4acc3ba22 | ||
|
|
28188bb3a6 | ||
|
|
f10a34c505 |
@@ -1 +1 @@
|
||||
[]
|
||||
[]
|
||||
@@ -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
19
.env.local.example
Normal 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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
29
Dockerfile
29
Dockerfile
@@ -1,18 +1,15 @@
|
||||
FROM node:22-alpine AS base
|
||||
# node:22-slim (Debian/glibc) is ~2x faster for Next.js builds vs Alpine/musl
|
||||
FROM node:22-slim AS base
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
# --- Install + Build in one stage ---
|
||||
# --- Install + Build ---
|
||||
FROM base AS builder
|
||||
# CACHE_BUST: change this value to force a full rebuild (busts Docker layer cache)
|
||||
ARG CACHE_BUST=14
|
||||
RUN echo "Cache bust: ${CACHE_BUST}"
|
||||
COPY . .
|
||||
RUN pnpm install --no-frozen-lockfile
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --prefer-offline
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# NEXT_PUBLIC_* vars are baked into the Next.js build at compile time.
|
||||
# Pass them as build args so the same Dockerfile works for any environment.
|
||||
ARG NEXT_PUBLIC_CI=false
|
||||
ARG NEXT_PUBLIC_SITE_URL=https://myeasycms.de
|
||||
ARG NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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') },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
87
apps/web/app/api/internal/cron/member-jobs/route.ts
Normal file
87
apps/web/app/api/internal/cron/member-jobs/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -146,7 +146,9 @@
|
||||
"next": "Weiter",
|
||||
"type": "Typ",
|
||||
"date": "Datum",
|
||||
"description": "Beschreibung"
|
||||
"description": "Beschreibung",
|
||||
"searchPlaceholder": "Rechnung suchen...",
|
||||
"all": "Alle"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Entwurf",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
BIN
apps/web/public/images/posts/digitale-verwaltung.webp
Normal file
BIN
apps/web/public/images/posts/digitale-verwaltung.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/web/public/images/posts/dsgvo-vereine.webp
Normal file
BIN
apps/web/public/images/posts/dsgvo-vereine.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
BIN
apps/web/public/images/posts/mitgliederverwaltung.webp
Normal file
BIN
apps/web/public/images/posts/mitgliederverwaltung.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
apps/web/public/images/posts/sepa-lastschrift.webp
Normal file
BIN
apps/web/public/images/posts/sepa-lastschrift.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
BIN
apps/web/public/images/posts/vereinswebsite.webp
Normal file
BIN
apps/web/public/images/posts/vereinswebsite.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
$$;
|
||||
260
apps/web/supabase/migrations/20260416000007_member_audit_log.sql
Normal file
260
apps/web/supabase/migrations/20260416000007_member_audit_log.sql
Normal 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;
|
||||
@@ -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;
|
||||
116
apps/web/supabase/migrations/20260416000009_member_tags.sql
Normal file
116
apps/web/supabase/migrations/20260416000009_member_tags.sql
Normal 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();
|
||||
274
apps/web/supabase/migrations/20260416000010_member_merge.sql
Normal file
274
apps/web/supabase/migrations/20260416000010_member_merge.sql
Normal 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;
|
||||
170
apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
Normal file
170
apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Enable btree_gist extension (required by booking overlap exclusion constraint)
|
||||
-- Separated into own migration to avoid "multiple commands in prepared statement" error
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
@@ -0,0 +1,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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,54 @@
|
||||
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
||||
p_account_id uuid,
|
||||
p_room_id uuid,
|
||||
p_guest_id uuid DEFAULT NULL,
|
||||
p_check_in date DEFAULT NULL,
|
||||
p_check_out date DEFAULT NULL,
|
||||
p_adults integer DEFAULT 1,
|
||||
p_children integer DEFAULT 0,
|
||||
p_status text DEFAULT 'confirmed',
|
||||
p_total_price numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $fn$
|
||||
DECLARE
|
||||
v_room record;
|
||||
v_computed_price numeric(10,2);
|
||||
v_booking_id uuid;
|
||||
BEGIN
|
||||
SELECT * INTO v_room FROM public.rooms WHERE id = p_room_id FOR UPDATE;
|
||||
IF v_room IS NULL THEN
|
||||
RAISE EXCEPTION 'Room % not found', p_room_id USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
||||
RAISE EXCEPTION 'check_in and check_out dates are required' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF p_check_out <= p_check_in THEN
|
||||
RAISE EXCEPTION 'check_out must be after check_in' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
IF (p_adults + p_children) > v_room.capacity THEN
|
||||
RAISE EXCEPTION 'Total guests exceed room capacity' USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
IF p_total_price IS NOT NULL THEN
|
||||
v_computed_price := p_total_price;
|
||||
ELSE
|
||||
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
||||
END IF;
|
||||
|
||||
INSERT INTO public.bookings (
|
||||
account_id, room_id, guest_id, check_in, check_out,
|
||||
adults, children, status, total_price, notes
|
||||
) VALUES (
|
||||
p_account_id, p_room_id, p_guest_id, p_check_in, p_check_out,
|
||||
p_adults, p_children, p_status, v_computed_price, p_notes
|
||||
)
|
||||
RETURNING id INTO v_booking_id;
|
||||
|
||||
RETURN v_booking_id;
|
||||
END;
|
||||
$fn$;
|
||||
@@ -0,0 +1 @@
|
||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
||||
@@ -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$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1,93 @@
|
||||
-- =====================================================
|
||||
-- Module Notification Rules & Queue
|
||||
-- Shared notification infrastructure for courses, events, bookings.
|
||||
-- =====================================================
|
||||
|
||||
-- Notification rules: define what triggers notifications
|
||||
CREATE TABLE IF NOT EXISTS public.module_notification_rules (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||
trigger_event text NOT NULL CHECK (trigger_event IN (
|
||||
'course.participant_enrolled', 'course.participant_waitlisted', 'course.participant_promoted',
|
||||
'course.participant_cancelled', 'course.status_changed', 'course.session_reminder',
|
||||
'event.registration_confirmed', 'event.registration_waitlisted', 'event.registration_promoted',
|
||||
'event.registration_cancelled', 'event.status_changed', 'event.reminder',
|
||||
'booking.confirmed', 'booking.check_in_reminder', 'booking.checked_in',
|
||||
'booking.checked_out', 'booking.cancelled'
|
||||
)),
|
||||
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
|
||||
recipient_type text NOT NULL DEFAULT 'admin' CHECK (recipient_type IN ('admin', 'participant', 'guest', 'instructor', 'specific_user')),
|
||||
recipient_config jsonb NOT NULL DEFAULT '{}',
|
||||
subject_template text,
|
||||
message_template text NOT NULL,
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_module_notification_rules_lookup
|
||||
ON public.module_notification_rules(account_id, module, trigger_event)
|
||||
WHERE is_active = true;
|
||||
|
||||
ALTER TABLE public.module_notification_rules ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.module_notification_rules FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_notification_rules TO authenticated;
|
||||
GRANT ALL ON public.module_notification_rules TO service_role;
|
||||
|
||||
CREATE POLICY module_notification_rules_select ON public.module_notification_rules
|
||||
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||
|
||||
CREATE POLICY module_notification_rules_mutate ON public.module_notification_rules
|
||||
FOR ALL TO authenticated USING (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
) WITH CHECK (
|
||||
public.has_permission(auth.uid(), account_id, 'settings.manage'::public.app_permissions)
|
||||
);
|
||||
|
||||
-- Pending notifications queue
|
||||
CREATE TABLE IF NOT EXISTS public.pending_module_notifications (
|
||||
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||
trigger_event text NOT NULL,
|
||||
entity_id uuid NOT NULL,
|
||||
context jsonb NOT NULL DEFAULT '{}',
|
||||
processed_at timestamptz,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_pending_module_notifications_unprocessed
|
||||
ON public.pending_module_notifications(created_at)
|
||||
WHERE processed_at IS NULL;
|
||||
|
||||
ALTER TABLE public.pending_module_notifications ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.pending_module_notifications FROM authenticated, service_role;
|
||||
GRANT SELECT ON public.pending_module_notifications TO authenticated;
|
||||
GRANT ALL ON public.pending_module_notifications TO service_role;
|
||||
|
||||
CREATE POLICY pending_module_notifications_select ON public.pending_module_notifications
|
||||
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||
|
||||
-- Enqueue helper
|
||||
CREATE OR REPLACE FUNCTION public.enqueue_module_notification(
|
||||
p_account_id uuid,
|
||||
p_module text,
|
||||
p_trigger_event text,
|
||||
p_entity_id uuid,
|
||||
p_context jsonb DEFAULT '{}'
|
||||
)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO public.pending_module_notifications
|
||||
(account_id, module, trigger_event, entity_id, context)
|
||||
VALUES
|
||||
(p_account_id, p_module, p_trigger_event, p_entity_id, p_context);
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.enqueue_module_notification(uuid, text, text, uuid, jsonb) TO service_role;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* -------------------------------------------------------
|
||||
* Shared Communications Table for Courses, Events, Bookings
|
||||
* Tracks email, phone, letter, meeting, note, sms entries
|
||||
* -------------------------------------------------------
|
||||
*/
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.module_communications (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||
module text NOT NULL CHECK (module IN ('courses', 'events', 'bookings')),
|
||||
entity_id uuid NOT NULL,
|
||||
type text NOT NULL DEFAULT 'note' CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
|
||||
direction text NOT NULL DEFAULT 'internal' CHECK (direction IN ('inbound', 'outbound', 'internal')),
|
||||
subject text,
|
||||
body text,
|
||||
email_to text,
|
||||
email_cc text,
|
||||
attachment_paths text[],
|
||||
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_module_communications_entity
|
||||
ON public.module_communications(module, entity_id, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_module_communications_account
|
||||
ON public.module_communications(account_id, module, created_at DESC);
|
||||
|
||||
ALTER TABLE public.module_communications ENABLE ROW LEVEL SECURITY;
|
||||
REVOKE ALL ON public.module_communications FROM authenticated, service_role;
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON public.module_communications TO authenticated;
|
||||
GRANT ALL ON public.module_communications TO service_role;
|
||||
|
||||
CREATE POLICY module_communications_select ON public.module_communications
|
||||
FOR SELECT TO authenticated USING (public.has_role_on_account(account_id));
|
||||
|
||||
CREATE POLICY module_communications_mutate ON public.module_communications
|
||||
FOR ALL TO authenticated USING (public.has_role_on_account(account_id))
|
||||
WITH CHECK (public.has_role_on_account(account_id));
|
||||
121
apps/web/supabase/tests/database/member-audit.test.sql
Normal file
121
apps/web/supabase/tests/database/member-audit.test.sql
Normal file
@@ -0,0 +1,121 @@
|
||||
begin;
|
||||
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
select no_plan();
|
||||
|
||||
-- =====================================================
|
||||
-- Audit Trigger & Version Tests
|
||||
-- Verifies triggers fire correctly on member changes
|
||||
-- =====================================================
|
||||
|
||||
-- Setup
|
||||
select tests.create_supabase_user('audit_owner', 'audit_owner@test.com');
|
||||
select makerkit.set_identifier('audit_owner', 'audit_owner@test.com');
|
||||
|
||||
set local role service_role;
|
||||
select public.create_team_account('Audit Verein', tests.get_supabase_uid('audit_owner'));
|
||||
|
||||
set local role postgres;
|
||||
insert into public.role_permissions (role, permission)
|
||||
values ('owner', 'members.write')
|
||||
on conflict do nothing;
|
||||
|
||||
-- Get account ID
|
||||
select makerkit.authenticate_as('audit_owner');
|
||||
|
||||
-- Insert a member (triggers audit INSERT)
|
||||
set local role service_role;
|
||||
insert into public.members (
|
||||
account_id, first_name, last_name, status, entry_date, member_number,
|
||||
created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'audit-verein' limit 1),
|
||||
'Audit', 'Test', 'active', current_date, '0001',
|
||||
tests.get_supabase_uid('audit_owner'),
|
||||
tests.get_supabase_uid('audit_owner')
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: INSERT creates audit entry
|
||||
-- -------------------------------------------------------
|
||||
select isnt_empty(
|
||||
$$ select * from public.member_audit_log
|
||||
where member_id = (select id from public.members where first_name = 'Audit' limit 1)
|
||||
and action = 'created' $$,
|
||||
'Member INSERT creates audit entry with action=created'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Version starts at 1
|
||||
-- -------------------------------------------------------
|
||||
select is(
|
||||
(select version from public.members where first_name = 'Audit' limit 1),
|
||||
1,
|
||||
'Initial version is 1'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: UPDATE increments version
|
||||
-- -------------------------------------------------------
|
||||
update public.members
|
||||
set first_name = 'AuditUpdated'
|
||||
where first_name = 'Audit';
|
||||
|
||||
select is(
|
||||
(select version from public.members where first_name = 'AuditUpdated' limit 1),
|
||||
2,
|
||||
'Version incremented to 2 after update'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: UPDATE creates audit entry with field diff
|
||||
-- -------------------------------------------------------
|
||||
select isnt_empty(
|
||||
$$ select * from public.member_audit_log
|
||||
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
|
||||
and action = 'updated'
|
||||
and changes ? 'first_name' $$,
|
||||
'Member UPDATE creates audit entry with first_name change diff'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Status change creates status_changed audit entry
|
||||
-- -------------------------------------------------------
|
||||
update public.members
|
||||
set status = 'inactive'
|
||||
where first_name = 'AuditUpdated';
|
||||
|
||||
select isnt_empty(
|
||||
$$ select * from public.member_audit_log
|
||||
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
|
||||
and action = 'status_changed' $$,
|
||||
'Status change creates audit entry with action=status_changed'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Archive creates archived audit entry
|
||||
-- -------------------------------------------------------
|
||||
update public.members
|
||||
set is_archived = true
|
||||
where first_name = 'AuditUpdated';
|
||||
|
||||
select isnt_empty(
|
||||
$$ select * from public.member_audit_log
|
||||
where member_id = (select id from public.members where first_name = 'AuditUpdated' limit 1)
|
||||
and action = 'archived' $$,
|
||||
'Archive creates audit entry with action=archived'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Multiple updates increment version correctly
|
||||
-- -------------------------------------------------------
|
||||
select is(
|
||||
(select version from public.members where first_name = 'AuditUpdated' limit 1),
|
||||
4,
|
||||
'Version is 4 after 3 updates (initial insert + 3 updates)'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
|
||||
rollback;
|
||||
186
apps/web/supabase/tests/database/member-constraints.test.sql
Normal file
186
apps/web/supabase/tests/database/member-constraints.test.sql
Normal file
@@ -0,0 +1,186 @@
|
||||
begin;
|
||||
|
||||
create extension "basejump-supabase_test_helpers" version '0.0.6';
|
||||
|
||||
select no_plan();
|
||||
|
||||
-- =====================================================
|
||||
-- CHECK Constraint Tests
|
||||
-- =====================================================
|
||||
|
||||
-- Setup
|
||||
select tests.create_supabase_user('constraint_owner', 'constraint_owner@test.com');
|
||||
select makerkit.set_identifier('constraint_owner', 'constraint_owner@test.com');
|
||||
|
||||
set local role service_role;
|
||||
select public.create_team_account('Constraint Verein', tests.get_supabase_uid('constraint_owner'));
|
||||
|
||||
set local role postgres;
|
||||
insert into public.role_permissions (role, permission)
|
||||
values ('owner', 'members.write')
|
||||
on conflict do nothing;
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: DOB in future rejected
|
||||
-- -------------------------------------------------------
|
||||
set local role service_role;
|
||||
|
||||
select throws_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, date_of_birth, status, entry_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'Future', 'Baby', current_date + interval '1 day', 'active', current_date,
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'new row for relation "members" violates check constraint "chk_members_dob_not_future"',
|
||||
'Future date of birth is rejected'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Exit date before entry date rejected
|
||||
-- -------------------------------------------------------
|
||||
select throws_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, status, entry_date, exit_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'Wrong', 'Dates', 'resigned', '2024-06-01', '2024-01-01',
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'new row for relation "members" violates check constraint "chk_members_exit_after_entry"',
|
||||
'Exit date before entry date is rejected'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Entry date in future rejected
|
||||
-- -------------------------------------------------------
|
||||
select throws_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, status, entry_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'Future', 'Entry', 'active', current_date + interval '2 days',
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'new row for relation "members" violates check constraint "chk_members_entry_not_future"',
|
||||
'Future entry date is rejected'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Valid member insert succeeds
|
||||
-- -------------------------------------------------------
|
||||
select lives_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, status, entry_date,
|
||||
date_of_birth, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'Valid', 'Member', 'active', '2024-01-15', '1990-05-20',
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'Valid member with correct dates succeeds'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Duplicate email in same account rejected
|
||||
-- -------------------------------------------------------
|
||||
insert into public.members (
|
||||
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'First', 'Email', 'duplicate@test.com', 'active', current_date,
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
);
|
||||
|
||||
select throws_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'Second', 'Email', 'duplicate@test.com', 'active', current_date,
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'duplicate key value violates unique constraint "uix_members_email_per_account"',
|
||||
'Duplicate email in same account is rejected'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: NULL emails allowed (multiple)
|
||||
-- -------------------------------------------------------
|
||||
select lives_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'No', 'Email1', null, 'active', current_date,
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'NULL email is allowed'
|
||||
);
|
||||
|
||||
select lives_ok(
|
||||
$test$ insert into public.members (
|
||||
account_id, first_name, last_name, email, status, entry_date, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'No', 'Email2', null, 'active', current_date,
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
) $test$,
|
||||
'Multiple NULL emails allowed'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Invalid IBAN rejected on sepa_mandates
|
||||
-- -------------------------------------------------------
|
||||
insert into public.members (
|
||||
account_id, first_name, last_name, status, entry_date, member_number, created_by, updated_by
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'SEPA', 'Test', 'active', current_date, 'SEPA01',
|
||||
tests.get_supabase_uid('constraint_owner'), tests.get_supabase_uid('constraint_owner')
|
||||
);
|
||||
|
||||
select throws_ok(
|
||||
$test$ insert into public.sepa_mandates (
|
||||
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
|
||||
) values (
|
||||
(select id from public.members where first_name = 'SEPA' limit 1),
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'MANDATE-001', 'invalid-iban', 'Test Holder', current_date, 'active'
|
||||
) $test$,
|
||||
'new row for relation "sepa_mandates" violates check constraint "chk_sepa_iban_format"',
|
||||
'Invalid IBAN format is rejected'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Valid IBAN accepted
|
||||
-- -------------------------------------------------------
|
||||
select lives_ok(
|
||||
$test$ insert into public.sepa_mandates (
|
||||
member_id, account_id, mandate_reference, iban, account_holder, mandate_date, status
|
||||
) values (
|
||||
(select id from public.members where first_name = 'SEPA' limit 1),
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'MANDATE-002', 'DE89370400440532013000', 'Test Holder', current_date, 'active'
|
||||
) $test$,
|
||||
'Valid German IBAN is accepted'
|
||||
);
|
||||
|
||||
-- -------------------------------------------------------
|
||||
-- Test: Negative dues amount rejected
|
||||
-- -------------------------------------------------------
|
||||
select throws_ok(
|
||||
$test$ insert into public.dues_categories (
|
||||
account_id, name, amount
|
||||
) values (
|
||||
(select id from public.accounts where slug = 'constraint-verein' limit 1),
|
||||
'Negative Fee', -50
|
||||
) $test$,
|
||||
'new row for relation "dues_categories" violates check constraint "chk_dues_amount_non_negative"',
|
||||
'Negative dues amount is rejected'
|
||||
);
|
||||
|
||||
select * from finish();
|
||||
|
||||
rollback;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user