feat/members-redesign #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.
|
||||
|
||||
|
||||
23
Dockerfile
23
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
|
||||
@@ -35,18 +32,18 @@ ENV NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||
ENV NEXT_PUBLIC_BILLING_PROVIDER=${NEXT_PUBLIC_BILLING_PROVIDER}
|
||||
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,7 +4,9 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('Member Management', () => {
|
||||
test('create member, edit, search, filter by status', async ({ page: _page }) => {
|
||||
test('create member, edit, search, filter by status', async ({
|
||||
page: _page,
|
||||
}) => {
|
||||
await page.goto('/auth/sign-in');
|
||||
await page.fill('input[name="email"]', 'test@example.com');
|
||||
await page.fill('input[name="password"]', 'testpassword123');
|
||||
|
||||
@@ -457,10 +457,7 @@ export function FeatureCarousel() {
|
||||
const [active, setActive] = useState(0);
|
||||
const slide = SLIDES[active]!;
|
||||
|
||||
const next = useCallback(
|
||||
() => setActive((i) => (i + 1) % SLIDES.length),
|
||||
[],
|
||||
);
|
||||
const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []);
|
||||
const prev = useCallback(
|
||||
() => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length),
|
||||
[],
|
||||
|
||||
@@ -8,10 +8,7 @@ import { Check, ExternalLink, X } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
} from '@kit/ui/card';
|
||||
import { Card, CardContent } from '@kit/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -265,7 +262,10 @@ function PriceBar({
|
||||
<div className="bg-muted h-6 overflow-hidden rounded-md">
|
||||
{available ? (
|
||||
<div
|
||||
className={cn('h-full rounded-md transition-all duration-500', color)}
|
||||
className={cn(
|
||||
'h-full rounded-md transition-all duration-500',
|
||||
color,
|
||||
)}
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
minWidth: pct > 0 ? 4 : 0,
|
||||
@@ -323,7 +323,10 @@ export function PricingCalculator() {
|
||||
<div className="mx-auto w-full max-w-4xl space-y-0">
|
||||
{/* ── Header ── */}
|
||||
<div className="bg-primary rounded-t-2xl px-8 py-7">
|
||||
<Badge variant="outline" className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] uppercase tracking-widest">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary-foreground/20 text-primary-foreground/80 mb-1 text-[10px] tracking-widest uppercase"
|
||||
>
|
||||
Preisvergleich
|
||||
</Badge>
|
||||
<h2 className="font-heading text-primary-foreground text-2xl font-bold tracking-tight">
|
||||
@@ -376,7 +379,7 @@ export function PricingCalculator() {
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Card className="bg-primary/5 border-primary/10 flex flex-1 items-center justify-between p-4">
|
||||
<div>
|
||||
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
|
||||
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
|
||||
Ihr MYeasyCMS-Tarif
|
||||
</div>
|
||||
<div className="font-heading text-primary text-xl font-bold">
|
||||
@@ -460,21 +463,20 @@ export function PricingCalculator() {
|
||||
{bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Card className="bg-primary/5 border-primary/10 p-5 text-center">
|
||||
<div className="text-primary text-[10px] font-bold uppercase tracking-wider">
|
||||
<div className="text-primary text-[10px] font-bold tracking-wider uppercase">
|
||||
Ersparnis vs. {bestSaving.name.split(' ')[0]}
|
||||
</div>
|
||||
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||
{fmt((bestSaving.p - tier.price) * 12)} €
|
||||
</div>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
pro Jahr (
|
||||
{Math.round((1 - tier.price / bestSaving.p) * 100)}%
|
||||
pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}%
|
||||
günstiger)
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-muted/50 p-5 text-center">
|
||||
<div className="text-muted-foreground text-[10px] font-bold uppercase tracking-wider">
|
||||
<div className="text-muted-foreground text-[10px] font-bold tracking-wider uppercase">
|
||||
Preis pro Mitglied
|
||||
</div>
|
||||
<div className="font-heading text-primary mt-1 text-3xl font-bold">
|
||||
@@ -521,9 +523,8 @@ export function PricingCalculator() {
|
||||
{USP_FEATURES.map((f, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell className="font-medium">{f.label}</TableCell>
|
||||
{(
|
||||
['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const
|
||||
).map((col) => (
|
||||
{(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map(
|
||||
(col) => (
|
||||
<TableCell
|
||||
key={col}
|
||||
className={cn(
|
||||
@@ -533,7 +534,8 @@ export function PricingCalculator() {
|
||||
>
|
||||
<FeatureCell value={f[col]} />
|
||||
</TableCell>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -82,7 +82,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
||||
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`;
|
||||
|
||||
const bookings = await api.listBookings(acct.id, {
|
||||
const bookings = await api.bookings.list(acct.id, {
|
||||
from: monthStart,
|
||||
to: monthEnd,
|
||||
page: 1,
|
||||
|
||||
@@ -34,7 +34,7 @@ export default async function GuestsPage({ params }: PageProps) {
|
||||
}
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const guests = await api.listGuests(acct.id);
|
||||
const guests = await api.guests.list(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('guests.title')}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function RoomsPage({ params }: PageProps) {
|
||||
}
|
||||
|
||||
const api = createBookingManagementApi(client);
|
||||
const rooms = await api.listRooms(acct.id);
|
||||
const rooms = await api.rooms.list(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('rooms.title')}>
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -40,8 +40,8 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
||||
const t = await getTranslations('courses');
|
||||
|
||||
const [course, participants] = await Promise.all([
|
||||
api.getCourse(courseId),
|
||||
api.getParticipants(courseId),
|
||||
api.courses.getById(courseId),
|
||||
api.enrollment.listParticipants(courseId),
|
||||
]);
|
||||
|
||||
if (!course) return <AccountNotFound />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function CategoriesPage({ params }: PageProps) {
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const categories = await api.listCategories(acct.id);
|
||||
const categories = await api.referenceData.listCategories(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>
|
||||
|
||||
@@ -30,7 +30,7 @@ export default async function InstructorsPage({ params }: PageProps) {
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const instructors = await api.listInstructors(acct.id);
|
||||
const instructors = await api.referenceData.listInstructors(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createCourseManagementApi(client);
|
||||
const locations = await api.listLocations(acct.id);
|
||||
const locations = await api.referenceData.listLocations(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('pages.locationsTitle')}>
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -36,8 +36,8 @@ export default async function EventDetailPage({ params }: PageProps) {
|
||||
const t = await getTranslations('cms.events');
|
||||
|
||||
const [event, registrations] = await Promise.all([
|
||||
api.getEvent(eventId),
|
||||
api.getRegistrations(eventId),
|
||||
api.events.getById(eventId),
|
||||
api.registrations.list(eventId),
|
||||
]);
|
||||
|
||||
if (!event) return <div>{t('notFound')}</div>;
|
||||
|
||||
@@ -29,7 +29,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createEventManagementApi(client);
|
||||
const passes = await api.listHolidayPasses(acct.id);
|
||||
const passes = await api.holidayPasses.list(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell account={account} title={t('holidayPasses')}>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(memberId);
|
||||
const { query } = createMemberServices(client);
|
||||
const member = await query.getById(acct.id, memberId);
|
||||
if (!member) return <div>{t('detail.notFound')}</div>;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { MemberDetailView } from '@kit/member-management/components';
|
||||
import { MemberDetailTabs } from '@kit/member-management/components';
|
||||
import { createMemberServices } from '@kit/member-management/services';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string; memberId: string }>;
|
||||
@@ -19,26 +18,18 @@ export default async function MemberDetailPage({ params }: Props) {
|
||||
.single();
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const member = await api.getMember(memberId);
|
||||
const { query, organization } = createMemberServices(client);
|
||||
const member = await query.getById(acct.id, memberId);
|
||||
if (!member) return <AccountNotFound />;
|
||||
|
||||
// Fetch sub-entities in parallel
|
||||
const [roles, honors, mandates] = await Promise.all([
|
||||
api.listMemberRoles(memberId),
|
||||
api.listMemberHonors(memberId),
|
||||
api.listMandates(memberId),
|
||||
organization.listMemberRoles(memberId),
|
||||
organization.listMemberHonors(memberId),
|
||||
organization.listMandates(memberId),
|
||||
]);
|
||||
|
||||
const memberName = `${String(member.first_name)} ${String(member.last_name)}`;
|
||||
|
||||
return (
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title={memberName}
|
||||
breadcrumbValues={{ [memberId]: memberName }}
|
||||
>
|
||||
<MemberDetailView
|
||||
<MemberDetailTabs
|
||||
member={member}
|
||||
account={account}
|
||||
accountId={acct.id}
|
||||
@@ -46,6 +37,5 @@ export default async function MemberDetailPage({ params }: Props) {
|
||||
honors={honors}
|
||||
mandates={mandates}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
|
||||
import {
|
||||
FileDown,
|
||||
FileUp,
|
||||
IdCard,
|
||||
KeyRound,
|
||||
LayoutList,
|
||||
Settings,
|
||||
Tag,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
MemberStatsBar,
|
||||
MemberCommandPalette,
|
||||
} from '@kit/member-management/components';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@kit/ui/dropdown-menu';
|
||||
import { PageBody } from '@kit/ui/page';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface MembersCmsLayoutClientProps {
|
||||
header: ReactNode;
|
||||
children: ReactNode;
|
||||
account: string;
|
||||
accountId: string;
|
||||
stats: {
|
||||
total: number;
|
||||
active: number;
|
||||
pending: number;
|
||||
newThisYear: number;
|
||||
pendingApplications: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function MembersCmsLayoutClient({
|
||||
header,
|
||||
children,
|
||||
account,
|
||||
accountId,
|
||||
stats,
|
||||
}: MembersCmsLayoutClientProps) {
|
||||
const pathname = usePathname();
|
||||
const basePath = `/home/${account}/members-cms`;
|
||||
|
||||
const isOnMembersTab =
|
||||
pathname.endsWith('/members-cms') ||
|
||||
pathname.includes('/members-cms/new') ||
|
||||
/\/members-cms\/[^/]+$/.test(pathname);
|
||||
const isOnApplicationsTab = pathname.includes('/applications');
|
||||
const isOnSubPage =
|
||||
pathname.includes('/import') ||
|
||||
pathname.includes('/edit') ||
|
||||
(/\/members-cms\/[^/]+$/.test(pathname) &&
|
||||
!pathname.endsWith('/members-cms'));
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
|
||||
<PageBody>
|
||||
<div className="space-y-4">
|
||||
{/* Stats bar — only on main views */}
|
||||
{!isOnSubPage && <MemberStatsBar stats={stats} />}
|
||||
|
||||
{/* Tab navigation + settings */}
|
||||
{!isOnSubPage && (
|
||||
<div className="flex items-center justify-between border-b">
|
||||
<nav className="-mb-px flex gap-4">
|
||||
<TabLink
|
||||
href={basePath}
|
||||
active={isOnMembersTab && !isOnApplicationsTab}
|
||||
>
|
||||
<Users className="size-4" />
|
||||
Mitglieder
|
||||
</TabLink>
|
||||
<TabLink
|
||||
href={`${basePath}/applications`}
|
||||
active={isOnApplicationsTab}
|
||||
>
|
||||
<FileUp className="size-4" />
|
||||
Aufnahmeanträge
|
||||
{stats.pendingApplications > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="ml-1 h-5 min-w-5 px-1 text-xs"
|
||||
>
|
||||
{stats.pendingApplications}
|
||||
</Badge>
|
||||
)}
|
||||
</TabLink>
|
||||
</nav>
|
||||
|
||||
<SettingsMenu basePath={basePath} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<MemberCommandPalette account={account} accountId={accountId} />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TabLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 border-b-2 px-1 pb-2 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground border-transparent',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsMenu({ basePath }: { basePath: string }) {
|
||||
const router = useRouter();
|
||||
|
||||
const navigate = (path: string) => () => router.push(path);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="hover:bg-muted inline-flex size-7 items-center justify-center rounded-lg"
|
||||
data-test="members-settings-menu"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-52">
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/dues`)}>
|
||||
<LayoutList className="mr-2 size-4" />
|
||||
Beitragskategorien
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/departments`)}>
|
||||
<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
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/invitations`)}>
|
||||
<KeyRound className="mr-2 size-4" />
|
||||
Portal-Einladungen
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={navigate(`${basePath}/import`)}>
|
||||
<FileDown className="mr-2 size-4" />
|
||||
Import
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||
import { createMemberServices } from '@kit/member-management/services';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -14,7 +11,7 @@ interface Props {
|
||||
export default async function ApplicationsPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
const t = await getTranslations('members');
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
@@ -22,20 +19,14 @@ export default async function ApplicationsPage({ params }: Props) {
|
||||
.single();
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const applications = await api.listApplications(acct.id);
|
||||
const { workflow } = createMemberServices(client);
|
||||
const applications = await workflow.listApplications(acct.id);
|
||||
|
||||
return (
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title={t('nav.applications')}
|
||||
description={t('applications.subtitle')}
|
||||
>
|
||||
<ApplicationWorkflow
|
||||
applications={applications}
|
||||
accountId={acct.id}
|
||||
account={account}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
47
apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
Normal file
47
apps/web/app/[locale]/home/[account]/members-cms/layout.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { createMemberServices } from '@kit/member-management/services';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header';
|
||||
|
||||
import { MembersCmsLayoutClient } from './_components/members-cms-layout-client';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
params: Promise<{ account: string }>;
|
||||
}
|
||||
|
||||
export default async function MembersCmsLayout({ children, params }: Props) {
|
||||
const { account } = await params;
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
.eq('slug', account)
|
||||
.single();
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const { query } = createMemberServices(client);
|
||||
const stats = await query.getQuickStats(acct.id);
|
||||
|
||||
return (
|
||||
<MembersCmsLayoutClient
|
||||
header={
|
||||
<TeamAccountLayoutPageHeader
|
||||
account={account}
|
||||
title="Mitglieder"
|
||||
description={`${stats.total} Mitglieder verwalten`}
|
||||
/>
|
||||
}
|
||||
account={account}
|
||||
accountId={acct.id}
|
||||
stats={stats}
|
||||
>
|
||||
{children}
|
||||
</MembersCmsLayoutClient>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { CreateMemberForm } from '@kit/member-management/components';
|
||||
import { MemberCreateWizard } from '@kit/member-management/components';
|
||||
import { createMemberServices } from '@kit/member-management/services';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ account: string }>;
|
||||
@@ -13,7 +10,6 @@ interface Props {
|
||||
|
||||
export default async function NewMemberPage({ params }: Props) {
|
||||
const { account } = await params;
|
||||
const t = await getTranslations('members');
|
||||
const client = getSupabaseServerClient();
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
@@ -22,16 +18,11 @@ 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 (
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title={t('form.newMemberTitle')}
|
||||
description={t('form.newMemberDescription')}
|
||||
>
|
||||
<CreateMemberForm
|
||||
<MemberCreateWizard
|
||||
accountId={acct.id}
|
||||
account={account}
|
||||
duesCategories={(duesCategories ?? []).map(
|
||||
@@ -42,6 +33,5 @@ export default async function NewMemberPage({ params }: Props) {
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { MembersDataTable } from '@kit/member-management/components';
|
||||
import { MembersListView } from '@kit/member-management/components';
|
||||
import { createMemberServices } from '@kit/member-management/services';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AccountNotFound } from '~/components/account-not-found';
|
||||
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@@ -18,7 +15,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
||||
const { account } = await params;
|
||||
const search = await searchParams;
|
||||
const client = getSupabaseServerClient();
|
||||
const t = await getTranslations('members');
|
||||
|
||||
const { data: acct } = await client
|
||||
.from('accounts')
|
||||
.select('id')
|
||||
@@ -26,23 +23,66 @@ 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;
|
||||
const result = await api.listMembers(acct.id, {
|
||||
|
||||
// Parse multi-status filter
|
||||
const statusParam = search.status;
|
||||
const statusFilter = statusParam
|
||||
? Array.isArray(statusParam)
|
||||
? statusParam
|
||||
: statusParam.split(',')
|
||||
: undefined;
|
||||
|
||||
const result = await query.search({
|
||||
accountId: acct.id,
|
||||
search: search.q as string,
|
||||
status: search.status as string,
|
||||
status: statusFilter as any,
|
||||
duesCategoryId: search.duesCategoryId as string,
|
||||
sortBy: (search.sortBy as string) ?? 'last_name',
|
||||
sortDirection: (search.sortDirection as 'asc' | 'desc') ?? 'asc',
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
});
|
||||
const duesCategories = await api.listDuesCategories(acct.id);
|
||||
|
||||
// Fetch categories, departments, and tags in parallel
|
||||
const [duesCategories, departments, tagsResult, tagAssignmentsResult] =
|
||||
await Promise.all([
|
||||
organization.listDuesCategories(acct.id),
|
||||
organization.listDepartmentsWithCounts(acct.id),
|
||||
(client.from as any)('member_tags')
|
||||
.select('id, name, color')
|
||||
.eq('account_id', acct.id)
|
||||
.order('sort_order'),
|
||||
(client.from as any)('member_tag_assignments')
|
||||
.select('member_id, tag_id, member_tags(id, name, color)')
|
||||
.in(
|
||||
'member_id',
|
||||
result.data.map((m: any) => m.id),
|
||||
),
|
||||
]);
|
||||
|
||||
// Build memberTags lookup: { memberId: [{ id, name, color }] }
|
||||
const memberTags: Record<
|
||||
string,
|
||||
Array<{ id: string; name: string; color: string }>
|
||||
> = {};
|
||||
|
||||
for (const a of tagAssignmentsResult.data ?? []) {
|
||||
const memberId = String(a.member_id);
|
||||
const tag = a.member_tags;
|
||||
if (!tag) continue;
|
||||
|
||||
if (!memberTags[memberId]) memberTags[memberId] = [];
|
||||
memberTags[memberId]!.push({
|
||||
id: String(tag.id),
|
||||
name: String(tag.name),
|
||||
color: String(tag.color),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<CmsPageShell
|
||||
account={account}
|
||||
title={t('nav.members')}
|
||||
description={`${result.total} ${t('nav.members')}`}
|
||||
>
|
||||
<MembersDataTable
|
||||
<MembersListView
|
||||
data={result.data}
|
||||
total={result.total}
|
||||
page={page}
|
||||
@@ -55,7 +95,17 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
||||
name: String(c.name),
|
||||
}),
|
||||
)}
|
||||
departments={(departments ?? []).map((d) => ({
|
||||
id: String(d.id),
|
||||
name: String(d.name),
|
||||
memberCount: d.memberCount,
|
||||
}))}
|
||||
tags={(tagsResult.data ?? []).map((t: any) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.name),
|
||||
color: String(t.color),
|
||||
}))}
|
||||
memberTags={memberTags}
|
||||
/>
|
||||
</CmsPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
||||
import { createMemberServices } from '@kit/member-management/services';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||
|
||||
@@ -34,8 +34,8 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
||||
|
||||
if (!acct) return <AccountNotFound />;
|
||||
|
||||
const api = createMemberManagementApi(client);
|
||||
const stats = await api.getMemberStatistics(acct.id);
|
||||
const { query } = createMemberServices(client);
|
||||
const stats = await query.getStatistics(acct.id);
|
||||
|
||||
const statusChartData = [
|
||||
{ name: t('status.active'), value: stats.active ?? 0 },
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import { FileText, Plus } from 'lucide-react';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,6 @@ import {
|
||||
CreditCard,
|
||||
// People (Members + Access)
|
||||
UserCheck,
|
||||
UserPlus,
|
||||
IdCard,
|
||||
ClipboardList,
|
||||
// Courses
|
||||
GraduationCap,
|
||||
CalendarDays,
|
||||
@@ -69,7 +66,10 @@ import pathsConfig from '~/config/paths.config';
|
||||
|
||||
const iconClasses = 'w-4';
|
||||
|
||||
const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) => {
|
||||
const getRoutes = (
|
||||
account: string,
|
||||
accountFeatures?: Record<string, boolean>,
|
||||
) => {
|
||||
const routes: Array<
|
||||
| {
|
||||
label: string;
|
||||
@@ -110,46 +110,11 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
||||
}> = [];
|
||||
|
||||
if (featureFlagsConfig.enableMemberManagement) {
|
||||
peopleChildren.push(
|
||||
{
|
||||
peopleChildren.push({
|
||||
label: 'common.routes.clubMembers',
|
||||
path: createPath(pathsConfig.app.accountCmsMembers, account),
|
||||
Icon: <UserCheck className={iconClasses} />,
|
||||
},
|
||||
{
|
||||
label: 'common.routes.memberApplications',
|
||||
path: createPath(
|
||||
pathsConfig.app.accountCmsMembers + '/applications',
|
||||
account,
|
||||
),
|
||||
Icon: <UserPlus className={iconClasses} />,
|
||||
},
|
||||
// NOTE: memberPortal page does not exist yet — nav entry commented out until built
|
||||
// {
|
||||
// label: 'common.routes.memberPortal',
|
||||
// path: createPath(
|
||||
// pathsConfig.app.accountCmsMembers + '/portal',
|
||||
// account,
|
||||
// ),
|
||||
// Icon: <KeyRound className={iconClasses} />,
|
||||
// },
|
||||
{
|
||||
label: 'common.routes.memberCards',
|
||||
path: createPath(
|
||||
pathsConfig.app.accountCmsMembers + '/cards',
|
||||
account,
|
||||
),
|
||||
Icon: <IdCard className={iconClasses} />,
|
||||
},
|
||||
{
|
||||
label: 'common.routes.memberDues',
|
||||
path: createPath(
|
||||
pathsConfig.app.accountCmsMembers + '/dues',
|
||||
account,
|
||||
),
|
||||
Icon: <ClipboardList className={iconClasses} />,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Admin users who can log in — always visible
|
||||
@@ -417,7 +382,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
||||
}
|
||||
|
||||
// ── Fisheries ──
|
||||
if (featureFlagsConfig.enableFischerei && (accountFeatures?.fischerei !== false)) {
|
||||
if (
|
||||
featureFlagsConfig.enableFischerei &&
|
||||
accountFeatures?.fischerei !== false
|
||||
) {
|
||||
routes.push({
|
||||
label: 'common.routes.fisheriesManagement',
|
||||
collapsible: true,
|
||||
@@ -473,7 +441,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
||||
}
|
||||
|
||||
// ── Meeting Protocols ──
|
||||
if (featureFlagsConfig.enableMeetingProtocols && (accountFeatures?.meetings !== false)) {
|
||||
if (
|
||||
featureFlagsConfig.enableMeetingProtocols &&
|
||||
accountFeatures?.meetings !== false
|
||||
) {
|
||||
routes.push({
|
||||
label: 'common.routes.meetingProtocols',
|
||||
collapsible: true,
|
||||
@@ -502,7 +473,10 @@ const getRoutes = (account: string, accountFeatures?: Record<string, boolean>) =
|
||||
}
|
||||
|
||||
// ── Association Management (Verband) ──
|
||||
if (featureFlagsConfig.enableVerbandsverwaltung && (accountFeatures?.verband !== false)) {
|
||||
if (
|
||||
featureFlagsConfig.enableVerbandsverwaltung &&
|
||||
accountFeatures?.verband !== false
|
||||
) {
|
||||
routes.push({
|
||||
label: 'common.routes.associationManagement',
|
||||
collapsible: true,
|
||||
|
||||
@@ -6404,6 +6404,22 @@ export type Database = {
|
||||
total_upcoming_events: number
|
||||
}[]
|
||||
}
|
||||
get_member_quick_stats: {
|
||||
Args: { p_account_id: string }
|
||||
Returns: {
|
||||
active: number
|
||||
inactive: number
|
||||
new_this_year: number
|
||||
pending: number
|
||||
pending_applications: number
|
||||
resigned: number
|
||||
total: number
|
||||
}[]
|
||||
}
|
||||
get_next_member_number: {
|
||||
Args: { p_account_id: string }
|
||||
Returns: string
|
||||
}
|
||||
get_nonce_status: { Args: { p_id: string }; Returns: Json }
|
||||
get_upper_system_role: { Args: never; Returns: string }
|
||||
get_user_visible_accounts: { Args: never; Returns: string[] }
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
-- Migration: Enhanced member search and quick stats
|
||||
-- Adds: full-text search index, quick stats RPC, next member number function
|
||||
|
||||
-- Full-text search index (German) for faster member search
|
||||
CREATE INDEX IF NOT EXISTS ix_members_fulltext ON public.members
|
||||
USING gin(
|
||||
to_tsvector(
|
||||
'german',
|
||||
coalesce(first_name, '') || ' ' ||
|
||||
coalesce(last_name, '') || ' ' ||
|
||||
coalesce(email, '') || ' ' ||
|
||||
coalesce(member_number, '') || ' ' ||
|
||||
coalesce(city, '')
|
||||
)
|
||||
);
|
||||
|
||||
-- Trigram index on names for fuzzy / ILIKE search
|
||||
CREATE INDEX IF NOT EXISTS ix_members_name_trgm
|
||||
ON public.members
|
||||
USING gin ((lower(first_name || ' ' || last_name)) gin_trgm_ops);
|
||||
|
||||
-- Quick stats RPC — returns a single row with KPI counts
|
||||
-- Includes has_role_on_account guard to prevent cross-tenant data leaks
|
||||
CREATE OR REPLACE FUNCTION public.get_member_quick_stats(p_account_id uuid)
|
||||
RETURNS TABLE(
|
||||
total bigint,
|
||||
active bigint,
|
||||
inactive bigint,
|
||||
pending bigint,
|
||||
resigned bigint,
|
||||
new_this_year bigint,
|
||||
pending_applications bigint
|
||||
)
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
BEGIN
|
||||
-- Verify caller has access to this account
|
||||
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied to account %', p_account_id;
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
count(*)::bigint AS total,
|
||||
count(*) FILTER (WHERE m.status = 'active')::bigint AS active,
|
||||
count(*) FILTER (WHERE m.status = 'inactive')::bigint AS inactive,
|
||||
count(*) FILTER (WHERE m.status = 'pending')::bigint AS pending,
|
||||
count(*) FILTER (WHERE m.status = 'resigned')::bigint AS resigned,
|
||||
count(*) FILTER (WHERE m.status = 'active'
|
||||
AND m.entry_date >= date_trunc('year', current_date)::date)::bigint AS new_this_year,
|
||||
(
|
||||
SELECT count(*)
|
||||
FROM public.membership_applications a
|
||||
WHERE a.account_id = p_account_id
|
||||
AND a.status = 'submitted'
|
||||
)::bigint AS pending_applications
|
||||
FROM public.members m
|
||||
WHERE m.account_id = p_account_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_member_quick_stats(uuid) TO authenticated;
|
||||
|
||||
-- Next member number: returns max(member_number) + 1 as text
|
||||
-- Includes has_role_on_account guard
|
||||
CREATE OR REPLACE FUNCTION public.get_next_member_number(p_account_id uuid)
|
||||
RETURNS text
|
||||
LANGUAGE plpgsql STABLE SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_result text;
|
||||
BEGIN
|
||||
-- Verify caller has access to this account
|
||||
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied to account %', p_account_id;
|
||||
END IF;
|
||||
|
||||
SELECT LPAD(
|
||||
(COALESCE(
|
||||
MAX(
|
||||
CASE
|
||||
WHEN member_number ~ '^\d+$' THEN member_number::integer
|
||||
ELSE 0
|
||||
END
|
||||
),
|
||||
0
|
||||
) + 1)::text,
|
||||
4,
|
||||
'0'
|
||||
) INTO v_result
|
||||
FROM public.members
|
||||
WHERE account_id = p_account_id;
|
||||
|
||||
RETURN v_result;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_next_member_number(uuid) TO authenticated;
|
||||
@@ -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,61 @@
|
||||
-- =====================================================
|
||||
-- Missing Database Constraints
|
||||
--
|
||||
-- Adds CHECK constraints for data sanity, UNIQUE index
|
||||
-- for email per account, and IBAN format validation.
|
||||
--
|
||||
-- Fixes existing invalid data before adding constraints.
|
||||
-- =====================================================
|
||||
|
||||
-- Fix existing invalid data before adding constraints
|
||||
UPDATE public.members SET date_of_birth = NULL
|
||||
WHERE date_of_birth IS NOT NULL AND date_of_birth > current_date;
|
||||
|
||||
UPDATE public.members SET exit_date = entry_date
|
||||
WHERE exit_date IS NOT NULL AND entry_date IS NOT NULL AND exit_date < entry_date;
|
||||
|
||||
UPDATE public.members SET entry_date = current_date
|
||||
WHERE entry_date IS NOT NULL AND entry_date > current_date;
|
||||
|
||||
-- Normalize IBANs in sepa_mandates to uppercase, strip spaces
|
||||
UPDATE public.sepa_mandates
|
||||
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
||||
WHERE iban IS NOT NULL AND iban != '';
|
||||
|
||||
-- Date sanity constraints
|
||||
ALTER TABLE public.members
|
||||
ADD CONSTRAINT chk_members_dob_not_future
|
||||
CHECK (date_of_birth IS NULL OR date_of_birth <= current_date);
|
||||
|
||||
ALTER TABLE public.members
|
||||
ADD CONSTRAINT chk_members_exit_after_entry
|
||||
CHECK (exit_date IS NULL OR entry_date IS NULL OR exit_date >= entry_date);
|
||||
|
||||
ALTER TABLE public.members
|
||||
ADD CONSTRAINT chk_members_entry_not_future
|
||||
CHECK (entry_date IS NULL OR entry_date <= current_date);
|
||||
|
||||
-- Email uniqueness per account (partial index — allows NULLs and empty strings)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uix_members_email_per_account
|
||||
ON public.members(account_id, lower(email))
|
||||
WHERE email IS NOT NULL AND email != '';
|
||||
|
||||
-- IBAN format on sepa_mandates (2-letter country + 2 check digits + 11-30 alphanumeric)
|
||||
ALTER TABLE public.sepa_mandates
|
||||
ADD CONSTRAINT chk_sepa_iban_format
|
||||
CHECK (iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$');
|
||||
|
||||
-- Mandate reference must not be empty
|
||||
ALTER TABLE public.sepa_mandates
|
||||
ADD CONSTRAINT chk_sepa_mandate_reference_not_empty
|
||||
CHECK (mandate_reference IS NOT NULL AND mandate_reference != '');
|
||||
|
||||
-- Member roles: from_date should not be after until_date
|
||||
ALTER TABLE public.member_roles
|
||||
ADD CONSTRAINT chk_member_roles_date_range
|
||||
CHECK (until_date IS NULL OR from_date IS NULL OR until_date >= from_date);
|
||||
|
||||
-- Dues categories: amount must be non-negative
|
||||
ALTER TABLE public.dues_categories
|
||||
ADD CONSTRAINT chk_dues_amount_non_negative
|
||||
CHECK (amount >= 0);
|
||||
@@ -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
|
||||
JOIN public.members m ON m.account_id = e.account_id
|
||||
AND lower(m.email) = lower(er.email)
|
||||
AND m.email IS NOT NULL AND m.email != ''
|
||||
AND m.status IN ('active', 'inactive', 'pending')
|
||||
WHERE e.id = er.event_id
|
||||
AND er.member_id IS NULL
|
||||
AND er.email IS NOT NULL AND er.email != '';
|
||||
|
||||
-- Update transfer_member to count active events via member_id instead of email
|
||||
CREATE OR REPLACE FUNCTION public.transfer_member(
|
||||
p_member_id uuid,
|
||||
p_target_account_id uuid,
|
||||
p_reason text DEFAULT NULL,
|
||||
p_keep_sepa boolean DEFAULT false
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_source_account_id uuid;
|
||||
v_source_name varchar;
|
||||
v_target_name varchar;
|
||||
v_active_courses bigint;
|
||||
v_active_events bigint;
|
||||
v_cleared_data jsonb;
|
||||
v_transfer_id uuid;
|
||||
v_member record;
|
||||
BEGIN
|
||||
-- Get current member state
|
||||
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
|
||||
IF v_member IS NULL THEN
|
||||
RAISE EXCEPTION 'Member not found';
|
||||
END IF;
|
||||
|
||||
v_source_account_id := v_member.account_id;
|
||||
|
||||
-- Verify target account exists
|
||||
IF NOT EXISTS (SELECT 1 FROM public.accounts WHERE id = p_target_account_id) THEN
|
||||
RAISE EXCEPTION 'Target account not found';
|
||||
END IF;
|
||||
|
||||
-- Ensure caller has access to source account
|
||||
IF NOT public.has_role_on_account_or_ancestor(v_source_account_id) THEN
|
||||
RAISE EXCEPTION 'Access denied to source account';
|
||||
END IF;
|
||||
|
||||
-- Same account? No-op
|
||||
IF v_source_account_id = p_target_account_id THEN
|
||||
RAISE EXCEPTION 'Cannot transfer member to the same account';
|
||||
END IF;
|
||||
|
||||
-- Ensure both accounts share a common ancestor
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM public.get_account_ancestors(v_source_account_id) sa
|
||||
JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Source and target accounts do not share a common ancestor (Verband)';
|
||||
END IF;
|
||||
|
||||
-- Get org names for the transfer note
|
||||
SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id;
|
||||
SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id;
|
||||
|
||||
-- Count active relationships (informational, for the log)
|
||||
SELECT count(*) INTO v_active_courses
|
||||
FROM public.course_participants cp
|
||||
JOIN public.courses c ON c.id = cp.course_id
|
||||
WHERE cp.member_id = p_member_id AND cp.status = 'enrolled';
|
||||
|
||||
-- Use member_id for event lookups instead of fragile email matching
|
||||
SELECT count(*) INTO v_active_events
|
||||
FROM public.event_registrations er
|
||||
JOIN public.events e ON e.id = er.event_id
|
||||
WHERE er.member_id = p_member_id
|
||||
AND er.status IN ('confirmed', 'pending')
|
||||
AND e.event_date >= current_date;
|
||||
|
||||
-- Perform the transfer
|
||||
UPDATE public.members
|
||||
SET
|
||||
account_id = p_target_account_id,
|
||||
-- Clear org-specific admin data
|
||||
dues_category_id = NULL,
|
||||
member_number = NULL,
|
||||
-- Clear primary_mandate_id FK (mandate needs re-confirmation in new org)
|
||||
primary_mandate_id = NULL,
|
||||
-- Legacy inline SEPA fields (deprecated, kept for backward compat)
|
||||
sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END,
|
||||
sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END,
|
||||
sepa_mandate_status = 'pending',
|
||||
-- Append transfer note
|
||||
notes = COALESCE(notes, '') ||
|
||||
E'\n[Transfer ' || to_char(now(), 'YYYY-MM-DD') || '] ' ||
|
||||
v_source_name || ' → ' || v_target_name ||
|
||||
COALESCE(' | Grund: ' || p_reason, ''),
|
||||
is_transferred = true
|
||||
WHERE id = p_member_id;
|
||||
|
||||
-- Reset SEPA mandate(s) in the mandates table
|
||||
UPDATE public.sepa_mandates
|
||||
SET status = 'pending'
|
||||
WHERE member_id = p_member_id
|
||||
AND status = 'active';
|
||||
|
||||
-- Build cleared data snapshot for the transfer log
|
||||
v_cleared_data := jsonb_build_object(
|
||||
'member_number', v_member.member_number,
|
||||
'dues_category_id', v_member.dues_category_id,
|
||||
'active_courses', v_active_courses,
|
||||
'active_events', v_active_events
|
||||
);
|
||||
|
||||
-- Create transfer log entry
|
||||
INSERT INTO public.member_transfers (
|
||||
member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data
|
||||
) VALUES (
|
||||
p_member_id,
|
||||
v_source_account_id,
|
||||
p_target_account_id,
|
||||
COALESCE(nullif(current_setting('app.current_user_id', true), '')::uuid, auth.uid()),
|
||||
p_reason,
|
||||
v_cleared_data
|
||||
)
|
||||
RETURNING id INTO v_transfer_id;
|
||||
|
||||
RETURN v_transfer_id;
|
||||
END;
|
||||
$$;
|
||||
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,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,128 @@
|
||||
-- =====================================================
|
||||
-- Atomic Booking Creation with Overlap Prevention
|
||||
--
|
||||
-- Problem: Creating a booking requires checking room
|
||||
-- availability, validating capacity, and inserting — all
|
||||
-- as separate queries. Race conditions can double-book
|
||||
-- a room for overlapping dates.
|
||||
--
|
||||
-- Fix:
|
||||
-- A) Enable btree_gist extension for exclusion constraints.
|
||||
-- B) Add GiST exclusion constraint to prevent overlapping
|
||||
-- bookings for the same room (non-cancelled/no_show).
|
||||
-- C) Single transactional PG function that locks the room,
|
||||
-- validates inputs, calculates price, and inserts. The
|
||||
-- exclusion constraint provides a final safety net.
|
||||
-- =====================================================
|
||||
|
||||
-- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types)
|
||||
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||
|
||||
-- B) Add exclusion constraint to prevent overlapping bookings (idempotent)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
|
||||
) THEN
|
||||
ALTER TABLE public.bookings
|
||||
ADD CONSTRAINT excl_booking_room_dates
|
||||
EXCLUDE USING gist (
|
||||
room_id WITH =,
|
||||
daterange(check_in, check_out) WITH &&
|
||||
) WHERE (status NOT IN ('cancelled', 'no_show'));
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- C) Atomic booking creation function
|
||||
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
||||
p_account_id uuid,
|
||||
p_room_id uuid,
|
||||
p_guest_id uuid DEFAULT NULL,
|
||||
p_check_in date DEFAULT NULL,
|
||||
p_check_out date DEFAULT NULL,
|
||||
p_adults integer DEFAULT 1,
|
||||
p_children integer DEFAULT 0,
|
||||
p_status text DEFAULT 'confirmed',
|
||||
p_total_price numeric DEFAULT NULL,
|
||||
p_notes text DEFAULT NULL
|
||||
)
|
||||
RETURNS uuid
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_room record;
|
||||
v_computed_price numeric(10,2);
|
||||
v_booking_id uuid;
|
||||
BEGIN
|
||||
-- 1. Lock the room row to serialize booking attempts
|
||||
SELECT * INTO v_room
|
||||
FROM public.rooms
|
||||
WHERE id = p_room_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- 2. Validate room exists
|
||||
IF v_room IS NULL THEN
|
||||
RAISE EXCEPTION 'Room % not found', p_room_id
|
||||
USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- 3. Validate check_out > check_in
|
||||
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
||||
RAISE EXCEPTION 'check_in and check_out dates are required'
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
IF p_check_out <= p_check_in THEN
|
||||
RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- 4. Validate total guests do not exceed room capacity
|
||||
IF (p_adults + p_children) > v_room.capacity THEN
|
||||
RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity
|
||||
USING ERRCODE = 'P0001';
|
||||
END IF;
|
||||
|
||||
-- 5. Calculate price if not provided
|
||||
IF p_total_price IS NOT NULL THEN
|
||||
v_computed_price := p_total_price;
|
||||
ELSE
|
||||
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
||||
END IF;
|
||||
|
||||
-- 6. Insert the booking (exclusion constraint prevents double-booking)
|
||||
INSERT INTO public.bookings (
|
||||
account_id,
|
||||
room_id,
|
||||
guest_id,
|
||||
check_in,
|
||||
check_out,
|
||||
adults,
|
||||
children,
|
||||
status,
|
||||
total_price,
|
||||
notes
|
||||
) VALUES (
|
||||
p_account_id,
|
||||
p_room_id,
|
||||
p_guest_id,
|
||||
p_check_in,
|
||||
p_check_out,
|
||||
p_adults,
|
||||
p_children,
|
||||
p_status,
|
||||
v_computed_price,
|
||||
p_notes
|
||||
)
|
||||
RETURNING id INTO v_booking_id;
|
||||
|
||||
-- 7. Return the new booking id
|
||||
RETURN v_booking_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
||||
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role;
|
||||
@@ -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);
|
||||
@@ -285,3 +285,146 @@ SELECT pg_catalog.setval('"public"."role_permissions_id_seq"', 7, true);
|
||||
--
|
||||
|
||||
SELECT pg_catalog.setval('"supabase_functions"."hooks_id_seq"', 19, true);
|
||||
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
-- Member Management Seed Data
|
||||
-- 30 realistic German/Austrian club members for demo/development
|
||||
-- ══════════════════════════════════════════════════════════════
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_account_id uuid := '5deaa894-2094-4da3-b4fd-1fada0809d1c';
|
||||
v_user_id uuid := '31a03e74-1639-45b6-bfa7-77447f1a4762';
|
||||
v_cat_regular uuid;
|
||||
v_cat_youth uuid;
|
||||
v_cat_senior uuid;
|
||||
v_dept_vorstand uuid;
|
||||
v_dept_jugend uuid;
|
||||
v_dept_sport uuid;
|
||||
v_m1 uuid; v_m2 uuid; v_m3 uuid; v_m4 uuid; v_m5 uuid;
|
||||
v_m6 uuid; v_m7 uuid; v_m8 uuid; v_m9 uuid; v_m10 uuid;
|
||||
BEGIN
|
||||
|
||||
-- Dues Categories
|
||||
INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_default, sort_order)
|
||||
VALUES (gen_random_uuid(), v_account_id, 'Erwachsene', 'Regulärer Mitgliedsbeitrag', 120.00, 'yearly', true, 1)
|
||||
RETURNING id INTO v_cat_regular;
|
||||
|
||||
INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_youth, sort_order)
|
||||
VALUES (gen_random_uuid(), v_account_id, 'Jugend (bis 18)', 'Ermäßigter Jugendbeitrag', 48.00, 'yearly', true, 2)
|
||||
RETURNING id INTO v_cat_youth;
|
||||
|
||||
INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, sort_order)
|
||||
VALUES (gen_random_uuid(), v_account_id, 'Senioren (ab 65)', 'Ermäßigter Seniorenbeitrag', 72.00, 'yearly', 3)
|
||||
RETURNING id INTO v_cat_senior;
|
||||
|
||||
-- Departments
|
||||
INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
|
||||
VALUES (gen_random_uuid(), v_account_id, 'Vorstand', 'Vereinsvorstand und Leitung', 1)
|
||||
RETURNING id INTO v_dept_vorstand;
|
||||
|
||||
INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
|
||||
VALUES (gen_random_uuid(), v_account_id, 'Jugendabteilung', 'Kinder- und Jugendarbeit', 2)
|
||||
RETURNING id INTO v_dept_jugend;
|
||||
|
||||
INSERT INTO public.member_departments (id, account_id, name, description, sort_order)
|
||||
VALUES (gen_random_uuid(), v_account_id, 'Sportabteilung', 'Training und Wettkampf', 3)
|
||||
RETURNING id INTO v_dept_sport;
|
||||
|
||||
-- Members 1-10 (with variables for relationships)
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, is_founding_member, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0001', 'Johann', 'Maier', '1968-03-15', 'male', 'Herr', 'johann.maier@example.at', '+43 512 123456', '+43 664 1234567', 'Hauptstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2005-01-15', v_cat_regular, 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', true, true, true, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m1;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, gdpr_consent, gdpr_newsletter, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0002', 'Maria', 'Huber', '1975-07-22', 'female', 'Frau', 'maria.huber@example.at', '+43 512 234567', '+43 660 2345678', 'Bahnhofstraße', '5a', '6020', 'Innsbruck', 'AT', 'active', '2008-03-01', v_cat_regular, 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', true, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m2;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0003', 'Thomas', 'Berger', '1982-11-08', 'male', 'Herr', 'thomas.berger@example.at', '+43 512 345678', 'Museumstraße', '3', '6020', 'Innsbruck', 'AT', 'active', '2010-06-15', v_cat_regular, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m3;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0004', 'Anna', 'Steiner', '1990-04-12', 'female', 'Frau', 'anna.steiner@example.at', '+43 676 3456789', 'Leopoldstraße', '18', '6020', 'Innsbruck', 'AT', 'active', '2012-09-01', v_cat_regular, true, true, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m4;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, title, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_honorary, is_founding_member, gdpr_consent, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0005', 'Franz', 'Gruber', '1945-09-03', 'male', 'Herr', 'Dr.', 'franz.gruber@example.at', '+43 512 456789', 'Rennweg', '7', '6020', 'Innsbruck', 'AT', 'active', '1998-01-01', v_cat_senior, true, true, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m5;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, email, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_youth, guardian_name, guardian_phone, guardian_email, gdpr_consent, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0006', 'Lukas', 'Hofer', '2010-02-28', 'male', 'lukas.hofer@example.at', 'Schillerstraße', '22', '6020', 'Innsbruck', 'AT', 'active', '2022-03-01', v_cat_youth, true, 'Stefan Hofer', '+43 664 5678901', 'stefan.hofer@example.at', true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m6;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0007', 'Katharina', 'Wimmer', '1988-12-05', 'female', 'Frau', 'k.wimmer@example.at', '+43 512 567890', 'Maria-Theresien-Straße', '15', '6020', 'Innsbruck', 'AT', 'inactive', '2015-01-01', v_cat_regular, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m7;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, street, house_number, postal_code, city, country, status, entry_date, exit_date, exit_reason, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0008', 'Peter', 'Moser', '1970-06-18', 'male', 'Herr', 'peter.moser@example.at', 'Anichstraße', '29', '6020', 'Innsbruck', 'AT', 'resigned', '2010-05-01', '2025-12-31', 'Umzug', v_cat_regular, false, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m8;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0009', 'Sophie', 'Eder', '1995-08-30', 'female', 'Frau', 'sophie.eder@example.at', '+43 680 6789012', 'Universitätsstraße', '8', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-15', v_cat_regular, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m9;
|
||||
|
||||
INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_retiree, gdpr_consent, gdpr_print, gdpr_birthday_info, created_by, updated_by)
|
||||
VALUES (gen_random_uuid(), v_account_id, '0010', 'Helmut', 'Bauer', '1952-01-14', 'male', 'Herr', 'helmut.bauer@example.at', '+43 512 678901', 'Sillgasse', '14', '6020', 'Innsbruck', 'AT', 'active', '2001-07-01', v_cat_senior, true, true, true, true, v_user_id, v_user_id)
|
||||
RETURNING id INTO v_m10;
|
||||
|
||||
-- Members 11-30 (bulk insert)
|
||||
INSERT INTO public.members (account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) VALUES
|
||||
(v_account_id, '0011', 'Christina', 'Pichler', '1993-05-17', 'female', 'Frau', 'christina.pichler@example.at', '+43 664 7890123', 'Innrain', '52', '6020', 'Innsbruck', 'AT', 'active', '2019-01-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0012', 'Michael', 'Ebner', '1985-09-23', 'male', 'Herr', 'michael.ebner@example.at', '+43 660 8901234', 'Höttinger Au', '3', '6020', 'Innsbruck', 'AT', 'active', '2017-04-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0013', 'Eva', 'Schwarz', '1978-02-09', 'female', 'Frau', 'eva.schwarz@example.at', '+43 676 9012345', 'Fallmerayerstraße', '6', '6020', 'Innsbruck', 'AT', 'active', '2014-09-15', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0014', 'Stefan', 'Wallner', '1991-11-30', 'male', 'Herr', 'stefan.wallner@example.at', '+43 664 0123456', 'Reichenauer Straße', '44', '6020', 'Innsbruck', 'AT', 'active', '2020-02-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0015', 'Martina', 'Lechner', '1987-04-25', 'female', 'Frau', 'martina.lechner@example.at', '+43 680 1234567', 'Olympiastraße', '10', '6020', 'Innsbruck', 'AT', 'active', '2016-06-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0016', 'Andreas', 'Koller', '1969-08-11', 'male', 'Herr', 'andreas.koller@example.at', '+43 664 2345670', 'Pradler Straße', '72', '6020', 'Innsbruck', 'AT', 'active', '2007-01-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0017', 'Laura', 'Reiter', '2008-07-19', 'female', NULL, 'laura.reiter@example.at', '+43 660 3456701', 'Gabelsbergerstraße', '4', '6020', 'Innsbruck', 'AT', 'active', '2023-01-01', v_cat_youth, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0018', 'Markus', 'Fuchs', '1980-10-02', 'male', 'Herr', 'markus.fuchs@example.at', '+43 676 4567012', 'Egger-Lienz-Straße', '28', '6020', 'Innsbruck', 'AT', 'active', '2013-03-15', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0019', 'Lisa', 'Müller', '1996-01-07', 'female', 'Frau', 'lisa.mueller@example.at', '+43 664 5670123', 'Amraser Straße', '16', '6020', 'Innsbruck', 'AT', 'active', '2021-09-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0020', 'Georg', 'Wagner', '1973-06-14', 'male', 'Herr', 'georg.wagner@example.at', '+43 680 6701234', 'Kaiserjägerstraße', '1', '6020', 'Innsbruck', 'AT', 'active', '2009-11-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0021', 'Claudia', 'Fischer', '1984-12-20', 'female', 'Frau', 'claudia.fischer@example.at', '+43 664 7012345', 'Technikerstraße', '9', '6020', 'Innsbruck', 'AT', 'active', '2018-05-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0022', 'Daniel', 'Wolf', '1998-03-28', 'male', 'Herr', 'daniel.wolf@example.at', '+43 660 8012346', 'Schöpfstraße', '31', '6020', 'Innsbruck', 'AT', 'active', '2022-01-15', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0023', 'Sandra', 'Brunner', '1976-09-06', 'female', 'Frau', NULL, '+43 512 901234', 'Defreggerstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2011-04-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0024', 'Robert', 'Lang', '1960-11-11', 'male', 'Herr', 'robert.lang@example.at', '+43 512 012345', 'Speckbacherstraße', '21', '6020', 'Innsbruck', 'AT', 'active', '2003-01-01', v_cat_senior, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0025', 'Nina', 'Winkler', '2009-05-03', 'female', NULL, 'nina.winkler@example.at', '+43 664 1230456', 'Müllerstraße', '7', '6020', 'Innsbruck', 'AT', 'active', '2023-09-01', v_cat_youth, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0026', 'Wolfgang', 'Schmid', '1955-04-22', 'male', 'Herr', 'wolfgang.schmid@example.at', '+43 512 2340567', 'Haller Straße', '55', '6020', 'Innsbruck', 'AT', 'inactive', '2000-06-01', v_cat_senior, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0027', 'Sabrina', 'Gruber', '1994-07-15', 'female', 'Frau', 'sabrina.gruber@example.at', '+43 676 3450678', 'Grabenweg', '33', '6020', 'Innsbruck', 'AT', 'active', '2020-11-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0028', 'Patrick', 'Stockinger', '1989-10-09', 'male', 'Herr', 'patrick.stockinger@example.at', '+43 660 4560789', 'Adamgasse', '19', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0029', 'Verena', 'Neuner', '1981-01-18', 'female', 'Frau', 'verena.neuner@example.at', '+43 664 5670890', 'Amthorstraße', '2', '6020', 'Innsbruck', 'AT', 'active', '2015-08-01', v_cat_regular, true, v_user_id, v_user_id),
|
||||
(v_account_id, '0030', 'Florian', 'Kofler', '2011-12-25', 'male', NULL, NULL, '+43 664 6780901', 'Hunoldstraße', '11', '6020', 'Innsbruck', 'AT', 'active', '2024-01-01', v_cat_youth, true, v_user_id, v_user_id);
|
||||
|
||||
-- Department Assignments
|
||||
INSERT INTO public.member_department_assignments (member_id, department_id) VALUES
|
||||
(v_m1, v_dept_vorstand), (v_m2, v_dept_vorstand), (v_m3, v_dept_vorstand),
|
||||
(v_m4, v_dept_jugend), (v_m6, v_dept_jugend),
|
||||
(v_m4, v_dept_sport), (v_m10, v_dept_sport);
|
||||
|
||||
-- Roles
|
||||
INSERT INTO public.member_roles (account_id, member_id, role_name, from_date, until_date, is_active) VALUES
|
||||
(v_account_id, v_m1, '1. Vorsitzender', '2015-01-01', NULL, true),
|
||||
(v_account_id, v_m2, 'Kassierin', '2018-01-01', NULL, true),
|
||||
(v_account_id, v_m3, 'Schriftführer', '2018-01-01', NULL, true),
|
||||
(v_account_id, v_m4, 'Jugendleiterin', '2020-01-01', NULL, true),
|
||||
(v_account_id, v_m1, '2. Vorsitzender', '2008-01-01', '2014-12-31', false),
|
||||
(v_account_id, v_m5, '1. Vorsitzender', '1998-01-01', '2014-12-31', false);
|
||||
|
||||
-- Honors
|
||||
INSERT INTO public.member_honors (account_id, member_id, honor_name, honor_date, description) VALUES
|
||||
(v_account_id, v_m5, 'Ehrenmitglied', '2015-01-01', 'Für 17 Jahre als Vorsitzender'),
|
||||
(v_account_id, v_m1, '20 Jahre Mitgliedschaft', '2025-01-15', 'Treueehrung'),
|
||||
(v_account_id, v_m5, 'Goldene Ehrennadel', '2010-06-15', 'Verdienstauszeichnung');
|
||||
|
||||
-- SEPA Mandates
|
||||
INSERT INTO public.sepa_mandates (account_id, member_id, mandate_reference, iban, bic, account_holder, mandate_date, status, sequence, is_primary) VALUES
|
||||
(v_account_id, v_m1, 'MNDT-2020-001', 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', '2020-01-01', 'active', 'RCUR', true),
|
||||
(v_account_id, v_m2, 'MNDT-2020-002', 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', '2020-01-01', 'active', 'RCUR', true);
|
||||
|
||||
-- Membership Applications
|
||||
INSERT INTO public.membership_applications (account_id, first_name, last_name, email, phone, street, postal_code, city, date_of_birth, message, status) VALUES
|
||||
(v_account_id, 'Maximilian', 'Ortner', 'max.ortner@example.at', '+43 664 9876543', 'Viaduktbogen', '6020', 'Innsbruck', '1997-08-14', 'Wurde von einem Mitglied empfohlen.', 'submitted'),
|
||||
(v_account_id, 'Hannah', 'Troger', 'hannah.troger@example.at', '+43 680 8765432', 'Erlerstraße', '6020', 'Innsbruck', '2001-03-22', 'Möchte gerne der Jugendabteilung beitreten.', 'submitted'),
|
||||
(v_account_id, 'Felix', 'Kirchmair', 'felix.kirchmair@example.at', '+43 660 7654321', 'Brennerstraße', '6020', 'Innsbruck', '1992-11-05', NULL, 'submitted');
|
||||
|
||||
END $$;
|
||||
|
||||
@@ -18,7 +18,7 @@ services:
|
||||
image: supabase/postgres:15.8.1.060
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '54322:5432'
|
||||
- '54322:54322'
|
||||
volumes:
|
||||
- supabase-db-data:/var/lib/postgresql/data
|
||||
- ./docker/db/zzz-role-passwords.sh:/docker-entrypoint-initdb.d/zzz-role-passwords.sh:ro
|
||||
@@ -317,7 +317,6 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# NEXT_PUBLIC_CI=true bypasses the HTTPS check during build
|
||||
NEXT_PUBLIC_CI: 'true'
|
||||
NEXT_PUBLIC_SITE_URL: http://localhost:3000
|
||||
NEXT_PUBLIC_SUPABASE_URL: http://localhost:8000
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts"
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./services/*": "./src/server/services/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { BookingStatusEnum } from '../schema/booking.schema';
|
||||
|
||||
type BookingStatus = z.infer<typeof BookingStatusEnum>;
|
||||
|
||||
/**
|
||||
* Booking status state machine.
|
||||
*
|
||||
* Defines valid transitions between booking statuses and their
|
||||
* side effects. Enforced in booking update operations.
|
||||
*/
|
||||
|
||||
type StatusTransition = {
|
||||
/** Fields to set automatically when this transition occurs */
|
||||
sideEffects?: Partial<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const TRANSITIONS: Record<
|
||||
BookingStatus,
|
||||
Partial<Record<BookingStatus, StatusTransition>>
|
||||
> = {
|
||||
pending: {
|
||||
confirmed: {},
|
||||
cancelled: {},
|
||||
},
|
||||
confirmed: {
|
||||
checked_in: {},
|
||||
cancelled: {},
|
||||
no_show: {},
|
||||
},
|
||||
checked_in: {
|
||||
checked_out: {},
|
||||
},
|
||||
// Terminal states — no transitions out
|
||||
checked_out: {},
|
||||
cancelled: {
|
||||
pending: {},
|
||||
},
|
||||
no_show: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid.
|
||||
*/
|
||||
export function canTransition(from: BookingStatus, to: BookingStatus): boolean {
|
||||
if (from === to) return true; // no-op is always valid
|
||||
return to in (TRANSITIONS[from] ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid target statuses from a given status.
|
||||
*/
|
||||
export function getValidTransitions(from: BookingStatus): BookingStatus[] {
|
||||
return Object.keys(TRANSITIONS[from] ?? {}) as BookingStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the side effects for a transition.
|
||||
* Returns an object of field->value pairs to apply alongside the status change.
|
||||
* Function values should be called to get the actual value.
|
||||
*/
|
||||
export function getTransitionSideEffects(
|
||||
from: BookingStatus,
|
||||
to: BookingStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
const transition = TRANSITIONS[from]?.[to];
|
||||
if (!transition?.sideEffects) return {};
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||
result[key] = typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a status transition and return side effects.
|
||||
* Throws if the transition is invalid.
|
||||
*/
|
||||
export function validateTransition(
|
||||
from: BookingStatus,
|
||||
to: BookingStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return getTransitionSideEffects(from, to);
|
||||
}
|
||||
131
packages/features/booking-management/src/lib/errors.ts
Normal file
131
packages/features/booking-management/src/lib/errors.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* Standardized error codes and domain error classes
|
||||
* for the booking management module.
|
||||
*/
|
||||
|
||||
export const BookingErrorCodes = {
|
||||
BOOKING_NOT_FOUND: 'BOOKING_NOT_FOUND',
|
||||
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
|
||||
ROOM_NOT_AVAILABLE: 'ROOM_NOT_AVAILABLE',
|
||||
ROOM_CAPACITY_EXCEEDED: 'ROOM_CAPACITY_EXCEEDED',
|
||||
INVALID_STATUS_TRANSITION: 'BOOKING_INVALID_TRANSITION',
|
||||
CONCURRENCY_CONFLICT: 'BOOKING_CONCURRENCY_CONFLICT',
|
||||
INVALID_DATE_RANGE: 'BOOKING_INVALID_DATE_RANGE',
|
||||
} as const;
|
||||
|
||||
export type BookingErrorCode =
|
||||
(typeof BookingErrorCodes)[keyof typeof BookingErrorCodes];
|
||||
|
||||
/**
|
||||
* Base domain error for booking management operations.
|
||||
*/
|
||||
export class BookingDomainError extends Error {
|
||||
readonly code: BookingErrorCode;
|
||||
readonly statusCode: number;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: BookingErrorCode,
|
||||
message: string,
|
||||
statusCode = 400,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'BookingDomainError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class BookingNotFoundError extends BookingDomainError {
|
||||
constructor(bookingId: string) {
|
||||
super(
|
||||
BookingErrorCodes.BOOKING_NOT_FOUND,
|
||||
`Buchung ${bookingId} nicht gefunden`,
|
||||
404,
|
||||
{ bookingId },
|
||||
);
|
||||
this.name = 'BookingNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomNotFoundError extends BookingDomainError {
|
||||
constructor(roomId: string) {
|
||||
super(
|
||||
BookingErrorCodes.ROOM_NOT_FOUND,
|
||||
`Raum ${roomId} nicht gefunden`,
|
||||
404,
|
||||
{ roomId },
|
||||
);
|
||||
this.name = 'RoomNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomNotAvailableError extends BookingDomainError {
|
||||
constructor(roomId: string, from: string, to: string) {
|
||||
super(
|
||||
BookingErrorCodes.ROOM_NOT_AVAILABLE,
|
||||
`Raum ${roomId} ist im Zeitraum ${from} bis ${to} nicht verfügbar`,
|
||||
409,
|
||||
{ roomId, from, to },
|
||||
);
|
||||
this.name = 'RoomNotAvailableError';
|
||||
}
|
||||
}
|
||||
|
||||
export class RoomCapacityExceededError extends BookingDomainError {
|
||||
constructor(roomId: string, capacity: number) {
|
||||
super(
|
||||
BookingErrorCodes.ROOM_CAPACITY_EXCEEDED,
|
||||
`Raum ${roomId} hat die maximale Kapazität (${capacity}) erreicht`,
|
||||
422,
|
||||
{ roomId, capacity },
|
||||
);
|
||||
this.name = 'RoomCapacityExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidBookingStatusTransitionError extends BookingDomainError {
|
||||
constructor(from: string, to: string, validTargets: string[]) {
|
||||
super(
|
||||
BookingErrorCodes.INVALID_STATUS_TRANSITION,
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
|
||||
422,
|
||||
{ from, to, validTargets },
|
||||
);
|
||||
this.name = 'InvalidBookingStatusTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BookingConcurrencyConflictError extends BookingDomainError {
|
||||
constructor() {
|
||||
super(
|
||||
BookingErrorCodes.CONCURRENCY_CONFLICT,
|
||||
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
|
||||
409,
|
||||
);
|
||||
this.name = 'BookingConcurrencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class BookingInvalidDateRangeError extends BookingDomainError {
|
||||
constructor(from: string, to: string) {
|
||||
super(
|
||||
BookingErrorCodes.INVALID_DATE_RANGE,
|
||||
`Ungültiger Zeitraum: ${from} bis ${to}. Das Enddatum muss nach dem Startdatum liegen.`,
|
||||
422,
|
||||
{ from, to },
|
||||
);
|
||||
this.name = 'BookingInvalidDateRangeError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a BookingDomainError.
|
||||
*/
|
||||
export function isBookingDomainError(
|
||||
error: unknown,
|
||||
): error is BookingDomainError {
|
||||
return error instanceof BookingDomainError;
|
||||
}
|
||||
@@ -20,7 +20,8 @@ export const CreateRoomSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CreateBookingSchema = z.object({
|
||||
export const CreateBookingSchema = z
|
||||
.object({
|
||||
accountId: z.string().uuid(),
|
||||
roomId: z.string().uuid(),
|
||||
guestId: z.string().uuid().optional(),
|
||||
@@ -29,11 +30,21 @@ export const CreateBookingSchema = z.object({
|
||||
adults: z.number().int().min(1).default(1),
|
||||
children: z.number().int().min(0).default(0),
|
||||
status: BookingStatusEnum.default('confirmed'),
|
||||
totalPrice: z.number().min(0).default(0),
|
||||
totalPrice: z.number().min(0).optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.refine((d) => d.checkOut > d.checkIn, {
|
||||
message: 'Abreisedatum muss nach dem Anreisedatum liegen',
|
||||
path: ['checkOut'],
|
||||
});
|
||||
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
|
||||
|
||||
export const UpdateBookingStatusSchema = z.object({
|
||||
bookingId: z.string().uuid(),
|
||||
status: BookingStatusEnum,
|
||||
version: z.number().int().optional(),
|
||||
});
|
||||
|
||||
export const CreateGuestSchema = z.object({
|
||||
accountId: z.string().uuid(),
|
||||
firstName: z.string().min(1),
|
||||
|
||||
@@ -1,71 +1,87 @@
|
||||
'use server';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { isBookingDomainError } from '../../lib/errors';
|
||||
import {
|
||||
CreateBookingSchema,
|
||||
CreateGuestSchema,
|
||||
CreateRoomSchema,
|
||||
UpdateBookingStatusSchema,
|
||||
} from '../../schema/booking.schema';
|
||||
import { createBookingManagementApi } from '../api';
|
||||
|
||||
export const createBooking = authActionClient
|
||||
.inputSchema(CreateBookingSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
try {
|
||||
logger.info({ name: 'booking.create' }, 'Creating booking...');
|
||||
const result = await api.createBooking(input);
|
||||
const result = await api.bookings.create(input);
|
||||
logger.info({ name: 'booking.create' }, 'Booking created');
|
||||
return { success: true, data: result };
|
||||
} catch (e) {
|
||||
if (isBookingDomainError(e)) {
|
||||
return { success: false, error: e.message, code: e.code };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
export const updateBookingStatus = authActionClient
|
||||
.inputSchema(
|
||||
z.object({
|
||||
bookingId: z.string().uuid(),
|
||||
status: z.string(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.inputSchema(UpdateBookingStatusSchema)
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
|
||||
const result = await api.updateBookingStatus(input.bookingId, input.status);
|
||||
try {
|
||||
logger.info(
|
||||
{ name: 'booking.updateStatus' },
|
||||
'Updating booking status...',
|
||||
);
|
||||
await api.bookings.updateStatus(
|
||||
input.bookingId,
|
||||
input.status,
|
||||
input.version,
|
||||
);
|
||||
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
|
||||
return { success: true, data: result };
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (isBookingDomainError(e)) {
|
||||
return { success: false, error: e.message, code: e.code };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
export const createRoom = authActionClient
|
||||
.inputSchema(CreateRoomSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
logger.info({ name: 'booking.createRoom' }, 'Creating room...');
|
||||
const result = await api.createRoom(input);
|
||||
const result = await api.rooms.create(input);
|
||||
logger.info({ name: 'booking.createRoom' }, 'Room created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const createGuest = authActionClient
|
||||
.inputSchema(CreateGuestSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createBookingManagementApi(client);
|
||||
|
||||
logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
|
||||
const result = await api.createGuest(input);
|
||||
const result = await api.guests.create(input);
|
||||
logger.info({ name: 'booking.createGuest' }, 'Guest created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
@@ -1,173 +1,16 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { CreateBookingInput } from '../schema/booking.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { createBookingCrudService } from './services/booking-crud.service';
|
||||
import { createGuestService } from './services/guest.service';
|
||||
import { createRoomService } from './services/room.service';
|
||||
|
||||
export function createBookingManagementApi(client: SupabaseClient<Database>) {
|
||||
const _db = client;
|
||||
|
||||
return {
|
||||
// --- Rooms ---
|
||||
async listRooms(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('rooms')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_active', true)
|
||||
.order('room_number');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async getRoom(roomId: string) {
|
||||
const { data, error } = await client
|
||||
.from('rooms')
|
||||
.select('*')
|
||||
.eq('id', roomId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Availability ---
|
||||
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
|
||||
const { count, error } = await client
|
||||
.from('bookings')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('room_id', roomId)
|
||||
.not('status', 'in', '("cancelled","no_show")')
|
||||
.lt('check_in', checkOut)
|
||||
.gt('check_out', checkIn);
|
||||
if (error) throw error;
|
||||
return (count ?? 0) === 0;
|
||||
},
|
||||
|
||||
// --- Bookings ---
|
||||
async listBookings(
|
||||
accountId: string,
|
||||
opts?: { status?: string; from?: string; to?: string; page?: number },
|
||||
) {
|
||||
let query = client
|
||||
.from('bookings')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('check_in', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.from) query = query.gte('check_in', opts.from);
|
||||
if (opts?.to) query = query.lte('check_out', opts.to);
|
||||
const page = opts?.page ?? 1;
|
||||
query = query.range((page - 1) * 25, page * 25 - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0 };
|
||||
},
|
||||
|
||||
async createBooking(input: CreateBookingInput) {
|
||||
const available = await this.checkAvailability(
|
||||
input.roomId,
|
||||
input.checkIn,
|
||||
input.checkOut,
|
||||
);
|
||||
if (!available)
|
||||
throw new Error('Room is not available for the selected dates');
|
||||
|
||||
const { data, error } = await client
|
||||
.from('bookings')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
room_id: input.roomId,
|
||||
guest_id: input.guestId,
|
||||
check_in: input.checkIn,
|
||||
check_out: input.checkOut,
|
||||
adults: input.adults,
|
||||
children: input.children,
|
||||
status: input.status,
|
||||
total_price: input.totalPrice,
|
||||
notes: input.notes,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateBookingStatus(bookingId: string, status: string) {
|
||||
const { error } = await client
|
||||
.from('bookings')
|
||||
.update({ status })
|
||||
.eq('id', bookingId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Guests ---
|
||||
async listGuests(accountId: string, search?: string) {
|
||||
let query = client
|
||||
.from('guests')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
if (search)
|
||||
query = query.or(
|
||||
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
|
||||
);
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createGuest(input: {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('guests')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
city: input.city,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createRoom(input: {
|
||||
accountId: string;
|
||||
roomNumber: string;
|
||||
name?: string;
|
||||
roomType?: string;
|
||||
capacity?: number;
|
||||
floor?: number;
|
||||
pricePerNight: number;
|
||||
description?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('rooms')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
room_number: input.roomNumber,
|
||||
name: input.name,
|
||||
room_type: input.roomType ?? 'standard',
|
||||
capacity: input.capacity ?? 2,
|
||||
floor: input.floor,
|
||||
price_per_night: input.pricePerNight,
|
||||
description: input.description,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
rooms: createRoomService(client),
|
||||
bookings: createBookingCrudService(client),
|
||||
guests: createGuestService(client),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import {
|
||||
getValidTransitions,
|
||||
validateTransition,
|
||||
} from '../../lib/booking-status-machine';
|
||||
import {
|
||||
BookingConcurrencyConflictError,
|
||||
InvalidBookingStatusTransitionError,
|
||||
} from '../../lib/errors';
|
||||
import type { CreateBookingInput } from '../../schema/booking.schema';
|
||||
|
||||
const NAMESPACE = 'booking-crud';
|
||||
|
||||
export function createBookingCrudService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
status?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('bookings')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('check_in', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.from) query = query.gte('check_in', opts.from);
|
||||
if (opts?.to) query = query.lte('check_out', opts.to);
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
const total = count ?? 0;
|
||||
return {
|
||||
data: data ?? [],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
},
|
||||
|
||||
async create(input: CreateBookingInput) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: NAMESPACE }, 'Creating booking...');
|
||||
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'create_booking_atomic',
|
||||
{
|
||||
p_account_id: input.accountId,
|
||||
p_room_id: input.roomId,
|
||||
p_guest_id: input.guestId ?? null,
|
||||
p_check_in: input.checkIn,
|
||||
p_check_out: input.checkOut,
|
||||
p_adults: input.adults ?? 1,
|
||||
p_children: input.children ?? 0,
|
||||
p_status: input.status ?? 'confirmed',
|
||||
p_total_price: input.totalPrice ?? null,
|
||||
p_notes: input.notes ?? null,
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
|
||||
// RPC returns the booking UUID; fetch the full row
|
||||
const bookingId = data as unknown as string;
|
||||
const { data: booking, error: fetchError } = await client
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('id', bookingId)
|
||||
.single();
|
||||
if (fetchError) throw fetchError;
|
||||
return booking;
|
||||
},
|
||||
|
||||
async updateStatus(
|
||||
bookingId: string,
|
||||
status: string,
|
||||
version?: number,
|
||||
userId?: string,
|
||||
) {
|
||||
const logger = await getLogger();
|
||||
logger.info(
|
||||
{ name: NAMESPACE, bookingId, status },
|
||||
'Updating booking status...',
|
||||
);
|
||||
|
||||
// Fetch current booking to get current status
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version column added via migration, not yet in generated types
|
||||
const { data: current, error: fetchError } = await (
|
||||
client.from('bookings').select('id, status, version') as any
|
||||
)
|
||||
.eq('id', bookingId)
|
||||
.single();
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
const currentStatus = (current as Record<string, unknown>)
|
||||
.status as string;
|
||||
|
||||
// Validate status transition using the state machine
|
||||
try {
|
||||
validateTransition(
|
||||
currentStatus as Parameters<typeof validateTransition>[0],
|
||||
status as Parameters<typeof validateTransition>[1],
|
||||
);
|
||||
} catch {
|
||||
const validTargets = getValidTransitions(
|
||||
currentStatus as Parameters<typeof getValidTransitions>[0],
|
||||
);
|
||||
throw new InvalidBookingStatusTransitionError(
|
||||
currentStatus,
|
||||
status,
|
||||
validTargets,
|
||||
);
|
||||
}
|
||||
|
||||
// Build the update query
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- updated_by column added via migration
|
||||
let query = client
|
||||
.from('bookings')
|
||||
.update({
|
||||
status,
|
||||
...(userId ? { updated_by: userId } : {}),
|
||||
} as any)
|
||||
.eq('id', bookingId);
|
||||
|
||||
// Optimistic concurrency control via version column (added by migration, not yet in generated types)
|
||||
if (version !== undefined) {
|
||||
query = (query as any).eq('version', version);
|
||||
}
|
||||
|
||||
const { data, error } = await query.select('id').single();
|
||||
|
||||
if (error) {
|
||||
// If no rows matched, it's a concurrency conflict
|
||||
if (error.code === 'PGRST116') {
|
||||
throw new BookingConcurrencyConflictError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new BookingConcurrencyConflictError();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createGuestService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(accountId: string, search?: string) {
|
||||
let query = client
|
||||
.from('guests')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
if (search)
|
||||
query = query.or(
|
||||
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
|
||||
);
|
||||
const { data, error } = await query;
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
city?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('guests')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email || null,
|
||||
phone: input.phone || null,
|
||||
city: input.city || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async getHistory(guestId: string) {
|
||||
const { data, error } = await client
|
||||
.from('bookings')
|
||||
.select('*')
|
||||
.eq('guest_id', guestId)
|
||||
.order('check_in', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createBookingCrudService } from './booking-crud.service';
|
||||
import { createGuestService } from './guest.service';
|
||||
import { createRoomService } from './room.service';
|
||||
|
||||
export { createBookingCrudService, createGuestService, createRoomService };
|
||||
|
||||
export function createBookingServices(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
rooms: createRoomService(client),
|
||||
bookings: createBookingCrudService(client),
|
||||
guests: createGuestService(client),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createRoomService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('rooms')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.eq('is_active', true)
|
||||
.order('room_number');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async getById(roomId: string) {
|
||||
const { data, error } = await client
|
||||
.from('rooms')
|
||||
.select('*')
|
||||
.eq('id', roomId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
|
||||
const { count, error } = await client
|
||||
.from('bookings')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('room_id', roomId)
|
||||
.not('status', 'in', '("cancelled","no_show")')
|
||||
.lt('check_in', checkOut)
|
||||
.gt('check_out', checkIn);
|
||||
if (error) throw error;
|
||||
return (count ?? 0) === 0;
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
accountId: string;
|
||||
roomNumber: string;
|
||||
name?: string;
|
||||
roomType?: string;
|
||||
capacity?: number;
|
||||
floor?: number;
|
||||
pricePerNight: number;
|
||||
description?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('rooms')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
room_number: input.roomNumber,
|
||||
name: input.name,
|
||||
room_type: input.roomType ?? 'standard',
|
||||
capacity: input.capacity ?? 2,
|
||||
floor: input.floor,
|
||||
price_per_night: input.pricePerNight,
|
||||
description: input.description,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts"
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./services/*": "./src/server/services/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { CourseStatusEnum } from '../schema/course.schema';
|
||||
|
||||
type CourseStatus = z.infer<typeof CourseStatusEnum>;
|
||||
|
||||
/**
|
||||
* Course status state machine.
|
||||
*
|
||||
* Defines valid transitions between course statuses and their
|
||||
* side effects. Enforced in course update operations.
|
||||
*/
|
||||
|
||||
type StatusTransition = {
|
||||
/** Fields to set automatically when this transition occurs */
|
||||
sideEffects?: Partial<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const TRANSITIONS: Record<
|
||||
CourseStatus,
|
||||
Partial<Record<CourseStatus, StatusTransition>>
|
||||
> = {
|
||||
planned: {
|
||||
open: {},
|
||||
cancelled: {},
|
||||
},
|
||||
open: {
|
||||
running: {},
|
||||
cancelled: {},
|
||||
},
|
||||
running: {
|
||||
completed: {},
|
||||
cancelled: {},
|
||||
},
|
||||
// Terminal state — no transitions out
|
||||
completed: {},
|
||||
cancelled: {
|
||||
planned: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid.
|
||||
*/
|
||||
export function canTransition(from: CourseStatus, to: CourseStatus): boolean {
|
||||
if (from === to) return true; // no-op is always valid
|
||||
return to in (TRANSITIONS[from] ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid target statuses from a given status.
|
||||
*/
|
||||
export function getValidTransitions(from: CourseStatus): CourseStatus[] {
|
||||
return Object.keys(TRANSITIONS[from] ?? {}) as CourseStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the side effects for a transition.
|
||||
* Returns an object of field->value pairs to apply alongside the status change.
|
||||
* Function values should be called to get the actual value.
|
||||
*/
|
||||
export function getTransitionSideEffects(
|
||||
from: CourseStatus,
|
||||
to: CourseStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
const transition = TRANSITIONS[from]?.[to];
|
||||
if (!transition?.sideEffects) return {};
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||
result[key] = typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a status transition and return side effects.
|
||||
* Throws if the transition is invalid.
|
||||
*/
|
||||
export function validateTransition(
|
||||
from: CourseStatus,
|
||||
to: CourseStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return getTransitionSideEffects(from, to);
|
||||
}
|
||||
128
packages/features/course-management/src/lib/errors.ts
Normal file
128
packages/features/course-management/src/lib/errors.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Standardized error codes and domain error classes
|
||||
* for the course management module.
|
||||
*/
|
||||
|
||||
export const CourseErrorCodes = {
|
||||
NOT_FOUND: 'COURSE_NOT_FOUND',
|
||||
CAPACITY_EXCEEDED: 'COURSE_CAPACITY_EXCEEDED',
|
||||
INVALID_STATUS_TRANSITION: 'COURSE_INVALID_TRANSITION',
|
||||
REGISTRATION_CLOSED: 'COURSE_REGISTRATION_CLOSED',
|
||||
CONCURRENCY_CONFLICT: 'COURSE_CONCURRENCY_CONFLICT',
|
||||
DUPLICATE_ENROLLMENT: 'COURSE_DUPLICATE_ENROLLMENT',
|
||||
MIN_PARTICIPANTS_NOT_MET: 'COURSE_MIN_PARTICIPANTS_NOT_MET',
|
||||
} as const;
|
||||
|
||||
export type CourseErrorCode =
|
||||
(typeof CourseErrorCodes)[keyof typeof CourseErrorCodes];
|
||||
|
||||
/**
|
||||
* Base domain error for course management operations.
|
||||
*/
|
||||
export class CourseDomainError extends Error {
|
||||
readonly code: CourseErrorCode;
|
||||
readonly statusCode: number;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: CourseErrorCode,
|
||||
message: string,
|
||||
statusCode = 400,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CourseDomainError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseNotFoundError extends CourseDomainError {
|
||||
constructor(courseId: string) {
|
||||
super(CourseErrorCodes.NOT_FOUND, `Kurs ${courseId} nicht gefunden`, 404, {
|
||||
courseId,
|
||||
});
|
||||
this.name = 'CourseNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseCapacityExceededError extends CourseDomainError {
|
||||
constructor(courseId: string, capacity: number) {
|
||||
super(
|
||||
CourseErrorCodes.CAPACITY_EXCEEDED,
|
||||
`Kurs ${courseId} hat die maximale Teilnehmerzahl (${capacity}) erreicht`,
|
||||
409,
|
||||
{ courseId, capacity },
|
||||
);
|
||||
this.name = 'CourseCapacityExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCourseStatusTransitionError extends CourseDomainError {
|
||||
constructor(from: string, to: string, validTargets: string[]) {
|
||||
super(
|
||||
CourseErrorCodes.INVALID_STATUS_TRANSITION,
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
|
||||
422,
|
||||
{ from, to, validTargets },
|
||||
);
|
||||
this.name = 'InvalidCourseStatusTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseRegistrationClosedError extends CourseDomainError {
|
||||
constructor(courseId: string) {
|
||||
super(
|
||||
CourseErrorCodes.REGISTRATION_CLOSED,
|
||||
`Anmeldung für Kurs ${courseId} ist geschlossen`,
|
||||
422,
|
||||
{ courseId },
|
||||
);
|
||||
this.name = 'CourseRegistrationClosedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseConcurrencyConflictError extends CourseDomainError {
|
||||
constructor() {
|
||||
super(
|
||||
CourseErrorCodes.CONCURRENCY_CONFLICT,
|
||||
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
|
||||
409,
|
||||
);
|
||||
this.name = 'CourseConcurrencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseDuplicateEnrollmentError extends CourseDomainError {
|
||||
constructor(courseId: string, memberId: string) {
|
||||
super(
|
||||
CourseErrorCodes.DUPLICATE_ENROLLMENT,
|
||||
`Mitglied ${memberId} ist bereits für Kurs ${courseId} angemeldet`,
|
||||
409,
|
||||
{ courseId, memberId },
|
||||
);
|
||||
this.name = 'CourseDuplicateEnrollmentError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseMinParticipantsError extends CourseDomainError {
|
||||
constructor(courseId: string, minParticipants: number, currentCount: number) {
|
||||
super(
|
||||
CourseErrorCodes.MIN_PARTICIPANTS_NOT_MET,
|
||||
`Kurs ${courseId} benötigt mindestens ${minParticipants} Teilnehmer (aktuell: ${currentCount})`,
|
||||
422,
|
||||
{ courseId, minParticipants, currentCount },
|
||||
);
|
||||
this.name = 'CourseMinParticipantsError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a CourseDomainError.
|
||||
*/
|
||||
export function isCourseDomainError(
|
||||
error: unknown,
|
||||
): error is CourseDomainError {
|
||||
return error instanceof CourseDomainError;
|
||||
}
|
||||
@@ -16,7 +16,8 @@ export const CourseStatusEnum = z.enum([
|
||||
'cancelled',
|
||||
]);
|
||||
|
||||
export const CreateCourseSchema = z.object({
|
||||
export const CreateCourseSchema = z
|
||||
.object({
|
||||
accountId: z.string().uuid(),
|
||||
courseNumber: z.string().optional(),
|
||||
name: z.string().min(1).max(256),
|
||||
@@ -33,12 +34,82 @@ export const CreateCourseSchema = z.object({
|
||||
status: CourseStatusEnum.default('planned'),
|
||||
registrationDeadline: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
})
|
||||
.refine((d) => d.reducedFee == null || d.reducedFee <= d.fee, {
|
||||
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
|
||||
path: ['reducedFee'],
|
||||
})
|
||||
.refine((d) => d.minParticipants == null || d.minParticipants <= d.capacity, {
|
||||
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
|
||||
path: ['minParticipants'],
|
||||
})
|
||||
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
|
||||
message: 'Enddatum muss nach dem Startdatum liegen',
|
||||
path: ['endDate'],
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
!d.registrationDeadline ||
|
||||
!d.startDate ||
|
||||
d.registrationDeadline <= d.startDate,
|
||||
{
|
||||
message: 'Anmeldefrist muss vor dem Startdatum liegen',
|
||||
path: ['registrationDeadline'],
|
||||
},
|
||||
);
|
||||
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
|
||||
|
||||
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
|
||||
export const UpdateCourseSchema = z
|
||||
.object({
|
||||
courseId: z.string().uuid(),
|
||||
});
|
||||
version: z.number().int().optional(),
|
||||
courseNumber: z.string().optional(),
|
||||
name: z.string().min(1).max(256).optional(),
|
||||
description: z.string().optional(),
|
||||
categoryId: z.string().uuid().optional(),
|
||||
instructorId: z.string().uuid().optional(),
|
||||
locationId: z.string().uuid().optional(),
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
fee: z.number().min(0).optional(),
|
||||
reducedFee: z.number().min(0).optional().nullable(),
|
||||
capacity: z.number().int().min(1).optional(),
|
||||
minParticipants: z.number().int().min(0).optional(),
|
||||
status: CourseStatusEnum.optional(),
|
||||
registrationDeadline: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(d) => d.reducedFee == null || d.fee == null || d.reducedFee <= d.fee,
|
||||
{
|
||||
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
|
||||
path: ['reducedFee'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(d) =>
|
||||
d.minParticipants == null ||
|
||||
d.capacity == null ||
|
||||
d.minParticipants <= d.capacity,
|
||||
{
|
||||
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
|
||||
path: ['minParticipants'],
|
||||
},
|
||||
)
|
||||
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
|
||||
message: 'Enddatum muss nach dem Startdatum liegen',
|
||||
path: ['endDate'],
|
||||
})
|
||||
.refine(
|
||||
(d) =>
|
||||
!d.registrationDeadline ||
|
||||
!d.startDate ||
|
||||
d.registrationDeadline <= d.startDate,
|
||||
{
|
||||
message: 'Anmeldefrist muss vor dem Startdatum liegen',
|
||||
path: ['registrationDeadline'],
|
||||
},
|
||||
);
|
||||
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
|
||||
|
||||
export const EnrollParticipantSchema = z.object({
|
||||
|
||||
@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { isCourseDomainError } from '../../lib/errors';
|
||||
import {
|
||||
CreateCourseSchema,
|
||||
UpdateCourseSchema,
|
||||
@@ -25,7 +26,7 @@ export const createCourse = authActionClient
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
logger.info({ name: 'course.create' }, 'Creating course...');
|
||||
const result = await api.createCourse(input);
|
||||
const result = await api.courses.create(input, ctx.user.id);
|
||||
logger.info({ name: 'course.create' }, 'Course created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
@@ -37,39 +38,60 @@ export const updateCourse = authActionClient
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
try {
|
||||
logger.info({ name: 'course.update' }, 'Updating course...');
|
||||
const result = await api.updateCourse(input);
|
||||
const result = await api.courses.update(input, ctx.user.id);
|
||||
logger.info({ name: 'course.update' }, 'Course updated');
|
||||
return { success: true, data: result };
|
||||
} catch (e) {
|
||||
if (isCourseDomainError(e)) {
|
||||
return { success: false, error: e.message, code: e.code };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteCourse = authActionClient
|
||||
.inputSchema(z.object({ courseId: z.string().uuid() }))
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
try {
|
||||
logger.info({ name: 'course.delete' }, 'Archiving course...');
|
||||
await api.deleteCourse(input.courseId);
|
||||
await api.courses.softDelete(input.courseId);
|
||||
logger.info({ name: 'course.delete' }, 'Course archived');
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (isCourseDomainError(e)) {
|
||||
return { success: false, error: e.message, code: e.code };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
export const enrollParticipant = authActionClient
|
||||
.inputSchema(EnrollParticipantSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
try {
|
||||
logger.info(
|
||||
{ name: 'course.enrollParticipant' },
|
||||
'Enrolling participant...',
|
||||
);
|
||||
const result = await api.enrollParticipant(input);
|
||||
const result = await api.enrollment.enroll(input);
|
||||
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
|
||||
return { success: true, data: result };
|
||||
} catch (e) {
|
||||
if (isCourseDomainError(e)) {
|
||||
return { success: false, error: e.message, code: e.code };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
export const cancelEnrollment = authActionClient
|
||||
@@ -78,7 +100,7 @@ export const cancelEnrollment = authActionClient
|
||||
participantId: z.string().uuid(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
@@ -87,9 +109,9 @@ export const cancelEnrollment = authActionClient
|
||||
{ name: 'course.cancelEnrollment' },
|
||||
'Cancelling enrollment...',
|
||||
);
|
||||
const result = await api.cancelEnrollment(input.participantId);
|
||||
await api.enrollment.cancel(input.participantId);
|
||||
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
|
||||
return { success: true, data: result };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const markAttendance = authActionClient
|
||||
@@ -100,69 +122,69 @@ export const markAttendance = authActionClient
|
||||
present: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
|
||||
const result = await api.markAttendance(
|
||||
await api.attendance.mark(
|
||||
input.sessionId,
|
||||
input.participantId,
|
||||
input.present,
|
||||
);
|
||||
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
|
||||
return { success: true, data: result };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
export const createCategory = authActionClient
|
||||
.inputSchema(CreateCategorySchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
logger.info({ name: 'course.createCategory' }, 'Creating category...');
|
||||
const result = await api.createCategory(input);
|
||||
const result = await api.referenceData.createCategory(input);
|
||||
logger.info({ name: 'course.createCategory' }, 'Category created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const createInstructor = authActionClient
|
||||
.inputSchema(CreateInstructorSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
|
||||
const result = await api.createInstructor(input);
|
||||
const result = await api.referenceData.createInstructor(input);
|
||||
logger.info({ name: 'course.createInstructor' }, 'Instructor created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const createLocation = authActionClient
|
||||
.inputSchema(CreateLocationSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
logger.info({ name: 'course.createLocation' }, 'Creating location...');
|
||||
const result = await api.createLocation(input);
|
||||
const result = await api.referenceData.createLocation(input);
|
||||
logger.info({ name: 'course.createLocation' }, 'Location created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
export const createSession = authActionClient
|
||||
.inputSchema(CreateSessionSchema)
|
||||
.action(async ({ parsedInput: input, ctx }) => {
|
||||
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const api = createCourseManagementApi(client);
|
||||
|
||||
logger.info({ name: 'course.createSession' }, 'Creating session...');
|
||||
const result = await api.createSession(input);
|
||||
const result = await api.sessions.create(input);
|
||||
logger.info({ name: 'course.createSession' }, 'Session created');
|
||||
return { success: true, data: result };
|
||||
});
|
||||
|
||||
@@ -1,361 +1,22 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type {
|
||||
CreateCourseInput,
|
||||
UpdateCourseInput,
|
||||
EnrollParticipantInput,
|
||||
} from '../schema/course.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { createAttendanceService } from './services/attendance.service';
|
||||
import { createCourseCrudService } from './services/course-crud.service';
|
||||
import { createCourseReferenceDataService } from './services/course-reference-data.service';
|
||||
import { createCourseStatisticsService } from './services/course-statistics.service';
|
||||
import { createEnrollmentService } from './services/enrollment.service';
|
||||
import { createSessionService } from './services/session.service';
|
||||
|
||||
export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
||||
const _db = client;
|
||||
|
||||
return {
|
||||
// --- Courses ---
|
||||
async listCourses(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('courses')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('start_date', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.search)
|
||||
query = query.or(
|
||||
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
|
||||
);
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async getCourse(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.select('*')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createCourse(input: CreateCourseInput) {
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
course_number: input.courseNumber || null,
|
||||
name: input.name,
|
||||
description: input.description || null,
|
||||
category_id: input.categoryId || null,
|
||||
instructor_id: input.instructorId || null,
|
||||
location_id: input.locationId || null,
|
||||
start_date: input.startDate || null,
|
||||
end_date: input.endDate || null,
|
||||
fee: input.fee,
|
||||
reduced_fee: input.reducedFee ?? null,
|
||||
capacity: input.capacity,
|
||||
min_participants: input.minParticipants,
|
||||
status: input.status,
|
||||
registration_deadline: input.registrationDeadline || null,
|
||||
notes: input.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateCourse(input: UpdateCourseInput) {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (input.name !== undefined) update.name = input.name;
|
||||
if (input.courseNumber !== undefined)
|
||||
update.course_number = input.courseNumber || null;
|
||||
if (input.description !== undefined)
|
||||
update.description = input.description || null;
|
||||
if (input.categoryId !== undefined)
|
||||
update.category_id = input.categoryId || null;
|
||||
if (input.instructorId !== undefined)
|
||||
update.instructor_id = input.instructorId || null;
|
||||
if (input.locationId !== undefined)
|
||||
update.location_id = input.locationId || null;
|
||||
if (input.startDate !== undefined)
|
||||
update.start_date = input.startDate || null;
|
||||
if (input.endDate !== undefined) update.end_date = input.endDate || null;
|
||||
if (input.fee !== undefined) update.fee = input.fee;
|
||||
if (input.reducedFee !== undefined)
|
||||
update.reduced_fee = input.reducedFee ?? null;
|
||||
if (input.capacity !== undefined) update.capacity = input.capacity;
|
||||
if (input.minParticipants !== undefined)
|
||||
update.min_participants = input.minParticipants;
|
||||
if (input.status !== undefined) update.status = input.status;
|
||||
if (input.registrationDeadline !== undefined)
|
||||
update.registration_deadline = input.registrationDeadline || null;
|
||||
if (input.notes !== undefined) update.notes = input.notes || null;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.update(update)
|
||||
.eq('id', input.courseId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteCourse(courseId: string) {
|
||||
const { error } = await client
|
||||
.from('courses')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', courseId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Enrollment ---
|
||||
async enrollParticipant(input: EnrollParticipantInput) {
|
||||
// Check capacity
|
||||
const { count } = await client
|
||||
.from('course_participants')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('course_id', input.courseId)
|
||||
.in('status', ['enrolled']);
|
||||
const course = await this.getCourse(input.courseId);
|
||||
const status =
|
||||
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
|
||||
|
||||
const { data, error } = await client
|
||||
.from('course_participants')
|
||||
.insert({
|
||||
course_id: input.courseId,
|
||||
member_id: input.memberId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
status,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancelEnrollment(participantId: string) {
|
||||
const { error } = await client
|
||||
.from('course_participants')
|
||||
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
|
||||
.eq('id', participantId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async getParticipants(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_participants')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('enrolled_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// --- Sessions ---
|
||||
async getSessions(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('session_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createSession(input: {
|
||||
courseId: string;
|
||||
sessionDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
locationId?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.insert({
|
||||
course_id: input.courseId,
|
||||
session_date: input.sessionDate,
|
||||
start_time: input.startTime,
|
||||
end_time: input.endTime,
|
||||
location_id: input.locationId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Attendance ---
|
||||
async getAttendance(sessionId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_attendance')
|
||||
.select('*')
|
||||
.eq('session_id', sessionId);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async markAttendance(
|
||||
sessionId: string,
|
||||
participantId: string,
|
||||
present: boolean,
|
||||
) {
|
||||
const { error } = await client.from('course_attendance').upsert(
|
||||
{
|
||||
session_id: sessionId,
|
||||
participant_id: participantId,
|
||||
present,
|
||||
},
|
||||
{ onConflict: 'session_id,participant_id' },
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Categories, Instructors, Locations ---
|
||||
async listCategories(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listInstructors(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listLocations(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// --- Statistics ---
|
||||
async getStatistics(accountId: string) {
|
||||
const { data: courses } = await client
|
||||
.from('courses')
|
||||
.select('status')
|
||||
.eq('account_id', accountId);
|
||||
const { count: totalParticipants } = await client
|
||||
.from('course_participants')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in(
|
||||
'course_id',
|
||||
(courses ?? []).map((c: any) => c.id),
|
||||
);
|
||||
|
||||
const stats = {
|
||||
totalCourses: 0,
|
||||
openCourses: 0,
|
||||
completedCourses: 0,
|
||||
totalParticipants: totalParticipants ?? 0,
|
||||
};
|
||||
for (const c of courses ?? []) {
|
||||
stats.totalCourses++;
|
||||
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
|
||||
if (c.status === 'completed') stats.completedCourses++;
|
||||
}
|
||||
return stats;
|
||||
},
|
||||
|
||||
// --- Create methods for CRUD ---
|
||||
async createCategory(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
parent_id: input.parentId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createInstructor(input: {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
qualifications?: string;
|
||||
hourlyRate?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
qualifications: input.qualifications,
|
||||
hourly_rate: input.hourlyRate,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createLocation(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
room?: string;
|
||||
capacity?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
address: input.address,
|
||||
room: input.room,
|
||||
capacity: input.capacity,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
courses: createCourseCrudService(client),
|
||||
enrollment: createEnrollmentService(client),
|
||||
sessions: createSessionService(client),
|
||||
attendance: createAttendanceService(client),
|
||||
referenceData: createCourseReferenceDataService(client),
|
||||
statistics: createCourseStatisticsService(client),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAttendanceService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async getBySession(sessionId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_attendance')
|
||||
.select('*')
|
||||
.eq('session_id', sessionId);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async mark(sessionId: string, participantId: string, present: boolean) {
|
||||
const { error } = await client.from('course_attendance').upsert(
|
||||
{
|
||||
session_id: sessionId,
|
||||
participant_id: participantId,
|
||||
present,
|
||||
},
|
||||
{ onConflict: 'session_id,participant_id' },
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import {
|
||||
canTransition,
|
||||
validateTransition,
|
||||
getValidTransitions,
|
||||
} from '../../lib/course-status-machine';
|
||||
import {
|
||||
CourseNotFoundError,
|
||||
CourseConcurrencyConflictError,
|
||||
InvalidCourseStatusTransitionError,
|
||||
} from '../../lib/errors';
|
||||
import type {
|
||||
CreateCourseInput,
|
||||
UpdateCourseInput,
|
||||
} from '../../schema/course.schema';
|
||||
|
||||
const NAMESPACE = 'course-crud';
|
||||
|
||||
export function createCourseCrudService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('courses')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('start_date', { ascending: false });
|
||||
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.search)
|
||||
query = query.or(
|
||||
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
|
||||
);
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const total = count ?? 0;
|
||||
|
||||
return {
|
||||
data: data ?? [],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
},
|
||||
|
||||
async getById(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.select('*')
|
||||
.eq('id', courseId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) throw new CourseNotFoundError(courseId);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(input: CreateCourseInput, userId?: string) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: NAMESPACE }, 'Creating course...');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- created_by/updated_by added via migration, not yet in generated types
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
course_number: input.courseNumber || null,
|
||||
name: input.name,
|
||||
description: input.description || null,
|
||||
category_id: input.categoryId || null,
|
||||
instructor_id: input.instructorId || null,
|
||||
location_id: input.locationId || null,
|
||||
start_date: input.startDate || null,
|
||||
end_date: input.endDate || null,
|
||||
fee: input.fee,
|
||||
reduced_fee: input.reducedFee ?? null,
|
||||
capacity: input.capacity,
|
||||
min_participants: input.minParticipants,
|
||||
status: input.status,
|
||||
registration_deadline: input.registrationDeadline || null,
|
||||
notes: input.notes || null,
|
||||
...(userId ? { created_by: userId, updated_by: userId } : {}),
|
||||
} as any)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(input: UpdateCourseInput, userId?: string) {
|
||||
const logger = await getLogger();
|
||||
logger.info(
|
||||
{ name: NAMESPACE, courseId: input.courseId },
|
||||
'Updating course...',
|
||||
);
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
|
||||
if (input.name !== undefined) update.name = input.name;
|
||||
if (input.courseNumber !== undefined)
|
||||
update.course_number = input.courseNumber || null;
|
||||
if (input.description !== undefined)
|
||||
update.description = input.description || null;
|
||||
if (input.categoryId !== undefined)
|
||||
update.category_id = input.categoryId || null;
|
||||
if (input.instructorId !== undefined)
|
||||
update.instructor_id = input.instructorId || null;
|
||||
if (input.locationId !== undefined)
|
||||
update.location_id = input.locationId || null;
|
||||
if (input.startDate !== undefined)
|
||||
update.start_date = input.startDate || null;
|
||||
if (input.endDate !== undefined) update.end_date = input.endDate || null;
|
||||
if (input.fee !== undefined) update.fee = input.fee;
|
||||
if (input.reducedFee !== undefined)
|
||||
update.reduced_fee = input.reducedFee ?? null;
|
||||
if (input.capacity !== undefined) update.capacity = input.capacity;
|
||||
if (input.minParticipants !== undefined)
|
||||
update.min_participants = input.minParticipants;
|
||||
if (input.registrationDeadline !== undefined)
|
||||
update.registration_deadline = input.registrationDeadline || null;
|
||||
if (input.notes !== undefined) update.notes = input.notes || null;
|
||||
|
||||
// Status transition validation
|
||||
if (input.status !== undefined) {
|
||||
const { data: current, error: fetchError } = await client
|
||||
.from('courses')
|
||||
.select('status')
|
||||
.eq('id', input.courseId)
|
||||
.single();
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
const currentStatus = current.status as string;
|
||||
if (currentStatus !== input.status) {
|
||||
try {
|
||||
const sideEffects = validateTransition(
|
||||
currentStatus as Parameters<typeof validateTransition>[0],
|
||||
input.status as Parameters<typeof validateTransition>[1],
|
||||
);
|
||||
Object.assign(update, sideEffects);
|
||||
} catch {
|
||||
throw new InvalidCourseStatusTransitionError(
|
||||
currentStatus,
|
||||
input.status,
|
||||
getValidTransitions(
|
||||
currentStatus as Parameters<typeof getValidTransitions>[0],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
update.status = input.status;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
update.updated_by = userId;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version/updated_by columns added via migration, not yet in generated types
|
||||
let query = client
|
||||
.from('courses')
|
||||
.update(update as any)
|
||||
.eq('id', input.courseId);
|
||||
|
||||
// Optimistic locking via version column
|
||||
if (input.version !== undefined) {
|
||||
query = (query as any).eq('version', input.version);
|
||||
}
|
||||
|
||||
const { data, error } = await query.select().single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116' && input.version !== undefined) {
|
||||
throw new CourseConcurrencyConflictError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async softDelete(courseId: string) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: NAMESPACE, courseId }, 'Cancelling course...');
|
||||
|
||||
const { data: current, error: fetchError } = await client
|
||||
.from('courses')
|
||||
.select('status')
|
||||
.eq('id', courseId)
|
||||
.maybeSingle();
|
||||
if (fetchError) throw fetchError;
|
||||
if (!current) throw new CourseNotFoundError(courseId);
|
||||
|
||||
type CourseStatus = Parameters<typeof canTransition>[0];
|
||||
if (!canTransition(current.status as CourseStatus, 'cancelled')) {
|
||||
throw new InvalidCourseStatusTransitionError(
|
||||
current.status,
|
||||
'cancelled',
|
||||
getValidTransitions(current.status as CourseStatus),
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await client
|
||||
.from('courses')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', courseId);
|
||||
if (error) throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createCourseReferenceDataService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return {
|
||||
async listCategories(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listInstructors(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listLocations(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createCategory(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
parent_id: input.parentId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createInstructor(input: {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
qualifications?: string;
|
||||
hourlyRate?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
qualifications: input.qualifications,
|
||||
hourly_rate: input.hourlyRate,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createLocation(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
room?: string;
|
||||
capacity?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
address: input.address,
|
||||
room: input.room,
|
||||
capacity: input.capacity,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createCourseStatisticsService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return {
|
||||
async getQuickStats(accountId: string) {
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'get_course_statistics',
|
||||
{ p_account_id: accountId },
|
||||
);
|
||||
if (error) throw error;
|
||||
// RPC returns a single row as an array
|
||||
const stats = Array.isArray(data) ? data[0] : data;
|
||||
return (
|
||||
stats ?? {
|
||||
total_courses: 0,
|
||||
open_courses: 0,
|
||||
running_courses: 0,
|
||||
completed_courses: 0,
|
||||
cancelled_courses: 0,
|
||||
total_participants: 0,
|
||||
total_waitlisted: 0,
|
||||
avg_occupancy_rate: 0,
|
||||
total_revenue: 0,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async getAttendanceSummary(courseId: string) {
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'get_course_attendance_summary',
|
||||
{ p_course_id: courseId },
|
||||
);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { EnrollParticipantInput } from '../../schema/course.schema';
|
||||
|
||||
export function createEnrollmentService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async enroll(input: EnrollParticipantInput) {
|
||||
// Uses the enroll_course_participant RPC which handles capacity checks
|
||||
// and waitlisting atomically, avoiding race conditions.
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'enroll_course_participant',
|
||||
{
|
||||
p_course_id: input.courseId,
|
||||
p_member_id: input.memberId ?? null,
|
||||
p_first_name: input.firstName,
|
||||
p_last_name: input.lastName,
|
||||
p_email: input.email || null,
|
||||
p_phone: input.phone || null,
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancel(participantId: string) {
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'cancel_course_enrollment',
|
||||
{ p_participant_id: participantId },
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as {
|
||||
cancelled_id: string;
|
||||
promoted_id: string | null;
|
||||
promoted_name: string | null;
|
||||
};
|
||||
},
|
||||
|
||||
async listParticipants(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_participants')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('enrolled_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createAttendanceService } from './attendance.service';
|
||||
import { createCourseCrudService } from './course-crud.service';
|
||||
import { createCourseReferenceDataService } from './course-reference-data.service';
|
||||
import { createCourseStatisticsService } from './course-statistics.service';
|
||||
import { createEnrollmentService } from './enrollment.service';
|
||||
import { createSessionService } from './session.service';
|
||||
|
||||
export {
|
||||
createAttendanceService,
|
||||
createCourseCrudService,
|
||||
createCourseReferenceDataService,
|
||||
createCourseStatisticsService,
|
||||
createEnrollmentService,
|
||||
createSessionService,
|
||||
};
|
||||
|
||||
export function createCourseServices(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
courses: createCourseCrudService(client),
|
||||
enrollment: createEnrollmentService(client),
|
||||
sessions: createSessionService(client),
|
||||
attendance: createAttendanceService(client),
|
||||
referenceData: createCourseReferenceDataService(client),
|
||||
statistics: createCourseStatisticsService(client),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createSessionService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('session_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
courseId: string;
|
||||
sessionDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
locationId?: string;
|
||||
}) {
|
||||
// Check instructor availability if course has an instructor
|
||||
const { data: course, error: courseError } = await client
|
||||
.from('courses')
|
||||
.select('instructor_id')
|
||||
.eq('id', input.courseId)
|
||||
.single();
|
||||
if (courseError) throw courseError;
|
||||
|
||||
if (course?.instructor_id) {
|
||||
const { data: isAvailable, error: availError } = await (
|
||||
client.rpc as CallableFunction
|
||||
)('check_instructor_availability', {
|
||||
p_instructor_id: course.instructor_id,
|
||||
p_session_date: input.sessionDate,
|
||||
p_start_time: input.startTime,
|
||||
p_end_time: input.endTime,
|
||||
});
|
||||
if (availError) throw availError;
|
||||
if (!isAvailable) {
|
||||
throw new Error('Kursleiter ist zu diesem Zeitpunkt nicht verfügbar');
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.insert({
|
||||
course_id: input.courseId,
|
||||
session_date: input.sessionDate,
|
||||
start_time: input.startTime,
|
||||
end_time: input.endTime,
|
||||
location_id: input.locationId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,9 @@
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts"
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./services/*": "./src/server/services/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user