diff --git a/.dockerignore b/.dockerignore index afbfc0dca..0dfe8e3f3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,8 +3,9 @@ node_modules .turbo **/.turbo .git -*.md .env* +!.env.example +!.env.local.example .DS_Store apps/e2e apps/dev-tool @@ -16,3 +17,6 @@ apps/dev-tool .github docs **/*.tsbuildinfo +**/*.md +!**/AGENTS.md +!**/CLAUDE.md diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 000000000..51ac484d7 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,19 @@ +# ===================================================== +# MyEasyCMS v2 — Local Development Environment +# Copy to .env and run: docker compose -f docker-compose.local.yml up -d +# ===================================================== + +# --- Database --- +POSTGRES_PASSWORD=postgres + +# --- Supabase Auth --- +JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long + +# --- Supabase Keys (demo keys — safe for local dev only) --- +SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU + +# --- Stripe (test keys) --- +# Get your own test keys from https://dashboard.stripe.com/test/apikeys +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_YOUR_KEY +STRIPE_SECRET_KEY=sk_test_YOUR_KEY diff --git a/AGENTS.md b/AGENTS.md index 26ca9df84..61e71f358 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,7 +72,7 @@ After implementation, always run: # GitNexus — Code Intelligence -This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/CLAUDE.md b/CLAUDE.md index f0862be7c..1ba3f4030 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,7 +3,7 @@ # GitNexus — Code Intelligence -This project is indexed by GitNexus as **myeasycms-v2** (5424 symbols, 14434 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **myeasycms-v2** (7081 symbols, 18885 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. > If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. diff --git a/Dockerfile b/Dockerfile index 5d9a6b60f..3b65a923d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,15 @@ -FROM node:22-alpine AS base +# node:22-slim (Debian/glibc) is ~2x faster for Next.js builds vs Alpine/musl +FROM node:22-slim AS base RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app -# --- Install + Build in one stage --- +# --- Install + Build --- FROM base AS builder -# CACHE_BUST: change this value to force a full rebuild (busts Docker layer cache) -ARG CACHE_BUST=14 -RUN echo "Cache bust: ${CACHE_BUST}" COPY . . -RUN pnpm install --no-frozen-lockfile +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \ + pnpm install --no-frozen-lockfile --prefer-offline ENV NEXT_TELEMETRY_DISABLED=1 -# NEXT_PUBLIC_* vars are baked into the Next.js build at compile time. -# Pass them as build args so the same Dockerfile works for any environment. ARG NEXT_PUBLIC_CI=false ARG NEXT_PUBLIC_SITE_URL=https://myeasycms.de ARG NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000 @@ -39,18 +36,18 @@ ENV NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING=${NEXT_PUBLIC_ENABLE_PERSONAL_AC ENV NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=${NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING} RUN pnpm --filter web build -# --- Run --- -FROM base AS runner +# --- Run (slim for smaller image than full Debian) --- +FROM node:22-slim AS runner +RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 COPY --from=builder /app/ ./ -RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs - -# Ensure Next.js cache directories are writable by the nextjs user +RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --create-home nextjs RUN mkdir -p /app/apps/web/.next/cache && chown -R nextjs:nodejs /app/apps/web/.next/cache +RUN mkdir -p /home/nextjs/.cache/node/corepack && chown -R nextjs:nodejs /home/nextjs/.cache USER nextjs diff --git a/apps/e2e/tests/member-lifecycle.spec.ts b/apps/e2e/tests/member-lifecycle.spec.ts index 17cfe00a1..b329b7000 100644 --- a/apps/e2e/tests/member-lifecycle.spec.ts +++ b/apps/e2e/tests/member-lifecycle.spec.ts @@ -4,7 +4,9 @@ import { test, expect } from '@playwright/test'; test.describe('Member Management', () => { - test('create member, edit, search, filter by status', async ({ page: _page }) => { + test('create member, edit, search, filter by status', async ({ + page: _page, + }) => { await page.goto('/auth/sign-in'); await page.fill('input[name="email"]', 'test@example.com'); await page.fill('input[name="password"]', 'testpassword123'); diff --git a/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx b/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx index ddfe84afc..0bf69eaef 100644 --- a/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/feature-carousel.tsx @@ -457,10 +457,7 @@ export function FeatureCarousel() { const [active, setActive] = useState(0); const slide = SLIDES[active]!; - const next = useCallback( - () => setActive((i) => (i + 1) % SLIDES.length), - [], - ); + const next = useCallback(() => setActive((i) => (i + 1) % SLIDES.length), []); const prev = useCallback( () => setActive((i) => (i - 1 + SLIDES.length) % SLIDES.length), [], diff --git a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx index 2b3045dfa..8ba82c27d 100644 --- a/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx +++ b/apps/web/app/[locale]/(marketing)/pricing/_components/pricing-calculator.tsx @@ -8,10 +8,7 @@ import { Check, ExternalLink, X } from 'lucide-react'; import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; -import { - Card, - CardContent, -} from '@kit/ui/card'; +import { Card, CardContent } from '@kit/ui/card'; import { Table, TableBody, @@ -265,7 +262,10 @@ function PriceBar({
{available ? (
0 ? 4 : 0, @@ -323,7 +323,10 @@ export function PricingCalculator() {
{/* ── Header ── */}
- + Preisvergleich

@@ -376,7 +379,7 @@ export function PricingCalculator() {
-
+
Ihr MYeasyCMS-Tarif
@@ -460,21 +463,20 @@ export function PricingCalculator() { {bestSaving && bestSaving.p !== null && bestSaving.p > tier.price && (
-
+
Ersparnis vs. {bestSaving.name.split(' ')[0]}
{fmt((bestSaving.p - tier.price) * 12)} €
- pro Jahr ( - {Math.round((1 - tier.price / bestSaving.p) * 100)}% + pro Jahr ({Math.round((1 - tier.price / bestSaving.p) * 100)}% günstiger)
-
+
Preis pro Mitglied
@@ -521,19 +523,19 @@ export function PricingCalculator() { {USP_FEATURES.map((f, i) => ( {f.label} - {( - ['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const - ).map((col) => ( - - - - ))} + {(['mcms', 'sewobe', 'easy', 'club', 'wiso'] as const).map( + (col) => ( + + + + ), + )} ))} diff --git a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx index 80a79be04..858648cb7 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/calendar/page.tsx @@ -82,7 +82,7 @@ export default async function BookingCalendarPage({ params }: PageProps) { const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`; const monthEnd = `${year}-${String(month + 1).padStart(2, '0')}-${String(daysInMonth).padStart(2, '0')}`; - const bookings = await api.listBookings(acct.id, { + const bookings = await api.bookings.list(acct.id, { from: monthStart, to: monthEnd, page: 1, diff --git a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx index 966950c02..f4e48b288 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/guests/page.tsx @@ -34,7 +34,7 @@ export default async function GuestsPage({ params }: PageProps) { } const api = createBookingManagementApi(client); - const guests = await api.listGuests(acct.id); + const guests = await api.guests.list(acct.id); return ( diff --git a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx index 9c2260269..a479ae82b 100644 --- a/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/bookings/new/page.tsx @@ -29,7 +29,7 @@ export default async function NewBookingPage({ params }: Props) { } const api = createBookingManagementApi(client); - const rooms = await api.listRooms(acct.id); + const rooms = await api.rooms.list(acct.id); return ( diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx index c3ede8b54..f58bc7f7a 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/attendance/page.tsx @@ -29,9 +29,9 @@ export default async function AttendancePage({ const t = await getTranslations('courses'); const [course, sessions, participants] = await Promise.all([ - api.getCourse(courseId), - api.getSessions(courseId), - api.getParticipants(courseId), + api.courses.getById(courseId), + api.sessions.list(courseId), + api.enrollment.listParticipants(courseId), ]); if (!course) return ; @@ -43,7 +43,7 @@ export default async function AttendancePage({ : null); const attendance = selectedSessionId - ? await api.getAttendance(selectedSessionId) + ? await api.attendance.getBySession(selectedSessionId) : []; const attendanceMap = new Map( diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx index 63e0d0dd4..fad7bb8e1 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/edit/page.tsx @@ -25,7 +25,7 @@ export default async function EditCoursePage({ params }: PageProps) { if (!acct) return ; const api = createCourseManagementApi(client); - const course = await api.getCourse(courseId); + const course = await api.courses.getById(courseId); if (!course) return ; const c = course as Record; diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx index 83dd2ac8a..e9c4c475b 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/page.tsx @@ -39,9 +39,9 @@ export default async function CourseDetailPage({ params }: PageProps) { const t = await getTranslations('courses'); const [course, participants, sessions] = await Promise.all([ - api.getCourse(courseId), - api.getParticipants(courseId), - api.getSessions(courseId), + api.courses.getById(courseId), + api.enrollment.listParticipants(courseId), + api.sessions.list(courseId), ]); if (!course) return ; diff --git a/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx b/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx index 13716ae69..a916d31f7 100644 --- a/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/[courseId]/participants/page.tsx @@ -40,8 +40,8 @@ export default async function ParticipantsPage({ params }: PageProps) { const t = await getTranslations('courses'); const [course, participants] = await Promise.all([ - api.getCourse(courseId), - api.getParticipants(courseId), + api.courses.getById(courseId), + api.enrollment.listParticipants(courseId), ]); if (!course) return ; diff --git a/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx b/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx index 06e51fc5c..e6b13e694 100644 --- a/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/calendar/page.tsx @@ -45,7 +45,7 @@ export default async function CourseCalendarPage({ if (!acct) return ; const api = createCourseManagementApi(client); - const courses = await api.listCourses(acct.id, { page: 1, pageSize: 100 }); + const courses = await api.courses.list(acct.id, { page: 1, pageSize: 100 }); const now = new Date(); const monthParam = search.month as string | undefined; diff --git a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx index d289131cf..a9a05d324 100644 --- a/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/categories/page.tsx @@ -29,7 +29,7 @@ export default async function CategoriesPage({ params }: PageProps) { if (!acct) return ; const api = createCourseManagementApi(client); - const categories = await api.listCategories(acct.id); + const categories = await api.referenceData.listCategories(acct.id); return ( diff --git a/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx b/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx index 24a051496..7abf3db4d 100644 --- a/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/instructors/page.tsx @@ -30,7 +30,7 @@ export default async function InstructorsPage({ params }: PageProps) { if (!acct) return ; const api = createCourseManagementApi(client); - const instructors = await api.listInstructors(acct.id); + const instructors = await api.referenceData.listInstructors(acct.id); return ( diff --git a/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx b/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx index c64024329..bf8cc3b07 100644 --- a/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/locations/page.tsx @@ -29,7 +29,7 @@ export default async function LocationsPage({ params }: PageProps) { if (!acct) return ; const api = createCourseManagementApi(client); - const locations = await api.listLocations(acct.id); + const locations = await api.referenceData.listLocations(acct.id); return ( diff --git a/apps/web/app/[locale]/home/[account]/courses/page.tsx b/apps/web/app/[locale]/home/[account]/courses/page.tsx index aea8e68f9..3d635ac18 100644 --- a/apps/web/app/[locale]/home/[account]/courses/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/page.tsx @@ -52,13 +52,13 @@ export default async function CoursesPage({ params, searchParams }: PageProps) { const page = Number(search.page) || 1; const [courses, stats] = await Promise.all([ - api.listCourses(acct.id, { + api.courses.list(acct.id, { search: search.q as string, status: search.status as string, page, pageSize: PAGE_SIZE, }), - api.getStatistics(acct.id), + api.statistics.getQuickStats(acct.id), ]); const totalPages = Math.ceil(courses.total / PAGE_SIZE); diff --git a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx index 394f6e474..2d4d1f795 100644 --- a/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx +++ b/apps/web/app/[locale]/home/[account]/courses/statistics/page.tsx @@ -33,7 +33,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) { if (!acct) return ; const api = createCourseManagementApi(client); - const stats = await api.getStatistics(acct.id); + const stats = await api.statistics.getQuickStats(acct.id); const statusChartData = [ { name: t('stats.active'), value: stats.openCourses }, diff --git a/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx index aace795d9..61df7e61f 100644 --- a/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/[eventId]/edit/page.tsx @@ -25,7 +25,7 @@ export default async function EditEventPage({ params }: PageProps) { if (!acct) return ; const api = createEventManagementApi(client); - const event = await api.getEvent(eventId); + const event = await api.events.getById(eventId); if (!event) return ; const e = event as Record; diff --git a/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx b/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx index a2f0ce447..521e5b04e 100644 --- a/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/[eventId]/page.tsx @@ -36,8 +36,8 @@ export default async function EventDetailPage({ params }: PageProps) { const t = await getTranslations('cms.events'); const [event, registrations] = await Promise.all([ - api.getEvent(eventId), - api.getRegistrations(eventId), + api.events.getById(eventId), + api.registrations.list(eventId), ]); if (!event) return
{t('notFound')}
; diff --git a/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx b/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx index 102f7f4cb..d9f069e96 100644 --- a/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/holiday-passes/page.tsx @@ -29,7 +29,7 @@ export default async function HolidayPassesPage({ params }: PageProps) { if (!acct) return ; const api = createEventManagementApi(client); - const passes = await api.listHolidayPasses(acct.id); + const passes = await api.holidayPasses.list(acct.id); return ( diff --git a/apps/web/app/[locale]/home/[account]/events/page.tsx b/apps/web/app/[locale]/home/[account]/events/page.tsx index bc680e686..98f4d0233 100644 --- a/apps/web/app/[locale]/home/[account]/events/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/page.tsx @@ -47,13 +47,13 @@ export default async function EventsPage({ params, searchParams }: PageProps) { const page = Number(search.page) || 1; const api = createEventManagementApi(client); - const events = await api.listEvents(acct.id, { page }); + const events = await api.events.list(acct.id, { page }); // Fetch registration counts for all events on this page const eventIds = events.data.map((eventItem: Record) => String(eventItem.id), ); - const registrationCounts = await api.getRegistrationCounts(eventIds); + const registrationCounts = await api.events.getRegistrationCounts(eventIds); // Pre-compute stats before rendering const uniqueLocationCount = new Set( diff --git a/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx b/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx index 55a419d83..3b598fa91 100644 --- a/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx +++ b/apps/web/app/[locale]/home/[account]/events/registrations/page.tsx @@ -36,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) { if (!acct) return ; const api = createEventManagementApi(client); - const events = await api.listEvents(acct.id, { page: 1 }); + const events = await api.events.list(acct.id, { page: 1 }); // Load registrations for each event in parallel const eventsWithRegistrations = await Promise.all( events.data.map(async (event: Record) => { - const registrations = await api.getRegistrations(String(event.id)); + const registrations = await api.registrations.list(String(event.id)); return { id: String(event.id), name: String(event.name), diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx index 138c09500..7b54971b8 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/edit/page.tsx @@ -1,7 +1,7 @@ import { getTranslations } from 'next-intl/server'; -import { createMemberManagementApi } from '@kit/member-management/api'; import { EditMemberForm } from '@kit/member-management/components'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; @@ -22,8 +22,8 @@ export default async function EditMemberPage({ params }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); - const member = await api.getMember(memberId); + const { query } = createMemberServices(client); + const member = await query.getById(acct.id, memberId); if (!member) return
{t('detail.notFound')}
; return ( diff --git a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx index b53ad293a..8e3115116 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/[memberId]/page.tsx @@ -1,9 +1,8 @@ -import { createMemberManagementApi } from '@kit/member-management/api'; -import { MemberDetailView } from '@kit/member-management/components'; +import { MemberDetailTabs } from '@kit/member-management/components'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; -import { CmsPageShell } from '~/components/cms-page-shell'; interface Props { params: Promise<{ account: string; memberId: string }>; @@ -19,33 +18,24 @@ export default async function MemberDetailPage({ params }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); - const member = await api.getMember(memberId); + const { query, organization } = createMemberServices(client); + const member = await query.getById(acct.id, memberId); if (!member) return ; - // Fetch sub-entities in parallel const [roles, honors, mandates] = await Promise.all([ - api.listMemberRoles(memberId), - api.listMemberHonors(memberId), - api.listMandates(memberId), + organization.listMemberRoles(memberId), + organization.listMemberHonors(memberId), + organization.listMandates(memberId), ]); - const memberName = `${String(member.first_name)} ${String(member.last_name)}`; - return ( - - - + accountId={acct.id} + roles={roles} + honors={honors} + mandates={mandates} + /> ); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx b/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx new file mode 100644 index 000000000..33192c8a3 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members-cms/_components/members-cms-layout-client.tsx @@ -0,0 +1,183 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; + +import { + FileDown, + FileUp, + IdCard, + KeyRound, + LayoutList, + Settings, + Tag, + Users, +} from 'lucide-react'; + +import { + MemberStatsBar, + MemberCommandPalette, +} from '@kit/member-management/components'; +import { Badge } from '@kit/ui/badge'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { PageBody } from '@kit/ui/page'; +import { cn } from '@kit/ui/utils'; + +interface MembersCmsLayoutClientProps { + header: ReactNode; + children: ReactNode; + account: string; + accountId: string; + stats: { + total: number; + active: number; + pending: number; + newThisYear: number; + pendingApplications: number; + }; +} + +export function MembersCmsLayoutClient({ + header, + children, + account, + accountId, + stats, +}: MembersCmsLayoutClientProps) { + const pathname = usePathname(); + const basePath = `/home/${account}/members-cms`; + + const isOnMembersTab = + pathname.endsWith('/members-cms') || + pathname.includes('/members-cms/new') || + /\/members-cms\/[^/]+$/.test(pathname); + const isOnApplicationsTab = pathname.includes('/applications'); + const isOnSubPage = + pathname.includes('/import') || + pathname.includes('/edit') || + (/\/members-cms\/[^/]+$/.test(pathname) && + !pathname.endsWith('/members-cms')); + + return ( + <> + {header} + + +
+ {/* Stats bar — only on main views */} + {!isOnSubPage && } + + {/* Tab navigation + settings */} + {!isOnSubPage && ( +
+ + + +
+ )} + + {children} +
+ + +
+ + ); +} + +function TabLink({ + href, + active, + children, +}: { + href: string; + active: boolean; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function SettingsMenu({ basePath }: { basePath: string }) { + const router = useRouter(); + + const navigate = (path: string) => () => router.push(path); + + return ( + + + + + + + + Beitragskategorien + + + + Abteilungen + + + + Tags verwalten + + + + Mitgliedsausweise + + + + Portal-Einladungen + + + + Import + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx index f30ac0b3b..d40218fee 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/applications/page.tsx @@ -1,11 +1,8 @@ -import { getTranslations } from 'next-intl/server'; - -import { createMemberManagementApi } from '@kit/member-management/api'; import { ApplicationWorkflow } from '@kit/member-management/components'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; -import { CmsPageShell } from '~/components/cms-page-shell'; interface Props { params: Promise<{ account: string }>; @@ -14,7 +11,7 @@ interface Props { export default async function ApplicationsPage({ params }: Props) { const { account } = await params; const client = getSupabaseServerClient(); - const t = await getTranslations('members'); + const { data: acct } = await client .from('accounts') .select('id') @@ -22,20 +19,14 @@ export default async function ApplicationsPage({ params }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); - const applications = await api.listApplications(acct.id); + const { workflow } = createMemberServices(client); + const applications = await workflow.listApplications(acct.id); return ( - - - + /> ); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx index f4d25a252..5c35c19e2 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/cards/page.tsx @@ -1,7 +1,7 @@ import { CreditCard } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; -import { createMemberManagementApi } from '@kit/member-management/api'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; @@ -26,8 +26,8 @@ export default async function MemberCardsPage({ params }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); - const result = await api.listMembers(acct.id, { + const { query } = createMemberServices(client); + const result = await query.list(acct.id, { status: 'active', pageSize: CARDS_PAGE_SIZE, }); diff --git a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx index 85f3db52b..bfcd80153 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/departments/page.tsx @@ -1,7 +1,7 @@ import { Users } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; -import { createMemberManagementApi } from '@kit/member-management/api'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; @@ -26,8 +26,8 @@ export default async function DepartmentsPage({ params }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); - const departments = await api.listDepartments(acct.id); + const { organization } = createMemberServices(client); + const departments = await organization.listDepartments(acct.id); return ( ; - const api = createMemberManagementApi(client); - const categories = await api.listDuesCategories(acct.id); + const { organization } = createMemberServices(client); + const categories = await organization.listDuesCategories(acct.id); return ( ; - const api = createMemberManagementApi(client); - const invitations = await api.listPortalInvitations(acct.id); + const { workflow } = createMemberServices(client); + const invitations = await workflow.listPortalInvitations(acct.id); // Fetch members for the "send invitation" dialog const { data: members } = await client diff --git a/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx b/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx new file mode 100644 index 000000000..4c0e10d1d --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members-cms/layout.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from 'react'; + +import { createMemberServices } from '@kit/member-management/services'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { AccountNotFound } from '~/components/account-not-found'; +import { TeamAccountLayoutPageHeader } from '~/home/[account]/_components/team-account-layout-page-header'; + +import { MembersCmsLayoutClient } from './_components/members-cms-layout-client'; + +interface Props { + children: ReactNode; + params: Promise<{ account: string }>; +} + +export default async function MembersCmsLayout({ children, params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + + if (!acct) return ; + + const { query } = createMemberServices(client); + const stats = await query.getQuickStats(acct.id); + + return ( + + } + account={account} + accountId={acct.id} + stats={stats} + > + {children} + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx index 4c7f49fb9..b2e298e25 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/new/page.tsx @@ -1,11 +1,8 @@ -import { getTranslations } from 'next-intl/server'; - -import { createMemberManagementApi } from '@kit/member-management/api'; -import { CreateMemberForm } from '@kit/member-management/components'; +import { MemberCreateWizard } from '@kit/member-management/components'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; -import { CmsPageShell } from '~/components/cms-page-shell'; interface Props { params: Promise<{ account: string }>; @@ -13,7 +10,6 @@ interface Props { export default async function NewMemberPage({ params }: Props) { const { account } = await params; - const t = await getTranslations('members'); const client = getSupabaseServerClient(); const { data: acct } = await client .from('accounts') @@ -22,26 +18,20 @@ export default async function NewMemberPage({ params }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); - const duesCategories = await api.listDuesCategories(acct.id); + const { organization } = createMemberServices(client); + const duesCategories = await organization.listDuesCategories(acct.id); return ( - - ) => ({ - id: String(c.id), - name: String(c.name), - amount: Number(c.amount ?? 0), - }), - )} - /> - + duesCategories={(duesCategories ?? []).map( + (c: Record) => ({ + id: String(c.id), + name: String(c.name), + amount: Number(c.amount ?? 0), + }), + )} + /> ); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx index c8951f7e6..7b12fb12e 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/page.tsx @@ -1,11 +1,8 @@ -import { getTranslations } from 'next-intl/server'; - -import { createMemberManagementApi } from '@kit/member-management/api'; -import { MembersDataTable } from '@kit/member-management/components'; +import { MembersListView } from '@kit/member-management/components'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AccountNotFound } from '~/components/account-not-found'; -import { CmsPageShell } from '~/components/cms-page-shell'; const PAGE_SIZE = 25; @@ -18,7 +15,7 @@ export default async function MembersPage({ params, searchParams }: Props) { const { account } = await params; const search = await searchParams; const client = getSupabaseServerClient(); - const t = await getTranslations('members'); + const { data: acct } = await client .from('accounts') .select('id') @@ -26,36 +23,89 @@ export default async function MembersPage({ params, searchParams }: Props) { .single(); if (!acct) return ; - const api = createMemberManagementApi(client); + const { query, organization } = createMemberServices(client); const page = Number(search.page) || 1; - const result = await api.listMembers(acct.id, { + + // Parse multi-status filter + const statusParam = search.status; + const statusFilter = statusParam + ? Array.isArray(statusParam) + ? statusParam + : statusParam.split(',') + : undefined; + + const result = await query.search({ + accountId: acct.id, search: search.q as string, - status: search.status as string, + status: statusFilter as any, + duesCategoryId: search.duesCategoryId as string, + sortBy: (search.sortBy as string) ?? 'last_name', + sortDirection: (search.sortDirection as 'asc' | 'desc') ?? 'asc', page, pageSize: PAGE_SIZE, }); - const duesCategories = await api.listDuesCategories(acct.id); + + // Fetch categories, departments, and tags in parallel + const [duesCategories, departments, tagsResult, tagAssignmentsResult] = + await Promise.all([ + organization.listDuesCategories(acct.id), + organization.listDepartmentsWithCounts(acct.id), + (client.from as any)('member_tags') + .select('id, name, color') + .eq('account_id', acct.id) + .order('sort_order'), + (client.from as any)('member_tag_assignments') + .select('member_id, tag_id, member_tags(id, name, color)') + .in( + 'member_id', + result.data.map((m: any) => m.id), + ), + ]); + + // Build memberTags lookup: { memberId: [{ id, name, color }] } + const memberTags: Record< + string, + Array<{ id: string; name: string; color: string }> + > = {}; + + for (const a of tagAssignmentsResult.data ?? []) { + const memberId = String(a.member_id); + const tag = a.member_tags; + if (!tag) continue; + + if (!memberTags[memberId]) memberTags[memberId] = []; + memberTags[memberId]!.push({ + id: String(tag.id), + name: String(tag.name), + color: String(tag.color), + }); + } return ( - - ) => ({ - id: String(c.id), - name: String(c.name), - }), - )} - /> - + accountId={acct.id} + duesCategories={(duesCategories ?? []).map( + (c: Record) => ({ + id: String(c.id), + name: String(c.name), + }), + )} + departments={(departments ?? []).map((d) => ({ + id: String(d.id), + name: String(d.name), + memberCount: d.memberCount, + }))} + tags={(tagsResult.data ?? []).map((t: any) => ({ + id: String(t.id), + name: String(t.name), + color: String(t.color), + }))} + memberTags={memberTags} + /> ); } diff --git a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx index f9df4e0ca..94f0855ed 100644 --- a/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx +++ b/apps/web/app/[locale]/home/[account]/members-cms/statistics/page.tsx @@ -8,7 +8,7 @@ import { } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; -import { createMemberManagementApi } from '@kit/member-management/api'; +import { createMemberServices } from '@kit/member-management/services'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; @@ -34,8 +34,8 @@ export default async function MemberStatisticsPage({ params }: PageProps) { if (!acct) return ; - const api = createMemberManagementApi(client); - const stats = await api.getMemberStatistics(acct.id); + const { query } = createMemberServices(client); + const stats = await query.getStatistics(acct.id); const statusChartData = [ { name: t('status.active'), value: stats.active ?? 0 }, diff --git a/apps/web/app/[locale]/home/[account]/members-cms/tags/page.tsx b/apps/web/app/[locale]/home/[account]/members-cms/tags/page.tsx new file mode 100644 index 000000000..9002dc805 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members-cms/tags/page.tsx @@ -0,0 +1,41 @@ +import { getTranslations } from 'next-intl/server'; + +import { TagsManager } from '@kit/member-management/components'; +import { createMemberServices } from '@kit/member-management/services'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { AccountNotFound } from '~/components/account-not-found'; +import { CmsPageShell } from '~/components/cms-page-shell'; + +interface Props { + params: Promise<{ account: string }>; +} + +export default async function TagsPage({ params }: Props) { + const { account } = await params; + const client = getSupabaseServerClient(); + const t = await getTranslations('members'); + + const { data: acct } = await client + .from('accounts') + .select('id') + .eq('slug', account) + .single(); + if (!acct) return ; + + // Fetch tags via direct query (table may not be in generated types yet) + const { data: tags } = await (client.from as any)('member_tags') + .select('*') + .eq('account_id', acct.id) + .order('sort_order'); + + return ( + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx index 590488988..4e5486d70 100644 --- a/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx +++ b/apps/web/app/[locale]/home/[account]/newsletter/templates/page.tsx @@ -1,4 +1,3 @@ - import { FileText, Plus } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; diff --git a/apps/web/app/[locale]/home/[account]/page.tsx b/apps/web/app/[locale]/home/[account]/page.tsx index 746c916ac..3adf1f7d9 100644 --- a/apps/web/app/[locale]/home/[account]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/page.tsx @@ -18,7 +18,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api'; import { createCourseManagementApi } from '@kit/course-management/api'; import { createEventManagementApi } from '@kit/event-management/api'; import { createFinanceApi } from '@kit/finance/api'; -import { createMemberManagementApi } from '@kit/member-management/api'; +import { createMemberServices } from '@kit/member-management/services'; import { createNewsletterApi } from '@kit/newsletter/api'; import { formatDate } from '@kit/shared/dates'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; @@ -64,18 +64,23 @@ export default async function TeamAccountHomePage({ bookingsResult, eventsResult, ] = await Promise.allSettled([ - createMemberManagementApi(client).getMemberStatistics(acct.id), - createCourseManagementApi(client).getStatistics(acct.id), + createMemberServices(client).query.getStatistics(acct.id), + createCourseManagementApi(client).statistics.getQuickStats(acct.id), createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }), createNewsletterApi(client).listNewsletters(acct.id), - createBookingManagementApi(client).listBookings(acct.id, { page: 1 }), - createEventManagementApi(client).listEvents(acct.id, { page: 1 }), + createBookingManagementApi(client).bookings.list(acct.id, { page: 1 }), + createEventManagementApi(client).events.list(acct.id, { page: 1 }), ]); - const memberStats = - memberStatsResult.status === 'fulfilled' - ? memberStatsResult.value - : { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 }; + const memberStatsRaw = + memberStatsResult.status === 'fulfilled' ? memberStatsResult.value : {}; + const memberStats = { + total: Object.values(memberStatsRaw).reduce((a, b) => a + b, 0), + active: memberStatsRaw.active ?? 0, + inactive: memberStatsRaw.inactive ?? 0, + pending: memberStatsRaw.pending ?? 0, + resigned: memberStatsRaw.resigned ?? 0, + }; const courseStats = courseStatsResult.status === 'fulfilled' diff --git a/apps/web/app/api/club/membership-apply/route.ts b/apps/web/app/api/club/membership-apply/route.ts index 37af843ee..202deb320 100644 --- a/apps/web/app/api/club/membership-apply/route.ts +++ b/apps/web/app/api/club/membership-apply/route.ts @@ -6,6 +6,7 @@ import { emailSchema, requiredString, } from '@kit/next/route-helpers'; +import { checkRateLimit, getClientIp } from '@kit/next/routes/rate-limit'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -20,12 +21,33 @@ const MembershipApplySchema = z.object({ city: z.string().optional(), dateOfBirth: z.string().optional(), message: z.string().optional(), + captchaToken: z.string().optional(), }); +// Rate limits +const MAX_PER_IP = 5; +const MAX_PER_ACCOUNT = 20; +const WINDOW_MS = 60 * 60 * 1000; // 1 hour + export async function POST(request: Request) { const logger = await getLogger(); try { + // Rate limit by IP + const ip = getClientIp(request); + const ipLimit = checkRateLimit( + `membership-apply:ip:${ip}`, + MAX_PER_IP, + WINDOW_MS, + ); + + if (!ipLimit.allowed) { + return apiError( + 'Zu viele Anfragen. Bitte versuchen Sie es später erneut.', + 429, + ); + } + const body = await request.json(); const parsed = MembershipApplySchema.safeParse(body); @@ -44,10 +66,48 @@ export async function POST(request: Request) { city, dateOfBirth, message, + captchaToken, } = parsed.data; + // Rate limit by account + const accountLimit = checkRateLimit( + `membership-apply:account:${accountId}`, + MAX_PER_ACCOUNT, + WINDOW_MS, + ); + + if (!accountLimit.allowed) { + return apiError('Zu viele Bewerbungen für diese Organisation.', 429); + } + + // Verify CAPTCHA when configured — token is required, not optional + if (process.env.CAPTCHA_SECRET_TOKEN) { + if (!captchaToken) { + return apiError('CAPTCHA-Überprüfung erforderlich.', 400); + } + + const { verifyCaptchaToken } = await import('@kit/auth/captcha/server'); + + try { + await verifyCaptchaToken(captchaToken); + } catch { + return apiError('CAPTCHA-Überprüfung fehlgeschlagen.', 400); + } + } + const supabase = getSupabaseServerAdminClient(); + // Validate that the account exists before inserting + const { data: account } = await supabase + .from('accounts') + .select('id') + .eq('id', accountId) + .single(); + + if (!account) { + return apiError('Ungültige Organisation.', 400); + } + const { error } = await supabase.from('membership_applications').insert({ account_id: accountId, first_name: firstName, diff --git a/apps/web/app/api/internal/cron/member-jobs/route.ts b/apps/web/app/api/internal/cron/member-jobs/route.ts new file mode 100644 index 000000000..987e8d6bc --- /dev/null +++ b/apps/web/app/api/internal/cron/member-jobs/route.ts @@ -0,0 +1,87 @@ +import { NextResponse } from 'next/server'; + +import { createMemberNotificationService } from '@kit/member-management/services'; +import { getLogger } from '@kit/shared/logger'; +import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; + +const CRON_SECRET = process.env.CRON_SECRET; + +/** + * Internal cron endpoint for member scheduled jobs. + * Called hourly by pg_cron or external scheduler. + * + * POST /api/internal/cron/member-jobs + * Header: Authorization: Bearer + */ +export async function POST(request: Request) { + const logger = await getLogger(); + + // Verify cron secret + const authHeader = request.headers.get('authorization'); + const token = authHeader?.replace('Bearer ', ''); + + if (!CRON_SECRET || token !== CRON_SECRET) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + const client = getSupabaseServerAdminClient(); + const notificationService = createMemberNotificationService(client); + + // 1. Process pending notification queue + const queueResult = await notificationService.processPendingNotifications(); + + // 2. Run scheduled jobs for all accounts with enabled jobs + const { data: accounts } = await (client.from as any)( + 'scheduled_job_configs', + ) + .select('account_id') + .eq('is_enabled', true) + .or(`next_run_at.is.null,next_run_at.lte.${new Date().toISOString()}`); + + const uniqueAccountIds = [ + ...new Set((accounts ?? []).map((a: any) => a.account_id)), + ] as string[]; + + const jobResults: Record = {}; + + for (const accountId of uniqueAccountIds) { + try { + const result = await notificationService.runScheduledJobs(accountId); + jobResults[accountId] = result; + } catch (e) { + logger.error( + { accountId, error: e, context: 'cron-member-jobs' }, + 'Failed to run jobs for account', + ); + jobResults[accountId] = { + error: e instanceof Error ? e.message : 'Unknown error', + }; + } + } + + const summary = { + timestamp: new Date().toISOString(), + queue: queueResult, + accounts_processed: uniqueAccountIds.length, + jobs: jobResults, + }; + + logger.info( + { context: 'cron-member-jobs', ...summary }, + 'Member cron jobs completed', + ); + + return NextResponse.json(summary); + } catch (err) { + logger.error( + { error: err, context: 'cron-member-jobs' }, + 'Cron job failed', + ); + + return NextResponse.json( + { error: 'Internal server error' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/config/team-account-navigation.config.tsx b/apps/web/config/team-account-navigation.config.tsx index 8ffe909bf..f10b75835 100644 --- a/apps/web/config/team-account-navigation.config.tsx +++ b/apps/web/config/team-account-navigation.config.tsx @@ -5,9 +5,6 @@ import { CreditCard, // People (Members + Access) UserCheck, - UserPlus, - IdCard, - ClipboardList, // Courses GraduationCap, CalendarDays, @@ -69,7 +66,10 @@ import pathsConfig from '~/config/paths.config'; const iconClasses = 'w-4'; -const getRoutes = (account: string, accountFeatures?: Record) => { +const getRoutes = ( + account: string, + accountFeatures?: Record, +) => { const routes: Array< | { label: string; @@ -110,46 +110,11 @@ const getRoutes = (account: string, accountFeatures?: Record) = }> = []; if (featureFlagsConfig.enableMemberManagement) { - peopleChildren.push( - { - label: 'common.routes.clubMembers', - path: createPath(pathsConfig.app.accountCmsMembers, account), - Icon: , - }, - { - label: 'common.routes.memberApplications', - path: createPath( - pathsConfig.app.accountCmsMembers + '/applications', - account, - ), - Icon: , - }, - // NOTE: memberPortal page does not exist yet — nav entry commented out until built - // { - // label: 'common.routes.memberPortal', - // path: createPath( - // pathsConfig.app.accountCmsMembers + '/portal', - // account, - // ), - // Icon: , - // }, - { - label: 'common.routes.memberCards', - path: createPath( - pathsConfig.app.accountCmsMembers + '/cards', - account, - ), - Icon: , - }, - { - label: 'common.routes.memberDues', - path: createPath( - pathsConfig.app.accountCmsMembers + '/dues', - account, - ), - Icon: , - }, - ); + peopleChildren.push({ + label: 'common.routes.clubMembers', + path: createPath(pathsConfig.app.accountCmsMembers, account), + Icon: , + }); } // Admin users who can log in — always visible @@ -417,7 +382,10 @@ const getRoutes = (account: string, accountFeatures?: Record) = } // ── Fisheries ── - if (featureFlagsConfig.enableFischerei && (accountFeatures?.fischerei !== false)) { + if ( + featureFlagsConfig.enableFischerei && + accountFeatures?.fischerei !== false + ) { routes.push({ label: 'common.routes.fisheriesManagement', collapsible: true, @@ -473,7 +441,10 @@ const getRoutes = (account: string, accountFeatures?: Record) = } // ── Meeting Protocols ── - if (featureFlagsConfig.enableMeetingProtocols && (accountFeatures?.meetings !== false)) { + if ( + featureFlagsConfig.enableMeetingProtocols && + accountFeatures?.meetings !== false + ) { routes.push({ label: 'common.routes.meetingProtocols', collapsible: true, @@ -502,7 +473,10 @@ const getRoutes = (account: string, accountFeatures?: Record) = } // ── Association Management (Verband) ── - if (featureFlagsConfig.enableVerbandsverwaltung && (accountFeatures?.verband !== false)) { + if ( + featureFlagsConfig.enableVerbandsverwaltung && + accountFeatures?.verband !== false + ) { routes.push({ label: 'common.routes.associationManagement', collapsible: true, diff --git a/apps/web/i18n/messages/de/cms.json b/apps/web/i18n/messages/de/cms.json index 41acb62b7..b4b528572 100644 --- a/apps/web/i18n/messages/de/cms.json +++ b/apps/web/i18n/messages/de/cms.json @@ -832,4 +832,4 @@ "formatExcel": "Excel" } } -} \ No newline at end of file +} diff --git a/apps/web/i18n/messages/de/finance.json b/apps/web/i18n/messages/de/finance.json index 914bbf19a..e7f78316a 100644 --- a/apps/web/i18n/messages/de/finance.json +++ b/apps/web/i18n/messages/de/finance.json @@ -162,4 +162,4 @@ "completed": "Abgeschlossen", "failed": "Fehlgeschlagen" } -} \ No newline at end of file +} diff --git a/apps/web/i18n/messages/en/cms.json b/apps/web/i18n/messages/en/cms.json index b45a2c202..6f4ce7b76 100644 --- a/apps/web/i18n/messages/en/cms.json +++ b/apps/web/i18n/messages/en/cms.json @@ -352,4 +352,4 @@ } } } -} \ No newline at end of file +} diff --git a/apps/web/i18n/messages/en/finance.json b/apps/web/i18n/messages/en/finance.json index 669a5dded..7ae90eac1 100644 --- a/apps/web/i18n/messages/en/finance.json +++ b/apps/web/i18n/messages/en/finance.json @@ -162,4 +162,4 @@ "completed": "Completed", "failed": "Failed" } -} \ No newline at end of file +} diff --git a/apps/web/lib/database.types.ts b/apps/web/lib/database.types.ts index 8a49b1cc1..1307ff791 100644 --- a/apps/web/lib/database.types.ts +++ b/apps/web/lib/database.types.ts @@ -6404,6 +6404,22 @@ export type Database = { total_upcoming_events: number }[] } + get_member_quick_stats: { + Args: { p_account_id: string } + Returns: { + active: number + inactive: number + new_this_year: number + pending: number + pending_applications: number + resigned: number + total: number + }[] + } + get_next_member_number: { + Args: { p_account_id: string } + Returns: string + } get_nonce_status: { Args: { p_id: string }; Returns: Json } get_upper_system_role: { Args: never; Returns: string } get_user_visible_accounts: { Args: never; Returns: string[] } diff --git a/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql b/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql new file mode 100644 index 000000000..dc802c1a4 --- /dev/null +++ b/apps/web/supabase/migrations/20260415000001_member_search_and_stats.sql @@ -0,0 +1,100 @@ +-- Migration: Enhanced member search and quick stats +-- Adds: full-text search index, quick stats RPC, next member number function + +-- Full-text search index (German) for faster member search +CREATE INDEX IF NOT EXISTS ix_members_fulltext ON public.members + USING gin( + to_tsvector( + 'german', + coalesce(first_name, '') || ' ' || + coalesce(last_name, '') || ' ' || + coalesce(email, '') || ' ' || + coalesce(member_number, '') || ' ' || + coalesce(city, '') + ) + ); + +-- Trigram index on names for fuzzy / ILIKE search +CREATE INDEX IF NOT EXISTS ix_members_name_trgm + ON public.members + USING gin ((lower(first_name || ' ' || last_name)) gin_trgm_ops); + +-- Quick stats RPC — returns a single row with KPI counts +-- Includes has_role_on_account guard to prevent cross-tenant data leaks +CREATE OR REPLACE FUNCTION public.get_member_quick_stats(p_account_id uuid) +RETURNS TABLE( + total bigint, + active bigint, + inactive bigint, + pending bigint, + resigned bigint, + new_this_year bigint, + pending_applications bigint +) +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Verify caller has access to this account + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied to account %', p_account_id; + END IF; + + RETURN QUERY + SELECT + count(*)::bigint AS total, + count(*) FILTER (WHERE m.status = 'active')::bigint AS active, + count(*) FILTER (WHERE m.status = 'inactive')::bigint AS inactive, + count(*) FILTER (WHERE m.status = 'pending')::bigint AS pending, + count(*) FILTER (WHERE m.status = 'resigned')::bigint AS resigned, + count(*) FILTER (WHERE m.status = 'active' + AND m.entry_date >= date_trunc('year', current_date)::date)::bigint AS new_this_year, + ( + SELECT count(*) + FROM public.membership_applications a + WHERE a.account_id = p_account_id + AND a.status = 'submitted' + )::bigint AS pending_applications + FROM public.members m + WHERE m.account_id = p_account_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_member_quick_stats(uuid) TO authenticated; + +-- Next member number: returns max(member_number) + 1 as text +-- Includes has_role_on_account guard +CREATE OR REPLACE FUNCTION public.get_next_member_number(p_account_id uuid) +RETURNS text +LANGUAGE plpgsql STABLE SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_result text; +BEGIN + -- Verify caller has access to this account + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied to account %', p_account_id; + END IF; + + SELECT LPAD( + (COALESCE( + MAX( + CASE + WHEN member_number ~ '^\d+$' THEN member_number::integer + ELSE 0 + END + ), + 0 + ) + 1)::text, + 4, + '0' + ) INTO v_result + FROM public.members + WHERE account_id = p_account_id; + + RETURN v_result; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_next_member_number(uuid) TO authenticated; diff --git a/apps/web/supabase/migrations/20260416000001_atomic_application_workflow.sql b/apps/web/supabase/migrations/20260416000001_atomic_application_workflow.sql new file mode 100644 index 000000000..90e9c704b --- /dev/null +++ b/apps/web/supabase/migrations/20260416000001_atomic_application_workflow.sql @@ -0,0 +1,154 @@ +-- ===================================================== +-- Atomic Application Workflow +-- Replaces multi-query approve/reject in api.ts with +-- single transactional PG functions. +-- ===================================================== + +-- approve_application: atomically creates a member from an application +CREATE OR REPLACE FUNCTION public.approve_application( + p_application_id uuid, + p_user_id uuid +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_app record; + v_member_id uuid; + v_member_number text; +BEGIN + -- 1. Fetch and lock the application + SELECT * INTO v_app + FROM public.membership_applications + WHERE id = p_application_id + FOR UPDATE; + + IF v_app IS NULL THEN + RAISE EXCEPTION 'Application % not found', p_application_id + USING ERRCODE = 'P0002'; + END IF; + + -- Authorization: caller must have write permission on this account + IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN + RAISE EXCEPTION 'Access denied to account %', v_app.account_id + USING ERRCODE = '42501'; + END IF; + + IF v_app.status NOT IN ('submitted', 'review') THEN + RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status + USING ERRCODE = 'P0001'; + END IF; + + -- 2. Generate next member number + SELECT LPAD( + (COALESCE( + MAX(CASE WHEN member_number ~ '^\d+$' THEN member_number::integer ELSE 0 END), + 0 + ) + 1)::text, + 4, '0' + ) INTO v_member_number + FROM public.members + WHERE account_id = v_app.account_id; + + -- 3. Create the member + INSERT INTO public.members ( + account_id, + member_number, + first_name, + last_name, + email, + phone, + street, + postal_code, + city, + date_of_birth, + status, + entry_date, + created_by, + updated_by + ) VALUES ( + v_app.account_id, + v_member_number, + v_app.first_name, + v_app.last_name, + v_app.email, + v_app.phone, + v_app.street, + v_app.postal_code, + v_app.city, + v_app.date_of_birth, + 'active'::public.membership_status, + current_date, + auth.uid(), + auth.uid() + ) + RETURNING id INTO v_member_id; + + -- 4. Mark application as approved + UPDATE public.membership_applications + SET + status = 'approved'::public.application_status, + reviewed_by = auth.uid(), + reviewed_at = now(), + member_id = v_member_id, + updated_at = now() + WHERE id = p_application_id; + + RETURN v_member_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO service_role; + +-- reject_application: atomically rejects an application with notes +CREATE OR REPLACE FUNCTION public.reject_application( + p_application_id uuid, + p_user_id uuid, + p_review_notes text DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_app record; +BEGIN + -- Fetch and lock the application + SELECT * INTO v_app + FROM public.membership_applications + WHERE id = p_application_id + FOR UPDATE; + + IF v_app IS NULL THEN + RAISE EXCEPTION 'Application % not found', p_application_id + USING ERRCODE = 'P0002'; + END IF; + + -- Authorization: caller must have write permission on this account + IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN + RAISE EXCEPTION 'Access denied to account %', v_app.account_id + USING ERRCODE = '42501'; + END IF; + + IF v_app.status NOT IN ('submitted', 'review') THEN + RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status + USING ERRCODE = 'P0001'; + END IF; + + UPDATE public.membership_applications + SET + status = 'rejected'::public.application_status, + reviewed_by = auth.uid(), + reviewed_at = now(), + review_notes = p_review_notes, + updated_at = now() + WHERE id = p_application_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO service_role; diff --git a/apps/web/supabase/migrations/20260416000002_sepa_deduplication.sql b/apps/web/supabase/migrations/20260416000002_sepa_deduplication.sql new file mode 100644 index 000000000..083d5a6f2 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000002_sepa_deduplication.sql @@ -0,0 +1,150 @@ +-- ===================================================== +-- SEPA Data Deduplication (Phase 1) +-- +-- Problem: members table has inline SEPA fields (iban, bic, +-- account_holder, sepa_mandate_id, sepa_mandate_date, +-- sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name) +-- AND a separate sepa_mandates table. sepa_mandate_id is text, +-- not a FK to sepa_mandates(id) which is uuid. Data diverges. +-- +-- Fix: Add proper primary_mandate_id FK, migrate inline data +-- to sepa_mandates rows, rewrite RPCs to read from sepa_mandates. +-- Inline columns are kept read-only for backward compat (phase 2 drops them). +-- ===================================================== + +-- Step 1: Add proper FK column pointing to the primary mandate +ALTER TABLE public.members + ADD COLUMN IF NOT EXISTS primary_mandate_id uuid + REFERENCES public.sepa_mandates(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS ix_members_primary_mandate + ON public.members(primary_mandate_id) + WHERE primary_mandate_id IS NOT NULL; + +-- Step 2: For members with inline SEPA data but no sepa_mandates row, create one +DO $$ +DECLARE + r record; + v_mandate_id uuid; +BEGIN + FOR r IN + SELECT m.id AS member_id, m.account_id, + m.iban, m.bic, m.account_holder, + m.first_name, m.last_name, + m.sepa_mandate_id, m.sepa_mandate_date, + m.sepa_mandate_status, m.sepa_mandate_reference, + m.sepa_mandate_sequence, m.sepa_bank_name + FROM public.members m + WHERE m.iban IS NOT NULL AND m.iban != '' + AND NOT EXISTS ( + SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id + ) + LOOP + INSERT INTO public.sepa_mandates ( + member_id, account_id, mandate_reference, iban, bic, + account_holder, mandate_date, status, sequence, is_primary, notes + ) VALUES ( + r.member_id, + r.account_id, + COALESCE(NULLIF(r.sepa_mandate_reference, ''), NULLIF(r.sepa_mandate_id, ''), 'MIGRATED-' || r.member_id::text), + r.iban, + r.bic, + COALESCE(NULLIF(r.account_holder, ''), NULLIF(TRIM(COALESCE(r.first_name, '') || ' ' || COALESCE(r.last_name, '')), ''), 'Unbekannt'), + COALESCE(r.sepa_mandate_date, current_date), + COALESCE(r.sepa_mandate_status, 'pending'::public.sepa_mandate_status), + COALESCE(NULLIF(r.sepa_mandate_sequence, ''), 'RCUR'), + true, + CASE WHEN r.sepa_bank_name IS NOT NULL AND r.sepa_bank_name != '' + THEN 'Bank: ' || r.sepa_bank_name + ELSE NULL + END + ) + RETURNING id INTO v_mandate_id; + + UPDATE public.members SET primary_mandate_id = v_mandate_id WHERE id = r.member_id; + END LOOP; +END $$; + +-- Step 3: For members that already have sepa_mandates rows, link the primary one +UPDATE public.members m +SET primary_mandate_id = sm.id +FROM public.sepa_mandates sm +WHERE sm.member_id = m.id + AND sm.is_primary = true + AND m.primary_mandate_id IS NULL; + +-- If no mandate marked as primary, pick the most recent active one +UPDATE public.members m +SET primary_mandate_id = ( + SELECT sm.id FROM public.sepa_mandates sm + WHERE sm.member_id = m.id + ORDER BY + CASE WHEN sm.status = 'active' THEN 0 ELSE 1 END, + sm.created_at DESC + LIMIT 1 +) +WHERE m.primary_mandate_id IS NULL + AND EXISTS (SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id); + +-- Step 4: Rewrite list_hierarchy_sepa_eligible_members to read from sepa_mandates +CREATE OR REPLACE FUNCTION public.list_hierarchy_sepa_eligible_members( + root_account_id uuid, + p_account_filter uuid DEFAULT NULL +) +RETURNS TABLE ( + member_id uuid, + account_id uuid, + account_name varchar, + first_name text, + last_name text, + iban text, + bic text, + account_holder text, + mandate_id text, + mandate_date date, + dues_amount numeric +) +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF NOT public.has_role_on_account(root_account_id) THEN + RETURN; + END IF; + + RETURN QUERY + SELECT + m.id AS member_id, + m.account_id, + a.name AS account_name, + m.first_name, + m.last_name, + sm.iban, + sm.bic, + sm.account_holder, + sm.mandate_reference AS mandate_id, + sm.mandate_date, + COALESCE(dc.amount, 0) AS dues_amount + FROM public.members m + JOIN public.accounts a ON a.id = m.account_id + JOIN public.sepa_mandates sm ON sm.id = m.primary_mandate_id + LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id + WHERE m.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d) + AND m.status = 'active' + AND sm.iban IS NOT NULL + AND sm.status = 'active' + AND (p_account_filter IS NULL OR m.account_id = p_account_filter) + ORDER BY a.name, m.last_name, m.first_name; +END; +$$; + +-- Step 5: Add partial index for fast SEPA-eligible lookups +CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_primary + ON public.sepa_mandates(member_id) + WHERE status = 'active' AND is_primary = true; + +-- Note: Inline SEPA columns (iban, bic, account_holder, sepa_mandate_id, +-- sepa_mandate_date, sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name) +-- are kept for read-only backward compatibility. Phase 2 migration will drop them +-- after all code paths are migrated to use sepa_mandates via primary_mandate_id. diff --git a/apps/web/supabase/migrations/20260416000003_member_delete_safety.sql b/apps/web/supabase/migrations/20260416000003_member_delete_safety.sql new file mode 100644 index 000000000..958a766f7 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000003_member_delete_safety.sql @@ -0,0 +1,125 @@ +-- ===================================================== +-- Soft Delete Consistency +-- +-- Problem: deleteMember does a soft delete (status='resigned'), +-- but child table FKs use ON DELETE CASCADE. A hard DELETE +-- would silently destroy roles, honors, mandates, transfers +-- with no audit trail. +-- +-- Fix: Change CASCADE to RESTRICT on data-preserving tables, +-- add BEFORE DELETE audit trigger, provide safe_delete_member(). +-- ===================================================== + +-- Step 1: Change ON DELETE CASCADE → RESTRICT on tables where +-- child data has independent value and should be preserved. +-- We must drop and recreate the FK constraints. + +-- member_roles: board positions have historical value +ALTER TABLE public.member_roles + DROP CONSTRAINT IF EXISTS member_roles_member_id_fkey; +ALTER TABLE public.member_roles + ADD CONSTRAINT member_roles_member_id_fkey + FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; + +-- member_honors: awards/medals are permanent records +ALTER TABLE public.member_honors + DROP CONSTRAINT IF EXISTS member_honors_member_id_fkey; +ALTER TABLE public.member_honors + ADD CONSTRAINT member_honors_member_id_fkey + FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; + +-- sepa_mandates: financial records must be preserved +ALTER TABLE public.sepa_mandates + DROP CONSTRAINT IF EXISTS sepa_mandates_member_id_fkey; +ALTER TABLE public.sepa_mandates + ADD CONSTRAINT sepa_mandates_member_id_fkey + FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; + +-- member_transfers: audit trail must survive +ALTER TABLE public.member_transfers + DROP CONSTRAINT IF EXISTS member_transfers_member_id_fkey; +ALTER TABLE public.member_transfers + ADD CONSTRAINT member_transfers_member_id_fkey + FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT; + +-- Keep CASCADE on tables where data is tightly coupled: +-- member_department_assignments (junction table, no independent value) +-- member_cards (regeneratable) +-- member_portal_invitations (transient) + +-- Step 2: Audit trigger before hard delete — snapshot the full record +CREATE OR REPLACE FUNCTION public.audit_member_before_hard_delete() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- If an audit_log table exists, log the deletion + INSERT INTO public.audit_log ( + account_id, user_id, table_name, record_id, action, old_data + ) + SELECT + OLD.account_id, + COALESCE( + nullif(current_setting('app.current_user_id', true), '')::uuid, + auth.uid() + ), + 'members', + OLD.id::text, + 'delete', + to_jsonb(OLD); + + RETURN OLD; +EXCEPTION + WHEN undefined_table THEN + -- audit_log table doesn't exist yet, allow delete to proceed + RETURN OLD; +END; +$$; + +CREATE TRIGGER trg_members_audit_before_delete + BEFORE DELETE ON public.members + FOR EACH ROW + EXECUTE FUNCTION public.audit_member_before_hard_delete(); + +-- Step 3: Safe hard-delete function for super-admin use only +-- Archives all child records first, then performs the delete. +CREATE OR REPLACE FUNCTION public.safe_delete_member( + p_member_id uuid, + p_performed_by uuid DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_member record; +BEGIN + -- Fetch member for validation + SELECT * INTO v_member FROM public.members WHERE id = p_member_id; + IF v_member IS NULL THEN + RAISE EXCEPTION 'Member % not found', p_member_id + USING ERRCODE = 'P0002'; + END IF; + + -- Set the user ID for the audit trigger + IF p_performed_by IS NOT NULL THEN + PERFORM set_config('app.current_user_id', p_performed_by::text, true); + END IF; + + -- Delete child records that now use RESTRICT + DELETE FROM public.member_roles WHERE member_id = p_member_id; + DELETE FROM public.member_honors WHERE member_id = p_member_id; + DELETE FROM public.sepa_mandates WHERE member_id = p_member_id; + -- member_transfers: delete (the BEFORE DELETE trigger on members already snapshots everything) + DELETE FROM public.member_transfers WHERE member_id = p_member_id; + + -- Now the hard delete triggers audit_member_before_hard_delete + DELETE FROM public.members WHERE id = p_member_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.safe_delete_member(uuid, uuid) TO service_role; +-- Intentionally NOT granted to authenticated — super-admin only via admin client diff --git a/apps/web/supabase/migrations/20260416000004_member_constraints.sql b/apps/web/supabase/migrations/20260416000004_member_constraints.sql new file mode 100644 index 000000000..3f37a1484 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000004_member_constraints.sql @@ -0,0 +1,61 @@ +-- ===================================================== +-- Missing Database Constraints +-- +-- Adds CHECK constraints for data sanity, UNIQUE index +-- for email per account, and IBAN format validation. +-- +-- Fixes existing invalid data before adding constraints. +-- ===================================================== + +-- Fix existing invalid data before adding constraints +UPDATE public.members SET date_of_birth = NULL + WHERE date_of_birth IS NOT NULL AND date_of_birth > current_date; + +UPDATE public.members SET exit_date = entry_date + WHERE exit_date IS NOT NULL AND entry_date IS NOT NULL AND exit_date < entry_date; + +UPDATE public.members SET entry_date = current_date + WHERE entry_date IS NOT NULL AND entry_date > current_date; + +-- Normalize IBANs in sepa_mandates to uppercase, strip spaces +UPDATE public.sepa_mandates + SET iban = upper(regexp_replace(iban, '\s', '', 'g')) + WHERE iban IS NOT NULL AND iban != ''; + +-- Date sanity constraints +ALTER TABLE public.members + ADD CONSTRAINT chk_members_dob_not_future + CHECK (date_of_birth IS NULL OR date_of_birth <= current_date); + +ALTER TABLE public.members + ADD CONSTRAINT chk_members_exit_after_entry + CHECK (exit_date IS NULL OR entry_date IS NULL OR exit_date >= entry_date); + +ALTER TABLE public.members + ADD CONSTRAINT chk_members_entry_not_future + CHECK (entry_date IS NULL OR entry_date <= current_date); + +-- Email uniqueness per account (partial index — allows NULLs and empty strings) +CREATE UNIQUE INDEX IF NOT EXISTS uix_members_email_per_account + ON public.members(account_id, lower(email)) + WHERE email IS NOT NULL AND email != ''; + +-- IBAN format on sepa_mandates (2-letter country + 2 check digits + 11-30 alphanumeric) +ALTER TABLE public.sepa_mandates + ADD CONSTRAINT chk_sepa_iban_format + CHECK (iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$'); + +-- Mandate reference must not be empty +ALTER TABLE public.sepa_mandates + ADD CONSTRAINT chk_sepa_mandate_reference_not_empty + CHECK (mandate_reference IS NOT NULL AND mandate_reference != ''); + +-- Member roles: from_date should not be after until_date +ALTER TABLE public.member_roles + ADD CONSTRAINT chk_member_roles_date_range + CHECK (until_date IS NULL OR from_date IS NULL OR until_date >= from_date); + +-- Dues categories: amount must be non-negative +ALTER TABLE public.dues_categories + ADD CONSTRAINT chk_dues_amount_non_negative + CHECK (amount >= 0); diff --git a/apps/web/supabase/migrations/20260416000005_member_versioning.sql b/apps/web/supabase/migrations/20260416000005_member_versioning.sql new file mode 100644 index 000000000..d3b15a52e --- /dev/null +++ b/apps/web/supabase/migrations/20260416000005_member_versioning.sql @@ -0,0 +1,31 @@ +-- ===================================================== +-- Optimistic Locking via Version Column +-- +-- Problem: Two admins editing the same member silently +-- overwrite each other's changes. Last write wins. +-- +-- Fix: Add version column, auto-increment on update. +-- API layer checks version match before writing. +-- ===================================================== + +-- Add version column +ALTER TABLE public.members + ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1; + +-- Auto-increment version on every update +CREATE OR REPLACE FUNCTION public.increment_member_version() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + NEW.version := OLD.version + 1; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_members_increment_version + BEFORE UPDATE ON public.members + FOR EACH ROW + EXECUTE FUNCTION public.increment_member_version(); diff --git a/apps/web/supabase/migrations/20260416000006_event_member_link.sql b/apps/web/supabase/migrations/20260416000006_event_member_link.sql new file mode 100644 index 000000000..4af55d589 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000006_event_member_link.sql @@ -0,0 +1,155 @@ +-- ===================================================== +-- Event-Member Linkage +-- +-- Problem: event_registrations links to members by email +-- only. If a member changes their email, event history is +-- lost. transfer_member matches by email — fragile. +-- +-- Fix: Add member_id FK to event_registrations, backfill +-- from email matches, update transfer_member. +-- ===================================================== + +-- Add member_id FK column +ALTER TABLE public.event_registrations + ADD COLUMN IF NOT EXISTS member_id uuid + REFERENCES public.members(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS ix_event_registrations_member + ON public.event_registrations(member_id) + WHERE member_id IS NOT NULL; + +-- Backfill: match existing registrations to members by email within the same account +UPDATE public.event_registrations er +SET member_id = m.id +FROM public.events e +JOIN public.members m ON m.account_id = e.account_id + AND lower(m.email) = lower(er.email) + AND m.email IS NOT NULL AND m.email != '' + AND m.status IN ('active', 'inactive', 'pending') +WHERE e.id = er.event_id + AND er.member_id IS NULL + AND er.email IS NOT NULL AND er.email != ''; + +-- Update transfer_member to count active events via member_id instead of email +CREATE OR REPLACE FUNCTION public.transfer_member( + p_member_id uuid, + p_target_account_id uuid, + p_reason text DEFAULT NULL, + p_keep_sepa boolean DEFAULT false +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_source_account_id uuid; + v_source_name varchar; + v_target_name varchar; + v_active_courses bigint; + v_active_events bigint; + v_cleared_data jsonb; + v_transfer_id uuid; + v_member record; +BEGIN + -- Get current member state + SELECT * INTO v_member FROM public.members WHERE id = p_member_id; + IF v_member IS NULL THEN + RAISE EXCEPTION 'Member not found'; + END IF; + + v_source_account_id := v_member.account_id; + + -- Verify target account exists + IF NOT EXISTS (SELECT 1 FROM public.accounts WHERE id = p_target_account_id) THEN + RAISE EXCEPTION 'Target account not found'; + END IF; + + -- Ensure caller has access to source account + IF NOT public.has_role_on_account_or_ancestor(v_source_account_id) THEN + RAISE EXCEPTION 'Access denied to source account'; + END IF; + + -- Same account? No-op + IF v_source_account_id = p_target_account_id THEN + RAISE EXCEPTION 'Cannot transfer member to the same account'; + END IF; + + -- Ensure both accounts share a common ancestor + IF NOT EXISTS ( + SELECT 1 + FROM public.get_account_ancestors(v_source_account_id) sa + JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta + ) THEN + RAISE EXCEPTION 'Source and target accounts do not share a common ancestor (Verband)'; + END IF; + + -- Get org names for the transfer note + SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id; + SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id; + + -- Count active relationships (informational, for the log) + SELECT count(*) INTO v_active_courses + FROM public.course_participants cp + JOIN public.courses c ON c.id = cp.course_id + WHERE cp.member_id = p_member_id AND cp.status = 'enrolled'; + + -- Use member_id for event lookups instead of fragile email matching + SELECT count(*) INTO v_active_events + FROM public.event_registrations er + JOIN public.events e ON e.id = er.event_id + WHERE er.member_id = p_member_id + AND er.status IN ('confirmed', 'pending') + AND e.event_date >= current_date; + + -- Perform the transfer + UPDATE public.members + SET + account_id = p_target_account_id, + -- Clear org-specific admin data + dues_category_id = NULL, + member_number = NULL, + -- Clear primary_mandate_id FK (mandate needs re-confirmation in new org) + primary_mandate_id = NULL, + -- Legacy inline SEPA fields (deprecated, kept for backward compat) + sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END, + sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END, + sepa_mandate_status = 'pending', + -- Append transfer note + notes = COALESCE(notes, '') || + E'\n[Transfer ' || to_char(now(), 'YYYY-MM-DD') || '] ' || + v_source_name || ' → ' || v_target_name || + COALESCE(' | Grund: ' || p_reason, ''), + is_transferred = true + WHERE id = p_member_id; + + -- Reset SEPA mandate(s) in the mandates table + UPDATE public.sepa_mandates + SET status = 'pending' + WHERE member_id = p_member_id + AND status = 'active'; + + -- Build cleared data snapshot for the transfer log + v_cleared_data := jsonb_build_object( + 'member_number', v_member.member_number, + 'dues_category_id', v_member.dues_category_id, + 'active_courses', v_active_courses, + 'active_events', v_active_events + ); + + -- Create transfer log entry + INSERT INTO public.member_transfers ( + member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data + ) VALUES ( + p_member_id, + v_source_account_id, + p_target_account_id, + COALESCE(nullif(current_setting('app.current_user_id', true), '')::uuid, auth.uid()), + p_reason, + v_cleared_data + ) + RETURNING id INTO v_transfer_id; + + RETURN v_transfer_id; +END; +$$; diff --git a/apps/web/supabase/migrations/20260416000007_member_audit_log.sql b/apps/web/supabase/migrations/20260416000007_member_audit_log.sql new file mode 100644 index 000000000..ea296c793 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000007_member_audit_log.sql @@ -0,0 +1,260 @@ +-- ===================================================== +-- Member Audit Log +-- +-- Full change history for compliance: who changed what +-- field, old value→new value, when. Plus activity timeline. +-- ===================================================== + +-- 1. Audit log table +CREATE TABLE IF NOT EXISTS public.member_audit_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + action text NOT NULL CHECK (action IN ( + 'created', 'updated', 'status_changed', 'archived', 'unarchived', + 'department_assigned', 'department_removed', + 'role_assigned', 'role_removed', + 'honor_awarded', 'honor_removed', + 'mandate_created', 'mandate_updated', 'mandate_revoked', + 'transferred', 'merged', + 'application_approved', 'application_rejected', + 'portal_invited', 'portal_linked', + 'card_generated', + 'imported', 'exported', + 'gdpr_consent_changed', 'gdpr_anonymized', + 'tag_added', 'tag_removed', + 'communication_logged', 'note_added', + 'bulk_status_changed', 'bulk_archived' + )), + changes jsonb NOT NULL DEFAULT '{}', + metadata jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.member_audit_log IS + 'Immutable audit trail for all member lifecycle events'; + +CREATE INDEX ix_member_audit_member + ON public.member_audit_log(member_id, created_at DESC); +CREATE INDEX ix_member_audit_account + ON public.member_audit_log(account_id, created_at DESC); +CREATE INDEX ix_member_audit_user + ON public.member_audit_log(user_id) + WHERE user_id IS NOT NULL; +CREATE INDEX ix_member_audit_action + ON public.member_audit_log(account_id, action); + +ALTER TABLE public.member_audit_log ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_audit_log FROM authenticated, service_role; +GRANT SELECT ON public.member_audit_log TO authenticated; +GRANT ALL ON public.member_audit_log TO service_role; + +-- Read access: must have role on the account +CREATE POLICY member_audit_log_select + ON public.member_audit_log FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +-- No direct insert/update/delete for authenticated — only via SECURITY DEFINER functions + +-- 2. Auto-audit trigger on members UPDATE +-- Computes field-by-field diff and classifies the action type. +CREATE OR REPLACE FUNCTION public.trg_member_audit_on_update() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_changes jsonb := '{}'::jsonb; + v_user_id uuid; + v_action text; + v_old jsonb; + v_new jsonb; + v_key text; +BEGIN + v_user_id := nullif(current_setting('app.current_user_id', true), '')::uuid; + v_old := to_jsonb(OLD); + v_new := to_jsonb(NEW); + + -- Compare each field, skip meta columns + FOR v_key IN + SELECT jsonb_object_keys(v_new) + EXCEPT + SELECT unnest(ARRAY['updated_at', 'updated_by', 'version']) + LOOP + IF (v_old -> v_key) IS DISTINCT FROM (v_new -> v_key) THEN + v_changes := v_changes || jsonb_build_object( + v_key, jsonb_build_object('old', v_old -> v_key, 'new', v_new -> v_key) + ); + END IF; + END LOOP; + + -- Skip if nothing actually changed + IF v_changes = '{}'::jsonb THEN + RETURN NEW; + END IF; + + -- Classify the action + IF (v_old ->> 'status') IS DISTINCT FROM (v_new ->> 'status') THEN + v_action := 'status_changed'; + ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived') + AND COALESCE((v_new ->> 'is_archived'), 'false') = 'true' THEN + v_action := 'archived'; + ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived') THEN + v_action := 'unarchived'; + ELSE + v_action := 'updated'; + END IF; + + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes) + VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_members_audit_on_update + AFTER UPDATE ON public.members + FOR EACH ROW + EXECUTE FUNCTION public.trg_member_audit_on_update(); + +-- 3. Auto-audit trigger on members INSERT +CREATE OR REPLACE FUNCTION public.trg_member_audit_on_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; +BEGIN + v_user_id := COALESCE( + nullif(current_setting('app.current_user_id', true), '')::uuid, + NEW.created_by + ); + + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata) + VALUES ( + NEW.id, NEW.account_id, v_user_id, 'created', + jsonb_build_object( + 'member_number', NEW.member_number, + 'first_name', NEW.first_name, + 'last_name', NEW.last_name, + 'status', NEW.status + ) + ); + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_members_audit_on_insert + AFTER INSERT ON public.members + FOR EACH ROW + EXECUTE FUNCTION public.trg_member_audit_on_insert(); + +-- 4. Helper function to log explicit audit events (for related tables) +CREATE OR REPLACE FUNCTION public.log_member_audit_event( + p_member_id uuid, + p_account_id uuid, + p_action text, + p_changes jsonb DEFAULT '{}', + p_metadata jsonb DEFAULT '{}' +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Verify caller has access to the account + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- Force user_id to be the actual caller + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes, metadata) + VALUES (p_member_id, p_account_id, auth.uid(), p_action, p_changes, p_metadata); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.log_member_audit_event(uuid, uuid, text, jsonb, jsonb) + TO authenticated, service_role; + +-- 5. Activity timeline RPC (read layer on audit log) +CREATE OR REPLACE FUNCTION public.get_member_timeline( + p_member_id uuid, + p_page int DEFAULT 1, + p_page_size int DEFAULT 50, + p_action_filter text DEFAULT NULL +) +RETURNS TABLE ( + id bigint, + action text, + changes jsonb, + metadata jsonb, + user_id uuid, + user_display_name text, + created_at timestamptz, + total_count bigint +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_account_id uuid; + v_total bigint; + v_offset int; +BEGIN + -- Get member's account for access check + SELECT m.account_id INTO v_account_id + FROM public.members m WHERE m.id = p_member_id; + + IF v_account_id IS NULL THEN + RAISE EXCEPTION 'Member not found'; + END IF; + + IF NOT public.has_role_on_account(v_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + -- Clamp page size to prevent unbounded queries + p_page_size := LEAST(GREATEST(p_page_size, 1), 200); + v_offset := GREATEST(0, (p_page - 1)) * p_page_size; + + -- Get total count + SELECT count(*) INTO v_total + FROM public.member_audit_log al + WHERE al.member_id = p_member_id + AND (p_action_filter IS NULL OR al.action = p_action_filter); + + -- Return paginated results with user names + RETURN QUERY + SELECT + al.id, + al.action, + al.changes, + al.metadata, + al.user_id, + COALESCE( + u.raw_user_meta_data ->> 'display_name', + u.email, + al.user_id::text + ) AS user_display_name, + al.created_at, + v_total AS total_count + FROM public.member_audit_log al + LEFT JOIN auth.users u ON u.id = al.user_id + WHERE al.member_id = p_member_id + AND (p_action_filter IS NULL OR al.action = p_action_filter) + ORDER BY al.created_at DESC + OFFSET v_offset + LIMIT p_page_size; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_member_timeline(uuid, int, int, text) + TO authenticated; diff --git a/apps/web/supabase/migrations/20260416000008_member_communications.sql b/apps/web/supabase/migrations/20260416000008_member_communications.sql new file mode 100644 index 000000000..a1a658ff5 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000008_member_communications.sql @@ -0,0 +1,144 @@ +-- ===================================================== +-- Member Communications Tracking +-- +-- Records all communications with/about members: +-- emails sent, phone calls, notes, letters, meetings. +-- Communications are append-only for authenticated users. +-- Only service_role (admin) can delete. +-- Integrates with audit log via triggers. +-- ===================================================== + +CREATE TABLE IF NOT EXISTS public.member_communications ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + type text NOT NULL CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')), + direction text NOT NULL DEFAULT 'outbound' CHECK (direction IN ('inbound', 'outbound', 'internal')), + subject text CHECK (subject IS NULL OR length(subject) <= 500), + body text CHECK (body IS NULL OR length(body) <= 50000), + -- Email-specific fields + email_to text, + email_cc text, + email_message_id text, + -- Attachment references (Supabase Storage paths) + attachment_paths text[] CHECK (attachment_paths IS NULL OR array_length(attachment_paths, 1) <= 10), + -- Audit + created_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.member_communications IS + 'Communication log per member — emails, calls, notes, letters, meetings. Append-only for regular users.'; + +CREATE INDEX ix_member_comms_member + ON public.member_communications(member_id, created_at DESC); +CREATE INDEX ix_member_comms_account + ON public.member_communications(account_id, created_at DESC); +CREATE INDEX ix_member_comms_type + ON public.member_communications(account_id, type); + +ALTER TABLE public.member_communications ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_communications FROM authenticated, service_role; +-- Append-only: authenticated users can SELECT + INSERT, not UPDATE/DELETE +GRANT SELECT, INSERT ON public.member_communications TO authenticated; +GRANT ALL ON public.member_communications TO service_role; + +-- Read: must have a role on the account +CREATE POLICY member_comms_select + ON public.member_communications FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +-- Insert: must have members.write permission +CREATE POLICY member_comms_insert + ON public.member_communications FOR INSERT TO authenticated + WITH CHECK (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions)); + +-- No UPDATE/DELETE policies for authenticated — communications are immutable +-- service_role can still delete via admin client when necessary + +-- Auto-log to audit trail on communication INSERT +CREATE OR REPLACE FUNCTION public.trg_member_comm_audit_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + INSERT INTO public.member_audit_log ( + member_id, account_id, user_id, action, metadata + ) VALUES ( + NEW.member_id, + NEW.account_id, + NEW.created_by, + 'communication_logged', + jsonb_build_object( + 'communication_id', NEW.id, + 'type', NEW.type, + 'direction', NEW.direction, + 'subject', NEW.subject + ) + ); + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + -- Audit failure should not block the insert + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_member_comms_audit_insert + AFTER INSERT ON public.member_communications + FOR EACH ROW + EXECUTE FUNCTION public.trg_member_comm_audit_insert(); + +-- Safe delete function for admin use — logs before deleting +CREATE OR REPLACE FUNCTION public.delete_member_communication( + p_communication_id uuid, + p_account_id uuid +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_comm record; +BEGIN + -- Verify caller has access + IF NOT public.has_permission(auth.uid(), p_account_id, 'members.write'::public.app_permissions) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- Fetch the communication for audit + SELECT * INTO v_comm + FROM public.member_communications + WHERE id = p_communication_id AND account_id = p_account_id; + + IF v_comm IS NULL THEN + RAISE EXCEPTION 'Communication not found' USING ERRCODE = 'P0002'; + END IF; + + -- Log deletion to audit trail + INSERT INTO public.member_audit_log ( + member_id, account_id, user_id, action, metadata + ) VALUES ( + v_comm.member_id, + v_comm.account_id, + auth.uid(), + 'communication_logged', + jsonb_build_object( + 'deleted_communication_id', v_comm.id, + 'type', v_comm.type, + 'direction', v_comm.direction, + 'subject', v_comm.subject, + 'action_detail', 'deleted' + ) + ); + + -- Delete via service_role context (SECURITY DEFINER bypasses RLS) + DELETE FROM public.member_communications + WHERE id = p_communication_id AND account_id = p_account_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.delete_member_communication(uuid, uuid) + TO authenticated, service_role; diff --git a/apps/web/supabase/migrations/20260416000009_member_tags.sql b/apps/web/supabase/migrations/20260416000009_member_tags.sql new file mode 100644 index 000000000..3fa0802db --- /dev/null +++ b/apps/web/supabase/migrations/20260416000009_member_tags.sql @@ -0,0 +1,116 @@ +-- ===================================================== +-- Member Tags / Labels System +-- +-- Flexible, colored tags for member categorization +-- beyond departments (e.g., "Vorstand-Kandidat", +-- "Beitragsrückstand", "Newsletter-Opt-Out"). +-- ===================================================== + +-- Tag definitions (per account) +CREATE TABLE IF NOT EXISTS public.member_tags ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + name text NOT NULL, + color text NOT NULL DEFAULT '#6B7280', + description text, + sort_order int NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(account_id, name) +); + +COMMENT ON TABLE public.member_tags IS + 'Colored labels for flexible member categorization'; + +CREATE INDEX ix_member_tags_account + ON public.member_tags(account_id, sort_order); + +ALTER TABLE public.member_tags ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_tags FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_tags TO authenticated; +GRANT ALL ON public.member_tags TO service_role; + +CREATE POLICY member_tags_select + ON public.member_tags FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +CREATE POLICY member_tags_mutate + ON public.member_tags FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions)); + +-- Tag assignments (member ↔ tag junction) +CREATE TABLE IF NOT EXISTS public.member_tag_assignments ( + member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE, + tag_id uuid NOT NULL REFERENCES public.member_tags(id) ON DELETE CASCADE, + assigned_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + assigned_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (member_id, tag_id) +); + +COMMENT ON TABLE public.member_tag_assignments IS + 'Junction table linking members to tags'; + +CREATE INDEX ix_member_tag_assignments_tag + ON public.member_tag_assignments(tag_id); + +ALTER TABLE public.member_tag_assignments ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_tag_assignments FROM authenticated, service_role; +GRANT SELECT, INSERT, DELETE ON public.member_tag_assignments TO authenticated; +GRANT ALL ON public.member_tag_assignments TO service_role; + +-- Read: via member's account +CREATE POLICY mta_select + ON public.member_tag_assignments FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.members m + WHERE m.id = member_tag_assignments.member_id + AND public.has_role_on_account(m.account_id) + )); + +-- Write: via member's account with write permission +CREATE POLICY mta_mutate + ON public.member_tag_assignments FOR ALL TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.members m + WHERE m.id = member_tag_assignments.member_id + AND public.has_permission(auth.uid(), m.account_id, 'members.write'::public.app_permissions) + )); + +-- Audit triggers for tag assignment/removal +CREATE OR REPLACE FUNCTION public.trg_tag_assignment_audit() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_account_id uuid; + v_tag_name text; +BEGIN + IF TG_OP = 'INSERT' THEN + SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = NEW.member_id; + SELECT t.name INTO v_tag_name FROM public.member_tags t WHERE t.id = NEW.tag_id; + + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata) + VALUES (NEW.member_id, v_account_id, NEW.assigned_by, 'tag_added', + jsonb_build_object('tag_id', NEW.tag_id, 'tag_name', v_tag_name)); + RETURN NEW; + ELSIF TG_OP = 'DELETE' THEN + SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = OLD.member_id; + SELECT t.name INTO v_tag_name FROM public.member_tags t WHERE t.id = OLD.tag_id; + + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata) + VALUES (OLD.member_id, v_account_id, auth.uid(), 'tag_removed', + jsonb_build_object('tag_id', OLD.tag_id, 'tag_name', v_tag_name)); + RETURN OLD; + END IF; + RETURN NULL; +EXCEPTION WHEN OTHERS THEN + -- Audit failure should not block the operation + IF TG_OP = 'INSERT' THEN RETURN NEW; ELSE RETURN OLD; END IF; +END; +$$; + +CREATE TRIGGER trg_tag_assignment_audit + AFTER INSERT OR DELETE ON public.member_tag_assignments + FOR EACH ROW + EXECUTE FUNCTION public.trg_tag_assignment_audit(); diff --git a/apps/web/supabase/migrations/20260416000010_member_merge.sql b/apps/web/supabase/migrations/20260416000010_member_merge.sql new file mode 100644 index 000000000..f9a7508fd --- /dev/null +++ b/apps/web/supabase/migrations/20260416000010_member_merge.sql @@ -0,0 +1,274 @@ +-- ===================================================== +-- Member Merge / Deduplication +-- +-- Atomic function to merge two member records: +-- picks field values, moves all references, archives secondary. +-- ===================================================== + +-- Merge log table for audit trail and potential undo +CREATE TABLE IF NOT EXISTS public.member_merges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + primary_member_id uuid NOT NULL, + secondary_member_id uuid NOT NULL, + secondary_snapshot jsonb NOT NULL, + field_choices jsonb NOT NULL, + references_moved jsonb NOT NULL, + performed_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL, + performed_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_member_merges_account ON public.member_merges(account_id); + +ALTER TABLE public.member_merges ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_merges FROM authenticated, service_role; +GRANT SELECT ON public.member_merges TO authenticated; +GRANT ALL ON public.member_merges TO service_role; + +CREATE POLICY member_merges_select + ON public.member_merges FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +-- Atomic merge function +CREATE OR REPLACE FUNCTION public.merge_members( + p_primary_id uuid, + p_secondary_id uuid, + p_field_choices jsonb DEFAULT '{}', + p_performed_by uuid DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_primary record; + v_secondary record; + v_account_id uuid; + v_user_id uuid; + v_refs_moved jsonb := '{}'::jsonb; + v_count int; + v_field text; + v_choice text; + v_update jsonb := '{}'::jsonb; +BEGIN + v_user_id := COALESCE(p_performed_by, auth.uid()); + + -- 1. Fetch both members + SELECT * INTO v_primary FROM public.members WHERE id = p_primary_id; + SELECT * INTO v_secondary FROM public.members WHERE id = p_secondary_id; + + IF v_primary IS NULL THEN RAISE EXCEPTION 'Primary member not found'; END IF; + IF v_secondary IS NULL THEN RAISE EXCEPTION 'Secondary member not found'; END IF; + + IF v_primary.account_id != v_secondary.account_id THEN + RAISE EXCEPTION 'Members must belong to the same account'; + END IF; + + v_account_id := v_primary.account_id; + + -- Verify caller access + IF NOT public.has_permission(auth.uid(), v_account_id, 'members.write'::public.app_permissions) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- 2. Apply field choices: for each conflicting field, pick primary or secondary value + FOR v_field, v_choice IN SELECT * FROM jsonb_each_text(p_field_choices) + LOOP + -- Validate choice value + IF v_choice NOT IN ('primary', 'secondary') THEN + RAISE EXCEPTION 'Invalid choice "%" for field "%". Must be "primary" or "secondary"', v_choice, v_field; + END IF; + + -- Whitelist of mergeable fields (no IDs, FKs, or system columns) + IF v_field NOT IN ( + 'first_name', 'last_name', 'email', 'phone', 'mobile', 'phone2', 'fax', + 'street', 'house_number', 'street2', 'postal_code', 'city', 'country', + 'date_of_birth', 'gender', 'title', 'salutation', 'birthplace', 'birth_country', + 'notes', 'guardian_name', 'guardian_phone', 'guardian_email' + ) THEN + RAISE EXCEPTION 'Field "%" cannot be merged', v_field; + END IF; + + IF v_choice = 'secondary' THEN + v_update := v_update || jsonb_build_object(v_field, to_jsonb(v_secondary) -> v_field); + END IF; + END LOOP; + + -- Apply chosen fields to primary + IF v_update != '{}'::jsonb THEN + -- Build dynamic UPDATE + EXECUTE format( + 'UPDATE public.members SET %s WHERE id = $1', + (SELECT string_agg(format('%I = %L', key, value #>> '{}'), ', ') + FROM jsonb_each(v_update)) + ) USING p_primary_id; + END IF; + + -- 3. Move references from secondary to primary + + -- Department assignments + SELECT count(*) INTO v_count FROM public.member_department_assignments WHERE member_id = p_secondary_id; + INSERT INTO public.member_department_assignments (member_id, department_id) + SELECT p_primary_id, department_id + FROM public.member_department_assignments + WHERE member_id = p_secondary_id + ON CONFLICT (member_id, department_id) DO NOTHING; + DELETE FROM public.member_department_assignments WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('departments', v_count); + + -- Roles + SELECT count(*) INTO v_count FROM public.member_roles WHERE member_id = p_secondary_id; + UPDATE public.member_roles SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('roles', v_count); + + -- Honors + SELECT count(*) INTO v_count FROM public.member_honors WHERE member_id = p_secondary_id; + UPDATE public.member_honors SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('honors', v_count); + + -- SEPA mandates + SELECT count(*) INTO v_count FROM public.sepa_mandates WHERE member_id = p_secondary_id; + UPDATE public.sepa_mandates SET member_id = p_primary_id, is_primary = false WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('mandates', v_count); + + -- Member cards + SELECT count(*) INTO v_count FROM public.member_cards WHERE member_id = p_secondary_id; + UPDATE public.member_cards SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('cards', v_count); + + -- Portal invitations + SELECT count(*) INTO v_count FROM public.member_portal_invitations WHERE member_id = p_secondary_id; + UPDATE public.member_portal_invitations SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('invitations', v_count); + + -- Tag assignments + BEGIN + SELECT count(*) INTO v_count FROM public.member_tag_assignments WHERE member_id = p_secondary_id; + INSERT INTO public.member_tag_assignments (member_id, tag_id, assigned_by) + SELECT p_primary_id, tag_id, assigned_by + FROM public.member_tag_assignments + WHERE member_id = p_secondary_id + ON CONFLICT (member_id, tag_id) DO NOTHING; + DELETE FROM public.member_tag_assignments WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('tags', v_count); + EXCEPTION WHEN undefined_table THEN NULL; -- tags table may not exist yet + END; + + -- Event registrations (if member_id column exists) + BEGIN + SELECT count(*) INTO v_count FROM public.event_registrations WHERE member_id = p_secondary_id; + UPDATE public.event_registrations SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('events', v_count); + EXCEPTION WHEN undefined_column THEN NULL; + END; + + -- Communications + BEGIN + SELECT count(*) INTO v_count FROM public.member_communications WHERE member_id = p_secondary_id; + UPDATE public.member_communications SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('communications', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Course participants + BEGIN + SELECT count(*) INTO v_count FROM public.course_participants WHERE member_id = p_secondary_id; + UPDATE public.course_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('courses', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Catch books (Fischerei) + BEGIN + SELECT count(*) INTO v_count FROM public.catch_books WHERE member_id = p_secondary_id; + UPDATE public.catch_books SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('catch_books', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Catches + BEGIN + SELECT count(*) INTO v_count FROM public.catches WHERE member_id = p_secondary_id; + UPDATE public.catches SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('catches', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Water leases + BEGIN + SELECT count(*) INTO v_count FROM public.water_leases WHERE member_id = p_secondary_id; + UPDATE public.water_leases SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('water_leases', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Competition participants + BEGIN + SELECT count(*) INTO v_count FROM public.competition_participants WHERE member_id = p_secondary_id; + UPDATE public.competition_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('competitions', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Invoices + BEGIN + SELECT count(*) INTO v_count FROM public.invoices WHERE member_id = p_secondary_id; + UPDATE public.invoices SET member_id = p_primary_id WHERE member_id = p_secondary_id; + v_refs_moved := v_refs_moved || jsonb_build_object('invoices', v_count); + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Audit log entries + UPDATE public.member_audit_log SET member_id = p_primary_id WHERE member_id = p_secondary_id; + + -- 4. Merge custom_data (union of keys, primary wins on conflicts) + UPDATE public.members + SET custom_data = v_secondary.custom_data || v_primary.custom_data + WHERE id = p_primary_id; + + -- 5. Append merge note + UPDATE public.members + SET notes = COALESCE(notes, '') || + E'\n[Zusammenführung ' || to_char(now(), 'YYYY-MM-DD') || '] ' || + 'Zusammengeführt mit ' || v_secondary.first_name || ' ' || v_secondary.last_name || + COALESCE(' (Nr. ' || v_secondary.member_number || ')', '') + WHERE id = p_primary_id; + + -- 6. Archive the secondary member + UPDATE public.members + SET status = 'resigned', is_archived = true, + exit_date = current_date, exit_reason = 'Zusammenführung mit Mitglied ' || p_primary_id::text, + notes = COALESCE(notes, '') || E'\n[Zusammenführung] Archiviert zugunsten von ' || v_primary.first_name || ' ' || v_primary.last_name + WHERE id = p_secondary_id; + + -- 7. Create merge log entry + INSERT INTO public.member_merges ( + account_id, primary_member_id, secondary_member_id, + secondary_snapshot, field_choices, references_moved, performed_by + ) VALUES ( + v_account_id, p_primary_id, p_secondary_id, + to_jsonb(v_secondary), p_field_choices, v_refs_moved, v_user_id + ); + + -- 8. Audit log + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata) + VALUES (p_primary_id, v_account_id, v_user_id, 'merged', + jsonb_build_object( + 'secondary_member_id', p_secondary_id, + 'secondary_name', v_secondary.first_name || ' ' || v_secondary.last_name, + 'references_moved', v_refs_moved, + 'field_choices', p_field_choices + ) + ); + + RETURN jsonb_build_object( + 'primary_id', p_primary_id, + 'secondary_id', p_secondary_id, + 'references_moved', v_refs_moved + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.merge_members(uuid, uuid, jsonb, uuid) + TO authenticated, service_role; diff --git a/apps/web/supabase/migrations/20260416000011_gdpr_retention.sql b/apps/web/supabase/migrations/20260416000011_gdpr_retention.sql new file mode 100644 index 000000000..e9b1f6bf0 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000011_gdpr_retention.sql @@ -0,0 +1,170 @@ +-- ===================================================== +-- GDPR Data Retention Automation +-- +-- Configurable retention policies per account. +-- Automatic anonymization of resigned/excluded/deceased +-- members after retention period expires. +-- ===================================================== + +-- Retention policy configuration per account +CREATE TABLE IF NOT EXISTS public.gdpr_retention_policies ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + policy_name text NOT NULL DEFAULT 'Standard', + retention_days int NOT NULL DEFAULT 1095, -- 3 years + auto_anonymize boolean NOT NULL DEFAULT false, + applies_to_status text[] NOT NULL DEFAULT ARRAY['resigned', 'excluded', 'deceased'], + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(account_id) +); + +ALTER TABLE public.gdpr_retention_policies ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.gdpr_retention_policies FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE ON public.gdpr_retention_policies TO authenticated; +GRANT ALL ON public.gdpr_retention_policies TO service_role; + +CREATE POLICY gdpr_retention_select + ON public.gdpr_retention_policies FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +CREATE POLICY gdpr_retention_mutate + ON public.gdpr_retention_policies FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions)); + +-- Anonymize a single member (replaces all PII with placeholder) +CREATE OR REPLACE FUNCTION public.anonymize_member( + p_member_id uuid, + p_performed_by uuid DEFAULT NULL +) +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_member record; + v_user_id uuid; +BEGIN + v_user_id := COALESCE(p_performed_by, auth.uid()); + + SELECT * INTO v_member FROM public.members WHERE id = p_member_id; + IF v_member IS NULL THEN + RAISE EXCEPTION 'Member not found'; + END IF; + + -- Verify caller access + IF v_user_id IS NOT NULL AND NOT public.has_permission(v_user_id, v_member.account_id, 'members.write'::public.app_permissions) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- Snapshot full record to audit log before anonymization + INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata) + VALUES ( + p_member_id, v_member.account_id, v_user_id, 'gdpr_anonymized', + jsonb_build_object( + 'original_first_name', v_member.first_name, + 'original_last_name', v_member.last_name, + 'original_email', v_member.email, + 'reason', 'GDPR retention policy' + ) + ); + + -- Replace all PII with anonymized placeholders + UPDATE public.members SET + first_name = 'ANONYMISIERT', + last_name = 'ANONYMISIERT', + email = NULL, + phone = NULL, + mobile = NULL, + phone2 = NULL, + fax = NULL, + street = NULL, + house_number = NULL, + street2 = NULL, + postal_code = NULL, + city = NULL, + date_of_birth = NULL, + birthplace = NULL, + birth_country = NULL, + iban = NULL, + bic = NULL, + account_holder = NULL, + sepa_mandate_reference = NULL, + sepa_mandate_id = NULL, + primary_mandate_id = NULL, + guardian_name = NULL, + guardian_phone = NULL, + guardian_email = NULL, + notes = '[GDPR anonymisiert am ' || to_char(now(), 'YYYY-MM-DD') || ']', + custom_data = '{}'::jsonb, + online_access_key = NULL, + online_access_blocked = true, + gdpr_consent = false, + gdpr_newsletter = false, + gdpr_internet = false, + gdpr_print = false, + gdpr_birthday_info = false, + is_archived = true, + updated_by = v_user_id + WHERE id = p_member_id; + + -- Anonymize SEPA mandates (can't DELETE due to ON DELETE RESTRICT from Phase 1) + -- primary_mandate_id already cleared above in the members UPDATE + -- Anonymize SEPA PII fields (keep row for audit, revoke mandate) + UPDATE public.sepa_mandates + SET iban = 'DE00ANON0000000000000', bic = NULL, account_holder = 'ANONYMISIERT', + mandate_reference = 'ANON-' || id::text, status = 'revoked', + notes = '[GDPR anonymisiert]' + WHERE member_id = p_member_id; + + -- Remove communications (may contain PII) + BEGIN + DELETE FROM public.member_communications WHERE member_id = p_member_id; + EXCEPTION WHEN undefined_table THEN NULL; + END; + + -- Remove portal invitations + DELETE FROM public.member_portal_invitations WHERE member_id = p_member_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.anonymize_member(uuid, uuid) + TO authenticated, service_role; + +-- Batch enforcement: find and anonymize members matching retention criteria +CREATE OR REPLACE FUNCTION public.enforce_gdpr_retention_policies() +RETURNS int +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_policy record; + v_member record; + v_count int := 0; +BEGIN + FOR v_policy IN + SELECT * FROM public.gdpr_retention_policies + WHERE auto_anonymize = true + LOOP + FOR v_member IN + SELECT m.id + FROM public.members m + WHERE m.account_id = v_policy.account_id + AND m.status = ANY(v_policy.applies_to_status::public.membership_status[]) + AND m.first_name != 'ANONYMISIERT' -- not already anonymized + AND m.exit_date IS NOT NULL -- only retain based on actual exit date + AND m.exit_date + (v_policy.retention_days || ' days')::interval <= current_date + LOOP + PERFORM public.anonymize_member(v_member.id, NULL); + v_count := v_count + 1; + END LOOP; + END LOOP; + + RETURN v_count; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.enforce_gdpr_retention_policies() + TO service_role; diff --git a/apps/web/supabase/migrations/20260416000012_reporting_functions.sql b/apps/web/supabase/migrations/20260416000012_reporting_functions.sql new file mode 100644 index 000000000..6b3acdc38 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000012_reporting_functions.sql @@ -0,0 +1,295 @@ +-- ===================================================== +-- Reporting & Analytics RPC Functions +-- +-- Enterprise-grade reporting: demographics, retention, +-- geographic distribution, dues collection, membership +-- duration analysis. +-- ===================================================== + +-- 1. Age demographics by gender +CREATE OR REPLACE FUNCTION public.get_member_demographics(p_account_id uuid) +RETURNS TABLE ( + age_group text, + male_count bigint, + female_count bigint, + diverse_count bigint, + unknown_count bigint, + total bigint +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' +AS $$ +BEGIN + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + RETURN QUERY + SELECT + CASE + WHEN age < 18 THEN 'Unter 18' + WHEN age BETWEEN 18 AND 30 THEN '18-30' + WHEN age BETWEEN 31 AND 50 THEN '31-50' + WHEN age BETWEEN 51 AND 65 THEN '51-65' + WHEN age > 65 THEN 'Über 65' + ELSE 'Unbekannt' + END AS age_group, + count(*) FILTER (WHERE m.gender = 'male') AS male_count, + count(*) FILTER (WHERE m.gender = 'female') AS female_count, + count(*) FILTER (WHERE m.gender = 'diverse') AS diverse_count, + count(*) FILTER (WHERE m.gender IS NULL OR m.gender NOT IN ('male', 'female', 'diverse')) AS unknown_count, + count(*) AS total + FROM public.members m + LEFT JOIN LATERAL ( + SELECT CASE + WHEN m.date_of_birth IS NOT NULL THEN + extract(year FROM age(current_date, m.date_of_birth))::int + ELSE NULL + END AS age + ) ages ON true + WHERE m.account_id = p_account_id + AND m.status = 'active' + AND m.is_archived = false + GROUP BY age_group + ORDER BY + CASE age_group + WHEN 'Unter 18' THEN 1 + WHEN '18-30' THEN 2 + WHEN '31-50' THEN 3 + WHEN '51-65' THEN 4 + WHEN 'Über 65' THEN 5 + ELSE 6 + END; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_member_demographics(uuid) TO authenticated; + +-- 2. Year-over-year membership retention +CREATE OR REPLACE FUNCTION public.get_member_retention( + p_account_id uuid, + p_years int DEFAULT 5 +) +RETURNS TABLE ( + year int, + members_start bigint, + new_members bigint, + resigned_members bigint, + members_end bigint, + retention_rate numeric +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' +AS $$ +BEGIN + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + RETURN QUERY + WITH years AS ( + SELECT generate_series( + extract(year FROM current_date)::int - p_years + 1, + extract(year FROM current_date)::int + ) AS yr + ), + stats AS ( + SELECT + y.yr, + count(*) FILTER (WHERE m.entry_date < make_date(y.yr, 1, 1) + AND (m.exit_date IS NULL OR m.exit_date >= make_date(y.yr, 1, 1))) AS members_start, + count(*) FILTER (WHERE extract(year FROM m.entry_date) = y.yr) AS new_members, + count(*) FILTER (WHERE extract(year FROM m.exit_date) = y.yr) AS resigned_members, + count(*) FILTER (WHERE m.entry_date <= make_date(y.yr, 12, 31) + AND (m.exit_date IS NULL OR m.exit_date > make_date(y.yr, 12, 31))) AS members_end + FROM years y + CROSS JOIN public.members m + WHERE m.account_id = p_account_id AND m.is_archived = false + GROUP BY y.yr + ) + SELECT + s.yr AS year, + s.members_start, + s.new_members, + s.resigned_members, + s.members_end, + CASE WHEN s.members_start > 0 + THEN round((s.members_end::numeric / s.members_start) * 100, 1) + ELSE 0 + END AS retention_rate + FROM stats s + ORDER BY s.yr; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_member_retention(uuid, int) TO authenticated; + +-- 3. Geographic distribution by postal code prefix +CREATE OR REPLACE FUNCTION public.get_member_geographic_distribution(p_account_id uuid) +RETURNS TABLE ( + postal_prefix text, + city text, + member_count bigint +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' +AS $$ +BEGIN + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + RETURN QUERY + SELECT + CASE + WHEN m.postal_code IS NULL OR m.postal_code = '' THEN 'Keine Angabe' + ELSE left(m.postal_code, 2) + END AS postal_prefix, + COALESCE(NULLIF(m.city, ''), 'Keine Angabe') AS city, + count(*) AS member_count + FROM public.members m + WHERE m.account_id = p_account_id + AND m.status = 'active' + AND m.is_archived = false + GROUP BY postal_prefix, m.city + ORDER BY member_count DESC; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_member_geographic_distribution(uuid) TO authenticated; + +-- 4. Dues collection rates by category +CREATE OR REPLACE FUNCTION public.get_dues_collection_report(p_account_id uuid) +RETURNS TABLE ( + category_name text, + member_count bigint, + expected_amount numeric, + paid_count bigint, + collection_rate numeric +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' +AS $$ +BEGIN + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + RETURN QUERY + SELECT + COALESCE(dc.name, 'Keine Kategorie') AS category_name, + count(m.id) AS member_count, + COALESCE(sum(dc.amount), 0) AS expected_amount, + count(*) FILTER (WHERE m.dues_paid = true) AS paid_count, + CASE WHEN count(m.id) > 0 + THEN round((count(*) FILTER (WHERE m.dues_paid = true)::numeric / count(m.id)) * 100, 1) + ELSE 0 + END AS collection_rate + FROM public.members m + LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id + WHERE m.account_id = p_account_id + AND m.status = 'active' + AND m.is_archived = false + GROUP BY dc.name, dc.sort_order + ORDER BY dc.sort_order NULLS LAST; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_dues_collection_report(uuid) TO authenticated; + +-- 5. Membership duration analysis +CREATE OR REPLACE FUNCTION public.get_membership_duration_analysis(p_account_id uuid) +RETURNS TABLE ( + duration_bucket text, + member_count bigint, + percentage numeric +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' +AS $$ +DECLARE + v_total bigint; +BEGIN + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + SELECT count(*) INTO v_total + FROM public.members + WHERE account_id = p_account_id AND status = 'active' AND is_archived = false; + + RETURN QUERY + SELECT + CASE + WHEN years < 1 THEN 'Unter 1 Jahr' + WHEN years BETWEEN 1 AND 5 THEN '1-5 Jahre' + WHEN years BETWEEN 6 AND 10 THEN '6-10 Jahre' + WHEN years BETWEEN 11 AND 25 THEN '11-25 Jahre' + WHEN years > 25 THEN 'Über 25 Jahre' + ELSE 'Unbekannt' + END AS duration_bucket, + count(*) AS member_count, + CASE WHEN v_total > 0 + THEN round((count(*)::numeric / v_total) * 100, 1) + ELSE 0 + END AS percentage + FROM ( + SELECT + CASE WHEN m.entry_date IS NOT NULL + THEN extract(year FROM age(current_date, m.entry_date))::int + ELSE NULL + END AS years + FROM public.members m + WHERE m.account_id = p_account_id + AND m.status = 'active' + AND m.is_archived = false + ) sub + GROUP BY duration_bucket + ORDER BY + CASE duration_bucket + WHEN 'Unter 1 Jahr' THEN 1 + WHEN '1-5 Jahre' THEN 2 + WHEN '6-10 Jahre' THEN 3 + WHEN '11-25 Jahre' THEN 4 + WHEN 'Über 25 Jahre' THEN 5 + ELSE 6 + END; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_membership_duration_analysis(uuid) TO authenticated; + +-- 6. Department distribution +CREATE OR REPLACE FUNCTION public.get_department_distribution(p_account_id uuid) +RETURNS TABLE ( + department_name text, + member_count bigint, + percentage numeric +) +LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = '' +AS $$ +DECLARE + v_total bigint; +BEGIN + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied'; + END IF; + + SELECT count(DISTINCT m.id) INTO v_total + FROM public.members m + WHERE m.account_id = p_account_id AND m.status = 'active' AND m.is_archived = false; + + RETURN QUERY + SELECT + d.name AS department_name, + count(DISTINCT mda.member_id) AS member_count, + CASE WHEN v_total > 0 + THEN round((count(DISTINCT mda.member_id)::numeric / v_total) * 100, 1) + ELSE 0 + END AS percentage + FROM public.member_departments d + LEFT JOIN public.member_department_assignments mda ON mda.department_id = d.id + LEFT JOIN public.members m ON m.id = mda.member_id + AND m.status = 'active' AND m.is_archived = false + WHERE d.account_id = p_account_id + GROUP BY d.name, d.sort_order + ORDER BY member_count DESC; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_department_distribution(uuid) TO authenticated; diff --git a/apps/web/supabase/migrations/20260416000013_index_optimization.sql b/apps/web/supabase/migrations/20260416000013_index_optimization.sql new file mode 100644 index 000000000..84f97eb01 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000013_index_optimization.sql @@ -0,0 +1,77 @@ +-- ===================================================== +-- Index Optimization +-- +-- Adds partial indexes for common query patterns, +-- covers the advanced search filter combinations, +-- and optimizes reporting queries. +-- ===================================================== + +-- 1. Active members composite index (most common query pattern) +-- Covers: listMembers, searchMembers, all reporting functions +CREATE INDEX IF NOT EXISTS ix_members_active_account_status + ON public.members(account_id, status, last_name, first_name) + WHERE is_archived = false; + +-- 2. Entry date range queries (searchMembers with date filters) +CREATE INDEX IF NOT EXISTS ix_members_entry_date + ON public.members(account_id, entry_date) + WHERE entry_date IS NOT NULL; + +-- 3. Dues category filter (searchMembers) +CREATE INDEX IF NOT EXISTS ix_members_dues_category + ON public.members(account_id, dues_category_id) + WHERE dues_category_id IS NOT NULL; + +-- 4. Boolean flag filters (searchMembers flag queries) +-- Partial indexes only store rows where the flag is true (very compact) +CREATE INDEX IF NOT EXISTS ix_members_honorary + ON public.members(account_id) WHERE is_honorary = true; +CREATE INDEX IF NOT EXISTS ix_members_youth + ON public.members(account_id) WHERE is_youth = true; +CREATE INDEX IF NOT EXISTS ix_members_founding + ON public.members(account_id) WHERE is_founding_member = true; +CREATE INDEX IF NOT EXISTS ix_members_retiree + ON public.members(account_id) WHERE is_retiree = true; + +-- 5. Active SEPA mandates lookup (finance integration) +CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_lookup + ON public.sepa_mandates(member_id, status) + WHERE status = 'active' AND is_primary = true; + +-- 6. Communications per member (timeline queries) +CREATE INDEX IF NOT EXISTS ix_member_comms_member_date + ON public.member_communications(member_id, created_at DESC); + +-- 7. Audit log: action-type filtering (timeline with action filter) +CREATE INDEX IF NOT EXISTS ix_member_audit_member_action + ON public.member_audit_log(member_id, action, created_at DESC); + +-- 8. Tag assignments: member lookup (for search filter + detail view) +CREATE INDEX IF NOT EXISTS ix_tag_assignments_member + ON public.member_tag_assignments(member_id); + +-- 9. Reporting: active members for retention/duration CROSS JOIN +-- Column order: account_id first (equality), then date columns (range scans) +-- is_archived excluded from key since it's in WHERE clause +CREATE INDEX IF NOT EXISTS ix_members_active_reporting + ON public.members(account_id, entry_date, exit_date, status) + WHERE is_archived = false; + +-- 10. Member merge log: primary member lookup +CREATE INDEX IF NOT EXISTS ix_member_merges_primary + ON public.member_merges(primary_member_id); + +-- 11. GDPR: candidates for anonymization (batch enforcement query) +-- status excluded from key since enforcement query uses dynamic ANY(array) +-- Covers: WHERE account_id = ? AND exit_date IS NOT NULL AND exit_date + interval <= current_date +CREATE INDEX IF NOT EXISTS ix_members_gdpr_candidates + ON public.members(account_id, exit_date) + WHERE exit_date IS NOT NULL AND is_archived = false AND first_name != 'ANONYMISIERT'; + +-- 12. Portal invitations: account listing (listPortalInvitations query) +CREATE INDEX IF NOT EXISTS ix_portal_invitations_account_date + ON public.member_portal_invitations(account_id, created_at DESC); + +-- 13. Department assignments by department (searchMembers department filter subquery) +CREATE INDEX IF NOT EXISTS ix_dept_assignments_department + ON public.member_department_assignments(department_id, member_id); diff --git a/apps/web/supabase/migrations/20260416000014_notification_rules_and_jobs.sql b/apps/web/supabase/migrations/20260416000014_notification_rules_and_jobs.sql new file mode 100644 index 000000000..d11d44143 --- /dev/null +++ b/apps/web/supabase/migrations/20260416000014_notification_rules_and_jobs.sql @@ -0,0 +1,209 @@ +-- ===================================================== +-- Notification Rules + Scheduled Jobs +-- +-- Configurable notification triggers per account. +-- Scheduled job runner with tracking. +-- Pending notifications queue for async dispatch. +-- ===================================================== + +-- 1. Notification rules — configurable triggers per account +CREATE TABLE IF NOT EXISTS public.member_notification_rules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + trigger_event text NOT NULL CHECK (trigger_event IN ( + 'application.submitted', 'application.approved', 'application.rejected', + 'member.created', 'member.status_changed', + 'member.birthday', 'member.anniversary', + 'dues.unpaid', 'mandate.revoked' + )), + channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')), + recipient_type text NOT NULL CHECK (recipient_type IN ( + 'admin', 'member', 'specific_user', 'role_holder' + )), + recipient_config jsonb NOT NULL DEFAULT '{}', + subject_template text, + message_template text NOT NULL, + is_active boolean NOT NULL DEFAULT true, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX ix_notification_rules_account + ON public.member_notification_rules(account_id, trigger_event) + WHERE is_active = true; + +ALTER TABLE public.member_notification_rules ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.member_notification_rules FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_notification_rules TO authenticated; +GRANT ALL ON public.member_notification_rules TO service_role; + +CREATE POLICY notification_rules_select + ON public.member_notification_rules FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +CREATE POLICY notification_rules_mutate + ON public.member_notification_rules FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions)); + +-- 2. Scheduled job configuration per account +CREATE TABLE IF NOT EXISTS public.scheduled_job_configs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + job_type text NOT NULL CHECK (job_type IN ( + 'birthday_notification', 'anniversary_notification', + 'dues_reminder', 'data_quality_check', 'gdpr_retention_check' + )), + is_enabled boolean NOT NULL DEFAULT true, + config jsonb NOT NULL DEFAULT '{}', + last_run_at timestamptz, + next_run_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE(account_id, job_type) +); + +ALTER TABLE public.scheduled_job_configs ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.scheduled_job_configs FROM authenticated, service_role; +GRANT SELECT, INSERT, UPDATE ON public.scheduled_job_configs TO authenticated; +GRANT ALL ON public.scheduled_job_configs TO service_role; + +CREATE POLICY scheduled_jobs_select + ON public.scheduled_job_configs FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +CREATE POLICY scheduled_jobs_mutate + ON public.scheduled_job_configs FOR ALL TO authenticated + USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions)); + +-- 3. Job run history +CREATE TABLE IF NOT EXISTS public.scheduled_job_runs ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + job_config_id uuid NOT NULL REFERENCES public.scheduled_job_configs(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')), + result jsonb, + started_at timestamptz NOT NULL DEFAULT now(), + completed_at timestamptz +); + +CREATE INDEX ix_job_runs_config + ON public.scheduled_job_runs(job_config_id, started_at DESC); + +ALTER TABLE public.scheduled_job_runs ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.scheduled_job_runs FROM authenticated, service_role; +GRANT SELECT ON public.scheduled_job_runs TO authenticated; +GRANT ALL ON public.scheduled_job_runs TO service_role; + +CREATE POLICY job_runs_select + ON public.scheduled_job_runs FOR SELECT TO authenticated + USING (EXISTS ( + SELECT 1 FROM public.scheduled_job_configs jc + WHERE jc.id = scheduled_job_runs.job_config_id + AND public.has_role_on_account(jc.account_id) + )); + +-- 4. Pending notifications queue (lightweight, processed by cron) +CREATE TABLE IF NOT EXISTS public.pending_member_notifications ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + account_id uuid NOT NULL, + trigger_event text NOT NULL, + member_id uuid, + context jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now(), + processed_at timestamptz +); + +CREATE INDEX ix_pending_notifications_unprocessed + ON public.pending_member_notifications(created_at) + WHERE processed_at IS NULL; + +-- No RLS — only service_role accesses this table +REVOKE ALL ON public.pending_member_notifications FROM authenticated; +GRANT ALL ON public.pending_member_notifications TO service_role; + +-- 5. Trigger: queue notifications when audit events fire +CREATE OR REPLACE FUNCTION public.queue_notification_on_audit() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_event text; +BEGIN + -- Map audit action to notification trigger event + v_event := CASE NEW.action + WHEN 'created' THEN 'member.created' + WHEN 'status_changed' THEN 'member.status_changed' + WHEN 'application_approved' THEN 'application.approved' + WHEN 'application_rejected' THEN 'application.rejected' + ELSE NULL + END; + + IF v_event IS NULL THEN + RETURN NEW; + END IF; + + -- Only queue if there are active rules for this event + IF EXISTS ( + SELECT 1 FROM public.member_notification_rules + WHERE account_id = NEW.account_id + AND trigger_event = v_event + AND is_active = true + ) THEN + INSERT INTO public.pending_member_notifications (account_id, trigger_event, member_id, context) + VALUES ( + NEW.account_id, + v_event, + NEW.member_id, + jsonb_build_object( + 'audit_action', NEW.action, + 'changes', NEW.changes, + 'metadata', NEW.metadata, + 'user_id', NEW.user_id + ) + ); + END IF; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_audit_queue_notifications + AFTER INSERT ON public.member_audit_log + FOR EACH ROW + EXECUTE FUNCTION public.queue_notification_on_audit(); + +-- 6. Queue trigger for application submissions (from membership_applications, not audit log) +CREATE OR REPLACE FUNCTION public.queue_notification_on_application() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + IF NEW.status = 'submitted' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM NEW.status) THEN + IF EXISTS ( + SELECT 1 FROM public.member_notification_rules + WHERE account_id = NEW.account_id + AND trigger_event = 'application.submitted' + AND is_active = true + ) THEN + INSERT INTO public.pending_member_notifications (account_id, trigger_event, context) + VALUES ( + NEW.account_id, + 'application.submitted', + jsonb_build_object( + 'application_id', NEW.id, + 'first_name', NEW.first_name, + 'last_name', NEW.last_name, + 'email', NEW.email + ) + ); + END IF; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER trg_application_queue_notifications + AFTER INSERT OR UPDATE OF status ON public.membership_applications + FOR EACH ROW + EXECUTE FUNCTION public.queue_notification_on_application(); diff --git a/apps/web/supabase/migrations/20260417000001_course_atomic_enrollment.sql b/apps/web/supabase/migrations/20260417000001_course_atomic_enrollment.sql new file mode 100644 index 000000000..157c5d54e --- /dev/null +++ b/apps/web/supabase/migrations/20260417000001_course_atomic_enrollment.sql @@ -0,0 +1,109 @@ +-- ===================================================== +-- Atomic Course Enrollment +-- +-- Problem: Enrolling a participant in a course requires +-- multiple queries (check capacity, count enrolled, insert). +-- Race conditions can over-enroll a course. +-- +-- Fix: Single transactional PG function that locks the +-- course row, validates capacity, and inserts with the +-- correct status (enrolled vs waitlisted). +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.enroll_course_participant( + p_course_id uuid, + p_member_id uuid DEFAULT NULL, + p_first_name text DEFAULT NULL, + p_last_name text DEFAULT NULL, + p_email text DEFAULT NULL, + p_phone text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_course record; + v_enrolled_count bigint; + v_status public.enrollment_status; + v_waitlist_position bigint; + v_participant_id uuid; +BEGIN + -- 1. Lock the course row to prevent concurrent enrollment races + SELECT * INTO v_course + FROM public.courses + WHERE id = p_course_id + FOR UPDATE; + + IF v_course IS NULL THEN + RAISE EXCEPTION 'Course % not found', p_course_id + USING ERRCODE = 'P0002'; + END IF; + + -- 2. Validate course status is open for enrollment + IF v_course.status != 'open' THEN + RAISE EXCEPTION 'Course is not open for enrollment (current status: %)', v_course.status + USING ERRCODE = 'P0001'; + END IF; + + -- 3. Check registration deadline hasn't passed + IF v_course.registration_deadline IS NOT NULL AND v_course.registration_deadline < current_date THEN + RAISE EXCEPTION 'Registration deadline (%) has passed', v_course.registration_deadline + USING ERRCODE = 'P0001'; + END IF; + + -- 4. Count currently enrolled participants + SELECT count(*) INTO v_enrolled_count + FROM public.course_participants + WHERE course_id = p_course_id + AND status = 'enrolled'; + + -- 5. Determine status based on capacity + IF v_enrolled_count >= v_course.capacity THEN + v_status := 'waitlisted'; + ELSE + v_status := 'enrolled'; + END IF; + + -- 6. Insert the participant + INSERT INTO public.course_participants ( + course_id, + member_id, + first_name, + last_name, + email, + phone, + status, + enrolled_at + ) VALUES ( + p_course_id, + p_member_id, + p_first_name, + p_last_name, + p_email, + p_phone, + v_status, + now() + ) + RETURNING id INTO v_participant_id; + + -- 7. Calculate waitlist position if waitlisted + IF v_status = 'waitlisted' THEN + SELECT count(*) INTO v_waitlist_position + FROM public.course_participants + WHERE course_id = p_course_id + AND status = 'waitlisted'; + END IF; + + -- 8. Return result + RETURN jsonb_build_object( + 'participant_id', v_participant_id, + 'status', v_status::text, + 'waitlist_position', CASE WHEN v_status = 'waitlisted' THEN v_waitlist_position ELSE NULL END + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO service_role; diff --git a/apps/web/supabase/migrations/20260417000002_event_atomic_registration.sql b/apps/web/supabase/migrations/20260417000002_event_atomic_registration.sql new file mode 100644 index 000000000..b118ebf63 --- /dev/null +++ b/apps/web/supabase/migrations/20260417000002_event_atomic_registration.sql @@ -0,0 +1,140 @@ +-- ===================================================== +-- Atomic Event Registration +-- +-- Problem: Registering for an event requires multiple +-- queries (check capacity, validate age, count registrations, +-- insert). Race conditions can over-register an event. +-- +-- Fix: +-- A) Ensure member_id FK column exists on event_registrations +-- (idempotent — may already exist from 20260416000006). +-- B) Single transactional PG function that locks the event +-- row, validates capacity/age, and inserts with the +-- correct status (confirmed vs waitlisted). +-- ===================================================== + +-- A) Add member_id column if not already present +ALTER TABLE public.event_registrations + ADD COLUMN IF NOT EXISTS member_id uuid + REFERENCES public.members(id) ON DELETE SET NULL; + +-- Ensure index exists (idempotent) +CREATE INDEX IF NOT EXISTS ix_event_registrations_member + ON public.event_registrations(member_id) + WHERE member_id IS NOT NULL; + +-- The status CHECK constraint already includes 'waitlisted' in the +-- original schema: check (status in ('pending','confirmed','waitlisted','cancelled')) +-- No constraint modification needed. + +-- B) Atomic registration function +CREATE OR REPLACE FUNCTION public.register_for_event( + p_event_id uuid, + p_member_id uuid DEFAULT NULL, + p_first_name text DEFAULT NULL, + p_last_name text DEFAULT NULL, + p_email text DEFAULT NULL, + p_phone text DEFAULT NULL, + p_date_of_birth date DEFAULT NULL, + p_parent_name text DEFAULT NULL, + p_parent_phone text DEFAULT NULL +) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_event record; + v_reg_count bigint; + v_status text; + v_age integer; + v_registration_id uuid; +BEGIN + -- 1. Lock the event row to prevent concurrent registration races + SELECT * INTO v_event + FROM public.events + WHERE id = p_event_id + FOR UPDATE; + + IF v_event IS NULL THEN + RAISE EXCEPTION 'Event % not found', p_event_id + USING ERRCODE = 'P0002'; + END IF; + + -- 2. Validate event status is open for registration + IF v_event.status != 'open' THEN + RAISE EXCEPTION 'Event is not open for registration (current status: %)', v_event.status + USING ERRCODE = 'P0001'; + END IF; + + -- 3. Check registration deadline hasn't passed + IF v_event.registration_deadline IS NOT NULL AND v_event.registration_deadline < current_date THEN + RAISE EXCEPTION 'Registration deadline (%) has passed', v_event.registration_deadline + USING ERRCODE = 'P0001'; + END IF; + + -- 4. Age validation: calculate age at event_date if date_of_birth provided + IF p_date_of_birth IS NOT NULL THEN + v_age := extract(year FROM age(v_event.event_date, p_date_of_birth))::integer; + + IF v_event.min_age IS NOT NULL AND v_age < v_event.min_age THEN + RAISE EXCEPTION 'Participant age (%) is below the minimum age (%) for this event', v_age, v_event.min_age + USING ERRCODE = 'P0001'; + END IF; + + IF v_event.max_age IS NOT NULL AND v_age > v_event.max_age THEN + RAISE EXCEPTION 'Participant age (%) exceeds the maximum age (%) for this event', v_age, v_event.max_age + USING ERRCODE = 'P0001'; + END IF; + END IF; + + -- 5. Count confirmed + pending registrations + SELECT count(*) INTO v_reg_count + FROM public.event_registrations + WHERE event_id = p_event_id + AND status IN ('confirmed', 'pending'); + + -- 6. Determine status based on capacity + IF v_event.capacity IS NOT NULL AND v_reg_count >= v_event.capacity THEN + v_status := 'waitlisted'; + ELSE + v_status := 'confirmed'; + END IF; + + -- 7. Insert the registration + INSERT INTO public.event_registrations ( + event_id, + member_id, + first_name, + last_name, + email, + phone, + date_of_birth, + parent_name, + parent_phone, + status + ) VALUES ( + p_event_id, + p_member_id, + p_first_name, + p_last_name, + p_email, + p_phone, + p_date_of_birth, + p_parent_name, + p_parent_phone, + v_status + ) + RETURNING id INTO v_registration_id; + + -- 8. Return result + RETURN jsonb_build_object( + 'registration_id', v_registration_id, + 'status', v_status + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.register_for_event(uuid, uuid, text, text, text, text, date, text, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.register_for_event(uuid, uuid, text, text, text, text, date, text, text) TO service_role; diff --git a/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql b/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql new file mode 100644 index 000000000..0e2cafd16 --- /dev/null +++ b/apps/web/supabase/migrations/20260417000003_booking_atomic_create.sql @@ -0,0 +1,128 @@ +-- ===================================================== +-- Atomic Booking Creation with Overlap Prevention +-- +-- Problem: Creating a booking requires checking room +-- availability, validating capacity, and inserting — all +-- as separate queries. Race conditions can double-book +-- a room for overlapping dates. +-- +-- Fix: +-- A) Enable btree_gist extension for exclusion constraints. +-- B) Add GiST exclusion constraint to prevent overlapping +-- bookings for the same room (non-cancelled/no_show). +-- C) Single transactional PG function that locks the room, +-- validates inputs, calculates price, and inserts. The +-- exclusion constraint provides a final safety net. +-- ===================================================== + +-- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types) +CREATE EXTENSION IF NOT EXISTS btree_gist; + +-- B) Add exclusion constraint to prevent overlapping bookings (idempotent) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates' + ) THEN + ALTER TABLE public.bookings + ADD CONSTRAINT excl_booking_room_dates + EXCLUDE USING gist ( + room_id WITH =, + daterange(check_in, check_out) WITH && + ) WHERE (status NOT IN ('cancelled', 'no_show')); + END IF; +END; +$$; + +-- C) Atomic booking creation function +CREATE OR REPLACE FUNCTION public.create_booking_atomic( + p_account_id uuid, + p_room_id uuid, + p_guest_id uuid DEFAULT NULL, + p_check_in date DEFAULT NULL, + p_check_out date DEFAULT NULL, + p_adults integer DEFAULT 1, + p_children integer DEFAULT 0, + p_status text DEFAULT 'confirmed', + p_total_price numeric DEFAULT NULL, + p_notes text DEFAULT NULL +) +RETURNS uuid +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_room record; + v_computed_price numeric(10,2); + v_booking_id uuid; +BEGIN + -- 1. Lock the room row to serialize booking attempts + SELECT * INTO v_room + FROM public.rooms + WHERE id = p_room_id + FOR UPDATE; + + -- 2. Validate room exists + IF v_room IS NULL THEN + RAISE EXCEPTION 'Room % not found', p_room_id + USING ERRCODE = 'P0002'; + END IF; + + -- 3. Validate check_out > check_in + IF p_check_in IS NULL OR p_check_out IS NULL THEN + RAISE EXCEPTION 'check_in and check_out dates are required' + USING ERRCODE = 'P0001'; + END IF; + + IF p_check_out <= p_check_in THEN + RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in + USING ERRCODE = 'P0001'; + END IF; + + -- 4. Validate total guests do not exceed room capacity + IF (p_adults + p_children) > v_room.capacity THEN + RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity + USING ERRCODE = 'P0001'; + END IF; + + -- 5. Calculate price if not provided + IF p_total_price IS NOT NULL THEN + v_computed_price := p_total_price; + ELSE + v_computed_price := v_room.price_per_night * (p_check_out - p_check_in); + END IF; + + -- 6. Insert the booking (exclusion constraint prevents double-booking) + INSERT INTO public.bookings ( + account_id, + room_id, + guest_id, + check_in, + check_out, + adults, + children, + status, + total_price, + notes + ) VALUES ( + p_account_id, + p_room_id, + p_guest_id, + p_check_in, + p_check_out, + p_adults, + p_children, + p_status, + v_computed_price, + p_notes + ) + RETURNING id INTO v_booking_id; + + -- 7. Return the new booking id + RETURN v_booking_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated; +GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role; diff --git a/apps/web/supabase/migrations/20260417000004_courses_events_bookings_constraints.sql b/apps/web/supabase/migrations/20260417000004_courses_events_bookings_constraints.sql new file mode 100644 index 000000000..1fd9495c9 --- /dev/null +++ b/apps/web/supabase/migrations/20260417000004_courses_events_bookings_constraints.sql @@ -0,0 +1,143 @@ +-- ===================================================== +-- Data Integrity Constraints for Courses, Events, Bookings +-- +-- Adds CHECK constraints and partial unique indexes to +-- enforce business rules at the database level. +-- +-- All constraint additions are idempotent — wrapped in +-- DO blocks that check pg_constraint before adding. +-- ===================================================== + +-- ------------------------------------------------------- +-- COURSES +-- ------------------------------------------------------- + +-- reduced_fee must not exceed fee +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_reduced_fee_lte_fee' + ) THEN + ALTER TABLE public.courses + ADD CONSTRAINT chk_courses_reduced_fee_lte_fee + CHECK (reduced_fee IS NULL OR reduced_fee <= fee); + END IF; +END; +$$; + +-- min_participants must not exceed capacity +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_min_lte_capacity' + ) THEN + ALTER TABLE public.courses + ADD CONSTRAINT chk_courses_min_lte_capacity + CHECK (min_participants IS NULL OR min_participants <= capacity); + END IF; +END; +$$; + +-- end_date must be on or after start_date +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_date_range' + ) THEN + ALTER TABLE public.courses + ADD CONSTRAINT chk_courses_date_range + CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date); + END IF; +END; +$$; + +-- registration_deadline must be on or before start_date +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_deadline_before_start' + ) THEN + ALTER TABLE public.courses + ADD CONSTRAINT chk_courses_deadline_before_start + CHECK (registration_deadline IS NULL OR start_date IS NULL OR registration_deadline <= start_date); + END IF; +END; +$$; + +-- Unique course_number per account (partial index — allows NULLs and empty strings) +CREATE UNIQUE INDEX IF NOT EXISTS uix_courses_number_per_account + ON public.courses(account_id, course_number) + WHERE course_number IS NOT NULL AND course_number != ''; + +-- ------------------------------------------------------- +-- EVENTS +-- ------------------------------------------------------- + +-- min_age must not exceed max_age +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_age_range' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT chk_events_age_range + CHECK (min_age IS NULL OR max_age IS NULL OR min_age <= max_age); + END IF; +END; +$$; + +-- end_date must be on or after event_date +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_date_range' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT chk_events_date_range + CHECK (end_date IS NULL OR end_date >= event_date); + END IF; +END; +$$; + +-- registration_deadline must be on or before event_date +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_deadline_before_event' + ) THEN + ALTER TABLE public.events + ADD CONSTRAINT chk_events_deadline_before_event + CHECK (registration_deadline IS NULL OR registration_deadline <= event_date); + END IF; +END; +$$; + +-- ------------------------------------------------------- +-- BOOKINGS +-- ------------------------------------------------------- + +-- At least 1 adult required +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_bookings_min_adults' + ) THEN + ALTER TABLE public.bookings + ADD CONSTRAINT chk_bookings_min_adults + CHECK (adults >= 1); + END IF; +END; +$$; + +-- total_price must be non-negative +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'chk_bookings_price_non_negative' + ) THEN + ALTER TABLE public.bookings + ADD CONSTRAINT chk_bookings_price_non_negative + CHECK (total_price >= 0); + END IF; +END; +$$; diff --git a/apps/web/supabase/migrations/20260417000005_courses_events_bookings_versioning.sql b/apps/web/supabase/migrations/20260417000005_courses_events_bookings_versioning.sql new file mode 100644 index 000000000..ac1a73dd5 --- /dev/null +++ b/apps/web/supabase/migrations/20260417000005_courses_events_bookings_versioning.sql @@ -0,0 +1,84 @@ +-- ===================================================== +-- Optimistic Locking for Courses, Events, Bookings +-- +-- Problem: Concurrent edits to courses, events, or bookings +-- can silently overwrite each other (last write wins). +-- +-- Fix: Add version column to each table with an auto- +-- increment trigger on update. API layer checks version +-- match before writing, preventing silent overwrites. +-- +-- Reuses the same trigger function pattern established +-- in 20260416000005_member_versioning.sql but creates a +-- shared generic function instead of table-specific ones. +-- ===================================================== + +-- Shared version increment function (CREATE OR REPLACE is idempotent) +CREATE OR REPLACE FUNCTION public.increment_version() +RETURNS trigger +LANGUAGE plpgsql AS $$ +BEGIN + NEW.version := OLD.version + 1; + RETURN NEW; +END; +$$; + +-- ------------------------------------------------------- +-- COURSES +-- ------------------------------------------------------- + +ALTER TABLE public.courses + ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_courses_increment_version' + ) THEN + CREATE TRIGGER trg_courses_increment_version + BEFORE UPDATE ON public.courses + FOR EACH ROW + EXECUTE FUNCTION public.increment_version(); + END IF; +END; +$$; + +-- ------------------------------------------------------- +-- EVENTS +-- ------------------------------------------------------- + +ALTER TABLE public.events + ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_increment_version' + ) THEN + CREATE TRIGGER trg_events_increment_version + BEFORE UPDATE ON public.events + FOR EACH ROW + EXECUTE FUNCTION public.increment_version(); + END IF; +END; +$$; + +-- ------------------------------------------------------- +-- BOOKINGS +-- ------------------------------------------------------- + +ALTER TABLE public.bookings + ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bookings_increment_version' + ) THEN + CREATE TRIGGER trg_bookings_increment_version + BEFORE UPDATE ON public.bookings + FOR EACH ROW + EXECUTE FUNCTION public.increment_version(); + END IF; +END; +$$; diff --git a/apps/web/supabase/migrations/20260417000006_courses_events_bookings_audit.sql b/apps/web/supabase/migrations/20260417000006_courses_events_bookings_audit.sql new file mode 100644 index 000000000..ec4db6903 --- /dev/null +++ b/apps/web/supabase/migrations/20260417000006_courses_events_bookings_audit.sql @@ -0,0 +1,496 @@ +-- ===================================================== +-- Audit Logging for Courses, Events, Bookings +-- +-- Full change history for compliance: who changed what +-- field, old value -> new value, when. Mirrors the +-- member_audit_log pattern from 20260416000007. +-- ===================================================== + +-- ------------------------------------------------------- +-- A) Add created_by / updated_by to main tables +-- ------------------------------------------------------- + +ALTER TABLE public.courses + ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE public.events + ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; + +ALTER TABLE public.bookings + ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; + +-- ------------------------------------------------------- +-- B) Audit log tables +-- ------------------------------------------------------- + +-- B.1 Course audit log +CREATE TABLE IF NOT EXISTS public.course_audit_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + course_id uuid NOT NULL REFERENCES public.courses(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + action text NOT NULL CHECK (action IN ( + 'created', 'updated', 'status_changed', 'cancelled', + 'participant_enrolled', 'participant_cancelled', + 'participant_waitlisted', 'participant_promoted', + 'session_created', 'session_cancelled', + 'attendance_marked', 'instructor_changed', 'location_changed' + )), + changes jsonb NOT NULL DEFAULT '{}', + metadata jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.course_audit_log IS + 'Immutable audit trail for all course lifecycle events'; + +CREATE INDEX IF NOT EXISTS ix_course_audit_course + ON public.course_audit_log(course_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_course_audit_account + ON public.course_audit_log(account_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_course_audit_action + ON public.course_audit_log(account_id, action); + +ALTER TABLE public.course_audit_log ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.course_audit_log FROM authenticated, service_role; +GRANT SELECT ON public.course_audit_log TO authenticated; +GRANT INSERT, SELECT ON public.course_audit_log TO service_role; + +CREATE POLICY course_audit_log_select + ON public.course_audit_log FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +-- B.2 Event audit log +CREATE TABLE IF NOT EXISTS public.event_audit_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + action text NOT NULL CHECK (action IN ( + 'created', 'updated', 'status_changed', 'cancelled', + 'registration_confirmed', 'registration_waitlisted', + 'registration_cancelled', 'registration_promoted' + )), + changes jsonb NOT NULL DEFAULT '{}', + metadata jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.event_audit_log IS + 'Immutable audit trail for all event lifecycle events'; + +CREATE INDEX IF NOT EXISTS ix_event_audit_event + ON public.event_audit_log(event_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_event_audit_account + ON public.event_audit_log(account_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_event_audit_action + ON public.event_audit_log(account_id, action); + +ALTER TABLE public.event_audit_log ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.event_audit_log FROM authenticated, service_role; +GRANT SELECT ON public.event_audit_log TO authenticated; +GRANT INSERT, SELECT ON public.event_audit_log TO service_role; + +CREATE POLICY event_audit_log_select + ON public.event_audit_log FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +-- B.3 Booking audit log +CREATE TABLE IF NOT EXISTS public.booking_audit_log ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + booking_id uuid NOT NULL REFERENCES public.bookings(id) ON DELETE CASCADE, + account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE, + user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL, + action text NOT NULL CHECK (action IN ( + 'created', 'updated', 'status_changed', + 'checked_in', 'checked_out', 'cancelled', + 'no_show', 'price_changed' + )), + changes jsonb NOT NULL DEFAULT '{}', + metadata jsonb NOT NULL DEFAULT '{}', + created_at timestamptz NOT NULL DEFAULT now() +); + +COMMENT ON TABLE public.booking_audit_log IS + 'Immutable audit trail for all booking lifecycle events'; + +CREATE INDEX IF NOT EXISTS ix_booking_audit_booking + ON public.booking_audit_log(booking_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_booking_audit_account + ON public.booking_audit_log(account_id, created_at DESC); +CREATE INDEX IF NOT EXISTS ix_booking_audit_action + ON public.booking_audit_log(account_id, action); + +ALTER TABLE public.booking_audit_log ENABLE ROW LEVEL SECURITY; +REVOKE ALL ON public.booking_audit_log FROM authenticated, service_role; +GRANT SELECT ON public.booking_audit_log TO authenticated; +GRANT INSERT, SELECT ON public.booking_audit_log TO service_role; + +CREATE POLICY booking_audit_log_select + ON public.booking_audit_log FOR SELECT TO authenticated + USING (public.has_role_on_account(account_id)); + +-- ------------------------------------------------------- +-- C) Auto-audit triggers for UPDATE +-- ------------------------------------------------------- + +-- C.1 Courses UPDATE trigger +CREATE OR REPLACE FUNCTION public.trg_course_audit_on_update() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_changes jsonb := '{}'::jsonb; + v_action text := 'updated'; + v_user_id uuid; +BEGIN + -- Build changes diff (field by field) + IF OLD.name IS DISTINCT FROM NEW.name THEN + v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name)); + END IF; + IF OLD.description IS DISTINCT FROM NEW.description THEN + v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description)); + END IF; + IF OLD.course_number IS DISTINCT FROM NEW.course_number THEN + v_changes := v_changes || jsonb_build_object('course_number', jsonb_build_object('old', OLD.course_number, 'new', NEW.course_number)); + END IF; + IF OLD.category_id IS DISTINCT FROM NEW.category_id THEN + v_changes := v_changes || jsonb_build_object('category_id', jsonb_build_object('old', OLD.category_id, 'new', NEW.category_id)); + END IF; + IF OLD.instructor_id IS DISTINCT FROM NEW.instructor_id THEN + v_changes := v_changes || jsonb_build_object('instructor_id', jsonb_build_object('old', OLD.instructor_id, 'new', NEW.instructor_id)); + END IF; + IF OLD.location_id IS DISTINCT FROM NEW.location_id THEN + v_changes := v_changes || jsonb_build_object('location_id', jsonb_build_object('old', OLD.location_id, 'new', NEW.location_id)); + END IF; + IF OLD.start_date IS DISTINCT FROM NEW.start_date THEN + v_changes := v_changes || jsonb_build_object('start_date', jsonb_build_object('old', OLD.start_date, 'new', NEW.start_date)); + END IF; + IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN + v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date)); + END IF; + IF OLD.fee IS DISTINCT FROM NEW.fee THEN + v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee)); + END IF; + IF OLD.reduced_fee IS DISTINCT FROM NEW.reduced_fee THEN + v_changes := v_changes || jsonb_build_object('reduced_fee', jsonb_build_object('old', OLD.reduced_fee, 'new', NEW.reduced_fee)); + END IF; + IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN + v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity)); + END IF; + IF OLD.min_participants IS DISTINCT FROM NEW.min_participants THEN + v_changes := v_changes || jsonb_build_object('min_participants', jsonb_build_object('old', OLD.min_participants, 'new', NEW.min_participants)); + END IF; + IF OLD.status IS DISTINCT FROM NEW.status THEN + v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status)); + END IF; + IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN + v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline)); + END IF; + IF OLD.notes IS DISTINCT FROM NEW.notes THEN + v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes)); + END IF; + + -- Skip if nothing actually changed + IF v_changes = '{}'::jsonb THEN + RETURN NULL; + END IF; + + -- Classify the action + IF OLD.status IS DISTINCT FROM NEW.status THEN + v_action := 'status_changed'; + END IF; + + v_user_id := COALESCE( + NULLIF(current_setting('app.current_user_id', true), '')::uuid, + auth.uid() + ); + + INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, changes) + VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); + + RETURN NULL; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_courses_audit_on_update + AFTER UPDATE ON public.courses + FOR EACH ROW + EXECUTE FUNCTION public.trg_course_audit_on_update(); + +-- C.2 Events UPDATE trigger +CREATE OR REPLACE FUNCTION public.trg_event_audit_on_update() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_changes jsonb := '{}'::jsonb; + v_action text := 'updated'; + v_user_id uuid; +BEGIN + -- Build changes diff (field by field) + IF OLD.name IS DISTINCT FROM NEW.name THEN + v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name)); + END IF; + IF OLD.description IS DISTINCT FROM NEW.description THEN + v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description)); + END IF; + IF OLD.event_date IS DISTINCT FROM NEW.event_date THEN + v_changes := v_changes || jsonb_build_object('event_date', jsonb_build_object('old', OLD.event_date, 'new', NEW.event_date)); + END IF; + IF OLD.event_time IS DISTINCT FROM NEW.event_time THEN + v_changes := v_changes || jsonb_build_object('event_time', jsonb_build_object('old', OLD.event_time, 'new', NEW.event_time)); + END IF; + IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN + v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date)); + END IF; + IF OLD.location IS DISTINCT FROM NEW.location THEN + v_changes := v_changes || jsonb_build_object('location', jsonb_build_object('old', OLD.location, 'new', NEW.location)); + END IF; + IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN + v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity)); + END IF; + IF OLD.min_age IS DISTINCT FROM NEW.min_age THEN + v_changes := v_changes || jsonb_build_object('min_age', jsonb_build_object('old', OLD.min_age, 'new', NEW.min_age)); + END IF; + IF OLD.max_age IS DISTINCT FROM NEW.max_age THEN + v_changes := v_changes || jsonb_build_object('max_age', jsonb_build_object('old', OLD.max_age, 'new', NEW.max_age)); + END IF; + IF OLD.fee IS DISTINCT FROM NEW.fee THEN + v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee)); + END IF; + IF OLD.status IS DISTINCT FROM NEW.status THEN + v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status)); + END IF; + IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN + v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline)); + END IF; + IF OLD.contact_name IS DISTINCT FROM NEW.contact_name THEN + v_changes := v_changes || jsonb_build_object('contact_name', jsonb_build_object('old', OLD.contact_name, 'new', NEW.contact_name)); + END IF; + IF OLD.contact_email IS DISTINCT FROM NEW.contact_email THEN + v_changes := v_changes || jsonb_build_object('contact_email', jsonb_build_object('old', OLD.contact_email, 'new', NEW.contact_email)); + END IF; + IF OLD.contact_phone IS DISTINCT FROM NEW.contact_phone THEN + v_changes := v_changes || jsonb_build_object('contact_phone', jsonb_build_object('old', OLD.contact_phone, 'new', NEW.contact_phone)); + END IF; + + -- Skip if nothing actually changed + IF v_changes = '{}'::jsonb THEN + RETURN NULL; + END IF; + + -- Classify the action + IF OLD.status IS DISTINCT FROM NEW.status THEN + v_action := 'status_changed'; + END IF; + + v_user_id := COALESCE( + NULLIF(current_setting('app.current_user_id', true), '')::uuid, + auth.uid() + ); + + INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, changes) + VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); + + RETURN NULL; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_events_audit_on_update + AFTER UPDATE ON public.events + FOR EACH ROW + EXECUTE FUNCTION public.trg_event_audit_on_update(); + +-- C.3 Bookings UPDATE trigger +CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_update() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_changes jsonb := '{}'::jsonb; + v_action text := 'updated'; + v_user_id uuid; +BEGIN + -- Build changes diff (field by field) + IF OLD.room_id IS DISTINCT FROM NEW.room_id THEN + v_changes := v_changes || jsonb_build_object('room_id', jsonb_build_object('old', OLD.room_id, 'new', NEW.room_id)); + END IF; + IF OLD.guest_id IS DISTINCT FROM NEW.guest_id THEN + v_changes := v_changes || jsonb_build_object('guest_id', jsonb_build_object('old', OLD.guest_id, 'new', NEW.guest_id)); + END IF; + IF OLD.check_in IS DISTINCT FROM NEW.check_in THEN + v_changes := v_changes || jsonb_build_object('check_in', jsonb_build_object('old', OLD.check_in, 'new', NEW.check_in)); + END IF; + IF OLD.check_out IS DISTINCT FROM NEW.check_out THEN + v_changes := v_changes || jsonb_build_object('check_out', jsonb_build_object('old', OLD.check_out, 'new', NEW.check_out)); + END IF; + IF OLD.adults IS DISTINCT FROM NEW.adults THEN + v_changes := v_changes || jsonb_build_object('adults', jsonb_build_object('old', OLD.adults, 'new', NEW.adults)); + END IF; + IF OLD.children IS DISTINCT FROM NEW.children THEN + v_changes := v_changes || jsonb_build_object('children', jsonb_build_object('old', OLD.children, 'new', NEW.children)); + END IF; + IF OLD.status IS DISTINCT FROM NEW.status THEN + v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status)); + END IF; + IF OLD.total_price IS DISTINCT FROM NEW.total_price THEN + v_changes := v_changes || jsonb_build_object('total_price', jsonb_build_object('old', OLD.total_price, 'new', NEW.total_price)); + END IF; + IF OLD.notes IS DISTINCT FROM NEW.notes THEN + v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes)); + END IF; + + -- Skip if nothing actually changed + IF v_changes = '{}'::jsonb THEN + RETURN NULL; + END IF; + + -- Classify the action + IF OLD.status IS DISTINCT FROM NEW.status THEN + v_action := 'status_changed'; + END IF; + + v_user_id := COALESCE( + NULLIF(current_setting('app.current_user_id', true), '')::uuid, + auth.uid() + ); + + INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, changes) + VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes); + + RETURN NULL; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_bookings_audit_on_update + AFTER UPDATE ON public.bookings + FOR EACH ROW + EXECUTE FUNCTION public.trg_booking_audit_on_update(); + +-- ------------------------------------------------------- +-- D) Auto-audit triggers for INSERT +-- ------------------------------------------------------- + +-- D.1 Courses INSERT trigger +CREATE OR REPLACE FUNCTION public.trg_course_audit_on_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; +BEGIN + v_user_id := COALESCE( + NULLIF(current_setting('app.current_user_id', true), '')::uuid, + NEW.created_by + ); + + INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, metadata) + VALUES ( + NEW.id, NEW.account_id, v_user_id, 'created', + jsonb_build_object( + 'course_number', NEW.course_number, + 'name', NEW.name, + 'status', NEW.status, + 'fee', NEW.fee, + 'capacity', NEW.capacity, + 'start_date', NEW.start_date, + 'end_date', NEW.end_date + ) + ); + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_courses_audit_on_insert + AFTER INSERT ON public.courses + FOR EACH ROW + EXECUTE FUNCTION public.trg_course_audit_on_insert(); + +-- D.2 Events INSERT trigger +CREATE OR REPLACE FUNCTION public.trg_event_audit_on_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; +BEGIN + v_user_id := COALESCE( + NULLIF(current_setting('app.current_user_id', true), '')::uuid, + NEW.created_by + ); + + INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, metadata) + VALUES ( + NEW.id, NEW.account_id, v_user_id, 'created', + jsonb_build_object( + 'name', NEW.name, + 'status', NEW.status, + 'event_date', NEW.event_date, + 'location', NEW.location, + 'capacity', NEW.capacity, + 'fee', NEW.fee + ) + ); + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_events_audit_on_insert + AFTER INSERT ON public.events + FOR EACH ROW + EXECUTE FUNCTION public.trg_event_audit_on_insert(); + +-- D.3 Bookings INSERT trigger +CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_insert() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid; +BEGIN + v_user_id := COALESCE( + NULLIF(current_setting('app.current_user_id', true), '')::uuid, + NEW.created_by + ); + + INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, metadata) + VALUES ( + NEW.id, NEW.account_id, v_user_id, 'created', + jsonb_build_object( + 'room_id', NEW.room_id, + 'guest_id', NEW.guest_id, + 'check_in', NEW.check_in, + 'check_out', NEW.check_out, + 'status', NEW.status, + 'total_price', NEW.total_price, + 'adults', NEW.adults, + 'children', NEW.children + ) + ); + + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER trg_bookings_audit_on_insert + AFTER INSERT ON public.bookings + FOR EACH ROW + EXECUTE FUNCTION public.trg_booking_audit_on_insert(); diff --git a/apps/web/supabase/migrations/20260417000008_audit_timeline_rpcs.sql b/apps/web/supabase/migrations/20260417000008_audit_timeline_rpcs.sql new file mode 100644 index 000000000..dbc9ac6aa --- /dev/null +++ b/apps/web/supabase/migrations/20260417000008_audit_timeline_rpcs.sql @@ -0,0 +1,231 @@ +-- ===================================================== +-- Audit Timeline RPCs for Courses, Events, Bookings +-- +-- Paginated, filterable read layer on the audit logs. +-- Mirrors get_member_timeline from 20260416000007. +-- ===================================================== + +-- ------------------------------------------------------- +-- 1. Course timeline RPC +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_course_timeline( + p_course_id uuid, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 50, + p_action_filter text DEFAULT NULL +) +RETURNS TABLE ( + id bigint, + action text, + changes jsonb, + metadata jsonb, + user_id uuid, + user_email text, + created_at timestamptz, + total_count bigint +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_account_id uuid; + v_total bigint; + v_offset integer; +BEGIN + -- Get course's account for access check + SELECT c.account_id INTO v_account_id + FROM public.courses c WHERE c.id = p_course_id; + + IF v_account_id IS NULL THEN + RAISE EXCEPTION 'Course not found'; + END IF; + + IF NOT public.has_role_on_account(v_account_id) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- Clamp page size to prevent unbounded queries + p_page_size := LEAST(GREATEST(p_page_size, 1), 200); + v_offset := GREATEST(0, (p_page - 1)) * p_page_size; + + -- Get total count + SELECT count(*) INTO v_total + FROM public.course_audit_log cal + WHERE cal.course_id = p_course_id + AND (p_action_filter IS NULL OR cal.action = p_action_filter); + + -- Return paginated results with user email + RETURN QUERY + SELECT + cal.id, + cal.action, + cal.changes, + cal.metadata, + cal.user_id, + u.email::text AS user_email, + cal.created_at, + v_total AS total_count + FROM public.course_audit_log cal + LEFT JOIN auth.users u ON u.id = cal.user_id + WHERE cal.course_id = p_course_id + AND (p_action_filter IS NULL OR cal.action = p_action_filter) + ORDER BY cal.created_at DESC + LIMIT p_page_size OFFSET v_offset; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_course_timeline(uuid, integer, integer, text) + TO authenticated, service_role; + +-- ------------------------------------------------------- +-- 2. Event timeline RPC +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_event_timeline( + p_event_id uuid, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 50, + p_action_filter text DEFAULT NULL +) +RETURNS TABLE ( + id bigint, + action text, + changes jsonb, + metadata jsonb, + user_id uuid, + user_email text, + created_at timestamptz, + total_count bigint +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_account_id uuid; + v_total bigint; + v_offset integer; +BEGIN + -- Get event's account for access check + SELECT e.account_id INTO v_account_id + FROM public.events e WHERE e.id = p_event_id; + + IF v_account_id IS NULL THEN + RAISE EXCEPTION 'Event not found'; + END IF; + + IF NOT public.has_role_on_account(v_account_id) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- Clamp page size to prevent unbounded queries + p_page_size := LEAST(GREATEST(p_page_size, 1), 200); + v_offset := GREATEST(0, (p_page - 1)) * p_page_size; + + -- Get total count + SELECT count(*) INTO v_total + FROM public.event_audit_log eal + WHERE eal.event_id = p_event_id + AND (p_action_filter IS NULL OR eal.action = p_action_filter); + + -- Return paginated results with user email + RETURN QUERY + SELECT + eal.id, + eal.action, + eal.changes, + eal.metadata, + eal.user_id, + u.email::text AS user_email, + eal.created_at, + v_total AS total_count + FROM public.event_audit_log eal + LEFT JOIN auth.users u ON u.id = eal.user_id + WHERE eal.event_id = p_event_id + AND (p_action_filter IS NULL OR eal.action = p_action_filter) + ORDER BY eal.created_at DESC + LIMIT p_page_size OFFSET v_offset; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_event_timeline(uuid, integer, integer, text) + TO authenticated, service_role; + +-- ------------------------------------------------------- +-- 3. Booking timeline RPC +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_booking_timeline( + p_booking_id uuid, + p_page integer DEFAULT 1, + p_page_size integer DEFAULT 50, + p_action_filter text DEFAULT NULL +) +RETURNS TABLE ( + id bigint, + action text, + changes jsonb, + metadata jsonb, + user_id uuid, + user_email text, + created_at timestamptz, + total_count bigint +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_account_id uuid; + v_total bigint; + v_offset integer; +BEGIN + -- Get booking's account for access check + SELECT b.account_id INTO v_account_id + FROM public.bookings b WHERE b.id = p_booking_id; + + IF v_account_id IS NULL THEN + RAISE EXCEPTION 'Booking not found'; + END IF; + + IF NOT public.has_role_on_account(v_account_id) THEN + RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501'; + END IF; + + -- Clamp page size to prevent unbounded queries + p_page_size := LEAST(GREATEST(p_page_size, 1), 200); + v_offset := GREATEST(0, (p_page - 1)) * p_page_size; + + -- Get total count + SELECT count(*) INTO v_total + FROM public.booking_audit_log bal + WHERE bal.booking_id = p_booking_id + AND (p_action_filter IS NULL OR bal.action = p_action_filter); + + -- Return paginated results with user email + RETURN QUERY + SELECT + bal.id, + bal.action, + bal.changes, + bal.metadata, + bal.user_id, + u.email::text AS user_email, + bal.created_at, + v_total AS total_count + FROM public.booking_audit_log bal + LEFT JOIN auth.users u ON u.id = bal.user_id + WHERE bal.booking_id = p_booking_id + AND (p_action_filter IS NULL OR bal.action = p_action_filter) + ORDER BY bal.created_at DESC + LIMIT p_page_size OFFSET v_offset; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_booking_timeline(uuid, integer, integer, text) + TO authenticated, service_role; diff --git a/apps/web/supabase/migrations/20260418000001_waitlist_management.sql b/apps/web/supabase/migrations/20260418000001_waitlist_management.sql new file mode 100644 index 000000000..b6f56fda5 --- /dev/null +++ b/apps/web/supabase/migrations/20260418000001_waitlist_management.sql @@ -0,0 +1,146 @@ +-- ===================================================== +-- Waitlist Management +-- +-- A) Course cancellation with automatic waitlist promotion +-- B) Event cancellation with automatic waitlist promotion +-- +-- When an enrolled/confirmed participant is cancelled, +-- the oldest waitlisted entry is atomically promoted. +-- ===================================================== + +-- ------------------------------------------------------- +-- A) Course waitlist promotion +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.cancel_course_enrollment(p_participant_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_participant record; + v_course record; + v_promoted_id uuid; + v_promoted_name text; +BEGIN + -- Lock participant + SELECT * INTO v_participant + FROM public.course_participants + WHERE id = p_participant_id + FOR UPDATE; + + IF v_participant IS NULL THEN + RAISE EXCEPTION 'Teilnehmer nicht gefunden' + USING ERRCODE = 'P0002'; + END IF; + + -- Lock course + SELECT * INTO v_course + FROM public.courses + WHERE id = v_participant.course_id + FOR UPDATE; + + -- Cancel + UPDATE public.course_participants + SET status = 'cancelled'::public.enrollment_status, + cancelled_at = now() + WHERE id = p_participant_id; + + -- If was enrolled (not already waitlisted/cancelled), promote oldest waitlisted + IF v_participant.status = 'enrolled' THEN + UPDATE public.course_participants + SET status = 'enrolled'::public.enrollment_status + WHERE id = ( + SELECT id FROM public.course_participants + WHERE course_id = v_participant.course_id + AND status = 'waitlisted' + ORDER BY enrolled_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, first_name || ' ' || last_name + INTO v_promoted_id, v_promoted_name; + END IF; + + RETURN jsonb_build_object( + 'cancelled_id', p_participant_id, + 'promoted_id', v_promoted_id, + 'promoted_name', v_promoted_name + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.cancel_course_enrollment(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.cancel_course_enrollment(uuid) TO service_role; + +-- ------------------------------------------------------- +-- B) Event registration cancellation + waitlist promotion +-- ------------------------------------------------------- + +-- Add updated_at column if not present +ALTER TABLE public.event_registrations + ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now(); + +CREATE OR REPLACE FUNCTION public.cancel_event_registration(p_registration_id uuid) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_registration record; + v_event record; + v_promoted_id uuid; + v_promoted_name text; +BEGIN + -- Lock registration + SELECT * INTO v_registration + FROM public.event_registrations + WHERE id = p_registration_id + FOR UPDATE; + + IF v_registration IS NULL THEN + RAISE EXCEPTION 'Anmeldung nicht gefunden' + USING ERRCODE = 'P0002'; + END IF; + + -- Lock event + SELECT * INTO v_event + FROM public.events + WHERE id = v_registration.event_id + FOR UPDATE; + + -- Cancel + UPDATE public.event_registrations + SET status = 'cancelled', + updated_at = now() + WHERE id = p_registration_id; + + -- If was confirmed or pending, promote oldest waitlisted + IF v_registration.status IN ('confirmed', 'pending') THEN + UPDATE public.event_registrations + SET status = 'confirmed', + updated_at = now() + WHERE id = ( + SELECT id FROM public.event_registrations + WHERE event_id = v_registration.event_id + AND status = 'waitlisted' + ORDER BY created_at ASC + LIMIT 1 + FOR UPDATE SKIP LOCKED + ) + RETURNING id, first_name || ' ' || last_name + INTO v_promoted_id, v_promoted_name; + END IF; + + RETURN jsonb_build_object( + 'cancelled_id', p_registration_id, + 'promoted_id', v_promoted_id, + 'promoted_name', v_promoted_name + ); +END; +$$; + +GRANT EXECUTE ON FUNCTION public.cancel_event_registration(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.cancel_event_registration(uuid) TO service_role; diff --git a/apps/web/supabase/migrations/20260418000002_attendance_rollup.sql b/apps/web/supabase/migrations/20260418000002_attendance_rollup.sql new file mode 100644 index 000000000..5a620e397 --- /dev/null +++ b/apps/web/supabase/migrations/20260418000002_attendance_rollup.sql @@ -0,0 +1,66 @@ +-- ===================================================== +-- Attendance Rollup +-- +-- RPC that returns a per-participant attendance summary +-- for a given course: total sessions, sessions attended, +-- and attendance rate (%). +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.get_course_attendance_summary(p_course_id uuid) +RETURNS TABLE ( + participant_id uuid, + participant_name text, + enrollment_status public.enrollment_status, + total_sessions bigint, + sessions_attended bigint, + attendance_rate numeric +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Access check + IF NOT public.has_role_on_account( + (SELECT account_id FROM public.courses WHERE id = p_course_id) + ) THEN + RAISE EXCEPTION 'Access denied' + USING ERRCODE = '42501'; + END IF; + + RETURN QUERY + WITH session_count AS ( + SELECT count(*)::bigint AS cnt + FROM public.course_sessions + WHERE course_id = p_course_id + AND is_cancelled = false + ) + SELECT + cp.id AS participant_id, + (cp.first_name || ' ' || cp.last_name)::text AS participant_name, + cp.status AS enrollment_status, + sc.cnt AS total_sessions, + COALESCE(count(ca.id) FILTER (WHERE ca.present = true), 0)::bigint AS sessions_attended, + CASE WHEN sc.cnt > 0 THEN + ROUND( + COALESCE(count(ca.id) FILTER (WHERE ca.present = true), 0)::numeric + / sc.cnt * 100, + 1 + ) + ELSE 0 END AS attendance_rate + FROM public.course_participants cp + CROSS JOIN session_count sc + LEFT JOIN public.course_attendance ca ON ca.participant_id = cp.id + LEFT JOIN public.course_sessions cs + ON cs.id = ca.session_id + AND cs.is_cancelled = false + WHERE cp.course_id = p_course_id + AND cp.status IN ('enrolled', 'completed') + GROUP BY cp.id, cp.first_name, cp.last_name, cp.status, sc.cnt + ORDER BY cp.last_name, cp.first_name; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_course_attendance_summary(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_course_attendance_summary(uuid) TO service_role; diff --git a/apps/web/supabase/migrations/20260418000003_instructor_availability.sql b/apps/web/supabase/migrations/20260418000003_instructor_availability.sql new file mode 100644 index 000000000..864ae605b --- /dev/null +++ b/apps/web/supabase/migrations/20260418000003_instructor_availability.sql @@ -0,0 +1,36 @@ +-- ===================================================== +-- Instructor Availability Check +-- +-- Returns TRUE if the instructor has no scheduling +-- conflicts for the requested date/time window. +-- Optionally excludes a specific session (for edits). +-- ===================================================== + +CREATE OR REPLACE FUNCTION public.check_instructor_availability( + p_instructor_id uuid, + p_session_date date, + p_start_time time, + p_end_time time, + p_exclude_session_id uuid DEFAULT NULL +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT NOT EXISTS ( + SELECT 1 + FROM public.course_sessions cs + JOIN public.courses c ON c.id = cs.course_id + WHERE c.instructor_id = p_instructor_id + AND cs.session_date = p_session_date + AND cs.start_time < p_end_time + AND cs.end_time > p_start_time + AND (p_exclude_session_id IS NULL OR cs.id != p_exclude_session_id) + AND cs.is_cancelled = false + ); +$$; + +GRANT EXECUTE ON FUNCTION public.check_instructor_availability(uuid, date, time, time, uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.check_instructor_availability(uuid, date, time, time, uuid) TO service_role; diff --git a/apps/web/supabase/migrations/20260418000004_module_statistics_rpcs.sql b/apps/web/supabase/migrations/20260418000004_module_statistics_rpcs.sql new file mode 100644 index 000000000..b5170042b --- /dev/null +++ b/apps/web/supabase/migrations/20260418000004_module_statistics_rpcs.sql @@ -0,0 +1,294 @@ +-- ===================================================== +-- Module Statistics RPCs +-- +-- A) Course statistics — counts per status, participants, +-- average occupancy, total revenue +-- B) Event statistics — counts, upcoming/past, registrations, +-- average occupancy +-- C) Booking statistics — counts, revenue, avg stay, +-- occupancy rate for a date range +-- D) Event registration counts — batch lookup replacing +-- N+1 JS iteration +-- ===================================================== + +-- ------------------------------------------------------- +-- A) Course statistics +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_course_statistics(p_account_id uuid) +RETURNS TABLE ( + total_courses bigint, + open_courses bigint, + running_courses bigint, + completed_courses bigint, + cancelled_courses bigint, + total_participants bigint, + total_waitlisted bigint, + avg_occupancy_rate numeric, + total_revenue numeric +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Access check + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied' + USING ERRCODE = '42501'; + END IF; + + RETURN QUERY + WITH course_stats AS ( + SELECT + count(*)::bigint AS total_courses, + count(*) FILTER (WHERE c.status = 'open')::bigint AS open_courses, + count(*) FILTER (WHERE c.status = 'running')::bigint AS running_courses, + count(*) FILTER (WHERE c.status = 'completed')::bigint AS completed_courses, + count(*) FILTER (WHERE c.status = 'cancelled')::bigint AS cancelled_courses + FROM public.courses c + WHERE c.account_id = p_account_id + ), + participant_stats AS ( + SELECT + count(*) FILTER (WHERE cp.status = 'enrolled')::bigint AS total_participants, + count(*) FILTER (WHERE cp.status = 'waitlisted')::bigint AS total_waitlisted + FROM public.course_participants cp + JOIN public.courses c ON c.id = cp.course_id + WHERE c.account_id = p_account_id + ), + occupancy_stats AS ( + SELECT + ROUND( + AVG( + CASE WHEN c.capacity > 0 THEN + enrolled_ct::numeric / c.capacity * 100 + ELSE 0 END + ), + 1 + ) AS avg_occupancy_rate + FROM public.courses c + LEFT JOIN LATERAL ( + SELECT count(*)::numeric AS enrolled_ct + FROM public.course_participants cp + WHERE cp.course_id = c.id AND cp.status = 'enrolled' + ) ec ON true + WHERE c.account_id = p_account_id + AND c.status != 'cancelled' + ), + revenue_stats AS ( + SELECT + COALESCE(SUM(c.fee * enrolled_ct), 0)::numeric AS total_revenue + FROM public.courses c + LEFT JOIN LATERAL ( + SELECT count(*)::numeric AS enrolled_ct + FROM public.course_participants cp + WHERE cp.course_id = c.id AND cp.status IN ('enrolled', 'completed') + ) ec ON true + WHERE c.account_id = p_account_id + AND c.status != 'cancelled' + ) + SELECT + cs.total_courses, + cs.open_courses, + cs.running_courses, + cs.completed_courses, + cs.cancelled_courses, + ps.total_participants, + ps.total_waitlisted, + os.avg_occupancy_rate, + rs.total_revenue + FROM course_stats cs + CROSS JOIN participant_stats ps + CROSS JOIN occupancy_stats os + CROSS JOIN revenue_stats rs; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_course_statistics(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_course_statistics(uuid) TO service_role; + +-- ------------------------------------------------------- +-- B) Event statistics +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_event_statistics(p_account_id uuid) +RETURNS TABLE ( + total_events bigint, + upcoming_events bigint, + past_events bigint, + total_registrations bigint, + avg_occupancy_rate numeric +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +BEGIN + -- Access check + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied' + USING ERRCODE = '42501'; + END IF; + + RETURN QUERY + WITH event_counts AS ( + SELECT + count(*)::bigint AS total_events, + count(*) FILTER ( + WHERE e.event_date >= current_date + AND e.status NOT IN ('cancelled', 'completed') + )::bigint AS upcoming_events, + count(*) FILTER ( + WHERE e.event_date < current_date + OR e.status IN ('completed') + )::bigint AS past_events + FROM public.events e + WHERE e.account_id = p_account_id + ), + reg_counts AS ( + SELECT count(*)::bigint AS total_registrations + FROM public.event_registrations er + JOIN public.events e ON e.id = er.event_id + WHERE e.account_id = p_account_id + AND er.status IN ('confirmed', 'pending') + ), + occupancy AS ( + SELECT + ROUND( + AVG( + CASE WHEN e.capacity IS NOT NULL AND e.capacity > 0 THEN + reg_ct::numeric / e.capacity * 100 + ELSE NULL END + ), + 1 + ) AS avg_occupancy_rate + FROM public.events e + LEFT JOIN LATERAL ( + SELECT count(*)::numeric AS reg_ct + FROM public.event_registrations er + WHERE er.event_id = e.id AND er.status IN ('confirmed', 'pending') + ) rc ON true + WHERE e.account_id = p_account_id + AND e.status != 'cancelled' + ) + SELECT + ec.total_events, + ec.upcoming_events, + ec.past_events, + rc.total_registrations, + COALESCE(occ.avg_occupancy_rate, 0)::numeric + FROM event_counts ec + CROSS JOIN reg_counts rc + CROSS JOIN occupancy occ; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_event_statistics(uuid) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_event_statistics(uuid) TO service_role; + +-- ------------------------------------------------------- +-- C) Booking statistics +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_booking_statistics( + p_account_id uuid, + p_from date DEFAULT NULL, + p_to date DEFAULT NULL +) +RETURNS TABLE ( + total_bookings bigint, + active_bookings bigint, + checked_in_count bigint, + total_revenue numeric, + avg_stay_nights numeric, + occupancy_rate numeric +) +LANGUAGE plpgsql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_from date; + v_to date; + v_total_rooms bigint; + v_total_room_nights numeric; + v_booked_room_nights numeric; +BEGIN + -- Access check + IF NOT public.has_role_on_account(p_account_id) THEN + RAISE EXCEPTION 'Access denied' + USING ERRCODE = '42501'; + END IF; + + -- Default date range: current month + v_from := COALESCE(p_from, date_trunc('month', current_date)::date); + v_to := COALESCE(p_to, (date_trunc('month', current_date) + interval '1 month' - interval '1 day')::date); + + -- Calculate total available room-nights + SELECT count(*)::bigint INTO v_total_rooms + FROM public.rooms + WHERE account_id = p_account_id + AND is_active = true; + + v_total_room_nights := v_total_rooms::numeric * (v_to - v_from + 1); + + -- Calculate booked room-nights in range (non-cancelled) + SELECT COALESCE(SUM( + LEAST(b.check_out, v_to + 1) - GREATEST(b.check_in, v_from) + ), 0)::numeric + INTO v_booked_room_nights + FROM public.bookings b + WHERE b.account_id = p_account_id + AND b.status NOT IN ('cancelled', 'no_show') + AND b.check_in <= v_to + AND b.check_out > v_from; + + RETURN QUERY + SELECT + count(*)::bigint AS total_bookings, + count(*) FILTER (WHERE b.status IN ('confirmed', 'checked_in'))::bigint AS active_bookings, + count(*) FILTER (WHERE b.status = 'checked_in')::bigint AS checked_in_count, + COALESCE(SUM(b.total_price) FILTER (WHERE b.status != 'cancelled'), 0)::numeric AS total_revenue, + ROUND( + COALESCE(AVG((b.check_out - b.check_in)::numeric) FILTER (WHERE b.status != 'cancelled'), 0), + 1 + ) AS avg_stay_nights, + CASE WHEN v_total_room_nights > 0 THEN + ROUND(v_booked_room_nights / v_total_room_nights * 100, 1) + ELSE 0 END AS occupancy_rate + FROM public.bookings b + WHERE b.account_id = p_account_id + AND b.check_in <= v_to + AND b.check_out > v_from; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.get_booking_statistics(uuid, date, date) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_booking_statistics(uuid, date, date) TO service_role; + +-- ------------------------------------------------------- +-- D) Event registration counts (batch lookup) +-- ------------------------------------------------------- + +CREATE OR REPLACE FUNCTION public.get_event_registration_counts(p_event_ids uuid[]) +RETURNS TABLE (event_id uuid, registration_count bigint) +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = '' +AS $$ + SELECT + er.event_id, + count(*)::bigint AS registration_count + FROM public.event_registrations er + WHERE er.event_id = ANY(p_event_ids) + AND er.status IN ('confirmed', 'pending') + GROUP BY er.event_id; +$$; + +GRANT EXECUTE ON FUNCTION public.get_event_registration_counts(uuid[]) TO authenticated; +GRANT EXECUTE ON FUNCTION public.get_event_registration_counts(uuid[]) TO service_role; diff --git a/apps/web/supabase/migrations/20260418000005_additional_indexes.sql b/apps/web/supabase/migrations/20260418000005_additional_indexes.sql new file mode 100644 index 000000000..3d8861a98 --- /dev/null +++ b/apps/web/supabase/migrations/20260418000005_additional_indexes.sql @@ -0,0 +1,43 @@ +-- ===================================================== +-- Additional Indexes +-- +-- Partial indexes for common query patterns across +-- course-management, event-management, and +-- booking-management modules. +-- ===================================================== + +-- Course participants: fast capacity counting +CREATE INDEX IF NOT EXISTS ix_course_participants_active_status + ON public.course_participants(course_id, status) + WHERE status IN ('enrolled', 'waitlisted'); + +-- Event registrations: fast registration counting +CREATE INDEX IF NOT EXISTS ix_event_registrations_active_status + ON public.event_registrations(event_id, status) + WHERE status IN ('confirmed', 'pending', 'waitlisted'); + +-- Bookings: active bookings for availability queries +CREATE INDEX IF NOT EXISTS ix_bookings_active_dates + ON public.bookings(room_id, check_in, check_out) + WHERE status NOT IN ('cancelled', 'no_show'); + +-- Bookings: guest history lookup +CREATE INDEX IF NOT EXISTS ix_bookings_guest_checkin + ON public.bookings(guest_id, check_in DESC) + WHERE guest_id IS NOT NULL; + +-- Course sessions: instructor scheduling conflict checks +CREATE INDEX IF NOT EXISTS ix_course_sessions_instructor_date + ON public.course_sessions(session_date, start_time, end_time) + WHERE is_cancelled = false; + +-- Audit log indexes for timeline queries +-- Safety nets in case earlier migration did not cover them +CREATE INDEX IF NOT EXISTS ix_course_audit_account_action + ON public.course_audit_log(account_id, action); + +CREATE INDEX IF NOT EXISTS ix_event_audit_account_action + ON public.event_audit_log(account_id, action); + +CREATE INDEX IF NOT EXISTS ix_booking_audit_account_action + ON public.booking_audit_log(account_id, action); diff --git a/apps/web/supabase/seed.sql b/apps/web/supabase/seed.sql index 58eb6aa13..f94634f61 100644 --- a/apps/web/supabase/seed.sql +++ b/apps/web/supabase/seed.sql @@ -285,3 +285,146 @@ SELECT pg_catalog.setval('"public"."role_permissions_id_seq"', 7, true); -- SELECT pg_catalog.setval('"supabase_functions"."hooks_id_seq"', 19, true); + +-- ══════════════════════════════════════════════════════════════ +-- Member Management Seed Data +-- 30 realistic German/Austrian club members for demo/development +-- ══════════════════════════════════════════════════════════════ + +DO $$ +DECLARE + v_account_id uuid := '5deaa894-2094-4da3-b4fd-1fada0809d1c'; + v_user_id uuid := '31a03e74-1639-45b6-bfa7-77447f1a4762'; + v_cat_regular uuid; + v_cat_youth uuid; + v_cat_senior uuid; + v_dept_vorstand uuid; + v_dept_jugend uuid; + v_dept_sport uuid; + v_m1 uuid; v_m2 uuid; v_m3 uuid; v_m4 uuid; v_m5 uuid; + v_m6 uuid; v_m7 uuid; v_m8 uuid; v_m9 uuid; v_m10 uuid; +BEGIN + +-- Dues Categories +INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_default, sort_order) +VALUES (gen_random_uuid(), v_account_id, 'Erwachsene', 'Regulärer Mitgliedsbeitrag', 120.00, 'yearly', true, 1) +RETURNING id INTO v_cat_regular; + +INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, is_youth, sort_order) +VALUES (gen_random_uuid(), v_account_id, 'Jugend (bis 18)', 'Ermäßigter Jugendbeitrag', 48.00, 'yearly', true, 2) +RETURNING id INTO v_cat_youth; + +INSERT INTO public.dues_categories (id, account_id, name, description, amount, interval, sort_order) +VALUES (gen_random_uuid(), v_account_id, 'Senioren (ab 65)', 'Ermäßigter Seniorenbeitrag', 72.00, 'yearly', 3) +RETURNING id INTO v_cat_senior; + +-- Departments +INSERT INTO public.member_departments (id, account_id, name, description, sort_order) +VALUES (gen_random_uuid(), v_account_id, 'Vorstand', 'Vereinsvorstand und Leitung', 1) +RETURNING id INTO v_dept_vorstand; + +INSERT INTO public.member_departments (id, account_id, name, description, sort_order) +VALUES (gen_random_uuid(), v_account_id, 'Jugendabteilung', 'Kinder- und Jugendarbeit', 2) +RETURNING id INTO v_dept_jugend; + +INSERT INTO public.member_departments (id, account_id, name, description, sort_order) +VALUES (gen_random_uuid(), v_account_id, 'Sportabteilung', 'Training und Wettkampf', 3) +RETURNING id INTO v_dept_sport; + +-- Members 1-10 (with variables for relationships) +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, is_founding_member, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0001', 'Johann', 'Maier', '1968-03-15', 'male', 'Herr', 'johann.maier@example.at', '+43 512 123456', '+43 664 1234567', 'Hauptstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2005-01-15', v_cat_regular, 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', true, true, true, true, v_user_id, v_user_id) +RETURNING id INTO v_m1; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, iban, bic, account_holder, gdpr_consent, gdpr_newsletter, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0002', 'Maria', 'Huber', '1975-07-22', 'female', 'Frau', 'maria.huber@example.at', '+43 512 234567', '+43 660 2345678', 'Bahnhofstraße', '5a', '6020', 'Innsbruck', 'AT', 'active', '2008-03-01', v_cat_regular, 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', true, true, v_user_id, v_user_id) +RETURNING id INTO v_m2; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0003', 'Thomas', 'Berger', '1982-11-08', 'male', 'Herr', 'thomas.berger@example.at', '+43 512 345678', 'Museumstraße', '3', '6020', 'Innsbruck', 'AT', 'active', '2010-06-15', v_cat_regular, true, v_user_id, v_user_id) +RETURNING id INTO v_m3; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, gdpr_newsletter, gdpr_internet, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0004', 'Anna', 'Steiner', '1990-04-12', 'female', 'Frau', 'anna.steiner@example.at', '+43 676 3456789', 'Leopoldstraße', '18', '6020', 'Innsbruck', 'AT', 'active', '2012-09-01', v_cat_regular, true, true, true, v_user_id, v_user_id) +RETURNING id INTO v_m4; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, title, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_honorary, is_founding_member, gdpr_consent, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0005', 'Franz', 'Gruber', '1945-09-03', 'male', 'Herr', 'Dr.', 'franz.gruber@example.at', '+43 512 456789', 'Rennweg', '7', '6020', 'Innsbruck', 'AT', 'active', '1998-01-01', v_cat_senior, true, true, true, v_user_id, v_user_id) +RETURNING id INTO v_m5; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, email, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_youth, guardian_name, guardian_phone, guardian_email, gdpr_consent, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0006', 'Lukas', 'Hofer', '2010-02-28', 'male', 'lukas.hofer@example.at', 'Schillerstraße', '22', '6020', 'Innsbruck', 'AT', 'active', '2022-03-01', v_cat_youth, true, 'Stefan Hofer', '+43 664 5678901', 'stefan.hofer@example.at', true, v_user_id, v_user_id) +RETURNING id INTO v_m6; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0007', 'Katharina', 'Wimmer', '1988-12-05', 'female', 'Frau', 'k.wimmer@example.at', '+43 512 567890', 'Maria-Theresien-Straße', '15', '6020', 'Innsbruck', 'AT', 'inactive', '2015-01-01', v_cat_regular, true, v_user_id, v_user_id) +RETURNING id INTO v_m7; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, street, house_number, postal_code, city, country, status, entry_date, exit_date, exit_reason, dues_category_id, gdpr_consent, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0008', 'Peter', 'Moser', '1970-06-18', 'male', 'Herr', 'peter.moser@example.at', 'Anichstraße', '29', '6020', 'Innsbruck', 'AT', 'resigned', '2010-05-01', '2025-12-31', 'Umzug', v_cat_regular, false, v_user_id, v_user_id) +RETURNING id INTO v_m8; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0009', 'Sophie', 'Eder', '1995-08-30', 'female', 'Frau', 'sophie.eder@example.at', '+43 680 6789012', 'Universitätsstraße', '8', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-15', v_cat_regular, true, v_user_id, v_user_id) +RETURNING id INTO v_m9; + +INSERT INTO public.members (id, account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, phone, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, is_retiree, gdpr_consent, gdpr_print, gdpr_birthday_info, created_by, updated_by) +VALUES (gen_random_uuid(), v_account_id, '0010', 'Helmut', 'Bauer', '1952-01-14', 'male', 'Herr', 'helmut.bauer@example.at', '+43 512 678901', 'Sillgasse', '14', '6020', 'Innsbruck', 'AT', 'active', '2001-07-01', v_cat_senior, true, true, true, true, v_user_id, v_user_id) +RETURNING id INTO v_m10; + +-- Members 11-30 (bulk insert) +INSERT INTO public.members (account_id, member_number, first_name, last_name, date_of_birth, gender, salutation, email, mobile, street, house_number, postal_code, city, country, status, entry_date, dues_category_id, gdpr_consent, created_by, updated_by) VALUES + (v_account_id, '0011', 'Christina', 'Pichler', '1993-05-17', 'female', 'Frau', 'christina.pichler@example.at', '+43 664 7890123', 'Innrain', '52', '6020', 'Innsbruck', 'AT', 'active', '2019-01-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0012', 'Michael', 'Ebner', '1985-09-23', 'male', 'Herr', 'michael.ebner@example.at', '+43 660 8901234', 'Höttinger Au', '3', '6020', 'Innsbruck', 'AT', 'active', '2017-04-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0013', 'Eva', 'Schwarz', '1978-02-09', 'female', 'Frau', 'eva.schwarz@example.at', '+43 676 9012345', 'Fallmerayerstraße', '6', '6020', 'Innsbruck', 'AT', 'active', '2014-09-15', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0014', 'Stefan', 'Wallner', '1991-11-30', 'male', 'Herr', 'stefan.wallner@example.at', '+43 664 0123456', 'Reichenauer Straße', '44', '6020', 'Innsbruck', 'AT', 'active', '2020-02-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0015', 'Martina', 'Lechner', '1987-04-25', 'female', 'Frau', 'martina.lechner@example.at', '+43 680 1234567', 'Olympiastraße', '10', '6020', 'Innsbruck', 'AT', 'active', '2016-06-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0016', 'Andreas', 'Koller', '1969-08-11', 'male', 'Herr', 'andreas.koller@example.at', '+43 664 2345670', 'Pradler Straße', '72', '6020', 'Innsbruck', 'AT', 'active', '2007-01-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0017', 'Laura', 'Reiter', '2008-07-19', 'female', NULL, 'laura.reiter@example.at', '+43 660 3456701', 'Gabelsbergerstraße', '4', '6020', 'Innsbruck', 'AT', 'active', '2023-01-01', v_cat_youth, true, v_user_id, v_user_id), + (v_account_id, '0018', 'Markus', 'Fuchs', '1980-10-02', 'male', 'Herr', 'markus.fuchs@example.at', '+43 676 4567012', 'Egger-Lienz-Straße', '28', '6020', 'Innsbruck', 'AT', 'active', '2013-03-15', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0019', 'Lisa', 'Müller', '1996-01-07', 'female', 'Frau', 'lisa.mueller@example.at', '+43 664 5670123', 'Amraser Straße', '16', '6020', 'Innsbruck', 'AT', 'active', '2021-09-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0020', 'Georg', 'Wagner', '1973-06-14', 'male', 'Herr', 'georg.wagner@example.at', '+43 680 6701234', 'Kaiserjägerstraße', '1', '6020', 'Innsbruck', 'AT', 'active', '2009-11-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0021', 'Claudia', 'Fischer', '1984-12-20', 'female', 'Frau', 'claudia.fischer@example.at', '+43 664 7012345', 'Technikerstraße', '9', '6020', 'Innsbruck', 'AT', 'active', '2018-05-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0022', 'Daniel', 'Wolf', '1998-03-28', 'male', 'Herr', 'daniel.wolf@example.at', '+43 660 8012346', 'Schöpfstraße', '31', '6020', 'Innsbruck', 'AT', 'active', '2022-01-15', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0023', 'Sandra', 'Brunner', '1976-09-06', 'female', 'Frau', NULL, '+43 512 901234', 'Defreggerstraße', '12', '6020', 'Innsbruck', 'AT', 'active', '2011-04-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0024', 'Robert', 'Lang', '1960-11-11', 'male', 'Herr', 'robert.lang@example.at', '+43 512 012345', 'Speckbacherstraße', '21', '6020', 'Innsbruck', 'AT', 'active', '2003-01-01', v_cat_senior, true, v_user_id, v_user_id), + (v_account_id, '0025', 'Nina', 'Winkler', '2009-05-03', 'female', NULL, 'nina.winkler@example.at', '+43 664 1230456', 'Müllerstraße', '7', '6020', 'Innsbruck', 'AT', 'active', '2023-09-01', v_cat_youth, true, v_user_id, v_user_id), + (v_account_id, '0026', 'Wolfgang', 'Schmid', '1955-04-22', 'male', 'Herr', 'wolfgang.schmid@example.at', '+43 512 2340567', 'Haller Straße', '55', '6020', 'Innsbruck', 'AT', 'inactive', '2000-06-01', v_cat_senior, true, v_user_id, v_user_id), + (v_account_id, '0027', 'Sabrina', 'Gruber', '1994-07-15', 'female', 'Frau', 'sabrina.gruber@example.at', '+43 676 3450678', 'Grabenweg', '33', '6020', 'Innsbruck', 'AT', 'active', '2020-11-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0028', 'Patrick', 'Stockinger', '1989-10-09', 'male', 'Herr', 'patrick.stockinger@example.at', '+43 660 4560789', 'Adamgasse', '19', '6020', 'Innsbruck', 'AT', 'pending', '2026-03-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0029', 'Verena', 'Neuner', '1981-01-18', 'female', 'Frau', 'verena.neuner@example.at', '+43 664 5670890', 'Amthorstraße', '2', '6020', 'Innsbruck', 'AT', 'active', '2015-08-01', v_cat_regular, true, v_user_id, v_user_id), + (v_account_id, '0030', 'Florian', 'Kofler', '2011-12-25', 'male', NULL, NULL, '+43 664 6780901', 'Hunoldstraße', '11', '6020', 'Innsbruck', 'AT', 'active', '2024-01-01', v_cat_youth, true, v_user_id, v_user_id); + +-- Department Assignments +INSERT INTO public.member_department_assignments (member_id, department_id) VALUES + (v_m1, v_dept_vorstand), (v_m2, v_dept_vorstand), (v_m3, v_dept_vorstand), + (v_m4, v_dept_jugend), (v_m6, v_dept_jugend), + (v_m4, v_dept_sport), (v_m10, v_dept_sport); + +-- Roles +INSERT INTO public.member_roles (account_id, member_id, role_name, from_date, until_date, is_active) VALUES + (v_account_id, v_m1, '1. Vorsitzender', '2015-01-01', NULL, true), + (v_account_id, v_m2, 'Kassierin', '2018-01-01', NULL, true), + (v_account_id, v_m3, 'Schriftführer', '2018-01-01', NULL, true), + (v_account_id, v_m4, 'Jugendleiterin', '2020-01-01', NULL, true), + (v_account_id, v_m1, '2. Vorsitzender', '2008-01-01', '2014-12-31', false), + (v_account_id, v_m5, '1. Vorsitzender', '1998-01-01', '2014-12-31', false); + +-- Honors +INSERT INTO public.member_honors (account_id, member_id, honor_name, honor_date, description) VALUES + (v_account_id, v_m5, 'Ehrenmitglied', '2015-01-01', 'Für 17 Jahre als Vorsitzender'), + (v_account_id, v_m1, '20 Jahre Mitgliedschaft', '2025-01-15', 'Treueehrung'), + (v_account_id, v_m5, 'Goldene Ehrennadel', '2010-06-15', 'Verdienstauszeichnung'); + +-- SEPA Mandates +INSERT INTO public.sepa_mandates (account_id, member_id, mandate_reference, iban, bic, account_holder, mandate_date, status, sequence, is_primary) VALUES + (v_account_id, v_m1, 'MNDT-2020-001', 'AT483200000012345864', 'RLNWATWW', 'Johann Maier', '2020-01-01', 'active', 'RCUR', true), + (v_account_id, v_m2, 'MNDT-2020-002', 'AT611904300234573201', 'BKAUATWW', 'Maria Huber', '2020-01-01', 'active', 'RCUR', true); + +-- Membership Applications +INSERT INTO public.membership_applications (account_id, first_name, last_name, email, phone, street, postal_code, city, date_of_birth, message, status) VALUES + (v_account_id, 'Maximilian', 'Ortner', 'max.ortner@example.at', '+43 664 9876543', 'Viaduktbogen', '6020', 'Innsbruck', '1997-08-14', 'Wurde von einem Mitglied empfohlen.', 'submitted'), + (v_account_id, 'Hannah', 'Troger', 'hannah.troger@example.at', '+43 680 8765432', 'Erlerstraße', '6020', 'Innsbruck', '2001-03-22', 'Möchte gerne der Jugendabteilung beitreten.', 'submitted'), + (v_account_id, 'Felix', 'Kirchmair', 'felix.kirchmair@example.at', '+43 660 7654321', 'Brennerstraße', '6020', 'Innsbruck', '1992-11-05', NULL, 'submitted'); + +END $$; diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 83ad376b1..a39069592 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -18,7 +18,7 @@ services: image: supabase/postgres:15.8.1.060 restart: unless-stopped ports: - - '54322:5432' + - '54322:54322' volumes: - supabase-db-data:/var/lib/postgresql/data - ./docker/db/zzz-role-passwords.sh:/docker-entrypoint-initdb.d/zzz-role-passwords.sh:ro @@ -317,7 +317,6 @@ services: context: . dockerfile: Dockerfile args: - # NEXT_PUBLIC_CI=true bypasses the HTTPS check during build NEXT_PUBLIC_CI: 'true' NEXT_PUBLIC_SITE_URL: http://localhost:3000 NEXT_PUBLIC_SUPABASE_URL: http://localhost:8000 diff --git a/packages/features/booking-management/package.json b/packages/features/booking-management/package.json index cf80075ee..0d1dfe35c 100644 --- a/packages/features/booking-management/package.json +++ b/packages/features/booking-management/package.json @@ -13,7 +13,9 @@ "./api": "./src/server/api.ts", "./schema/*": "./src/schema/*.ts", "./components": "./src/components/index.ts", - "./actions/*": "./src/server/actions/*.ts" + "./actions/*": "./src/server/actions/*.ts", + "./services/*": "./src/server/services/*.ts", + "./lib/*": "./src/lib/*.ts" }, "scripts": { "clean": "git clean -xdf .turbo node_modules", diff --git a/packages/features/booking-management/src/lib/booking-status-machine.ts b/packages/features/booking-management/src/lib/booking-status-machine.ts new file mode 100644 index 000000000..849607916 --- /dev/null +++ b/packages/features/booking-management/src/lib/booking-status-machine.ts @@ -0,0 +1,98 @@ +import type { z } from 'zod'; + +import type { BookingStatusEnum } from '../schema/booking.schema'; + +type BookingStatus = z.infer; + +/** + * Booking status state machine. + * + * Defines valid transitions between booking statuses and their + * side effects. Enforced in booking update operations. + */ + +type StatusTransition = { + /** Fields to set automatically when this transition occurs */ + sideEffects?: Partial>; +}; + +const TRANSITIONS: Record< + BookingStatus, + Partial> +> = { + pending: { + confirmed: {}, + cancelled: {}, + }, + confirmed: { + checked_in: {}, + cancelled: {}, + no_show: {}, + }, + checked_in: { + checked_out: {}, + }, + // Terminal states — no transitions out + checked_out: {}, + cancelled: { + pending: {}, + }, + no_show: {}, +}; + +/** + * Check if a status transition is valid. + */ +export function canTransition(from: BookingStatus, to: BookingStatus): boolean { + if (from === to) return true; // no-op is always valid + return to in (TRANSITIONS[from] ?? {}); +} + +/** + * Get all valid target statuses from a given status. + */ +export function getValidTransitions(from: BookingStatus): BookingStatus[] { + return Object.keys(TRANSITIONS[from] ?? {}) as BookingStatus[]; +} + +/** + * Get the side effects for a transition. + * Returns an object of field->value pairs to apply alongside the status change. + * Function values should be called to get the actual value. + */ +export function getTransitionSideEffects( + from: BookingStatus, + to: BookingStatus, +): Record { + if (from === to) return {}; + + const transition = TRANSITIONS[from]?.[to]; + if (!transition?.sideEffects) return {}; + + const result: Record = {}; + + for (const [key, value] of Object.entries(transition.sideEffects)) { + result[key] = typeof value === 'function' ? value() : value; + } + + return result; +} + +/** + * Validate a status transition and return side effects. + * Throws if the transition is invalid. + */ +export function validateTransition( + from: BookingStatus, + to: BookingStatus, +): Record { + if (from === to) return {}; + + if (!canTransition(from, to)) { + throw new Error( + `Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`, + ); + } + + return getTransitionSideEffects(from, to); +} diff --git a/packages/features/booking-management/src/lib/errors.ts b/packages/features/booking-management/src/lib/errors.ts new file mode 100644 index 000000000..a2d2862f3 --- /dev/null +++ b/packages/features/booking-management/src/lib/errors.ts @@ -0,0 +1,131 @@ +/** + * Standardized error codes and domain error classes + * for the booking management module. + */ + +export const BookingErrorCodes = { + BOOKING_NOT_FOUND: 'BOOKING_NOT_FOUND', + ROOM_NOT_FOUND: 'ROOM_NOT_FOUND', + ROOM_NOT_AVAILABLE: 'ROOM_NOT_AVAILABLE', + ROOM_CAPACITY_EXCEEDED: 'ROOM_CAPACITY_EXCEEDED', + INVALID_STATUS_TRANSITION: 'BOOKING_INVALID_TRANSITION', + CONCURRENCY_CONFLICT: 'BOOKING_CONCURRENCY_CONFLICT', + INVALID_DATE_RANGE: 'BOOKING_INVALID_DATE_RANGE', +} as const; + +export type BookingErrorCode = + (typeof BookingErrorCodes)[keyof typeof BookingErrorCodes]; + +/** + * Base domain error for booking management operations. + */ +export class BookingDomainError extends Error { + readonly code: BookingErrorCode; + readonly statusCode: number; + readonly details?: Record; + + constructor( + code: BookingErrorCode, + message: string, + statusCode = 400, + details?: Record, + ) { + super(message); + this.name = 'BookingDomainError'; + this.code = code; + this.statusCode = statusCode; + this.details = details; + } +} + +export class BookingNotFoundError extends BookingDomainError { + constructor(bookingId: string) { + super( + BookingErrorCodes.BOOKING_NOT_FOUND, + `Buchung ${bookingId} nicht gefunden`, + 404, + { bookingId }, + ); + this.name = 'BookingNotFoundError'; + } +} + +export class RoomNotFoundError extends BookingDomainError { + constructor(roomId: string) { + super( + BookingErrorCodes.ROOM_NOT_FOUND, + `Raum ${roomId} nicht gefunden`, + 404, + { roomId }, + ); + this.name = 'RoomNotFoundError'; + } +} + +export class RoomNotAvailableError extends BookingDomainError { + constructor(roomId: string, from: string, to: string) { + super( + BookingErrorCodes.ROOM_NOT_AVAILABLE, + `Raum ${roomId} ist im Zeitraum ${from} bis ${to} nicht verfügbar`, + 409, + { roomId, from, to }, + ); + this.name = 'RoomNotAvailableError'; + } +} + +export class RoomCapacityExceededError extends BookingDomainError { + constructor(roomId: string, capacity: number) { + super( + BookingErrorCodes.ROOM_CAPACITY_EXCEEDED, + `Raum ${roomId} hat die maximale Kapazität (${capacity}) erreicht`, + 422, + { roomId, capacity }, + ); + this.name = 'RoomCapacityExceededError'; + } +} + +export class InvalidBookingStatusTransitionError extends BookingDomainError { + constructor(from: string, to: string, validTargets: string[]) { + super( + BookingErrorCodes.INVALID_STATUS_TRANSITION, + `Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`, + 422, + { from, to, validTargets }, + ); + this.name = 'InvalidBookingStatusTransitionError'; + } +} + +export class BookingConcurrencyConflictError extends BookingDomainError { + constructor() { + super( + BookingErrorCodes.CONCURRENCY_CONFLICT, + 'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.', + 409, + ); + this.name = 'BookingConcurrencyConflictError'; + } +} + +export class BookingInvalidDateRangeError extends BookingDomainError { + constructor(from: string, to: string) { + super( + BookingErrorCodes.INVALID_DATE_RANGE, + `Ungültiger Zeitraum: ${from} bis ${to}. Das Enddatum muss nach dem Startdatum liegen.`, + 422, + { from, to }, + ); + this.name = 'BookingInvalidDateRangeError'; + } +} + +/** + * Check if an error is a BookingDomainError. + */ +export function isBookingDomainError( + error: unknown, +): error is BookingDomainError { + return error instanceof BookingDomainError; +} diff --git a/packages/features/booking-management/src/schema/booking.schema.ts b/packages/features/booking-management/src/schema/booking.schema.ts index 60bcbf800..67bbac3b7 100644 --- a/packages/features/booking-management/src/schema/booking.schema.ts +++ b/packages/features/booking-management/src/schema/booking.schema.ts @@ -20,20 +20,31 @@ export const CreateRoomSchema = z.object({ description: z.string().optional(), }); -export const CreateBookingSchema = z.object({ - accountId: z.string().uuid(), - roomId: z.string().uuid(), - guestId: z.string().uuid().optional(), - checkIn: z.string(), - checkOut: z.string(), - adults: z.number().int().min(1).default(1), - children: z.number().int().min(0).default(0), - status: BookingStatusEnum.default('confirmed'), - totalPrice: z.number().min(0).default(0), - notes: z.string().optional(), -}); +export const CreateBookingSchema = z + .object({ + accountId: z.string().uuid(), + roomId: z.string().uuid(), + guestId: z.string().uuid().optional(), + checkIn: z.string(), + checkOut: z.string(), + adults: z.number().int().min(1).default(1), + children: z.number().int().min(0).default(0), + status: BookingStatusEnum.default('confirmed'), + totalPrice: z.number().min(0).optional(), + notes: z.string().optional(), + }) + .refine((d) => d.checkOut > d.checkIn, { + message: 'Abreisedatum muss nach dem Anreisedatum liegen', + path: ['checkOut'], + }); export type CreateBookingInput = z.infer; +export const UpdateBookingStatusSchema = z.object({ + bookingId: z.string().uuid(), + status: BookingStatusEnum, + version: z.number().int().optional(), +}); + export const CreateGuestSchema = z.object({ accountId: z.string().uuid(), firstName: z.string().min(1), diff --git a/packages/features/booking-management/src/server/actions/booking-actions.ts b/packages/features/booking-management/src/server/actions/booking-actions.ts index 8b046a253..c9acf3dbd 100644 --- a/packages/features/booking-management/src/server/actions/booking-actions.ts +++ b/packages/features/booking-management/src/server/actions/booking-actions.ts @@ -1,71 +1,87 @@ 'use server'; -import { z } from 'zod'; - import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { isBookingDomainError } from '../../lib/errors'; import { CreateBookingSchema, CreateGuestSchema, CreateRoomSchema, + UpdateBookingStatusSchema, } from '../../schema/booking.schema'; import { createBookingManagementApi } from '../api'; export const createBooking = authActionClient .inputSchema(CreateBookingSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createBookingManagementApi(client); - logger.info({ name: 'booking.create' }, 'Creating booking...'); - const result = await api.createBooking(input); - logger.info({ name: 'booking.create' }, 'Booking created'); - return { success: true, data: result }; + try { + logger.info({ name: 'booking.create' }, 'Creating booking...'); + const result = await api.bookings.create(input); + logger.info({ name: 'booking.create' }, 'Booking created'); + return { success: true, data: result }; + } catch (e) { + if (isBookingDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const updateBookingStatus = authActionClient - .inputSchema( - z.object({ - bookingId: z.string().uuid(), - status: z.string(), - }), - ) - .action(async ({ parsedInput: input, ctx }) => { + .inputSchema(UpdateBookingStatusSchema) + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createBookingManagementApi(client); - logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...'); - const result = await api.updateBookingStatus(input.bookingId, input.status); - logger.info({ name: 'booking.updateStatus' }, 'Booking status updated'); - return { success: true, data: result }; + try { + logger.info( + { name: 'booking.updateStatus' }, + 'Updating booking status...', + ); + await api.bookings.updateStatus( + input.bookingId, + input.status, + input.version, + ); + logger.info({ name: 'booking.updateStatus' }, 'Booking status updated'); + return { success: true }; + } catch (e) { + if (isBookingDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const createRoom = authActionClient .inputSchema(CreateRoomSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createBookingManagementApi(client); logger.info({ name: 'booking.createRoom' }, 'Creating room...'); - const result = await api.createRoom(input); + const result = await api.rooms.create(input); logger.info({ name: 'booking.createRoom' }, 'Room created'); return { success: true, data: result }; }); export const createGuest = authActionClient .inputSchema(CreateGuestSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createBookingManagementApi(client); logger.info({ name: 'booking.createGuest' }, 'Creating guest...'); - const result = await api.createGuest(input); + const result = await api.guests.create(input); logger.info({ name: 'booking.createGuest' }, 'Guest created'); return { success: true, data: result }; }); diff --git a/packages/features/booking-management/src/server/api.ts b/packages/features/booking-management/src/server/api.ts index afc9badb4..a2b7ad9a0 100644 --- a/packages/features/booking-management/src/server/api.ts +++ b/packages/features/booking-management/src/server/api.ts @@ -1,173 +1,16 @@ +import 'server-only'; import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '@kit/supabase/database'; -import type { CreateBookingInput } from '../schema/booking.schema'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createBookingCrudService } from './services/booking-crud.service'; +import { createGuestService } from './services/guest.service'; +import { createRoomService } from './services/room.service'; export function createBookingManagementApi(client: SupabaseClient) { - const _db = client; - return { - // --- Rooms --- - async listRooms(accountId: string) { - const { data, error } = await client - .from('rooms') - .select('*') - .eq('account_id', accountId) - .eq('is_active', true) - .order('room_number'); - if (error) throw error; - return data ?? []; - }, - - async getRoom(roomId: string) { - const { data, error } = await client - .from('rooms') - .select('*') - .eq('id', roomId) - .single(); - if (error) throw error; - return data; - }, - - // --- Availability --- - async checkAvailability(roomId: string, checkIn: string, checkOut: string) { - const { count, error } = await client - .from('bookings') - .select('*', { count: 'exact', head: true }) - .eq('room_id', roomId) - .not('status', 'in', '("cancelled","no_show")') - .lt('check_in', checkOut) - .gt('check_out', checkIn); - if (error) throw error; - return (count ?? 0) === 0; - }, - - // --- Bookings --- - async listBookings( - accountId: string, - opts?: { status?: string; from?: string; to?: string; page?: number }, - ) { - let query = client - .from('bookings') - .select('*', { count: 'exact' }) - .eq('account_id', accountId) - .order('check_in', { ascending: false }); - if (opts?.status) query = query.eq('status', opts.status); - if (opts?.from) query = query.gte('check_in', opts.from); - if (opts?.to) query = query.lte('check_out', opts.to); - const page = opts?.page ?? 1; - query = query.range((page - 1) * 25, page * 25 - 1); - const { data, error, count } = await query; - if (error) throw error; - return { data: data ?? [], total: count ?? 0 }; - }, - - async createBooking(input: CreateBookingInput) { - const available = await this.checkAvailability( - input.roomId, - input.checkIn, - input.checkOut, - ); - if (!available) - throw new Error('Room is not available for the selected dates'); - - const { data, error } = await client - .from('bookings') - .insert({ - account_id: input.accountId, - room_id: input.roomId, - guest_id: input.guestId, - check_in: input.checkIn, - check_out: input.checkOut, - adults: input.adults, - children: input.children, - status: input.status, - total_price: input.totalPrice, - notes: input.notes, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async updateBookingStatus(bookingId: string, status: string) { - const { error } = await client - .from('bookings') - .update({ status }) - .eq('id', bookingId); - if (error) throw error; - }, - - // --- Guests --- - async listGuests(accountId: string, search?: string) { - let query = client - .from('guests') - .select('*') - .eq('account_id', accountId) - .order('last_name'); - if (search) - query = query.or( - `last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`, - ); - const { data, error } = await query; - if (error) throw error; - return data ?? []; - }, - - async createGuest(input: { - accountId: string; - firstName: string; - lastName: string; - email?: string; - phone?: string; - city?: string; - }) { - const { data, error } = await client - .from('guests') - .insert({ - account_id: input.accountId, - first_name: input.firstName, - last_name: input.lastName, - email: input.email, - phone: input.phone, - city: input.city, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async createRoom(input: { - accountId: string; - roomNumber: string; - name?: string; - roomType?: string; - capacity?: number; - floor?: number; - pricePerNight: number; - description?: string; - }) { - const { data, error } = await client - .from('rooms') - .insert({ - account_id: input.accountId, - room_number: input.roomNumber, - name: input.name, - room_type: input.roomType ?? 'standard', - capacity: input.capacity ?? 2, - floor: input.floor, - price_per_night: input.pricePerNight, - description: input.description, - }) - .select() - .single(); - if (error) throw error; - return data; - }, + rooms: createRoomService(client), + bookings: createBookingCrudService(client), + guests: createGuestService(client), }; } diff --git a/packages/features/booking-management/src/server/services/booking-crud.service.ts b/packages/features/booking-management/src/server/services/booking-crud.service.ts new file mode 100644 index 000000000..be2b7c087 --- /dev/null +++ b/packages/features/booking-management/src/server/services/booking-crud.service.ts @@ -0,0 +1,157 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { getLogger } from '@kit/shared/logger'; +import type { Database } from '@kit/supabase/database'; + +import { + getValidTransitions, + validateTransition, +} from '../../lib/booking-status-machine'; +import { + BookingConcurrencyConflictError, + InvalidBookingStatusTransitionError, +} from '../../lib/errors'; +import type { CreateBookingInput } from '../../schema/booking.schema'; + +const NAMESPACE = 'booking-crud'; + +export function createBookingCrudService(client: SupabaseClient) { + return { + async list( + accountId: string, + opts?: { + status?: string; + from?: string; + to?: string; + page?: number; + pageSize?: number; + }, + ) { + let query = client + .from('bookings') + .select('*', { count: 'exact' }) + .eq('account_id', accountId) + .order('check_in', { ascending: false }); + if (opts?.status) query = query.eq('status', opts.status); + if (opts?.from) query = query.gte('check_in', opts.from); + if (opts?.to) query = query.lte('check_out', opts.to); + const page = opts?.page ?? 1; + const pageSize = opts?.pageSize ?? 25; + query = query.range((page - 1) * pageSize, page * pageSize - 1); + const { data, error, count } = await query; + if (error) throw error; + const total = count ?? 0; + return { + data: data ?? [], + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }; + }, + + async create(input: CreateBookingInput) { + const logger = await getLogger(); + logger.info({ name: NAMESPACE }, 'Creating booking...'); + + const { data, error } = await (client.rpc as CallableFunction)( + 'create_booking_atomic', + { + p_account_id: input.accountId, + p_room_id: input.roomId, + p_guest_id: input.guestId ?? null, + p_check_in: input.checkIn, + p_check_out: input.checkOut, + p_adults: input.adults ?? 1, + p_children: input.children ?? 0, + p_status: input.status ?? 'confirmed', + p_total_price: input.totalPrice ?? null, + p_notes: input.notes ?? null, + }, + ); + if (error) throw error; + + // RPC returns the booking UUID; fetch the full row + const bookingId = data as unknown as string; + const { data: booking, error: fetchError } = await client + .from('bookings') + .select('*') + .eq('id', bookingId) + .single(); + if (fetchError) throw fetchError; + return booking; + }, + + async updateStatus( + bookingId: string, + status: string, + version?: number, + userId?: string, + ) { + const logger = await getLogger(); + logger.info( + { name: NAMESPACE, bookingId, status }, + 'Updating booking status...', + ); + + // Fetch current booking to get current status + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- version column added via migration, not yet in generated types + const { data: current, error: fetchError } = await ( + client.from('bookings').select('id, status, version') as any + ) + .eq('id', bookingId) + .single(); + if (fetchError) throw fetchError; + + const currentStatus = (current as Record) + .status as string; + + // Validate status transition using the state machine + try { + validateTransition( + currentStatus as Parameters[0], + status as Parameters[1], + ); + } catch { + const validTargets = getValidTransitions( + currentStatus as Parameters[0], + ); + throw new InvalidBookingStatusTransitionError( + currentStatus, + status, + validTargets, + ); + } + + // Build the update query + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- updated_by column added via migration + let query = client + .from('bookings') + .update({ + status, + ...(userId ? { updated_by: userId } : {}), + } as any) + .eq('id', bookingId); + + // Optimistic concurrency control via version column (added by migration, not yet in generated types) + if (version !== undefined) { + query = (query as any).eq('version', version); + } + + const { data, error } = await query.select('id').single(); + + if (error) { + // If no rows matched, it's a concurrency conflict + if (error.code === 'PGRST116') { + throw new BookingConcurrencyConflictError(); + } + throw error; + } + + if (!data) { + throw new BookingConcurrencyConflictError(); + } + }, + }; +} diff --git a/packages/features/booking-management/src/server/services/guest.service.ts b/packages/features/booking-management/src/server/services/guest.service.ts new file mode 100644 index 000000000..481266b8a --- /dev/null +++ b/packages/features/booking-management/src/server/services/guest.service.ts @@ -0,0 +1,57 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createGuestService(client: SupabaseClient) { + return { + async list(accountId: string, search?: string) { + let query = client + .from('guests') + .select('*') + .eq('account_id', accountId) + .order('last_name'); + if (search) + query = query.or( + `last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`, + ); + const { data, error } = await query; + if (error) throw error; + return data ?? []; + }, + + async create(input: { + accountId: string; + firstName: string; + lastName: string; + email?: string; + phone?: string; + city?: string; + }) { + const { data, error } = await client + .from('guests') + .insert({ + account_id: input.accountId, + first_name: input.firstName, + last_name: input.lastName, + email: input.email || null, + phone: input.phone || null, + city: input.city || null, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + + async getHistory(guestId: string) { + const { data, error } = await client + .from('bookings') + .select('*') + .eq('guest_id', guestId) + .order('check_in', { ascending: false }); + if (error) throw error; + return data ?? []; + }, + }; +} diff --git a/packages/features/booking-management/src/server/services/index.ts b/packages/features/booking-management/src/server/services/index.ts new file mode 100644 index 000000000..ad23c1a59 --- /dev/null +++ b/packages/features/booking-management/src/server/services/index.ts @@ -0,0 +1,18 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +import { createBookingCrudService } from './booking-crud.service'; +import { createGuestService } from './guest.service'; +import { createRoomService } from './room.service'; + +export { createBookingCrudService, createGuestService, createRoomService }; + +export function createBookingServices(client: SupabaseClient) { + return { + rooms: createRoomService(client), + bookings: createBookingCrudService(client), + guests: createGuestService(client), + }; +} diff --git a/packages/features/booking-management/src/server/services/room.service.ts b/packages/features/booking-management/src/server/services/room.service.ts new file mode 100644 index 000000000..da3f8e6be --- /dev/null +++ b/packages/features/booking-management/src/server/services/room.service.ts @@ -0,0 +1,69 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createRoomService(client: SupabaseClient) { + return { + async list(accountId: string) { + const { data, error } = await client + .from('rooms') + .select('*') + .eq('account_id', accountId) + .eq('is_active', true) + .order('room_number'); + if (error) throw error; + return data ?? []; + }, + + async getById(roomId: string) { + const { data, error } = await client + .from('rooms') + .select('*') + .eq('id', roomId) + .single(); + if (error) throw error; + return data; + }, + + async checkAvailability(roomId: string, checkIn: string, checkOut: string) { + const { count, error } = await client + .from('bookings') + .select('*', { count: 'exact', head: true }) + .eq('room_id', roomId) + .not('status', 'in', '("cancelled","no_show")') + .lt('check_in', checkOut) + .gt('check_out', checkIn); + if (error) throw error; + return (count ?? 0) === 0; + }, + + async create(input: { + accountId: string; + roomNumber: string; + name?: string; + roomType?: string; + capacity?: number; + floor?: number; + pricePerNight: number; + description?: string; + }) { + const { data, error } = await client + .from('rooms') + .insert({ + account_id: input.accountId, + room_number: input.roomNumber, + name: input.name, + room_type: input.roomType ?? 'standard', + capacity: input.capacity ?? 2, + floor: input.floor, + price_per_night: input.pricePerNight, + description: input.description, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + }; +} diff --git a/packages/features/course-management/package.json b/packages/features/course-management/package.json index 167250d8e..c44b159dd 100644 --- a/packages/features/course-management/package.json +++ b/packages/features/course-management/package.json @@ -13,7 +13,9 @@ "./api": "./src/server/api.ts", "./schema/*": "./src/schema/*.ts", "./components": "./src/components/index.ts", - "./actions/*": "./src/server/actions/*.ts" + "./actions/*": "./src/server/actions/*.ts", + "./services/*": "./src/server/services/*.ts", + "./lib/*": "./src/lib/*.ts" }, "scripts": { "clean": "git clean -xdf .turbo node_modules", diff --git a/packages/features/course-management/src/lib/course-status-machine.ts b/packages/features/course-management/src/lib/course-status-machine.ts new file mode 100644 index 000000000..4604ecdc9 --- /dev/null +++ b/packages/features/course-management/src/lib/course-status-machine.ts @@ -0,0 +1,97 @@ +import type { z } from 'zod'; + +import type { CourseStatusEnum } from '../schema/course.schema'; + +type CourseStatus = z.infer; + +/** + * Course status state machine. + * + * Defines valid transitions between course statuses and their + * side effects. Enforced in course update operations. + */ + +type StatusTransition = { + /** Fields to set automatically when this transition occurs */ + sideEffects?: Partial>; +}; + +const TRANSITIONS: Record< + CourseStatus, + Partial> +> = { + planned: { + open: {}, + cancelled: {}, + }, + open: { + running: {}, + cancelled: {}, + }, + running: { + completed: {}, + cancelled: {}, + }, + // Terminal state — no transitions out + completed: {}, + cancelled: { + planned: {}, + }, +}; + +/** + * Check if a status transition is valid. + */ +export function canTransition(from: CourseStatus, to: CourseStatus): boolean { + if (from === to) return true; // no-op is always valid + return to in (TRANSITIONS[from] ?? {}); +} + +/** + * Get all valid target statuses from a given status. + */ +export function getValidTransitions(from: CourseStatus): CourseStatus[] { + return Object.keys(TRANSITIONS[from] ?? {}) as CourseStatus[]; +} + +/** + * Get the side effects for a transition. + * Returns an object of field->value pairs to apply alongside the status change. + * Function values should be called to get the actual value. + */ +export function getTransitionSideEffects( + from: CourseStatus, + to: CourseStatus, +): Record { + if (from === to) return {}; + + const transition = TRANSITIONS[from]?.[to]; + if (!transition?.sideEffects) return {}; + + const result: Record = {}; + + for (const [key, value] of Object.entries(transition.sideEffects)) { + result[key] = typeof value === 'function' ? value() : value; + } + + return result; +} + +/** + * Validate a status transition and return side effects. + * Throws if the transition is invalid. + */ +export function validateTransition( + from: CourseStatus, + to: CourseStatus, +): Record { + if (from === to) return {}; + + if (!canTransition(from, to)) { + throw new Error( + `Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`, + ); + } + + return getTransitionSideEffects(from, to); +} diff --git a/packages/features/course-management/src/lib/errors.ts b/packages/features/course-management/src/lib/errors.ts new file mode 100644 index 000000000..4d67ed579 --- /dev/null +++ b/packages/features/course-management/src/lib/errors.ts @@ -0,0 +1,128 @@ +/** + * Standardized error codes and domain error classes + * for the course management module. + */ + +export const CourseErrorCodes = { + NOT_FOUND: 'COURSE_NOT_FOUND', + CAPACITY_EXCEEDED: 'COURSE_CAPACITY_EXCEEDED', + INVALID_STATUS_TRANSITION: 'COURSE_INVALID_TRANSITION', + REGISTRATION_CLOSED: 'COURSE_REGISTRATION_CLOSED', + CONCURRENCY_CONFLICT: 'COURSE_CONCURRENCY_CONFLICT', + DUPLICATE_ENROLLMENT: 'COURSE_DUPLICATE_ENROLLMENT', + MIN_PARTICIPANTS_NOT_MET: 'COURSE_MIN_PARTICIPANTS_NOT_MET', +} as const; + +export type CourseErrorCode = + (typeof CourseErrorCodes)[keyof typeof CourseErrorCodes]; + +/** + * Base domain error for course management operations. + */ +export class CourseDomainError extends Error { + readonly code: CourseErrorCode; + readonly statusCode: number; + readonly details?: Record; + + constructor( + code: CourseErrorCode, + message: string, + statusCode = 400, + details?: Record, + ) { + super(message); + this.name = 'CourseDomainError'; + this.code = code; + this.statusCode = statusCode; + this.details = details; + } +} + +export class CourseNotFoundError extends CourseDomainError { + constructor(courseId: string) { + super(CourseErrorCodes.NOT_FOUND, `Kurs ${courseId} nicht gefunden`, 404, { + courseId, + }); + this.name = 'CourseNotFoundError'; + } +} + +export class CourseCapacityExceededError extends CourseDomainError { + constructor(courseId: string, capacity: number) { + super( + CourseErrorCodes.CAPACITY_EXCEEDED, + `Kurs ${courseId} hat die maximale Teilnehmerzahl (${capacity}) erreicht`, + 409, + { courseId, capacity }, + ); + this.name = 'CourseCapacityExceededError'; + } +} + +export class InvalidCourseStatusTransitionError extends CourseDomainError { + constructor(from: string, to: string, validTargets: string[]) { + super( + CourseErrorCodes.INVALID_STATUS_TRANSITION, + `Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`, + 422, + { from, to, validTargets }, + ); + this.name = 'InvalidCourseStatusTransitionError'; + } +} + +export class CourseRegistrationClosedError extends CourseDomainError { + constructor(courseId: string) { + super( + CourseErrorCodes.REGISTRATION_CLOSED, + `Anmeldung für Kurs ${courseId} ist geschlossen`, + 422, + { courseId }, + ); + this.name = 'CourseRegistrationClosedError'; + } +} + +export class CourseConcurrencyConflictError extends CourseDomainError { + constructor() { + super( + CourseErrorCodes.CONCURRENCY_CONFLICT, + 'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.', + 409, + ); + this.name = 'CourseConcurrencyConflictError'; + } +} + +export class CourseDuplicateEnrollmentError extends CourseDomainError { + constructor(courseId: string, memberId: string) { + super( + CourseErrorCodes.DUPLICATE_ENROLLMENT, + `Mitglied ${memberId} ist bereits für Kurs ${courseId} angemeldet`, + 409, + { courseId, memberId }, + ); + this.name = 'CourseDuplicateEnrollmentError'; + } +} + +export class CourseMinParticipantsError extends CourseDomainError { + constructor(courseId: string, minParticipants: number, currentCount: number) { + super( + CourseErrorCodes.MIN_PARTICIPANTS_NOT_MET, + `Kurs ${courseId} benötigt mindestens ${minParticipants} Teilnehmer (aktuell: ${currentCount})`, + 422, + { courseId, minParticipants, currentCount }, + ); + this.name = 'CourseMinParticipantsError'; + } +} + +/** + * Check if an error is a CourseDomainError. + */ +export function isCourseDomainError( + error: unknown, +): error is CourseDomainError { + return error instanceof CourseDomainError; +} diff --git a/packages/features/course-management/src/schema/course.schema.ts b/packages/features/course-management/src/schema/course.schema.ts index 1e4b3e156..a7fba3714 100644 --- a/packages/features/course-management/src/schema/course.schema.ts +++ b/packages/features/course-management/src/schema/course.schema.ts @@ -16,29 +16,100 @@ export const CourseStatusEnum = z.enum([ 'cancelled', ]); -export const CreateCourseSchema = z.object({ - accountId: z.string().uuid(), - courseNumber: z.string().optional(), - name: z.string().min(1).max(256), - description: z.string().optional(), - categoryId: z.string().uuid().optional(), - instructorId: z.string().uuid().optional(), - locationId: z.string().uuid().optional(), - startDate: z.string().optional(), - endDate: z.string().optional(), - fee: z.number().min(0).default(0), - reducedFee: z.number().min(0).optional(), - capacity: z.number().int().min(1).default(20), - minParticipants: z.number().int().min(0).default(5), - status: CourseStatusEnum.default('planned'), - registrationDeadline: z.string().optional(), - notes: z.string().optional(), -}); +export const CreateCourseSchema = z + .object({ + accountId: z.string().uuid(), + courseNumber: z.string().optional(), + name: z.string().min(1).max(256), + description: z.string().optional(), + categoryId: z.string().uuid().optional(), + instructorId: z.string().uuid().optional(), + locationId: z.string().uuid().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + fee: z.number().min(0).default(0), + reducedFee: z.number().min(0).optional(), + capacity: z.number().int().min(1).default(20), + minParticipants: z.number().int().min(0).default(5), + status: CourseStatusEnum.default('planned'), + registrationDeadline: z.string().optional(), + notes: z.string().optional(), + }) + .refine((d) => d.reducedFee == null || d.reducedFee <= d.fee, { + message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen', + path: ['reducedFee'], + }) + .refine((d) => d.minParticipants == null || d.minParticipants <= d.capacity, { + message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen', + path: ['minParticipants'], + }) + .refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, { + message: 'Enddatum muss nach dem Startdatum liegen', + path: ['endDate'], + }) + .refine( + (d) => + !d.registrationDeadline || + !d.startDate || + d.registrationDeadline <= d.startDate, + { + message: 'Anmeldefrist muss vor dem Startdatum liegen', + path: ['registrationDeadline'], + }, + ); export type CreateCourseInput = z.infer; -export const UpdateCourseSchema = CreateCourseSchema.partial().extend({ - courseId: z.string().uuid(), -}); +export const UpdateCourseSchema = z + .object({ + courseId: z.string().uuid(), + version: z.number().int().optional(), + courseNumber: z.string().optional(), + name: z.string().min(1).max(256).optional(), + description: z.string().optional(), + categoryId: z.string().uuid().optional(), + instructorId: z.string().uuid().optional(), + locationId: z.string().uuid().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + fee: z.number().min(0).optional(), + reducedFee: z.number().min(0).optional().nullable(), + capacity: z.number().int().min(1).optional(), + minParticipants: z.number().int().min(0).optional(), + status: CourseStatusEnum.optional(), + registrationDeadline: z.string().optional(), + notes: z.string().optional(), + }) + .refine( + (d) => d.reducedFee == null || d.fee == null || d.reducedFee <= d.fee, + { + message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen', + path: ['reducedFee'], + }, + ) + .refine( + (d) => + d.minParticipants == null || + d.capacity == null || + d.minParticipants <= d.capacity, + { + message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen', + path: ['minParticipants'], + }, + ) + .refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, { + message: 'Enddatum muss nach dem Startdatum liegen', + path: ['endDate'], + }) + .refine( + (d) => + !d.registrationDeadline || + !d.startDate || + d.registrationDeadline <= d.startDate, + { + message: 'Anmeldefrist muss vor dem Startdatum liegen', + path: ['registrationDeadline'], + }, + ); export type UpdateCourseInput = z.infer; export const EnrollParticipantSchema = z.object({ diff --git a/packages/features/course-management/src/server/actions/course-actions.ts b/packages/features/course-management/src/server/actions/course-actions.ts index c0bcbfc81..3b532a7aa 100644 --- a/packages/features/course-management/src/server/actions/course-actions.ts +++ b/packages/features/course-management/src/server/actions/course-actions.ts @@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { isCourseDomainError } from '../../lib/errors'; import { CreateCourseSchema, UpdateCourseSchema, @@ -25,7 +26,7 @@ export const createCourse = authActionClient const api = createCourseManagementApi(client); logger.info({ name: 'course.create' }, 'Creating course...'); - const result = await api.createCourse(input); + const result = await api.courses.create(input, ctx.user.id); logger.info({ name: 'course.create' }, 'Course created'); return { success: true, data: result }; }); @@ -37,39 +38,60 @@ export const updateCourse = authActionClient const logger = await getLogger(); const api = createCourseManagementApi(client); - logger.info({ name: 'course.update' }, 'Updating course...'); - const result = await api.updateCourse(input); - logger.info({ name: 'course.update' }, 'Course updated'); - return { success: true, data: result }; + try { + logger.info({ name: 'course.update' }, 'Updating course...'); + const result = await api.courses.update(input, ctx.user.id); + logger.info({ name: 'course.update' }, 'Course updated'); + return { success: true, data: result }; + } catch (e) { + if (isCourseDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const deleteCourse = authActionClient .inputSchema(z.object({ courseId: z.string().uuid() })) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); - logger.info({ name: 'course.delete' }, 'Archiving course...'); - await api.deleteCourse(input.courseId); - logger.info({ name: 'course.delete' }, 'Course archived'); - return { success: true }; + try { + logger.info({ name: 'course.delete' }, 'Archiving course...'); + await api.courses.softDelete(input.courseId); + logger.info({ name: 'course.delete' }, 'Course archived'); + return { success: true }; + } catch (e) { + if (isCourseDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const enrollParticipant = authActionClient .inputSchema(EnrollParticipantSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); - logger.info( - { name: 'course.enrollParticipant' }, - 'Enrolling participant...', - ); - const result = await api.enrollParticipant(input); - logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled'); - return { success: true, data: result }; + try { + logger.info( + { name: 'course.enrollParticipant' }, + 'Enrolling participant...', + ); + const result = await api.enrollment.enroll(input); + logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled'); + return { success: true, data: result }; + } catch (e) { + if (isCourseDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const cancelEnrollment = authActionClient @@ -78,7 +100,7 @@ export const cancelEnrollment = authActionClient participantId: z.string().uuid(), }), ) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); @@ -87,9 +109,9 @@ export const cancelEnrollment = authActionClient { name: 'course.cancelEnrollment' }, 'Cancelling enrollment...', ); - const result = await api.cancelEnrollment(input.participantId); + await api.enrollment.cancel(input.participantId); logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled'); - return { success: true, data: result }; + return { success: true }; }); export const markAttendance = authActionClient @@ -100,69 +122,69 @@ export const markAttendance = authActionClient present: z.boolean(), }), ) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); logger.info({ name: 'course.markAttendance' }, 'Marking attendance...'); - const result = await api.markAttendance( + await api.attendance.mark( input.sessionId, input.participantId, input.present, ); logger.info({ name: 'course.markAttendance' }, 'Attendance marked'); - return { success: true, data: result }; + return { success: true }; }); export const createCategory = authActionClient .inputSchema(CreateCategorySchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); logger.info({ name: 'course.createCategory' }, 'Creating category...'); - const result = await api.createCategory(input); + const result = await api.referenceData.createCategory(input); logger.info({ name: 'course.createCategory' }, 'Category created'); return { success: true, data: result }; }); export const createInstructor = authActionClient .inputSchema(CreateInstructorSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); logger.info({ name: 'course.createInstructor' }, 'Creating instructor...'); - const result = await api.createInstructor(input); + const result = await api.referenceData.createInstructor(input); logger.info({ name: 'course.createInstructor' }, 'Instructor created'); return { success: true, data: result }; }); export const createLocation = authActionClient .inputSchema(CreateLocationSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); logger.info({ name: 'course.createLocation' }, 'Creating location...'); - const result = await api.createLocation(input); + const result = await api.referenceData.createLocation(input); logger.info({ name: 'course.createLocation' }, 'Location created'); return { success: true, data: result }; }); export const createSession = authActionClient .inputSchema(CreateSessionSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input, ctx: _ctx }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createCourseManagementApi(client); logger.info({ name: 'course.createSession' }, 'Creating session...'); - const result = await api.createSession(input); + const result = await api.sessions.create(input); logger.info({ name: 'course.createSession' }, 'Session created'); return { success: true, data: result }; }); diff --git a/packages/features/course-management/src/server/api.ts b/packages/features/course-management/src/server/api.ts index e5ae2d0d3..696ed58f7 100644 --- a/packages/features/course-management/src/server/api.ts +++ b/packages/features/course-management/src/server/api.ts @@ -1,361 +1,22 @@ +import 'server-only'; import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '@kit/supabase/database'; -import type { - CreateCourseInput, - UpdateCourseInput, - EnrollParticipantInput, -} from '../schema/course.schema'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createAttendanceService } from './services/attendance.service'; +import { createCourseCrudService } from './services/course-crud.service'; +import { createCourseReferenceDataService } from './services/course-reference-data.service'; +import { createCourseStatisticsService } from './services/course-statistics.service'; +import { createEnrollmentService } from './services/enrollment.service'; +import { createSessionService } from './services/session.service'; export function createCourseManagementApi(client: SupabaseClient) { - const _db = client; - return { - // --- Courses --- - async listCourses( - accountId: string, - opts?: { - status?: string; - search?: string; - page?: number; - pageSize?: number; - }, - ) { - let query = client - .from('courses') - .select('*', { count: 'exact' }) - .eq('account_id', accountId) - .order('start_date', { ascending: false }); - if (opts?.status) query = query.eq('status', opts.status); - if (opts?.search) - query = query.or( - `name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`, - ); - const page = opts?.page ?? 1; - const pageSize = opts?.pageSize ?? 25; - query = query.range((page - 1) * pageSize, page * pageSize - 1); - const { data, error, count } = await query; - if (error) throw error; - return { data: data ?? [], total: count ?? 0, page, pageSize }; - }, - - async getCourse(courseId: string) { - const { data, error } = await client - .from('courses') - .select('*') - .eq('id', courseId) - .single(); - if (error) throw error; - return data; - }, - - async createCourse(input: CreateCourseInput) { - const { data, error } = await client - .from('courses') - .insert({ - account_id: input.accountId, - course_number: input.courseNumber || null, - name: input.name, - description: input.description || null, - category_id: input.categoryId || null, - instructor_id: input.instructorId || null, - location_id: input.locationId || null, - start_date: input.startDate || null, - end_date: input.endDate || null, - fee: input.fee, - reduced_fee: input.reducedFee ?? null, - capacity: input.capacity, - min_participants: input.minParticipants, - status: input.status, - registration_deadline: input.registrationDeadline || null, - notes: input.notes || null, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async updateCourse(input: UpdateCourseInput) { - const update: Record = {}; - if (input.name !== undefined) update.name = input.name; - if (input.courseNumber !== undefined) - update.course_number = input.courseNumber || null; - if (input.description !== undefined) - update.description = input.description || null; - if (input.categoryId !== undefined) - update.category_id = input.categoryId || null; - if (input.instructorId !== undefined) - update.instructor_id = input.instructorId || null; - if (input.locationId !== undefined) - update.location_id = input.locationId || null; - if (input.startDate !== undefined) - update.start_date = input.startDate || null; - if (input.endDate !== undefined) update.end_date = input.endDate || null; - if (input.fee !== undefined) update.fee = input.fee; - if (input.reducedFee !== undefined) - update.reduced_fee = input.reducedFee ?? null; - if (input.capacity !== undefined) update.capacity = input.capacity; - if (input.minParticipants !== undefined) - update.min_participants = input.minParticipants; - if (input.status !== undefined) update.status = input.status; - if (input.registrationDeadline !== undefined) - update.registration_deadline = input.registrationDeadline || null; - if (input.notes !== undefined) update.notes = input.notes || null; - - const { data, error } = await client - .from('courses') - .update(update) - .eq('id', input.courseId) - .select() - .single(); - if (error) throw error; - return data; - }, - - async deleteCourse(courseId: string) { - const { error } = await client - .from('courses') - .update({ status: 'cancelled' }) - .eq('id', courseId); - if (error) throw error; - }, - - // --- Enrollment --- - async enrollParticipant(input: EnrollParticipantInput) { - // Check capacity - const { count } = await client - .from('course_participants') - .select('*', { count: 'exact', head: true }) - .eq('course_id', input.courseId) - .in('status', ['enrolled']); - const course = await this.getCourse(input.courseId); - const status = - (count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled'; - - const { data, error } = await client - .from('course_participants') - .insert({ - course_id: input.courseId, - member_id: input.memberId, - first_name: input.firstName, - last_name: input.lastName, - email: input.email, - phone: input.phone, - status, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async cancelEnrollment(participantId: string) { - const { error } = await client - .from('course_participants') - .update({ status: 'cancelled', cancelled_at: new Date().toISOString() }) - .eq('id', participantId); - if (error) throw error; - }, - - async getParticipants(courseId: string) { - const { data, error } = await client - .from('course_participants') - .select('*') - .eq('course_id', courseId) - .order('enrolled_at'); - if (error) throw error; - return data ?? []; - }, - - // --- Sessions --- - async getSessions(courseId: string) { - const { data, error } = await client - .from('course_sessions') - .select('*') - .eq('course_id', courseId) - .order('session_date'); - if (error) throw error; - return data ?? []; - }, - - async createSession(input: { - courseId: string; - sessionDate: string; - startTime: string; - endTime: string; - locationId?: string; - }) { - const { data, error } = await client - .from('course_sessions') - .insert({ - course_id: input.courseId, - session_date: input.sessionDate, - start_time: input.startTime, - end_time: input.endTime, - location_id: input.locationId, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - // --- Attendance --- - async getAttendance(sessionId: string) { - const { data, error } = await client - .from('course_attendance') - .select('*') - .eq('session_id', sessionId); - if (error) throw error; - return data ?? []; - }, - - async markAttendance( - sessionId: string, - participantId: string, - present: boolean, - ) { - const { error } = await client.from('course_attendance').upsert( - { - session_id: sessionId, - participant_id: participantId, - present, - }, - { onConflict: 'session_id,participant_id' }, - ); - if (error) throw error; - }, - - // --- Categories, Instructors, Locations --- - async listCategories(accountId: string) { - const { data, error } = await client - .from('course_categories') - .select('*') - .eq('account_id', accountId) - .order('sort_order'); - if (error) throw error; - return data ?? []; - }, - - async listInstructors(accountId: string) { - const { data, error } = await client - .from('course_instructors') - .select('*') - .eq('account_id', accountId) - .order('last_name'); - if (error) throw error; - return data ?? []; - }, - - async listLocations(accountId: string) { - const { data, error } = await client - .from('course_locations') - .select('*') - .eq('account_id', accountId) - .order('name'); - if (error) throw error; - return data ?? []; - }, - - // --- Statistics --- - async getStatistics(accountId: string) { - const { data: courses } = await client - .from('courses') - .select('status') - .eq('account_id', accountId); - const { count: totalParticipants } = await client - .from('course_participants') - .select('*', { count: 'exact', head: true }) - .in( - 'course_id', - (courses ?? []).map((c: any) => c.id), - ); - - const stats = { - totalCourses: 0, - openCourses: 0, - completedCourses: 0, - totalParticipants: totalParticipants ?? 0, - }; - for (const c of courses ?? []) { - stats.totalCourses++; - if (c.status === 'open' || c.status === 'running') stats.openCourses++; - if (c.status === 'completed') stats.completedCourses++; - } - return stats; - }, - - // --- Create methods for CRUD --- - async createCategory(input: { - accountId: string; - name: string; - description?: string; - parentId?: string; - }) { - const { data, error } = await client - .from('course_categories') - .insert({ - account_id: input.accountId, - name: input.name, - description: input.description, - parent_id: input.parentId, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async createInstructor(input: { - accountId: string; - firstName: string; - lastName: string; - email?: string; - phone?: string; - qualifications?: string; - hourlyRate?: number; - }) { - const { data, error } = await client - .from('course_instructors') - .insert({ - account_id: input.accountId, - first_name: input.firstName, - last_name: input.lastName, - email: input.email, - phone: input.phone, - qualifications: input.qualifications, - hourly_rate: input.hourlyRate, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async createLocation(input: { - accountId: string; - name: string; - address?: string; - room?: string; - capacity?: number; - }) { - const { data, error } = await client - .from('course_locations') - .insert({ - account_id: input.accountId, - name: input.name, - address: input.address, - room: input.room, - capacity: input.capacity, - }) - .select() - .single(); - if (error) throw error; - return data; - }, + courses: createCourseCrudService(client), + enrollment: createEnrollmentService(client), + sessions: createSessionService(client), + attendance: createAttendanceService(client), + referenceData: createCourseReferenceDataService(client), + statistics: createCourseStatisticsService(client), }; } diff --git a/packages/features/course-management/src/server/services/attendance.service.ts b/packages/features/course-management/src/server/services/attendance.service.ts new file mode 100644 index 000000000..bd7df7b69 --- /dev/null +++ b/packages/features/course-management/src/server/services/attendance.service.ts @@ -0,0 +1,29 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createAttendanceService(client: SupabaseClient) { + return { + async getBySession(sessionId: string) { + const { data, error } = await client + .from('course_attendance') + .select('*') + .eq('session_id', sessionId); + if (error) throw error; + return data ?? []; + }, + + async mark(sessionId: string, participantId: string, present: boolean) { + const { error } = await client.from('course_attendance').upsert( + { + session_id: sessionId, + participant_id: participantId, + present, + }, + { onConflict: 'session_id,participant_id' }, + ); + if (error) throw error; + }, + }; +} diff --git a/packages/features/course-management/src/server/services/course-crud.service.ts b/packages/features/course-management/src/server/services/course-crud.service.ts new file mode 100644 index 000000000..a8de1b1d8 --- /dev/null +++ b/packages/features/course-management/src/server/services/course-crud.service.ts @@ -0,0 +1,227 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { getLogger } from '@kit/shared/logger'; +import type { Database } from '@kit/supabase/database'; + +import { + canTransition, + validateTransition, + getValidTransitions, +} from '../../lib/course-status-machine'; +import { + CourseNotFoundError, + CourseConcurrencyConflictError, + InvalidCourseStatusTransitionError, +} from '../../lib/errors'; +import type { + CreateCourseInput, + UpdateCourseInput, +} from '../../schema/course.schema'; + +const NAMESPACE = 'course-crud'; + +export function createCourseCrudService(client: SupabaseClient) { + return { + async list( + accountId: string, + opts?: { + status?: string; + search?: string; + page?: number; + pageSize?: number; + }, + ) { + let query = client + .from('courses') + .select('*', { count: 'exact' }) + .eq('account_id', accountId) + .order('start_date', { ascending: false }); + + if (opts?.status) query = query.eq('status', opts.status); + if (opts?.search) + query = query.or( + `name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`, + ); + + const page = opts?.page ?? 1; + const pageSize = opts?.pageSize ?? 25; + query = query.range((page - 1) * pageSize, page * pageSize - 1); + + const { data, error, count } = await query; + if (error) throw error; + + const total = count ?? 0; + + return { + data: data ?? [], + total, + page, + pageSize, + totalPages: Math.max(1, Math.ceil(total / pageSize)), + }; + }, + + async getById(courseId: string) { + const { data, error } = await client + .from('courses') + .select('*') + .eq('id', courseId) + .maybeSingle(); + if (error) throw error; + if (!data) throw new CourseNotFoundError(courseId); + return data; + }, + + async create(input: CreateCourseInput, userId?: string) { + const logger = await getLogger(); + logger.info({ name: NAMESPACE }, 'Creating course...'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- created_by/updated_by added via migration, not yet in generated types + const { data, error } = await client + .from('courses') + .insert({ + account_id: input.accountId, + course_number: input.courseNumber || null, + name: input.name, + description: input.description || null, + category_id: input.categoryId || null, + instructor_id: input.instructorId || null, + location_id: input.locationId || null, + start_date: input.startDate || null, + end_date: input.endDate || null, + fee: input.fee, + reduced_fee: input.reducedFee ?? null, + capacity: input.capacity, + min_participants: input.minParticipants, + status: input.status, + registration_deadline: input.registrationDeadline || null, + notes: input.notes || null, + ...(userId ? { created_by: userId, updated_by: userId } : {}), + } as any) + .select() + .single(); + if (error) throw error; + return data; + }, + + async update(input: UpdateCourseInput, userId?: string) { + const logger = await getLogger(); + logger.info( + { name: NAMESPACE, courseId: input.courseId }, + 'Updating course...', + ); + + const update: Record = {}; + + if (input.name !== undefined) update.name = input.name; + if (input.courseNumber !== undefined) + update.course_number = input.courseNumber || null; + if (input.description !== undefined) + update.description = input.description || null; + if (input.categoryId !== undefined) + update.category_id = input.categoryId || null; + if (input.instructorId !== undefined) + update.instructor_id = input.instructorId || null; + if (input.locationId !== undefined) + update.location_id = input.locationId || null; + if (input.startDate !== undefined) + update.start_date = input.startDate || null; + if (input.endDate !== undefined) update.end_date = input.endDate || null; + if (input.fee !== undefined) update.fee = input.fee; + if (input.reducedFee !== undefined) + update.reduced_fee = input.reducedFee ?? null; + if (input.capacity !== undefined) update.capacity = input.capacity; + if (input.minParticipants !== undefined) + update.min_participants = input.minParticipants; + if (input.registrationDeadline !== undefined) + update.registration_deadline = input.registrationDeadline || null; + if (input.notes !== undefined) update.notes = input.notes || null; + + // Status transition validation + if (input.status !== undefined) { + const { data: current, error: fetchError } = await client + .from('courses') + .select('status') + .eq('id', input.courseId) + .single(); + if (fetchError) throw fetchError; + + const currentStatus = current.status as string; + if (currentStatus !== input.status) { + try { + const sideEffects = validateTransition( + currentStatus as Parameters[0], + input.status as Parameters[1], + ); + Object.assign(update, sideEffects); + } catch { + throw new InvalidCourseStatusTransitionError( + currentStatus, + input.status, + getValidTransitions( + currentStatus as Parameters[0], + ), + ); + } + } + + update.status = input.status; + } + + if (userId) { + update.updated_by = userId; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- version/updated_by columns added via migration, not yet in generated types + let query = client + .from('courses') + .update(update as any) + .eq('id', input.courseId); + + // Optimistic locking via version column + if (input.version !== undefined) { + query = (query as any).eq('version', input.version); + } + + const { data, error } = await query.select().single(); + + if (error) { + if (error.code === 'PGRST116' && input.version !== undefined) { + throw new CourseConcurrencyConflictError(); + } + throw error; + } + + return data; + }, + + async softDelete(courseId: string) { + const logger = await getLogger(); + logger.info({ name: NAMESPACE, courseId }, 'Cancelling course...'); + + const { data: current, error: fetchError } = await client + .from('courses') + .select('status') + .eq('id', courseId) + .maybeSingle(); + if (fetchError) throw fetchError; + if (!current) throw new CourseNotFoundError(courseId); + + type CourseStatus = Parameters[0]; + if (!canTransition(current.status as CourseStatus, 'cancelled')) { + throw new InvalidCourseStatusTransitionError( + current.status, + 'cancelled', + getValidTransitions(current.status as CourseStatus), + ); + } + + const { error } = await client + .from('courses') + .update({ status: 'cancelled' }) + .eq('id', courseId); + if (error) throw error; + }, + }; +} diff --git a/packages/features/course-management/src/server/services/course-reference-data.service.ts b/packages/features/course-management/src/server/services/course-reference-data.service.ts new file mode 100644 index 000000000..df3646605 --- /dev/null +++ b/packages/features/course-management/src/server/services/course-reference-data.service.ts @@ -0,0 +1,108 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createCourseReferenceDataService( + client: SupabaseClient, +) { + return { + async listCategories(accountId: string) { + const { data, error } = await client + .from('course_categories') + .select('*') + .eq('account_id', accountId) + .order('sort_order'); + if (error) throw error; + return data ?? []; + }, + + async listInstructors(accountId: string) { + const { data, error } = await client + .from('course_instructors') + .select('*') + .eq('account_id', accountId) + .order('last_name'); + if (error) throw error; + return data ?? []; + }, + + async listLocations(accountId: string) { + const { data, error } = await client + .from('course_locations') + .select('*') + .eq('account_id', accountId) + .order('name'); + if (error) throw error; + return data ?? []; + }, + + async createCategory(input: { + accountId: string; + name: string; + description?: string; + parentId?: string; + }) { + const { data, error } = await client + .from('course_categories') + .insert({ + account_id: input.accountId, + name: input.name, + description: input.description, + parent_id: input.parentId, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + + async createInstructor(input: { + accountId: string; + firstName: string; + lastName: string; + email?: string; + phone?: string; + qualifications?: string; + hourlyRate?: number; + }) { + const { data, error } = await client + .from('course_instructors') + .insert({ + account_id: input.accountId, + first_name: input.firstName, + last_name: input.lastName, + email: input.email, + phone: input.phone, + qualifications: input.qualifications, + hourly_rate: input.hourlyRate, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + + async createLocation(input: { + accountId: string; + name: string; + address?: string; + room?: string; + capacity?: number; + }) { + const { data, error } = await client + .from('course_locations') + .insert({ + account_id: input.accountId, + name: input.name, + address: input.address, + room: input.room, + capacity: input.capacity, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + }; +} diff --git a/packages/features/course-management/src/server/services/course-statistics.service.ts b/packages/features/course-management/src/server/services/course-statistics.service.ts new file mode 100644 index 000000000..3e86bcc07 --- /dev/null +++ b/packages/features/course-management/src/server/services/course-statistics.service.ts @@ -0,0 +1,42 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createCourseStatisticsService( + client: SupabaseClient, +) { + return { + async getQuickStats(accountId: string) { + const { data, error } = await (client.rpc as CallableFunction)( + 'get_course_statistics', + { p_account_id: accountId }, + ); + if (error) throw error; + // RPC returns a single row as an array + const stats = Array.isArray(data) ? data[0] : data; + return ( + stats ?? { + total_courses: 0, + open_courses: 0, + running_courses: 0, + completed_courses: 0, + cancelled_courses: 0, + total_participants: 0, + total_waitlisted: 0, + avg_occupancy_rate: 0, + total_revenue: 0, + } + ); + }, + + async getAttendanceSummary(courseId: string) { + const { data, error } = await (client.rpc as CallableFunction)( + 'get_course_attendance_summary', + { p_course_id: courseId }, + ); + if (error) throw error; + return data ?? []; + }, + }; +} diff --git a/packages/features/course-management/src/server/services/enrollment.service.ts b/packages/features/course-management/src/server/services/enrollment.service.ts new file mode 100644 index 000000000..385f25e22 --- /dev/null +++ b/packages/features/course-management/src/server/services/enrollment.service.ts @@ -0,0 +1,51 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +import type { EnrollParticipantInput } from '../../schema/course.schema'; + +export function createEnrollmentService(client: SupabaseClient) { + return { + async enroll(input: EnrollParticipantInput) { + // Uses the enroll_course_participant RPC which handles capacity checks + // and waitlisting atomically, avoiding race conditions. + const { data, error } = await (client.rpc as CallableFunction)( + 'enroll_course_participant', + { + p_course_id: input.courseId, + p_member_id: input.memberId ?? null, + p_first_name: input.firstName, + p_last_name: input.lastName, + p_email: input.email || null, + p_phone: input.phone || null, + }, + ); + if (error) throw error; + return data; + }, + + async cancel(participantId: string) { + const { data, error } = await (client.rpc as CallableFunction)( + 'cancel_course_enrollment', + { p_participant_id: participantId }, + ); + if (error) throw error; + return data as { + cancelled_id: string; + promoted_id: string | null; + promoted_name: string | null; + }; + }, + + async listParticipants(courseId: string) { + const { data, error } = await client + .from('course_participants') + .select('*') + .eq('course_id', courseId) + .order('enrolled_at'); + if (error) throw error; + return data ?? []; + }, + }; +} diff --git a/packages/features/course-management/src/server/services/index.ts b/packages/features/course-management/src/server/services/index.ts new file mode 100644 index 000000000..14f532b26 --- /dev/null +++ b/packages/features/course-management/src/server/services/index.ts @@ -0,0 +1,31 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +import { createAttendanceService } from './attendance.service'; +import { createCourseCrudService } from './course-crud.service'; +import { createCourseReferenceDataService } from './course-reference-data.service'; +import { createCourseStatisticsService } from './course-statistics.service'; +import { createEnrollmentService } from './enrollment.service'; +import { createSessionService } from './session.service'; + +export { + createAttendanceService, + createCourseCrudService, + createCourseReferenceDataService, + createCourseStatisticsService, + createEnrollmentService, + createSessionService, +}; + +export function createCourseServices(client: SupabaseClient) { + return { + courses: createCourseCrudService(client), + enrollment: createEnrollmentService(client), + sessions: createSessionService(client), + attendance: createAttendanceService(client), + referenceData: createCourseReferenceDataService(client), + statistics: createCourseStatisticsService(client), + }; +} diff --git a/packages/features/course-management/src/server/services/session.service.ts b/packages/features/course-management/src/server/services/session.service.ts new file mode 100644 index 000000000..93a4f6f16 --- /dev/null +++ b/packages/features/course-management/src/server/services/session.service.ts @@ -0,0 +1,63 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createSessionService(client: SupabaseClient) { + return { + async list(courseId: string) { + const { data, error } = await client + .from('course_sessions') + .select('*') + .eq('course_id', courseId) + .order('session_date'); + if (error) throw error; + return data ?? []; + }, + + async create(input: { + courseId: string; + sessionDate: string; + startTime: string; + endTime: string; + locationId?: string; + }) { + // Check instructor availability if course has an instructor + const { data: course, error: courseError } = await client + .from('courses') + .select('instructor_id') + .eq('id', input.courseId) + .single(); + if (courseError) throw courseError; + + if (course?.instructor_id) { + const { data: isAvailable, error: availError } = await ( + client.rpc as CallableFunction + )('check_instructor_availability', { + p_instructor_id: course.instructor_id, + p_session_date: input.sessionDate, + p_start_time: input.startTime, + p_end_time: input.endTime, + }); + if (availError) throw availError; + if (!isAvailable) { + throw new Error('Kursleiter ist zu diesem Zeitpunkt nicht verfügbar'); + } + } + + const { data, error } = await client + .from('course_sessions') + .insert({ + course_id: input.courseId, + session_date: input.sessionDate, + start_time: input.startTime, + end_time: input.endTime, + location_id: input.locationId, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + }; +} diff --git a/packages/features/event-management/package.json b/packages/features/event-management/package.json index e75575157..4bfd01c45 100644 --- a/packages/features/event-management/package.json +++ b/packages/features/event-management/package.json @@ -13,7 +13,9 @@ "./api": "./src/server/api.ts", "./schema/*": "./src/schema/*.ts", "./components": "./src/components/index.ts", - "./actions/*": "./src/server/actions/*.ts" + "./actions/*": "./src/server/actions/*.ts", + "./services/*": "./src/server/services/*.ts", + "./lib/*": "./src/lib/*.ts" }, "scripts": { "clean": "git clean -xdf .turbo node_modules", diff --git a/packages/features/event-management/src/lib/errors.ts b/packages/features/event-management/src/lib/errors.ts new file mode 100644 index 000000000..e8ffad424 --- /dev/null +++ b/packages/features/event-management/src/lib/errors.ts @@ -0,0 +1,123 @@ +/** + * Standardized error codes and domain error classes + * for the event management module. + */ + +export const EventErrorCodes = { + NOT_FOUND: 'EVENT_NOT_FOUND', + FULL: 'EVENT_FULL', + INVALID_STATUS_TRANSITION: 'EVENT_INVALID_TRANSITION', + REGISTRATION_CLOSED: 'EVENT_REGISTRATION_CLOSED', + AGE_RESTRICTION: 'EVENT_AGE_RESTRICTION', + CONCURRENCY_CONFLICT: 'EVENT_CONCURRENCY_CONFLICT', +} as const; + +export type EventErrorCode = + (typeof EventErrorCodes)[keyof typeof EventErrorCodes]; + +/** + * Base domain error for event management operations. + */ +export class EventDomainError extends Error { + readonly code: EventErrorCode; + readonly statusCode: number; + readonly details?: Record; + + constructor( + code: EventErrorCode, + message: string, + statusCode = 400, + details?: Record, + ) { + super(message); + this.name = 'EventDomainError'; + this.code = code; + this.statusCode = statusCode; + this.details = details; + } +} + +export class EventNotFoundError extends EventDomainError { + constructor(eventId: string) { + super( + EventErrorCodes.NOT_FOUND, + `Veranstaltung ${eventId} nicht gefunden`, + 404, + { eventId }, + ); + this.name = 'EventNotFoundError'; + } +} + +export class EventFullError extends EventDomainError { + constructor(eventId: string, capacity: number) { + super( + EventErrorCodes.FULL, + `Veranstaltung ${eventId} ist ausgebucht (max. ${capacity} Teilnehmer)`, + 409, + { eventId, capacity }, + ); + this.name = 'EventFullError'; + } +} + +export class InvalidEventStatusTransitionError extends EventDomainError { + constructor(from: string, to: string, validTargets: string[]) { + super( + EventErrorCodes.INVALID_STATUS_TRANSITION, + `Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`, + 422, + { from, to, validTargets }, + ); + this.name = 'InvalidEventStatusTransitionError'; + } +} + +export class EventRegistrationClosedError extends EventDomainError { + constructor(eventId: string) { + super( + EventErrorCodes.REGISTRATION_CLOSED, + `Anmeldung für Veranstaltung ${eventId} ist geschlossen`, + 422, + { eventId }, + ); + this.name = 'EventRegistrationClosedError'; + } +} + +export class EventAgeRestrictionError extends EventDomainError { + constructor(eventId: string, minAge?: number, maxAge?: number) { + const ageRange = + minAge && maxAge + ? `${minAge}–${maxAge} Jahre` + : minAge + ? `ab ${minAge} Jahre` + : `bis ${maxAge} Jahre`; + + super( + EventErrorCodes.AGE_RESTRICTION, + `Altersbeschränkung für Veranstaltung ${eventId}: ${ageRange}`, + 422, + { eventId, minAge, maxAge }, + ); + this.name = 'EventAgeRestrictionError'; + } +} + +export class EventConcurrencyConflictError extends EventDomainError { + constructor() { + super( + EventErrorCodes.CONCURRENCY_CONFLICT, + 'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.', + 409, + ); + this.name = 'EventConcurrencyConflictError'; + } +} + +/** + * Check if an error is an EventDomainError. + */ +export function isEventDomainError(error: unknown): error is EventDomainError { + return error instanceof EventDomainError; +} diff --git a/packages/features/event-management/src/lib/event-status-machine.ts b/packages/features/event-management/src/lib/event-status-machine.ts new file mode 100644 index 000000000..b4062332d --- /dev/null +++ b/packages/features/event-management/src/lib/event-status-machine.ts @@ -0,0 +1,103 @@ +import type { z } from 'zod'; + +import type { EventStatusEnum } from '../schema/event.schema'; + +type EventStatus = z.infer; + +/** + * Event status state machine. + * + * Defines valid transitions between event statuses and their + * side effects. Enforced in event update operations. + */ + +type StatusTransition = { + /** Fields to set automatically when this transition occurs */ + sideEffects?: Partial>; +}; + +const TRANSITIONS: Record< + EventStatus, + Partial> +> = { + planned: { + open: {}, + cancelled: {}, + }, + open: { + full: {}, + running: {}, + cancelled: {}, + }, + full: { + open: {}, + running: {}, + cancelled: {}, + }, + running: { + completed: {}, + cancelled: {}, + }, + // Terminal state — no transitions out + completed: {}, + cancelled: { + planned: {}, + }, +}; + +/** + * Check if a status transition is valid. + */ +export function canTransition(from: EventStatus, to: EventStatus): boolean { + if (from === to) return true; // no-op is always valid + return to in (TRANSITIONS[from] ?? {}); +} + +/** + * Get all valid target statuses from a given status. + */ +export function getValidTransitions(from: EventStatus): EventStatus[] { + return Object.keys(TRANSITIONS[from] ?? {}) as EventStatus[]; +} + +/** + * Get the side effects for a transition. + * Returns an object of field->value pairs to apply alongside the status change. + * Function values should be called to get the actual value. + */ +export function getTransitionSideEffects( + from: EventStatus, + to: EventStatus, +): Record { + if (from === to) return {}; + + const transition = TRANSITIONS[from]?.[to]; + if (!transition?.sideEffects) return {}; + + const result: Record = {}; + + for (const [key, value] of Object.entries(transition.sideEffects)) { + result[key] = typeof value === 'function' ? value() : value; + } + + return result; +} + +/** + * Validate a status transition and return side effects. + * Throws if the transition is invalid. + */ +export function validateTransition( + from: EventStatus, + to: EventStatus, +): Record { + if (from === to) return {}; + + if (!canTransition(from, to)) { + throw new Error( + `Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`, + ); + } + + return getTransitionSideEffects(from, to); +} diff --git a/packages/features/event-management/src/schema/event.schema.ts b/packages/features/event-management/src/schema/event.schema.ts index dd31885cb..216357834 100644 --- a/packages/features/event-management/src/schema/event.schema.ts +++ b/packages/features/event-management/src/schema/event.schema.ts @@ -9,29 +9,80 @@ export const EventStatusEnum = z.enum([ 'cancelled', ]); -export const CreateEventSchema = z.object({ - accountId: z.string().uuid(), - name: z.string().min(1).max(256), - description: z.string().optional(), - eventDate: z.string(), - eventTime: z.string().optional(), - endDate: z.string().optional(), - location: z.string().optional(), - capacity: z.number().int().optional(), - minAge: z.number().int().optional(), - maxAge: z.number().int().optional(), - fee: z.number().min(0).default(0), - status: EventStatusEnum.default('planned'), - registrationDeadline: z.string().optional(), - contactName: z.string().optional(), - contactEmail: z.string().email().optional().or(z.literal('')), - contactPhone: z.string().optional(), -}); +export const CreateEventSchema = z + .object({ + accountId: z.string().uuid(), + name: z.string().min(1).max(256), + description: z.string().optional(), + eventDate: z.string(), + eventTime: z.string().optional(), + endDate: z.string().optional(), + location: z.string().optional(), + capacity: z.number().int().optional(), + minAge: z.number().int().optional(), + maxAge: z.number().int().optional(), + fee: z.number().min(0).default(0), + status: EventStatusEnum.default('planned'), + registrationDeadline: z.string().optional(), + contactName: z.string().optional(), + contactEmail: z.string().email().optional().or(z.literal('')), + contactPhone: z.string().optional(), + }) + .refine((d) => d.minAge == null || d.maxAge == null || d.minAge <= d.maxAge, { + message: 'Mindestalter darf das Höchstalter nicht übersteigen', + path: ['minAge'], + }) + .refine((d) => !d.endDate || d.endDate >= d.eventDate, { + message: 'Enddatum muss nach dem Veranstaltungsdatum liegen', + path: ['endDate'], + }) + .refine( + (d) => !d.registrationDeadline || d.registrationDeadline <= d.eventDate, + { + message: 'Anmeldefrist muss vor dem Veranstaltungsdatum liegen', + path: ['registrationDeadline'], + }, + ); export type CreateEventInput = z.infer; -export const UpdateEventSchema = CreateEventSchema.partial().extend({ - eventId: z.string().uuid(), -}); +export const UpdateEventSchema = z + .object({ + eventId: z.string().uuid(), + version: z.number().int().optional(), + name: z.string().min(1).max(256).optional(), + description: z.string().optional(), + eventDate: z.string().optional(), + eventTime: z.string().optional(), + endDate: z.string().optional(), + location: z.string().optional(), + capacity: z.number().int().optional(), + minAge: z.number().int().optional().nullable(), + maxAge: z.number().int().optional().nullable(), + fee: z.number().min(0).optional(), + status: EventStatusEnum.optional(), + registrationDeadline: z.string().optional(), + contactName: z.string().optional(), + contactEmail: z.string().email().optional().or(z.literal('')), + contactPhone: z.string().optional(), + }) + .refine((d) => d.minAge == null || d.maxAge == null || d.minAge <= d.maxAge, { + message: 'Mindestalter darf das Höchstalter nicht übersteigen', + path: ['minAge'], + }) + .refine((d) => !d.endDate || !d.eventDate || d.endDate >= d.eventDate, { + message: 'Enddatum muss nach dem Veranstaltungsdatum liegen', + path: ['endDate'], + }) + .refine( + (d) => + !d.registrationDeadline || + !d.eventDate || + d.registrationDeadline <= d.eventDate, + { + message: 'Anmeldefrist muss vor dem Veranstaltungsdatum liegen', + path: ['registrationDeadline'], + }, + ); export type UpdateEventInput = z.infer; export const EventRegistrationSchema = z.object({ diff --git a/packages/features/event-management/src/server/actions/event-actions.ts b/packages/features/event-management/src/server/actions/event-actions.ts index 479980544..32676fe5f 100644 --- a/packages/features/event-management/src/server/actions/event-actions.ts +++ b/packages/features/event-management/src/server/actions/event-actions.ts @@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { isEventDomainError } from '../../lib/errors'; import { CreateEventSchema, UpdateEventSchema, @@ -22,7 +23,7 @@ export const createEvent = authActionClient const api = createEventManagementApi(client); logger.info({ name: 'event.create' }, 'Creating event...'); - const result = await api.createEvent(input); + const result = await api.events.create(input, ctx.user.id); logger.info({ name: 'event.create' }, 'Event created'); return { success: true, data: result }; }); @@ -34,41 +35,62 @@ export const updateEvent = authActionClient const logger = await getLogger(); const api = createEventManagementApi(client); - logger.info({ name: 'event.update' }, 'Updating event...'); - const result = await api.updateEvent(input); - logger.info({ name: 'event.update' }, 'Event updated'); - return { success: true, data: result }; + try { + logger.info({ name: 'event.update' }, 'Updating event...'); + const result = await api.events.update(input, ctx.user.id); + logger.info({ name: 'event.update' }, 'Event updated'); + return { success: true, data: result }; + } catch (e) { + if (isEventDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const deleteEvent = authActionClient .inputSchema(z.object({ eventId: z.string().uuid() })) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createEventManagementApi(client); - logger.info({ name: 'event.delete' }, 'Cancelling event...'); - await api.deleteEvent(input.eventId); - logger.info({ name: 'event.delete' }, 'Event cancelled'); - return { success: true }; + try { + logger.info({ name: 'event.delete' }, 'Cancelling event...'); + await api.events.softDelete(input.eventId); + logger.info({ name: 'event.delete' }, 'Event cancelled'); + return { success: true }; + } catch (e) { + if (isEventDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const registerForEvent = authActionClient .inputSchema(EventRegistrationSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createEventManagementApi(client); - logger.info({ name: 'event.register' }, 'Registering for event...'); - const result = await api.registerForEvent(input); - logger.info({ name: 'event.register' }, 'Registered for event'); - return { success: true, data: result }; + try { + logger.info({ name: 'event.register' }, 'Registering for event...'); + const result = await api.registrations.register(input); + logger.info({ name: 'event.register' }, 'Registered for event'); + return { success: true, data: result }; + } catch (e) { + if (isEventDomainError(e)) { + return { success: false, error: e.message, code: e.code }; + } + throw e; + } }); export const createHolidayPass = authActionClient .inputSchema(CreateHolidayPassSchema) - .action(async ({ parsedInput: input, ctx }) => { + .action(async ({ parsedInput: input }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const api = createEventManagementApi(client); @@ -77,7 +99,7 @@ export const createHolidayPass = authActionClient { name: 'event.createHolidayPass' }, 'Creating holiday pass...', ); - const result = await api.createHolidayPass(input); + const result = await api.holidayPasses.create(input); logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created'); return { success: true, data: result }; }); diff --git a/packages/features/event-management/src/server/api.ts b/packages/features/event-management/src/server/api.ts index 351e4f989..ddbdd380a 100644 --- a/packages/features/event-management/src/server/api.ts +++ b/packages/features/event-management/src/server/api.ts @@ -1,232 +1,16 @@ +import 'server-only'; import type { SupabaseClient } from '@supabase/supabase-js'; import type { Database } from '@kit/supabase/database'; -import type { - CreateEventInput, - UpdateEventInput, -} from '../schema/event.schema'; - -/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createEventCrudService } from './services/event-crud.service'; +import { createEventRegistrationService } from './services/event-registration.service'; +import { createHolidayPassService } from './services/holiday-pass.service'; export function createEventManagementApi(client: SupabaseClient) { - const PAGE_SIZE = 25; - const _db = client; - return { - async listEvents( - accountId: string, - opts?: { status?: string; page?: number }, - ) { - let query = client - .from('events') - .select('*', { count: 'exact' }) - .eq('account_id', accountId) - .order('event_date', { ascending: false }); - if (opts?.status) query = query.eq('status', opts.status); - const page = opts?.page ?? 1; - query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1); - const { data, error, count } = await query; - if (error) throw error; - const total = count ?? 0; - return { - data: data ?? [], - total, - page, - pageSize: PAGE_SIZE, - totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)), - }; - }, - - async getRegistrationCounts(eventIds: string[]) { - if (eventIds.length === 0) return {} as Record; - const { data, error } = await client - .from('event_registrations') - .select('event_id', { count: 'exact', head: false }) - .in('event_id', eventIds) - .in('status', ['pending', 'confirmed']); - if (error) throw error; - - const counts: Record = {}; - for (const row of data ?? []) { - const eid = (row as Record).event_id as string; - counts[eid] = (counts[eid] ?? 0) + 1; - } - return counts; - }, - - async getEvent(eventId: string) { - const { data, error } = await client - .from('events') - .select('*') - .eq('id', eventId) - .single(); - if (error) throw error; - return data; - }, - - async createEvent(input: CreateEventInput) { - const { data, error } = await client - .from('events') - .insert({ - account_id: input.accountId, - name: input.name, - description: input.description || null, - event_date: input.eventDate, - event_time: input.eventTime || null, - end_date: input.endDate || null, - location: input.location || null, - capacity: input.capacity, - min_age: input.minAge ?? null, - max_age: input.maxAge ?? null, - fee: input.fee, - status: input.status, - registration_deadline: input.registrationDeadline || null, - contact_name: input.contactName || null, - contact_email: input.contactEmail || null, - contact_phone: input.contactPhone || null, - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async updateEvent(input: UpdateEventInput) { - const update: Record = {}; - if (input.name !== undefined) update.name = input.name; - if (input.description !== undefined) - update.description = input.description || null; - if (input.eventDate !== undefined) - update.event_date = input.eventDate || null; - if (input.eventTime !== undefined) - update.event_time = input.eventTime || null; - if (input.endDate !== undefined) update.end_date = input.endDate || null; - if (input.location !== undefined) - update.location = input.location || null; - if (input.capacity !== undefined) update.capacity = input.capacity; - if (input.minAge !== undefined) update.min_age = input.minAge ?? null; - if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null; - if (input.fee !== undefined) update.fee = input.fee; - if (input.status !== undefined) update.status = input.status; - if (input.registrationDeadline !== undefined) - update.registration_deadline = input.registrationDeadline || null; - if (input.contactName !== undefined) - update.contact_name = input.contactName || null; - if (input.contactEmail !== undefined) - update.contact_email = input.contactEmail || null; - if (input.contactPhone !== undefined) - update.contact_phone = input.contactPhone || null; - - const { data, error } = await client - .from('events') - .update(update) - .eq('id', input.eventId) - .select() - .single(); - if (error) throw error; - return data; - }, - - async deleteEvent(eventId: string) { - const { error } = await client - .from('events') - .update({ status: 'cancelled' }) - .eq('id', eventId); - if (error) throw error; - }, - - async registerForEvent(input: { - eventId: string; - firstName: string; - lastName: string; - email?: string; - parentName?: string; - }) { - // Check capacity - const event = await this.getEvent(input.eventId); - if (event.capacity) { - const { count } = await client - .from('event_registrations') - .select('*', { count: 'exact', head: true }) - .eq('event_id', input.eventId) - .in('status', ['pending', 'confirmed']); - if ((count ?? 0) >= event.capacity) { - throw new Error('Event is full'); - } - } - - const { data, error } = await client - .from('event_registrations') - .insert({ - event_id: input.eventId, - first_name: input.firstName, - last_name: input.lastName, - email: input.email, - parent_name: input.parentName, - status: 'confirmed', - }) - .select() - .single(); - if (error) throw error; - return data; - }, - - async getRegistrations(eventId: string) { - const { data, error } = await client - .from('event_registrations') - .select('*') - .eq('event_id', eventId) - .order('created_at'); - if (error) throw error; - return data ?? []; - }, - - // Holiday passes - async listHolidayPasses(accountId: string) { - const { data, error } = await client - .from('holiday_passes') - .select('*') - .eq('account_id', accountId) - .order('year', { ascending: false }); - if (error) throw error; - return data ?? []; - }, - - async getPassActivities(passId: string) { - const { data, error } = await client - .from('holiday_pass_activities') - .select('*') - .eq('pass_id', passId) - .order('activity_date'); - if (error) throw error; - return data ?? []; - }, - - async createHolidayPass(input: { - accountId: string; - name: string; - year: number; - description?: string; - price?: number; - validFrom?: string; - validUntil?: string; - }) { - const { data, error } = await client - .from('holiday_passes') - .insert({ - account_id: input.accountId, - name: input.name, - year: input.year, - description: input.description, - price: input.price ?? 0, - valid_from: input.validFrom, - valid_until: input.validUntil, - }) - .select() - .single(); - if (error) throw error; - return data; - }, + events: createEventCrudService(client), + registrations: createEventRegistrationService(client), + holidayPasses: createHolidayPassService(client), }; } diff --git a/packages/features/event-management/src/server/services/event-crud.service.ts b/packages/features/event-management/src/server/services/event-crud.service.ts new file mode 100644 index 000000000..5649acad3 --- /dev/null +++ b/packages/features/event-management/src/server/services/event-crud.service.ts @@ -0,0 +1,237 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { getLogger } from '@kit/shared/logger'; +import type { Database } from '@kit/supabase/database'; + +import { + EventConcurrencyConflictError, + EventNotFoundError, + InvalidEventStatusTransitionError, +} from '../../lib/errors'; +import { + canTransition, + validateTransition, + getValidTransitions, +} from '../../lib/event-status-machine'; +import type { + CreateEventInput, + UpdateEventInput, +} from '../../schema/event.schema'; + +const PAGE_SIZE = 25; + +const NAMESPACE = 'event-crud'; + +export function createEventCrudService(client: SupabaseClient) { + return { + async list(accountId: string, opts?: { status?: string; page?: number }) { + let query = client + .from('events') + .select('*', { count: 'exact' }) + .eq('account_id', accountId) + .order('event_date', { ascending: false }); + + if (opts?.status) query = query.eq('status', opts.status); + + const page = opts?.page ?? 1; + query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1); + + const { data, error, count } = await query; + if (error) throw error; + + const total = count ?? 0; + + return { + data: data ?? [], + total, + page, + pageSize: PAGE_SIZE, + totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)), + }; + }, + + async getById(eventId: string) { + const { data, error } = await client + .from('events') + .select('*') + .eq('id', eventId) + .maybeSingle(); + if (error) throw error; + if (!data) throw new EventNotFoundError(eventId); + return data; + }, + + async getRegistrationCounts(eventIds: string[]) { + if (eventIds.length === 0) return {} as Record; + const { data, error } = await (client.rpc as CallableFunction)( + 'get_event_registration_counts', + { p_event_ids: eventIds }, + ); + if (error) throw error; + const counts: Record = {}; + for (const row of (data ?? []) as Array<{ + event_id: string; + registration_count: number; + }>) { + counts[row.event_id] = Number(row.registration_count); + } + return counts; + }, + + async create(input: CreateEventInput, userId?: string) { + const logger = await getLogger(); + logger.info({ name: NAMESPACE }, 'Creating event...'); + + const { data, error } = await client + .from('events') + .insert({ + account_id: input.accountId, + name: input.name, + description: input.description || null, + event_date: input.eventDate, + event_time: input.eventTime || null, + end_date: input.endDate || null, + location: input.location || null, + capacity: input.capacity, + min_age: input.minAge ?? null, + max_age: input.maxAge ?? null, + fee: input.fee, + status: input.status, + registration_deadline: input.registrationDeadline || null, + contact_name: input.contactName || null, + contact_email: input.contactEmail || null, + contact_phone: input.contactPhone || null, + ...(userId ? { created_by: userId, updated_by: userId } : {}), + } as any) + .select() + .single(); + if (error) throw error; + return data; + }, + + async update(input: UpdateEventInput, userId?: string) { + const logger = await getLogger(); + logger.info( + { name: NAMESPACE, eventId: input.eventId }, + 'Updating event...', + ); + + const update: Record = {}; + + if (input.name !== undefined) update.name = input.name; + if (input.description !== undefined) + update.description = input.description || null; + if (input.eventDate !== undefined) + update.event_date = input.eventDate || null; + if (input.eventTime !== undefined) + update.event_time = input.eventTime || null; + if (input.endDate !== undefined) update.end_date = input.endDate || null; + if (input.location !== undefined) + update.location = input.location || null; + if (input.capacity !== undefined) update.capacity = input.capacity; + if (input.minAge !== undefined) update.min_age = input.minAge ?? null; + if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null; + if (input.fee !== undefined) update.fee = input.fee; + if (input.registrationDeadline !== undefined) + update.registration_deadline = input.registrationDeadline || null; + if (input.contactName !== undefined) + update.contact_name = input.contactName || null; + if (input.contactEmail !== undefined) + update.contact_email = input.contactEmail || null; + if (input.contactPhone !== undefined) + update.contact_phone = input.contactPhone || null; + + // Status machine validation + if (input.status !== undefined) { + // Fetch current event to validate transition + const { data: current, error: fetchError } = await client + .from('events') + .select('status') + .eq('id', input.eventId) + .single(); + if (fetchError) throw fetchError; + + const currentStatus = (current as Record) + .status as string; + + if (currentStatus !== input.status) { + try { + const sideEffects = validateTransition( + currentStatus as Parameters[0], + input.status as Parameters[1], + ); + Object.assign(update, sideEffects); + } catch { + throw new InvalidEventStatusTransitionError( + currentStatus, + input.status, + getValidTransitions( + currentStatus as Parameters[0], + ), + ); + } + } + + update.status = input.status; + } + + if (userId) { + update.updated_by = userId; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic fields + future version column + let query = client + .from('events') + .update(update as any) + .eq('id', input.eventId); + + // Optimistic locking — version column may be added via migration + if (input.version !== undefined) { + query = (query as any).eq('version', input.version); + } + + const { data, error } = await query.select().single(); + + // If version was provided and no rows matched, it's a concurrency conflict + if (error && input.version !== undefined) { + // PGRST116 = "JSON object requested, multiple (or no) rows returned" + if (error.code === 'PGRST116') { + throw new EventConcurrencyConflictError(); + } + } + + if (error) throw error; + + return data; + }, + + async softDelete(eventId: string) { + const logger = await getLogger(); + logger.info({ name: NAMESPACE, eventId }, 'Cancelling event...'); + + const { data: current, error: fetchError } = await client + .from('events') + .select('status') + .eq('id', eventId) + .maybeSingle(); + if (fetchError) throw fetchError; + if (!current) throw new EventNotFoundError(eventId); + + type EventStatus = Parameters[0]; + if (!canTransition(current.status as EventStatus, 'cancelled')) { + throw new InvalidEventStatusTransitionError( + current.status, + 'cancelled', + getValidTransitions(current.status as EventStatus), + ); + } + + const { error } = await client + .from('events') + .update({ status: 'cancelled' }) + .eq('id', eventId); + if (error) throw error; + }, + }; +} diff --git a/packages/features/event-management/src/server/services/event-registration.service.ts b/packages/features/event-management/src/server/services/event-registration.service.ts new file mode 100644 index 000000000..9cf26a339 --- /dev/null +++ b/packages/features/event-management/src/server/services/event-registration.service.ts @@ -0,0 +1,64 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createEventRegistrationService( + client: SupabaseClient, +) { + return { + async register(input: { + eventId: string; + memberId?: string; + firstName: string; + lastName: string; + email?: string; + phone?: string; + dateOfBirth?: string; + parentName?: string; + parentPhone?: string; + }) { + // RPC function defined in migration; cast to bypass generated types + // until `pnpm supabase:web:typegen` is re-run. + const { data, error } = await (client.rpc as CallableFunction)( + 'register_for_event', + { + p_event_id: input.eventId, + p_member_id: input.memberId ?? null, + p_first_name: input.firstName, + p_last_name: input.lastName, + p_email: input.email || null, + p_phone: input.phone || null, + p_date_of_birth: input.dateOfBirth ?? null, + p_parent_name: input.parentName ?? null, + p_parent_phone: input.parentPhone ?? null, + }, + ); + if (error) throw error; + return data; + }, + + async cancel(registrationId: string) { + const { data, error } = await (client.rpc as CallableFunction)( + 'cancel_event_registration', + { p_registration_id: registrationId }, + ); + if (error) throw error; + return data as { + cancelled_id: string; + promoted_id: string | null; + promoted_name: string | null; + }; + }, + + async list(eventId: string) { + const { data, error } = await client + .from('event_registrations') + .select('*') + .eq('event_id', eventId) + .order('created_at'); + if (error) throw error; + return data ?? []; + }, + }; +} diff --git a/packages/features/event-management/src/server/services/holiday-pass.service.ts b/packages/features/event-management/src/server/services/holiday-pass.service.ts new file mode 100644 index 000000000..c15db88b7 --- /dev/null +++ b/packages/features/event-management/src/server/services/holiday-pass.service.ts @@ -0,0 +1,54 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +export function createHolidayPassService(client: SupabaseClient) { + return { + async list(accountId: string) { + const { data, error } = await client + .from('holiday_passes') + .select('*') + .eq('account_id', accountId) + .order('year', { ascending: false }); + if (error) throw error; + return data ?? []; + }, + + async getActivities(passId: string) { + const { data, error } = await client + .from('holiday_pass_activities') + .select('*') + .eq('pass_id', passId) + .order('activity_date'); + if (error) throw error; + return data ?? []; + }, + + async create(input: { + accountId: string; + name: string; + year: number; + description?: string; + price?: number; + validFrom?: string; + validUntil?: string; + }) { + const { data, error } = await client + .from('holiday_passes') + .insert({ + account_id: input.accountId, + name: input.name, + year: input.year, + description: input.description, + price: input.price ?? 0, + valid_from: input.validFrom, + valid_until: input.validUntil, + }) + .select() + .single(); + if (error) throw error; + return data; + }, + }; +} diff --git a/packages/features/event-management/src/server/services/index.ts b/packages/features/event-management/src/server/services/index.ts new file mode 100644 index 000000000..2276518f0 --- /dev/null +++ b/packages/features/event-management/src/server/services/index.ts @@ -0,0 +1,22 @@ +import 'server-only'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +import type { Database } from '@kit/supabase/database'; + +import { createEventCrudService } from './event-crud.service'; +import { createEventRegistrationService } from './event-registration.service'; +import { createHolidayPassService } from './holiday-pass.service'; + +export { + createEventCrudService, + createEventRegistrationService, + createHolidayPassService, +}; + +export function createEventServices(client: SupabaseClient) { + return { + events: createEventCrudService(client), + registrations: createEventRegistrationService(client), + holidayPasses: createHolidayPassService(client), + }; +} diff --git a/packages/features/member-management/package.json b/packages/features/member-management/package.json index b7ba90244..e27aef37c 100644 --- a/packages/features/member-management/package.json +++ b/packages/features/member-management/package.json @@ -10,7 +10,8 @@ } }, "exports": { - "./api": "./src/server/api.ts", + "./services": "./src/server/services/index.ts", + "./services/*": "./src/server/services/*.ts", "./schema/*": "./src/schema/*.ts", "./components": "./src/components/index.ts", "./actions/*": "./src/server/actions/*.ts", @@ -22,12 +23,15 @@ }, "devDependencies": { "@hookform/resolvers": "catalog:", + "@kit/mailers": "workspace:*", "@kit/next": "workspace:*", + "@kit/notifications": "workspace:*", "@kit/shared": "workspace:*", "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", "@supabase/supabase-js": "catalog:", + "@tanstack/react-table": "catalog:", "@types/papaparse": "catalog:", "@types/react": "catalog:", "lucide-react": "catalog:", diff --git a/packages/features/member-management/src/components/index.ts b/packages/features/member-management/src/components/index.ts index 176706564..de888f7dc 100644 --- a/packages/features/member-management/src/components/index.ts +++ b/packages/features/member-management/src/components/index.ts @@ -1,8 +1,13 @@ export { CreateMemberForm } from './create-member-form'; export { EditMemberForm } from './edit-member-form'; -export { MembersDataTable } from './members-data-table'; -export { MemberDetailView } from './member-detail-view'; export { ApplicationWorkflow } from './application-workflow'; export { DuesCategoryManager } from './dues-category-manager'; export { MandateManager } from './mandate-manager'; export { MemberImportWizard } from './member-import-wizard'; +export { MemberAvatar } from './member-avatar'; +export { MemberStatsBar } from './member-stats-bar'; +export { MembersListView } from './members-list-view'; +export { MemberDetailTabs } from './member-detail-tabs'; +export { MemberCreateWizard } from './member-create-wizard'; +export { MemberCommandPalette } from './member-command-palette'; +export { TagsManager } from './tags-manager'; diff --git a/packages/features/member-management/src/components/member-avatar.tsx b/packages/features/member-management/src/components/member-avatar.tsx new file mode 100644 index 000000000..4296a5d7a --- /dev/null +++ b/packages/features/member-management/src/components/member-avatar.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { Avatar, AvatarFallback } from '@kit/ui/avatar'; +import { cn } from '@kit/ui/utils'; + +interface MemberAvatarProps { + firstName: string; + lastName: string; + size?: 'default' | 'sm' | 'lg'; + className?: string; +} + +function getInitials(firstName: string, lastName: string): string { + const f = firstName.trim().charAt(0).toUpperCase(); + const l = lastName.trim().charAt(0).toUpperCase(); + return `${f}${l}`; +} + +function getColorClass(firstName: string, lastName: string): string { + const name = `${firstName}${lastName}`; + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300', + 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300', + 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300', + 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300', + 'bg-rose-100 text-rose-700 dark:bg-rose-900 dark:text-rose-300', + 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900 dark:text-cyan-300', + 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300', + 'bg-teal-100 text-teal-700 dark:bg-teal-900 dark:text-teal-300', + ]; + return colors[Math.abs(hash) % colors.length]!; +} + +export function MemberAvatar({ + firstName, + lastName, + size = 'default', + className, +}: MemberAvatarProps) { + const initials = getInitials(firstName, lastName); + const colorClass = getColorClass(firstName, lastName); + + return ( + + + {initials} + + + ); +} diff --git a/packages/features/member-management/src/components/member-command-palette.tsx b/packages/features/member-management/src/components/member-command-palette.tsx new file mode 100644 index 000000000..598a28b04 --- /dev/null +++ b/packages/features/member-management/src/components/member-command-palette.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { FileUp, Plus, User } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; + +import { Badge } from '@kit/ui/badge'; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@kit/ui/command'; + +import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils'; +import { quickSearchMembers } from '../server/actions/member-actions'; +import { MemberAvatar } from './member-avatar'; + +interface MemberCommandPaletteProps { + account: string; + accountId: string; +} + +export function MemberCommandPalette({ + account, + accountId, +}: MemberCommandPaletteProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(''); + const [results, setResults] = useState< + Array<{ + id: string; + first_name: string; + last_name: string; + email: string | null; + member_number: string | null; + status: string; + }> + >([]); + + const { execute } = useAction(quickSearchMembers, { + onSuccess: ({ data }) => { + if (data?.data) setResults(data.data); + }, + }); + + // Keyboard shortcut + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setOpen((v) => !v); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, []); + + // Search on query change + useEffect(() => { + if (query.length >= 2) { + execute({ accountId, query, limit: 8 }); + } else { + setResults([]); + } + }, [query, accountId, execute]); + + const handleSelect = useCallback( + (memberId: string) => { + setOpen(false); + setQuery(''); + router.push(`/home/${account}/members-cms/${memberId}`); + }, + [router, account], + ); + + const basePath = `/home/${account}/members-cms`; + + return ( + + + + Keine Mitglieder gefunden. + + {results.length > 0 && ( + + {results.map((m) => ( + handleSelect(m.id)} + className="flex items-center gap-3" + > + +
+ + {m.first_name} {m.last_name} + + {m.member_number && ( + + Nr. {m.member_number} + + )} +
+ + {STATUS_LABELS[m.status] ?? m.status} + +
+ ))} +
+ )} + + + + + { + setOpen(false); + router.push(`${basePath}/new`); + }} + > + + Neues Mitglied erstellen + + { + setOpen(false); + router.push(`${basePath}/import`); + }} + > + + Import starten + + { + setOpen(false); + router.push(`${basePath}/applications`); + }} + > + + Aufnahmeanträge anzeigen + + +
+
+ ); +} diff --git a/packages/features/member-management/src/components/member-create-wizard.tsx b/packages/features/member-management/src/components/member-create-wizard.tsx new file mode 100644 index 000000000..0d62fcfc3 --- /dev/null +++ b/packages/features/member-management/src/components/member-create-wizard.tsx @@ -0,0 +1,770 @@ +'use client'; + +import { useState } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { Check } from 'lucide-react'; +import { useForm } from 'react-hook-form'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@kit/ui/alert-dialog'; +import { Button } from '@kit/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card'; +import { Checkbox } from '@kit/ui/checkbox'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { Input } from '@kit/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@kit/ui/select'; +import { Textarea } from '@kit/ui/textarea'; +import { useActionWithToast } from '@kit/ui/use-action-with-toast'; +import { cn } from '@kit/ui/utils'; + +import { CreateMemberSchema } from '../schema/member.schema'; +import { createMember } from '../server/actions/member-actions'; + +interface Props { + accountId: string; + account: string; + duesCategories: Array<{ id: string; name: string; amount: number }>; +} + +interface DuplicateEntry { + field: string; + message: string; + id?: string; +} + +const STEPS = [ + { id: 1, title: 'Basisdaten', description: 'Name und Mitgliedschaft' }, + { id: 2, title: 'Weitere Angaben', description: 'Kontakt und Adresse' }, + { + id: 3, + title: 'Mitgliedschaft & Finanzen', + description: 'Beiträge und Bankverbindung', + }, +] as const; + +export function MemberCreateWizard({ + accountId, + account, + duesCategories, +}: Props) { + const router = useRouter(); + const [step, setStep] = useState(1); + const [duplicates, setDuplicates] = useState([]); + + const form = useForm({ + resolver: zodResolver(CreateMemberSchema), + defaultValues: { + accountId, + firstName: '', + lastName: '', + email: '', + phone: '', + mobile: '', + street: '', + houseNumber: '', + postalCode: '', + city: '', + country: 'DE', + memberNumber: '', + status: 'active' as const, + entryDate: new Date().toISOString().split('T')[0]!, + dateOfBirth: '', + gender: undefined, + salutation: '', + title: '', + duesCategoryId: undefined, + iban: '', + bic: '', + accountHolder: '', + gdprConsent: false, + gdprNewsletter: false, + gdprInternet: false, + gdprPrint: false, + gdprBirthdayInfo: false, + isHonorary: false, + isFoundingMember: false, + isYouth: false, + isRetiree: false, + isProbationary: false, + guardianName: '', + guardianPhone: '', + guardianEmail: '', + notes: '', + }, + }); + + const { execute, isPending } = useActionWithToast(createMember, { + successMessage: 'Mitglied erstellt', + onSuccess: ({ data }: any) => { + if (data?.validationErrors) { + setDuplicates(data.validationErrors); + return; + } + router.push(`/home/${account}/members-cms`); + }, + }); + + const canProceedStep1 = + form.watch('firstName')?.trim() && form.watch('lastName')?.trim(); + + const handleNext = () => { + if (step < 3) setStep(step + 1); + }; + + const handleBack = () => { + if (step > 1) setStep(step - 1); + }; + + const handleSubmit = form.handleSubmit((data) => { + // Clean empty strings + const cleanData = { ...data }; + for (const [key, value] of Object.entries(cleanData)) { + if (value === '') { + (cleanData as any)[key] = undefined; + } + } + execute(cleanData); + }); + + return ( +
+ {/* Step indicator */} + + +
+ + {/* Step 1: Basisdaten */} + {step === 1 && ( + + + Basisdaten + + +
+ ( + + Vorname * + + + + + + )} + /> + ( + + Nachname * + + + + + + )} + /> +
+ + ( + + E-Mail + + + + + + )} + /> + +
+ ( + + Mitgliedsnr. + + + + + + )} + /> + ( + + Status + + + + )} + /> +
+ + ( + + Eintrittsdatum + + + + + + )} + /> +
+
+ )} + + {/* Step 2: Weitere Angaben */} + {step === 2 && ( +
+ + + Kontakt + + +
+ ( + + Telefon + + + + + + )} + /> + ( + + Mobil + + + + + + )} + /> +
+
+
+ + + + Adresse + + +
+ ( + + Straße + + + + + + )} + /> + ( + + Hausnr. + + + + + + )} + /> +
+
+ ( + + PLZ + + + + + + )} + /> + ( + + Ort + + + + + + )} + /> +
+
+
+ + + + Persönliche Daten + + +
+ ( + + Geburtsdatum + + + + + + )} + /> + ( + + Geschlecht + + + + )} + /> +
+
+ ( + + Anrede + + + + + + )} + /> + ( + + Titel + + + + + + )} + /> +
+
+
+
+ )} + + {/* Step 3: Mitgliedschaft & Finanzen */} + {step === 3 && ( +
+ + + Beitrag + + + {duesCategories.length > 0 && ( + ( + + Beitragskategorie + + + + )} + /> + )} + + + + + + Bankverbindung + + +
+ ( + + IBAN + + + + + + )} + /> + ( + + BIC + + + + + + )} + /> +
+ ( + + Kontoinhaber + + + + + + )} + /> +
+
+ + + + Merkmale + + +
+ {[ + { name: 'isHonorary' as const, label: 'Ehrenmitglied' }, + { + name: 'isFoundingMember' as const, + label: 'Gründungsmitglied', + }, + { name: 'isYouth' as const, label: 'Jugendmitglied' }, + { name: 'isRetiree' as const, label: 'Senior' }, + { name: 'isProbationary' as const, label: 'Probezeit' }, + ].map(({ name, label }) => ( + ( + + + + + + {label} + + + )} + /> + ))} +
+
+
+ + + + DSGVO-Einwilligungen + + +
+ {[ + { + name: 'gdprConsent' as const, + label: 'Allgemeine Einwilligung', + }, + { name: 'gdprNewsletter' as const, label: 'Newsletter' }, + { + name: 'gdprInternet' as const, + label: 'Internetveröffentlichung', + }, + { + name: 'gdprPrint' as const, + label: 'Printveröffentlichung', + }, + { + name: 'gdprBirthdayInfo' as const, + label: 'Geburtstagsinfo', + }, + ].map(({ name, label }) => ( + ( + + + + + + {label} + + + )} + /> + ))} +
+
+
+ + + + Notizen + + + ( + + +