feat/members-redesign #1
@@ -72,7 +72,7 @@ After implementation, always run:
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- gitnexus:start -->
|
<!-- gitnexus:start -->
|
||||||
# GitNexus — Code Intelligence
|
# 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.
|
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
|||||||
|
|
||||||
COPY --from=builder /app/ ./
|
COPY --from=builder /app/ ./
|
||||||
|
|
||||||
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 nextjs
|
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 /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
|
USER nextjs
|
||||||
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default async function BookingCalendarPage({ params }: PageProps) {
|
|||||||
const monthStart = `${year}-${String(month + 1).padStart(2, '0')}-01`;
|
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 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,
|
from: monthStart,
|
||||||
to: monthEnd,
|
to: monthEnd,
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default async function GuestsPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const guests = await api.listGuests(acct.id);
|
const guests = await api.guests.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('guests.title')}>
|
<CmsPageShell account={account} title={t('guests.title')}>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function NewBookingPage({ params }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const rooms = await api.listRooms(acct.id);
|
const rooms = await api.rooms.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<CmsPageShell
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default async function BookingsPage({
|
|||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const rooms = await api.listRooms(acct.id);
|
const rooms = await api.rooms.list(acct.id);
|
||||||
|
|
||||||
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
|
// Fetch bookings with joined room & guest names (avoids displaying raw UUIDs)
|
||||||
const bookingsQuery = client
|
const bookingsQuery = client
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default async function RoomsPage({ params }: PageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
const rooms = await api.listRooms(acct.id);
|
const rooms = await api.rooms.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('rooms.title')}>
|
<CmsPageShell account={account} title={t('rooms.title')}>
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export default async function AttendancePage({
|
|||||||
const t = await getTranslations('courses');
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const [course, sessions, participants] = await Promise.all([
|
const [course, sessions, participants] = await Promise.all([
|
||||||
api.getCourse(courseId),
|
api.courses.getById(courseId),
|
||||||
api.getSessions(courseId),
|
api.sessions.list(courseId),
|
||||||
api.getParticipants(courseId),
|
api.enrollment.listParticipants(courseId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) return <AccountNotFound />;
|
if (!course) return <AccountNotFound />;
|
||||||
@@ -43,7 +43,7 @@ export default async function AttendancePage({
|
|||||||
: null);
|
: null);
|
||||||
|
|
||||||
const attendance = selectedSessionId
|
const attendance = selectedSessionId
|
||||||
? await api.getAttendance(selectedSessionId)
|
? await api.attendance.getBySession(selectedSessionId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const attendanceMap = new Map(
|
const attendanceMap = new Map(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function EditCoursePage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const course = await api.getCourse(courseId);
|
const course = await api.courses.getById(courseId);
|
||||||
if (!course) return <AccountNotFound />;
|
if (!course) return <AccountNotFound />;
|
||||||
|
|
||||||
const c = course as Record<string, unknown>;
|
const c = course as Record<string, unknown>;
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export default async function CourseDetailPage({ params }: PageProps) {
|
|||||||
const t = await getTranslations('courses');
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const [course, participants, sessions] = await Promise.all([
|
const [course, participants, sessions] = await Promise.all([
|
||||||
api.getCourse(courseId),
|
api.courses.getById(courseId),
|
||||||
api.getParticipants(courseId),
|
api.enrollment.listParticipants(courseId),
|
||||||
api.getSessions(courseId),
|
api.sessions.list(courseId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) return <AccountNotFound />;
|
if (!course) return <AccountNotFound />;
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export default async function ParticipantsPage({ params }: PageProps) {
|
|||||||
const t = await getTranslations('courses');
|
const t = await getTranslations('courses');
|
||||||
|
|
||||||
const [course, participants] = await Promise.all([
|
const [course, participants] = await Promise.all([
|
||||||
api.getCourse(courseId),
|
api.courses.getById(courseId),
|
||||||
api.getParticipants(courseId),
|
api.enrollment.listParticipants(courseId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!course) return <AccountNotFound />;
|
if (!course) return <AccountNotFound />;
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default async function CourseCalendarPage({
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
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 now = new Date();
|
||||||
const monthParam = search.month as string | undefined;
|
const monthParam = search.month as string | undefined;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function CategoriesPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const categories = await api.listCategories(acct.id);
|
const categories = await api.referenceData.listCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>
|
<CmsPageShell account={account} title={t('pages.categoriesTitle')}>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default async function InstructorsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const instructors = await api.listInstructors(acct.id);
|
const instructors = await api.referenceData.listInstructors(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>
|
<CmsPageShell account={account} title={t('pages.instructorsTitle')}>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function LocationsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const locations = await api.listLocations(acct.id);
|
const locations = await api.referenceData.listLocations(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('pages.locationsTitle')}>
|
<CmsPageShell account={account} title={t('pages.locationsTitle')}>
|
||||||
|
|||||||
@@ -52,13 +52,13 @@ export default async function CoursesPage({ params, searchParams }: PageProps) {
|
|||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
|
||||||
const [courses, stats] = await Promise.all([
|
const [courses, stats] = await Promise.all([
|
||||||
api.listCourses(acct.id, {
|
api.courses.list(acct.id, {
|
||||||
search: search.q as string,
|
search: search.q as string,
|
||||||
status: search.status as string,
|
status: search.status as string,
|
||||||
page,
|
page,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
}),
|
}),
|
||||||
api.getStatistics(acct.id),
|
api.statistics.getQuickStats(acct.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
|
const totalPages = Math.ceil(courses.total / PAGE_SIZE);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default async function CourseStatisticsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
const stats = await api.getStatistics(acct.id);
|
const stats = await api.statistics.getQuickStats(acct.id);
|
||||||
|
|
||||||
const statusChartData = [
|
const statusChartData = [
|
||||||
{ name: t('stats.active'), value: stats.openCourses },
|
{ name: t('stats.active'), value: stats.openCourses },
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default async function EditEventPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
const event = await api.getEvent(eventId);
|
const event = await api.events.getById(eventId);
|
||||||
if (!event) return <AccountNotFound />;
|
if (!event) return <AccountNotFound />;
|
||||||
|
|
||||||
const e = event as Record<string, unknown>;
|
const e = event as Record<string, unknown>;
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export default async function EventDetailPage({ params }: PageProps) {
|
|||||||
const t = await getTranslations('cms.events');
|
const t = await getTranslations('cms.events');
|
||||||
|
|
||||||
const [event, registrations] = await Promise.all([
|
const [event, registrations] = await Promise.all([
|
||||||
api.getEvent(eventId),
|
api.events.getById(eventId),
|
||||||
api.getRegistrations(eventId),
|
api.registrations.list(eventId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!event) return <div>{t('notFound')}</div>;
|
if (!event) return <div>{t('notFound')}</div>;
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default async function HolidayPassesPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
const passes = await api.listHolidayPasses(acct.id);
|
const passes = await api.holidayPasses.list(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell account={account} title={t('holidayPasses')}>
|
<CmsPageShell account={account} title={t('holidayPasses')}>
|
||||||
|
|||||||
@@ -47,13 +47,13 @@ export default async function EventsPage({ params, searchParams }: PageProps) {
|
|||||||
|
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
const api = createEventManagementApi(client);
|
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
|
// Fetch registration counts for all events on this page
|
||||||
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
|
const eventIds = events.data.map((eventItem: Record<string, unknown>) =>
|
||||||
String(eventItem.id),
|
String(eventItem.id),
|
||||||
);
|
);
|
||||||
const registrationCounts = await api.getRegistrationCounts(eventIds);
|
const registrationCounts = await api.events.getRegistrationCounts(eventIds);
|
||||||
|
|
||||||
// Pre-compute stats before rendering
|
// Pre-compute stats before rendering
|
||||||
const uniqueLocationCount = new Set(
|
const uniqueLocationCount = new Set(
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export default async function EventRegistrationsPage({ params }: PageProps) {
|
|||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createEventManagementApi(client);
|
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
|
// Load registrations for each event in parallel
|
||||||
const eventsWithRegistrations = await Promise.all(
|
const eventsWithRegistrations = await Promise.all(
|
||||||
events.data.map(async (event: Record<string, unknown>) => {
|
events.data.map(async (event: Record<string, unknown>) => {
|
||||||
const registrations = await api.getRegistrations(String(event.id));
|
const registrations = await api.registrations.list(String(event.id));
|
||||||
return {
|
return {
|
||||||
id: String(event.id),
|
id: String(event.id),
|
||||||
name: String(event.name),
|
name: String(event.name),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
|
||||||
import { EditMemberForm } from '@kit/member-management/components';
|
import { EditMemberForm } from '@kit/member-management/components';
|
||||||
|
import { createMemberServices } from '@kit/member-management/services';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -22,8 +22,8 @@ export default async function EditMemberPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { query } = createMemberServices(client);
|
||||||
const member = await api.getMember(acct.id, memberId);
|
const member = await query.getById(acct.id, memberId);
|
||||||
if (!member) return <div>{t('detail.notFound')}</div>;
|
if (!member) return <div>{t('detail.notFound')}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
|
||||||
import { MemberDetailTabs } 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -18,14 +18,14 @@ export default async function MemberDetailPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { query, organization } = createMemberServices(client);
|
||||||
const member = await api.getMember(acct.id, memberId);
|
const member = await query.getById(acct.id, memberId);
|
||||||
if (!member) return <AccountNotFound />;
|
if (!member) return <AccountNotFound />;
|
||||||
|
|
||||||
const [roles, honors, mandates] = await Promise.all([
|
const [roles, honors, mandates] = await Promise.all([
|
||||||
api.listMemberRoles(memberId),
|
organization.listMemberRoles(memberId),
|
||||||
api.listMemberHonors(memberId),
|
organization.listMemberHonors(memberId),
|
||||||
api.listMandates(memberId),
|
organization.listMandates(memberId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
Settings,
|
Settings,
|
||||||
|
Tag,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -160,6 +161,10 @@ function SettingsMenu({ basePath }: { basePath: string }) {
|
|||||||
<Users className="mr-2 size-4" />
|
<Users className="mr-2 size-4" />
|
||||||
Abteilungen
|
Abteilungen
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={navigate(`${basePath}/tags`)}>
|
||||||
|
<Tag className="mr-2 size-4" />
|
||||||
|
Tags verwalten
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={navigate(`${basePath}/cards`)}>
|
<DropdownMenuItem onClick={navigate(`${basePath}/cards`)}>
|
||||||
<IdCard className="mr-2 size-4" />
|
<IdCard className="mr-2 size-4" />
|
||||||
Mitgliedsausweise
|
Mitgliedsausweise
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
|
||||||
import { ApplicationWorkflow } from '@kit/member-management/components';
|
import { ApplicationWorkflow } from '@kit/member-management/components';
|
||||||
|
import { createMemberServices } from '@kit/member-management/services';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -19,8 +19,8 @@ export default async function ApplicationsPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { workflow } = createMemberServices(client);
|
||||||
const applications = await api.listApplications(acct.id);
|
const applications = await workflow.listApplications(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ApplicationWorkflow
|
<ApplicationWorkflow
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { CreditCard } from 'lucide-react';
|
import { CreditCard } from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -26,8 +26,8 @@ export default async function MemberCardsPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { query } = createMemberServices(client);
|
||||||
const result = await api.listMembers(acct.id, {
|
const result = await query.list(acct.id, {
|
||||||
status: 'active',
|
status: 'active',
|
||||||
pageSize: CARDS_PAGE_SIZE,
|
pageSize: CARDS_PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Users } from 'lucide-react';
|
import { Users } from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -26,8 +26,8 @@ export default async function DepartmentsPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { organization } = createMemberServices(client);
|
||||||
const departments = await api.listDepartments(acct.id);
|
const departments = await organization.listDepartments(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<CmsPageShell
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
|
||||||
import { DuesCategoryManager } from '@kit/member-management/components';
|
import { DuesCategoryManager } from '@kit/member-management/components';
|
||||||
|
import { createMemberServices } from '@kit/member-management/services';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -22,8 +22,8 @@ export default async function DuesPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { organization } = createMemberServices(client);
|
||||||
const categories = await api.listDuesCategories(acct.id);
|
const categories = await organization.listDuesCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CmsPageShell
|
<CmsPageShell
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getTranslations } from 'next-intl/server';
|
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -23,8 +23,8 @@ export default async function InvitationsPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { workflow } = createMemberServices(client);
|
||||||
const invitations = await api.listPortalInvitations(acct.id);
|
const invitations = await workflow.listPortalInvitations(acct.id);
|
||||||
|
|
||||||
// Fetch members for the "send invitation" dialog
|
// Fetch members for the "send invitation" dialog
|
||||||
const { data: members } = await client
|
const { data: members } = await client
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
import { createMemberServices } from '@kit/member-management/services';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -25,8 +25,8 @@ export default async function MembersCmsLayout({ children, params }: Props) {
|
|||||||
|
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { query } = createMemberServices(client);
|
||||||
const stats = await api.getMemberQuickStats(acct.id);
|
const stats = await query.getQuickStats(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MembersCmsLayoutClient
|
<MembersCmsLayoutClient
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
|
||||||
import { MemberCreateWizard } 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -18,8 +18,8 @@ export default async function NewMemberPage({ params }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { organization } = createMemberServices(client);
|
||||||
const duesCategories = await api.listDuesCategories(acct.id);
|
const duesCategories = await organization.listDuesCategories(acct.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MemberCreateWizard
|
<MemberCreateWizard
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createMemberManagementApi } from '@kit/member-management/api';
|
|
||||||
import { MembersListView } 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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
import { AccountNotFound } from '~/components/account-not-found';
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
@@ -23,7 +23,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
.single();
|
.single();
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { query, organization } = createMemberServices(client);
|
||||||
const page = Number(search.page) || 1;
|
const page = Number(search.page) || 1;
|
||||||
|
|
||||||
// Parse multi-status filter
|
// Parse multi-status filter
|
||||||
@@ -34,7 +34,7 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
: statusParam.split(',')
|
: statusParam.split(',')
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const result = await api.searchMembers({
|
const result = await query.search({
|
||||||
accountId: acct.id,
|
accountId: acct.id,
|
||||||
search: search.q as string,
|
search: search.q as string,
|
||||||
status: statusFilter as any,
|
status: statusFilter as any,
|
||||||
@@ -45,10 +45,41 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [duesCategories, departments] = await Promise.all([
|
// Fetch categories, departments, and tags in parallel
|
||||||
api.listDuesCategories(acct.id),
|
const [duesCategories, departments, tagsResult, tagAssignmentsResult] =
|
||||||
api.listDepartmentsWithCounts(acct.id),
|
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 (
|
return (
|
||||||
<MembersListView
|
<MembersListView
|
||||||
@@ -69,6 +100,12 @@ export default async function MembersPage({ params, searchParams }: Props) {
|
|||||||
name: String(d.name),
|
name: String(d.name),
|
||||||
memberCount: d.memberCount,
|
memberCount: d.memberCount,
|
||||||
}))}
|
}))}
|
||||||
|
tags={(tagsResult.data ?? []).map((t: any) => ({
|
||||||
|
id: String(t.id),
|
||||||
|
name: String(t.name),
|
||||||
|
color: String(t.color),
|
||||||
|
}))}
|
||||||
|
memberTags={memberTags}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getTranslations } from 'next-intl/server';
|
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 { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
|
||||||
|
|
||||||
@@ -34,8 +34,8 @@ export default async function MemberStatisticsPage({ params }: PageProps) {
|
|||||||
|
|
||||||
if (!acct) return <AccountNotFound />;
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
const api = createMemberManagementApi(client);
|
const { query } = createMemberServices(client);
|
||||||
const stats = await api.getMemberStatistics(acct.id);
|
const stats = await query.getStatistics(acct.id);
|
||||||
|
|
||||||
const statusChartData = [
|
const statusChartData = [
|
||||||
{ name: t('status.active'), value: stats.active ?? 0 },
|
{ name: t('status.active'), value: stats.active ?? 0 },
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
import { TagsManager } from '@kit/member-management/components';
|
||||||
|
import { createMemberServices } from '@kit/member-management/services';
|
||||||
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { AccountNotFound } from '~/components/account-not-found';
|
||||||
|
import { CmsPageShell } from '~/components/cms-page-shell';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ account: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function TagsPage({ params }: Props) {
|
||||||
|
const { account } = await params;
|
||||||
|
const client = getSupabaseServerClient();
|
||||||
|
const t = await getTranslations('members');
|
||||||
|
|
||||||
|
const { data: acct } = await client
|
||||||
|
.from('accounts')
|
||||||
|
.select('id')
|
||||||
|
.eq('slug', account)
|
||||||
|
.single();
|
||||||
|
if (!acct) return <AccountNotFound />;
|
||||||
|
|
||||||
|
// Fetch tags via direct query (table may not be in generated types yet)
|
||||||
|
const { data: tags } = await (client.from as any)('member_tags')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', acct.id)
|
||||||
|
.order('sort_order');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CmsPageShell
|
||||||
|
account={account}
|
||||||
|
title="Tags verwalten"
|
||||||
|
description="Mitglieder-Tags erstellen und verwalten"
|
||||||
|
>
|
||||||
|
<TagsManager tags={tags ?? []} accountId={acct.id} />
|
||||||
|
</CmsPageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { createBookingManagementApi } from '@kit/booking-management/api';
|
|||||||
import { createCourseManagementApi } from '@kit/course-management/api';
|
import { createCourseManagementApi } from '@kit/course-management/api';
|
||||||
import { createEventManagementApi } from '@kit/event-management/api';
|
import { createEventManagementApi } from '@kit/event-management/api';
|
||||||
import { createFinanceApi } from '@kit/finance/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 { createNewsletterApi } from '@kit/newsletter/api';
|
||||||
import { formatDate } from '@kit/shared/dates';
|
import { formatDate } from '@kit/shared/dates';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
@@ -64,18 +64,23 @@ export default async function TeamAccountHomePage({
|
|||||||
bookingsResult,
|
bookingsResult,
|
||||||
eventsResult,
|
eventsResult,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
createMemberManagementApi(client).getMemberStatistics(acct.id),
|
createMemberServices(client).query.getStatistics(acct.id),
|
||||||
createCourseManagementApi(client).getStatistics(acct.id),
|
createCourseManagementApi(client).statistics.getQuickStats(acct.id),
|
||||||
createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }),
|
createFinanceApi(client).listInvoices(acct.id, { status: 'draft' }),
|
||||||
createNewsletterApi(client).listNewsletters(acct.id),
|
createNewsletterApi(client).listNewsletters(acct.id),
|
||||||
createBookingManagementApi(client).listBookings(acct.id, { page: 1 }),
|
createBookingManagementApi(client).bookings.list(acct.id, { page: 1 }),
|
||||||
createEventManagementApi(client).listEvents(acct.id, { page: 1 }),
|
createEventManagementApi(client).events.list(acct.id, { page: 1 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const memberStats =
|
const memberStatsRaw =
|
||||||
memberStatsResult.status === 'fulfilled'
|
memberStatsResult.status === 'fulfilled' ? memberStatsResult.value : {};
|
||||||
? memberStatsResult.value
|
const memberStats = {
|
||||||
: { total: 0, active: 0, inactive: 0, pending: 0, resigned: 0 };
|
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 =
|
const courseStats =
|
||||||
courseStatsResult.status === 'fulfilled'
|
courseStatsResult.status === 'fulfilled'
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
emailSchema,
|
emailSchema,
|
||||||
requiredString,
|
requiredString,
|
||||||
} from '@kit/next/route-helpers';
|
} from '@kit/next/route-helpers';
|
||||||
|
import { checkRateLimit, getClientIp } from '@kit/next/routes/rate-limit';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
@@ -20,12 +21,33 @@ const MembershipApplySchema = z.object({
|
|||||||
city: z.string().optional(),
|
city: z.string().optional(),
|
||||||
dateOfBirth: z.string().optional(),
|
dateOfBirth: z.string().optional(),
|
||||||
message: 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) {
|
export async function POST(request: Request) {
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
|
|
||||||
try {
|
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 body = await request.json();
|
||||||
const parsed = MembershipApplySchema.safeParse(body);
|
const parsed = MembershipApplySchema.safeParse(body);
|
||||||
|
|
||||||
@@ -44,10 +66,48 @@ export async function POST(request: Request) {
|
|||||||
city,
|
city,
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
message,
|
message,
|
||||||
|
captchaToken,
|
||||||
} = parsed.data;
|
} = 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();
|
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({
|
const { error } = await supabase.from('membership_applications').insert({
|
||||||
account_id: accountId,
|
account_id: accountId,
|
||||||
first_name: firstName,
|
first_name: firstName,
|
||||||
|
|||||||
87
apps/web/app/api/internal/cron/member-jobs/route.ts
Normal file
87
apps/web/app/api/internal/cron/member-jobs/route.ts
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { createMemberNotificationService } from '@kit/member-management/services';
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||||
|
|
||||||
|
const CRON_SECRET = process.env.CRON_SECRET;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal cron endpoint for member scheduled jobs.
|
||||||
|
* Called hourly by pg_cron or external scheduler.
|
||||||
|
*
|
||||||
|
* POST /api/internal/cron/member-jobs
|
||||||
|
* Header: Authorization: Bearer <CRON_SECRET>
|
||||||
|
*/
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
|
||||||
|
// Verify cron secret
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
const token = authHeader?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!CRON_SECRET || token !== CRON_SECRET) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getSupabaseServerAdminClient();
|
||||||
|
const notificationService = createMemberNotificationService(client);
|
||||||
|
|
||||||
|
// 1. Process pending notification queue
|
||||||
|
const queueResult = await notificationService.processPendingNotifications();
|
||||||
|
|
||||||
|
// 2. Run scheduled jobs for all accounts with enabled jobs
|
||||||
|
const { data: accounts } = await (client.from as any)(
|
||||||
|
'scheduled_job_configs',
|
||||||
|
)
|
||||||
|
.select('account_id')
|
||||||
|
.eq('is_enabled', true)
|
||||||
|
.or(`next_run_at.is.null,next_run_at.lte.${new Date().toISOString()}`);
|
||||||
|
|
||||||
|
const uniqueAccountIds = [
|
||||||
|
...new Set((accounts ?? []).map((a: any) => a.account_id)),
|
||||||
|
] as string[];
|
||||||
|
|
||||||
|
const jobResults: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const accountId of uniqueAccountIds) {
|
||||||
|
try {
|
||||||
|
const result = await notificationService.runScheduledJobs(accountId);
|
||||||
|
jobResults[accountId] = result;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
{ accountId, error: e, context: 'cron-member-jobs' },
|
||||||
|
'Failed to run jobs for account',
|
||||||
|
);
|
||||||
|
jobResults[accountId] = {
|
||||||
|
error: e instanceof Error ? e.message : 'Unknown error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
queue: queueResult,
|
||||||
|
accounts_processed: uniqueAccountIds.length,
|
||||||
|
jobs: jobResults,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{ context: 'cron-member-jobs', ...summary },
|
||||||
|
'Member cron jobs completed',
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(summary);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{ error: err, context: 'cron-member-jobs' },
|
||||||
|
'Cron job failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -832,4 +832,4 @@
|
|||||||
"formatExcel": "Excel"
|
"formatExcel": "Excel"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,4 +162,4 @@
|
|||||||
"completed": "Abgeschlossen",
|
"completed": "Abgeschlossen",
|
||||||
"failed": "Fehlgeschlagen"
|
"failed": "Fehlgeschlagen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,4 +352,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,4 +162,4 @@
|
|||||||
"completed": "Completed",
|
"completed": "Completed",
|
||||||
"failed": "Failed"
|
"failed": "Failed"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Atomic Application Workflow
|
||||||
|
-- Replaces multi-query approve/reject in api.ts with
|
||||||
|
-- single transactional PG functions.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- approve_application: atomically creates a member from an application
|
||||||
|
CREATE OR REPLACE FUNCTION public.approve_application(
|
||||||
|
p_application_id uuid,
|
||||||
|
p_user_id uuid
|
||||||
|
)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_app record;
|
||||||
|
v_member_id uuid;
|
||||||
|
v_member_number text;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Fetch and lock the application
|
||||||
|
SELECT * INTO v_app
|
||||||
|
FROM public.membership_applications
|
||||||
|
WHERE id = p_application_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF v_app IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Application % not found', p_application_id
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Authorization: caller must have write permission on this account
|
||||||
|
IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied to account %', v_app.account_id
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_app.status NOT IN ('submitted', 'review') THEN
|
||||||
|
RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Generate next member number
|
||||||
|
SELECT LPAD(
|
||||||
|
(COALESCE(
|
||||||
|
MAX(CASE WHEN member_number ~ '^\d+$' THEN member_number::integer ELSE 0 END),
|
||||||
|
0
|
||||||
|
) + 1)::text,
|
||||||
|
4, '0'
|
||||||
|
) INTO v_member_number
|
||||||
|
FROM public.members
|
||||||
|
WHERE account_id = v_app.account_id;
|
||||||
|
|
||||||
|
-- 3. Create the member
|
||||||
|
INSERT INTO public.members (
|
||||||
|
account_id,
|
||||||
|
member_number,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
street,
|
||||||
|
postal_code,
|
||||||
|
city,
|
||||||
|
date_of_birth,
|
||||||
|
status,
|
||||||
|
entry_date,
|
||||||
|
created_by,
|
||||||
|
updated_by
|
||||||
|
) VALUES (
|
||||||
|
v_app.account_id,
|
||||||
|
v_member_number,
|
||||||
|
v_app.first_name,
|
||||||
|
v_app.last_name,
|
||||||
|
v_app.email,
|
||||||
|
v_app.phone,
|
||||||
|
v_app.street,
|
||||||
|
v_app.postal_code,
|
||||||
|
v_app.city,
|
||||||
|
v_app.date_of_birth,
|
||||||
|
'active'::public.membership_status,
|
||||||
|
current_date,
|
||||||
|
auth.uid(),
|
||||||
|
auth.uid()
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_member_id;
|
||||||
|
|
||||||
|
-- 4. Mark application as approved
|
||||||
|
UPDATE public.membership_applications
|
||||||
|
SET
|
||||||
|
status = 'approved'::public.application_status,
|
||||||
|
reviewed_by = auth.uid(),
|
||||||
|
reviewed_at = now(),
|
||||||
|
member_id = v_member_id,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_application_id;
|
||||||
|
|
||||||
|
RETURN v_member_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.approve_application(uuid, uuid) TO service_role;
|
||||||
|
|
||||||
|
-- reject_application: atomically rejects an application with notes
|
||||||
|
CREATE OR REPLACE FUNCTION public.reject_application(
|
||||||
|
p_application_id uuid,
|
||||||
|
p_user_id uuid,
|
||||||
|
p_review_notes text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_app record;
|
||||||
|
BEGIN
|
||||||
|
-- Fetch and lock the application
|
||||||
|
SELECT * INTO v_app
|
||||||
|
FROM public.membership_applications
|
||||||
|
WHERE id = p_application_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF v_app IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Application % not found', p_application_id
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Authorization: caller must have write permission on this account
|
||||||
|
IF NOT public.has_permission(auth.uid(), v_app.account_id, 'members.write'::public.app_permissions) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied to account %', v_app.account_id
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_app.status NOT IN ('submitted', 'review') THEN
|
||||||
|
RAISE EXCEPTION 'Application is not in a reviewable state (current: %)', v_app.status
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
UPDATE public.membership_applications
|
||||||
|
SET
|
||||||
|
status = 'rejected'::public.application_status,
|
||||||
|
reviewed_by = auth.uid(),
|
||||||
|
reviewed_at = now(),
|
||||||
|
review_notes = p_review_notes,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_application_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.reject_application(uuid, uuid, text) TO service_role;
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- SEPA Data Deduplication (Phase 1)
|
||||||
|
--
|
||||||
|
-- Problem: members table has inline SEPA fields (iban, bic,
|
||||||
|
-- account_holder, sepa_mandate_id, sepa_mandate_date,
|
||||||
|
-- sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name)
|
||||||
|
-- AND a separate sepa_mandates table. sepa_mandate_id is text,
|
||||||
|
-- not a FK to sepa_mandates(id) which is uuid. Data diverges.
|
||||||
|
--
|
||||||
|
-- Fix: Add proper primary_mandate_id FK, migrate inline data
|
||||||
|
-- to sepa_mandates rows, rewrite RPCs to read from sepa_mandates.
|
||||||
|
-- Inline columns are kept read-only for backward compat (phase 2 drops them).
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Step 1: Add proper FK column pointing to the primary mandate
|
||||||
|
ALTER TABLE public.members
|
||||||
|
ADD COLUMN IF NOT EXISTS primary_mandate_id uuid
|
||||||
|
REFERENCES public.sepa_mandates(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_primary_mandate
|
||||||
|
ON public.members(primary_mandate_id)
|
||||||
|
WHERE primary_mandate_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Step 2: For members with inline SEPA data but no sepa_mandates row, create one
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
v_mandate_id uuid;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT m.id AS member_id, m.account_id,
|
||||||
|
m.iban, m.bic, m.account_holder,
|
||||||
|
m.first_name, m.last_name,
|
||||||
|
m.sepa_mandate_id, m.sepa_mandate_date,
|
||||||
|
m.sepa_mandate_status, m.sepa_mandate_reference,
|
||||||
|
m.sepa_mandate_sequence, m.sepa_bank_name
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.iban IS NOT NULL AND m.iban != ''
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id
|
||||||
|
)
|
||||||
|
LOOP
|
||||||
|
INSERT INTO public.sepa_mandates (
|
||||||
|
member_id, account_id, mandate_reference, iban, bic,
|
||||||
|
account_holder, mandate_date, status, sequence, is_primary, notes
|
||||||
|
) VALUES (
|
||||||
|
r.member_id,
|
||||||
|
r.account_id,
|
||||||
|
COALESCE(NULLIF(r.sepa_mandate_reference, ''), NULLIF(r.sepa_mandate_id, ''), 'MIGRATED-' || r.member_id::text),
|
||||||
|
r.iban,
|
||||||
|
r.bic,
|
||||||
|
COALESCE(NULLIF(r.account_holder, ''), NULLIF(TRIM(COALESCE(r.first_name, '') || ' ' || COALESCE(r.last_name, '')), ''), 'Unbekannt'),
|
||||||
|
COALESCE(r.sepa_mandate_date, current_date),
|
||||||
|
COALESCE(r.sepa_mandate_status, 'pending'::public.sepa_mandate_status),
|
||||||
|
COALESCE(NULLIF(r.sepa_mandate_sequence, ''), 'RCUR'),
|
||||||
|
true,
|
||||||
|
CASE WHEN r.sepa_bank_name IS NOT NULL AND r.sepa_bank_name != ''
|
||||||
|
THEN 'Bank: ' || r.sepa_bank_name
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_mandate_id;
|
||||||
|
|
||||||
|
UPDATE public.members SET primary_mandate_id = v_mandate_id WHERE id = r.member_id;
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Step 3: For members that already have sepa_mandates rows, link the primary one
|
||||||
|
UPDATE public.members m
|
||||||
|
SET primary_mandate_id = sm.id
|
||||||
|
FROM public.sepa_mandates sm
|
||||||
|
WHERE sm.member_id = m.id
|
||||||
|
AND sm.is_primary = true
|
||||||
|
AND m.primary_mandate_id IS NULL;
|
||||||
|
|
||||||
|
-- If no mandate marked as primary, pick the most recent active one
|
||||||
|
UPDATE public.members m
|
||||||
|
SET primary_mandate_id = (
|
||||||
|
SELECT sm.id FROM public.sepa_mandates sm
|
||||||
|
WHERE sm.member_id = m.id
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN sm.status = 'active' THEN 0 ELSE 1 END,
|
||||||
|
sm.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE m.primary_mandate_id IS NULL
|
||||||
|
AND EXISTS (SELECT 1 FROM public.sepa_mandates sm WHERE sm.member_id = m.id);
|
||||||
|
|
||||||
|
-- Step 4: Rewrite list_hierarchy_sepa_eligible_members to read from sepa_mandates
|
||||||
|
CREATE OR REPLACE FUNCTION public.list_hierarchy_sepa_eligible_members(
|
||||||
|
root_account_id uuid,
|
||||||
|
p_account_filter uuid DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
member_id uuid,
|
||||||
|
account_id uuid,
|
||||||
|
account_name varchar,
|
||||||
|
first_name text,
|
||||||
|
last_name text,
|
||||||
|
iban text,
|
||||||
|
bic text,
|
||||||
|
account_holder text,
|
||||||
|
mandate_id text,
|
||||||
|
mandate_date date,
|
||||||
|
dues_amount numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(root_account_id) THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
m.id AS member_id,
|
||||||
|
m.account_id,
|
||||||
|
a.name AS account_name,
|
||||||
|
m.first_name,
|
||||||
|
m.last_name,
|
||||||
|
sm.iban,
|
||||||
|
sm.bic,
|
||||||
|
sm.account_holder,
|
||||||
|
sm.mandate_reference AS mandate_id,
|
||||||
|
sm.mandate_date,
|
||||||
|
COALESCE(dc.amount, 0) AS dues_amount
|
||||||
|
FROM public.members m
|
||||||
|
JOIN public.accounts a ON a.id = m.account_id
|
||||||
|
JOIN public.sepa_mandates sm ON sm.id = m.primary_mandate_id
|
||||||
|
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
|
||||||
|
WHERE m.account_id IN (SELECT d FROM public.get_account_descendants(root_account_id) d)
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND sm.iban IS NOT NULL
|
||||||
|
AND sm.status = 'active'
|
||||||
|
AND (p_account_filter IS NULL OR m.account_id = p_account_filter)
|
||||||
|
ORDER BY a.name, m.last_name, m.first_name;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Step 5: Add partial index for fast SEPA-eligible lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_primary
|
||||||
|
ON public.sepa_mandates(member_id)
|
||||||
|
WHERE status = 'active' AND is_primary = true;
|
||||||
|
|
||||||
|
-- Note: Inline SEPA columns (iban, bic, account_holder, sepa_mandate_id,
|
||||||
|
-- sepa_mandate_date, sepa_mandate_status, sepa_mandate_sequence, sepa_bank_name)
|
||||||
|
-- are kept for read-only backward compatibility. Phase 2 migration will drop them
|
||||||
|
-- after all code paths are migrated to use sepa_mandates via primary_mandate_id.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Soft Delete Consistency
|
||||||
|
--
|
||||||
|
-- Problem: deleteMember does a soft delete (status='resigned'),
|
||||||
|
-- but child table FKs use ON DELETE CASCADE. A hard DELETE
|
||||||
|
-- would silently destroy roles, honors, mandates, transfers
|
||||||
|
-- with no audit trail.
|
||||||
|
--
|
||||||
|
-- Fix: Change CASCADE to RESTRICT on data-preserving tables,
|
||||||
|
-- add BEFORE DELETE audit trigger, provide safe_delete_member().
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Step 1: Change ON DELETE CASCADE → RESTRICT on tables where
|
||||||
|
-- child data has independent value and should be preserved.
|
||||||
|
-- We must drop and recreate the FK constraints.
|
||||||
|
|
||||||
|
-- member_roles: board positions have historical value
|
||||||
|
ALTER TABLE public.member_roles
|
||||||
|
DROP CONSTRAINT IF EXISTS member_roles_member_id_fkey;
|
||||||
|
ALTER TABLE public.member_roles
|
||||||
|
ADD CONSTRAINT member_roles_member_id_fkey
|
||||||
|
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- member_honors: awards/medals are permanent records
|
||||||
|
ALTER TABLE public.member_honors
|
||||||
|
DROP CONSTRAINT IF EXISTS member_honors_member_id_fkey;
|
||||||
|
ALTER TABLE public.member_honors
|
||||||
|
ADD CONSTRAINT member_honors_member_id_fkey
|
||||||
|
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- sepa_mandates: financial records must be preserved
|
||||||
|
ALTER TABLE public.sepa_mandates
|
||||||
|
DROP CONSTRAINT IF EXISTS sepa_mandates_member_id_fkey;
|
||||||
|
ALTER TABLE public.sepa_mandates
|
||||||
|
ADD CONSTRAINT sepa_mandates_member_id_fkey
|
||||||
|
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- member_transfers: audit trail must survive
|
||||||
|
ALTER TABLE public.member_transfers
|
||||||
|
DROP CONSTRAINT IF EXISTS member_transfers_member_id_fkey;
|
||||||
|
ALTER TABLE public.member_transfers
|
||||||
|
ADD CONSTRAINT member_transfers_member_id_fkey
|
||||||
|
FOREIGN KEY (member_id) REFERENCES public.members(id) ON DELETE RESTRICT;
|
||||||
|
|
||||||
|
-- Keep CASCADE on tables where data is tightly coupled:
|
||||||
|
-- member_department_assignments (junction table, no independent value)
|
||||||
|
-- member_cards (regeneratable)
|
||||||
|
-- member_portal_invitations (transient)
|
||||||
|
|
||||||
|
-- Step 2: Audit trigger before hard delete — snapshot the full record
|
||||||
|
CREATE OR REPLACE FUNCTION public.audit_member_before_hard_delete()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- If an audit_log table exists, log the deletion
|
||||||
|
INSERT INTO public.audit_log (
|
||||||
|
account_id, user_id, table_name, record_id, action, old_data
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
OLD.account_id,
|
||||||
|
COALESCE(
|
||||||
|
nullif(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
auth.uid()
|
||||||
|
),
|
||||||
|
'members',
|
||||||
|
OLD.id::text,
|
||||||
|
'delete',
|
||||||
|
to_jsonb(OLD);
|
||||||
|
|
||||||
|
RETURN OLD;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN undefined_table THEN
|
||||||
|
-- audit_log table doesn't exist yet, allow delete to proceed
|
||||||
|
RETURN OLD;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_members_audit_before_delete
|
||||||
|
BEFORE DELETE ON public.members
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.audit_member_before_hard_delete();
|
||||||
|
|
||||||
|
-- Step 3: Safe hard-delete function for super-admin use only
|
||||||
|
-- Archives all child records first, then performs the delete.
|
||||||
|
CREATE OR REPLACE FUNCTION public.safe_delete_member(
|
||||||
|
p_member_id uuid,
|
||||||
|
p_performed_by uuid DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_member record;
|
||||||
|
BEGIN
|
||||||
|
-- Fetch member for validation
|
||||||
|
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
|
||||||
|
IF v_member IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Member % not found', p_member_id
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Set the user ID for the audit trigger
|
||||||
|
IF p_performed_by IS NOT NULL THEN
|
||||||
|
PERFORM set_config('app.current_user_id', p_performed_by::text, true);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Delete child records that now use RESTRICT
|
||||||
|
DELETE FROM public.member_roles WHERE member_id = p_member_id;
|
||||||
|
DELETE FROM public.member_honors WHERE member_id = p_member_id;
|
||||||
|
DELETE FROM public.sepa_mandates WHERE member_id = p_member_id;
|
||||||
|
-- member_transfers: delete (the BEFORE DELETE trigger on members already snapshots everything)
|
||||||
|
DELETE FROM public.member_transfers WHERE member_id = p_member_id;
|
||||||
|
|
||||||
|
-- Now the hard delete triggers audit_member_before_hard_delete
|
||||||
|
DELETE FROM public.members WHERE id = p_member_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.safe_delete_member(uuid, uuid) TO service_role;
|
||||||
|
-- Intentionally NOT granted to authenticated — super-admin only via admin client
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Missing Database Constraints
|
||||||
|
--
|
||||||
|
-- Adds CHECK constraints for data sanity, UNIQUE index
|
||||||
|
-- for email per account, and IBAN format validation.
|
||||||
|
--
|
||||||
|
-- Fixes existing invalid data before adding constraints.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Fix existing invalid data before adding constraints
|
||||||
|
UPDATE public.members SET date_of_birth = NULL
|
||||||
|
WHERE date_of_birth IS NOT NULL AND date_of_birth > current_date;
|
||||||
|
|
||||||
|
UPDATE public.members SET exit_date = entry_date
|
||||||
|
WHERE exit_date IS NOT NULL AND entry_date IS NOT NULL AND exit_date < entry_date;
|
||||||
|
|
||||||
|
UPDATE public.members SET entry_date = current_date
|
||||||
|
WHERE entry_date IS NOT NULL AND entry_date > current_date;
|
||||||
|
|
||||||
|
-- Normalize IBANs in sepa_mandates to uppercase, strip spaces
|
||||||
|
UPDATE public.sepa_mandates
|
||||||
|
SET iban = upper(regexp_replace(iban, '\s', '', 'g'))
|
||||||
|
WHERE iban IS NOT NULL AND iban != '';
|
||||||
|
|
||||||
|
-- Date sanity constraints
|
||||||
|
ALTER TABLE public.members
|
||||||
|
ADD CONSTRAINT chk_members_dob_not_future
|
||||||
|
CHECK (date_of_birth IS NULL OR date_of_birth <= current_date);
|
||||||
|
|
||||||
|
ALTER TABLE public.members
|
||||||
|
ADD CONSTRAINT chk_members_exit_after_entry
|
||||||
|
CHECK (exit_date IS NULL OR entry_date IS NULL OR exit_date >= entry_date);
|
||||||
|
|
||||||
|
ALTER TABLE public.members
|
||||||
|
ADD CONSTRAINT chk_members_entry_not_future
|
||||||
|
CHECK (entry_date IS NULL OR entry_date <= current_date);
|
||||||
|
|
||||||
|
-- Email uniqueness per account (partial index — allows NULLs and empty strings)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uix_members_email_per_account
|
||||||
|
ON public.members(account_id, lower(email))
|
||||||
|
WHERE email IS NOT NULL AND email != '';
|
||||||
|
|
||||||
|
-- IBAN format on sepa_mandates (2-letter country + 2 check digits + 11-30 alphanumeric)
|
||||||
|
ALTER TABLE public.sepa_mandates
|
||||||
|
ADD CONSTRAINT chk_sepa_iban_format
|
||||||
|
CHECK (iban ~ '^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$');
|
||||||
|
|
||||||
|
-- Mandate reference must not be empty
|
||||||
|
ALTER TABLE public.sepa_mandates
|
||||||
|
ADD CONSTRAINT chk_sepa_mandate_reference_not_empty
|
||||||
|
CHECK (mandate_reference IS NOT NULL AND mandate_reference != '');
|
||||||
|
|
||||||
|
-- Member roles: from_date should not be after until_date
|
||||||
|
ALTER TABLE public.member_roles
|
||||||
|
ADD CONSTRAINT chk_member_roles_date_range
|
||||||
|
CHECK (until_date IS NULL OR from_date IS NULL OR until_date >= from_date);
|
||||||
|
|
||||||
|
-- Dues categories: amount must be non-negative
|
||||||
|
ALTER TABLE public.dues_categories
|
||||||
|
ADD CONSTRAINT chk_dues_amount_non_negative
|
||||||
|
CHECK (amount >= 0);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Optimistic Locking via Version Column
|
||||||
|
--
|
||||||
|
-- Problem: Two admins editing the same member silently
|
||||||
|
-- overwrite each other's changes. Last write wins.
|
||||||
|
--
|
||||||
|
-- Fix: Add version column, auto-increment on update.
|
||||||
|
-- API layer checks version match before writing.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Add version column
|
||||||
|
ALTER TABLE public.members
|
||||||
|
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- Auto-increment version on every update
|
||||||
|
CREATE OR REPLACE FUNCTION public.increment_member_version()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.version := OLD.version + 1;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_members_increment_version
|
||||||
|
BEFORE UPDATE ON public.members
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.increment_member_version();
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Event-Member Linkage
|
||||||
|
--
|
||||||
|
-- Problem: event_registrations links to members by email
|
||||||
|
-- only. If a member changes their email, event history is
|
||||||
|
-- lost. transfer_member matches by email — fragile.
|
||||||
|
--
|
||||||
|
-- Fix: Add member_id FK to event_registrations, backfill
|
||||||
|
-- from email matches, update transfer_member.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Add member_id FK column
|
||||||
|
ALTER TABLE public.event_registrations
|
||||||
|
ADD COLUMN IF NOT EXISTS member_id uuid
|
||||||
|
REFERENCES public.members(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_registrations_member
|
||||||
|
ON public.event_registrations(member_id)
|
||||||
|
WHERE member_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Backfill: match existing registrations to members by email within the same account
|
||||||
|
UPDATE public.event_registrations er
|
||||||
|
SET member_id = m.id
|
||||||
|
FROM public.events e
|
||||||
|
JOIN public.members m ON m.account_id = e.account_id
|
||||||
|
AND lower(m.email) = lower(er.email)
|
||||||
|
AND m.email IS NOT NULL AND m.email != ''
|
||||||
|
AND m.status IN ('active', 'inactive', 'pending')
|
||||||
|
WHERE e.id = er.event_id
|
||||||
|
AND er.member_id IS NULL
|
||||||
|
AND er.email IS NOT NULL AND er.email != '';
|
||||||
|
|
||||||
|
-- Update transfer_member to count active events via member_id instead of email
|
||||||
|
CREATE OR REPLACE FUNCTION public.transfer_member(
|
||||||
|
p_member_id uuid,
|
||||||
|
p_target_account_id uuid,
|
||||||
|
p_reason text DEFAULT NULL,
|
||||||
|
p_keep_sepa boolean DEFAULT false
|
||||||
|
)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_source_account_id uuid;
|
||||||
|
v_source_name varchar;
|
||||||
|
v_target_name varchar;
|
||||||
|
v_active_courses bigint;
|
||||||
|
v_active_events bigint;
|
||||||
|
v_cleared_data jsonb;
|
||||||
|
v_transfer_id uuid;
|
||||||
|
v_member record;
|
||||||
|
BEGIN
|
||||||
|
-- Get current member state
|
||||||
|
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
|
||||||
|
IF v_member IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Member not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_source_account_id := v_member.account_id;
|
||||||
|
|
||||||
|
-- Verify target account exists
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM public.accounts WHERE id = p_target_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Target account not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ensure caller has access to source account
|
||||||
|
IF NOT public.has_role_on_account_or_ancestor(v_source_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied to source account';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Same account? No-op
|
||||||
|
IF v_source_account_id = p_target_account_id THEN
|
||||||
|
RAISE EXCEPTION 'Cannot transfer member to the same account';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Ensure both accounts share a common ancestor
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.get_account_ancestors(v_source_account_id) sa
|
||||||
|
JOIN public.get_account_ancestors(p_target_account_id) ta ON sa = ta
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Source and target accounts do not share a common ancestor (Verband)';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get org names for the transfer note
|
||||||
|
SELECT name INTO v_source_name FROM public.accounts WHERE id = v_source_account_id;
|
||||||
|
SELECT name INTO v_target_name FROM public.accounts WHERE id = p_target_account_id;
|
||||||
|
|
||||||
|
-- Count active relationships (informational, for the log)
|
||||||
|
SELECT count(*) INTO v_active_courses
|
||||||
|
FROM public.course_participants cp
|
||||||
|
JOIN public.courses c ON c.id = cp.course_id
|
||||||
|
WHERE cp.member_id = p_member_id AND cp.status = 'enrolled';
|
||||||
|
|
||||||
|
-- Use member_id for event lookups instead of fragile email matching
|
||||||
|
SELECT count(*) INTO v_active_events
|
||||||
|
FROM public.event_registrations er
|
||||||
|
JOIN public.events e ON e.id = er.event_id
|
||||||
|
WHERE er.member_id = p_member_id
|
||||||
|
AND er.status IN ('confirmed', 'pending')
|
||||||
|
AND e.event_date >= current_date;
|
||||||
|
|
||||||
|
-- Perform the transfer
|
||||||
|
UPDATE public.members
|
||||||
|
SET
|
||||||
|
account_id = p_target_account_id,
|
||||||
|
-- Clear org-specific admin data
|
||||||
|
dues_category_id = NULL,
|
||||||
|
member_number = NULL,
|
||||||
|
-- Clear primary_mandate_id FK (mandate needs re-confirmation in new org)
|
||||||
|
primary_mandate_id = NULL,
|
||||||
|
-- Legacy inline SEPA fields (deprecated, kept for backward compat)
|
||||||
|
sepa_mandate_id = CASE WHEN p_keep_sepa THEN sepa_mandate_id ELSE NULL END,
|
||||||
|
sepa_mandate_date = CASE WHEN p_keep_sepa THEN sepa_mandate_date ELSE NULL END,
|
||||||
|
sepa_mandate_status = 'pending',
|
||||||
|
-- Append transfer note
|
||||||
|
notes = COALESCE(notes, '') ||
|
||||||
|
E'\n[Transfer ' || to_char(now(), 'YYYY-MM-DD') || '] ' ||
|
||||||
|
v_source_name || ' → ' || v_target_name ||
|
||||||
|
COALESCE(' | Grund: ' || p_reason, ''),
|
||||||
|
is_transferred = true
|
||||||
|
WHERE id = p_member_id;
|
||||||
|
|
||||||
|
-- Reset SEPA mandate(s) in the mandates table
|
||||||
|
UPDATE public.sepa_mandates
|
||||||
|
SET status = 'pending'
|
||||||
|
WHERE member_id = p_member_id
|
||||||
|
AND status = 'active';
|
||||||
|
|
||||||
|
-- Build cleared data snapshot for the transfer log
|
||||||
|
v_cleared_data := jsonb_build_object(
|
||||||
|
'member_number', v_member.member_number,
|
||||||
|
'dues_category_id', v_member.dues_category_id,
|
||||||
|
'active_courses', v_active_courses,
|
||||||
|
'active_events', v_active_events
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create transfer log entry
|
||||||
|
INSERT INTO public.member_transfers (
|
||||||
|
member_id, source_account_id, target_account_id, transferred_by, reason, cleared_data
|
||||||
|
) VALUES (
|
||||||
|
p_member_id,
|
||||||
|
v_source_account_id,
|
||||||
|
p_target_account_id,
|
||||||
|
COALESCE(nullif(current_setting('app.current_user_id', true), '')::uuid, auth.uid()),
|
||||||
|
p_reason,
|
||||||
|
v_cleared_data
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_transfer_id;
|
||||||
|
|
||||||
|
RETURN v_transfer_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
260
apps/web/supabase/migrations/20260416000007_member_audit_log.sql
Normal file
260
apps/web/supabase/migrations/20260416000007_member_audit_log.sql
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Member Audit Log
|
||||||
|
--
|
||||||
|
-- Full change history for compliance: who changed what
|
||||||
|
-- field, old value→new value, when. Plus activity timeline.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. Audit log table
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_audit_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
action text NOT NULL CHECK (action IN (
|
||||||
|
'created', 'updated', 'status_changed', 'archived', 'unarchived',
|
||||||
|
'department_assigned', 'department_removed',
|
||||||
|
'role_assigned', 'role_removed',
|
||||||
|
'honor_awarded', 'honor_removed',
|
||||||
|
'mandate_created', 'mandate_updated', 'mandate_revoked',
|
||||||
|
'transferred', 'merged',
|
||||||
|
'application_approved', 'application_rejected',
|
||||||
|
'portal_invited', 'portal_linked',
|
||||||
|
'card_generated',
|
||||||
|
'imported', 'exported',
|
||||||
|
'gdpr_consent_changed', 'gdpr_anonymized',
|
||||||
|
'tag_added', 'tag_removed',
|
||||||
|
'communication_logged', 'note_added',
|
||||||
|
'bulk_status_changed', 'bulk_archived'
|
||||||
|
)),
|
||||||
|
changes jsonb NOT NULL DEFAULT '{}',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.member_audit_log IS
|
||||||
|
'Immutable audit trail for all member lifecycle events';
|
||||||
|
|
||||||
|
CREATE INDEX ix_member_audit_member
|
||||||
|
ON public.member_audit_log(member_id, created_at DESC);
|
||||||
|
CREATE INDEX ix_member_audit_account
|
||||||
|
ON public.member_audit_log(account_id, created_at DESC);
|
||||||
|
CREATE INDEX ix_member_audit_user
|
||||||
|
ON public.member_audit_log(user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX ix_member_audit_action
|
||||||
|
ON public.member_audit_log(account_id, action);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_audit_log FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.member_audit_log TO authenticated;
|
||||||
|
GRANT ALL ON public.member_audit_log TO service_role;
|
||||||
|
|
||||||
|
-- Read access: must have role on the account
|
||||||
|
CREATE POLICY member_audit_log_select
|
||||||
|
ON public.member_audit_log FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- No direct insert/update/delete for authenticated — only via SECURITY DEFINER functions
|
||||||
|
|
||||||
|
-- 2. Auto-audit trigger on members UPDATE
|
||||||
|
-- Computes field-by-field diff and classifies the action type.
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_member_audit_on_update()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_changes jsonb := '{}'::jsonb;
|
||||||
|
v_user_id uuid;
|
||||||
|
v_action text;
|
||||||
|
v_old jsonb;
|
||||||
|
v_new jsonb;
|
||||||
|
v_key text;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := nullif(current_setting('app.current_user_id', true), '')::uuid;
|
||||||
|
v_old := to_jsonb(OLD);
|
||||||
|
v_new := to_jsonb(NEW);
|
||||||
|
|
||||||
|
-- Compare each field, skip meta columns
|
||||||
|
FOR v_key IN
|
||||||
|
SELECT jsonb_object_keys(v_new)
|
||||||
|
EXCEPT
|
||||||
|
SELECT unnest(ARRAY['updated_at', 'updated_by', 'version'])
|
||||||
|
LOOP
|
||||||
|
IF (v_old -> v_key) IS DISTINCT FROM (v_new -> v_key) THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object(
|
||||||
|
v_key, jsonb_build_object('old', v_old -> v_key, 'new', v_new -> v_key)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Skip if nothing actually changed
|
||||||
|
IF v_changes = '{}'::jsonb THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Classify the action
|
||||||
|
IF (v_old ->> 'status') IS DISTINCT FROM (v_new ->> 'status') THEN
|
||||||
|
v_action := 'status_changed';
|
||||||
|
ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived')
|
||||||
|
AND COALESCE((v_new ->> 'is_archived'), 'false') = 'true' THEN
|
||||||
|
v_action := 'archived';
|
||||||
|
ELSIF (v_old ->> 'is_archived') IS DISTINCT FROM (v_new ->> 'is_archived') THEN
|
||||||
|
v_action := 'unarchived';
|
||||||
|
ELSE
|
||||||
|
v_action := 'updated';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes)
|
||||||
|
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_members_audit_on_update
|
||||||
|
AFTER UPDATE ON public.members
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_member_audit_on_update();
|
||||||
|
|
||||||
|
-- 3. Auto-audit trigger on members INSERT
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_member_audit_on_insert()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
nullif(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
NEW.created_by
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (
|
||||||
|
NEW.id, NEW.account_id, v_user_id, 'created',
|
||||||
|
jsonb_build_object(
|
||||||
|
'member_number', NEW.member_number,
|
||||||
|
'first_name', NEW.first_name,
|
||||||
|
'last_name', NEW.last_name,
|
||||||
|
'status', NEW.status
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_members_audit_on_insert
|
||||||
|
AFTER INSERT ON public.members
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_member_audit_on_insert();
|
||||||
|
|
||||||
|
-- 4. Helper function to log explicit audit events (for related tables)
|
||||||
|
CREATE OR REPLACE FUNCTION public.log_member_audit_event(
|
||||||
|
p_member_id uuid,
|
||||||
|
p_account_id uuid,
|
||||||
|
p_action text,
|
||||||
|
p_changes jsonb DEFAULT '{}',
|
||||||
|
p_metadata jsonb DEFAULT '{}'
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Verify caller has access to the account
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Force user_id to be the actual caller
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, changes, metadata)
|
||||||
|
VALUES (p_member_id, p_account_id, auth.uid(), p_action, p_changes, p_metadata);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.log_member_audit_event(uuid, uuid, text, jsonb, jsonb)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- 5. Activity timeline RPC (read layer on audit log)
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_member_timeline(
|
||||||
|
p_member_id uuid,
|
||||||
|
p_page int DEFAULT 1,
|
||||||
|
p_page_size int DEFAULT 50,
|
||||||
|
p_action_filter text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id bigint,
|
||||||
|
action text,
|
||||||
|
changes jsonb,
|
||||||
|
metadata jsonb,
|
||||||
|
user_id uuid,
|
||||||
|
user_display_name text,
|
||||||
|
created_at timestamptz,
|
||||||
|
total_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_account_id uuid;
|
||||||
|
v_total bigint;
|
||||||
|
v_offset int;
|
||||||
|
BEGIN
|
||||||
|
-- Get member's account for access check
|
||||||
|
SELECT m.account_id INTO v_account_id
|
||||||
|
FROM public.members m WHERE m.id = p_member_id;
|
||||||
|
|
||||||
|
IF v_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Member not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT public.has_role_on_account(v_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Clamp page size to prevent unbounded queries
|
||||||
|
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
|
||||||
|
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
|
||||||
|
|
||||||
|
-- Get total count
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.member_audit_log al
|
||||||
|
WHERE al.member_id = p_member_id
|
||||||
|
AND (p_action_filter IS NULL OR al.action = p_action_filter);
|
||||||
|
|
||||||
|
-- Return paginated results with user names
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
al.id,
|
||||||
|
al.action,
|
||||||
|
al.changes,
|
||||||
|
al.metadata,
|
||||||
|
al.user_id,
|
||||||
|
COALESCE(
|
||||||
|
u.raw_user_meta_data ->> 'display_name',
|
||||||
|
u.email,
|
||||||
|
al.user_id::text
|
||||||
|
) AS user_display_name,
|
||||||
|
al.created_at,
|
||||||
|
v_total AS total_count
|
||||||
|
FROM public.member_audit_log al
|
||||||
|
LEFT JOIN auth.users u ON u.id = al.user_id
|
||||||
|
WHERE al.member_id = p_member_id
|
||||||
|
AND (p_action_filter IS NULL OR al.action = p_action_filter)
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
OFFSET v_offset
|
||||||
|
LIMIT p_page_size;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_member_timeline(uuid, int, int, text)
|
||||||
|
TO authenticated;
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Member Communications Tracking
|
||||||
|
--
|
||||||
|
-- Records all communications with/about members:
|
||||||
|
-- emails sent, phone calls, notes, letters, meetings.
|
||||||
|
-- Communications are append-only for authenticated users.
|
||||||
|
-- Only service_role (admin) can delete.
|
||||||
|
-- Integrates with audit log via triggers.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_communications (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
type text NOT NULL CHECK (type IN ('email', 'phone', 'letter', 'meeting', 'note', 'sms')),
|
||||||
|
direction text NOT NULL DEFAULT 'outbound' CHECK (direction IN ('inbound', 'outbound', 'internal')),
|
||||||
|
subject text CHECK (subject IS NULL OR length(subject) <= 500),
|
||||||
|
body text CHECK (body IS NULL OR length(body) <= 50000),
|
||||||
|
-- Email-specific fields
|
||||||
|
email_to text,
|
||||||
|
email_cc text,
|
||||||
|
email_message_id text,
|
||||||
|
-- Attachment references (Supabase Storage paths)
|
||||||
|
attachment_paths text[] CHECK (attachment_paths IS NULL OR array_length(attachment_paths, 1) <= 10),
|
||||||
|
-- Audit
|
||||||
|
created_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.member_communications IS
|
||||||
|
'Communication log per member — emails, calls, notes, letters, meetings. Append-only for regular users.';
|
||||||
|
|
||||||
|
CREATE INDEX ix_member_comms_member
|
||||||
|
ON public.member_communications(member_id, created_at DESC);
|
||||||
|
CREATE INDEX ix_member_comms_account
|
||||||
|
ON public.member_communications(account_id, created_at DESC);
|
||||||
|
CREATE INDEX ix_member_comms_type
|
||||||
|
ON public.member_communications(account_id, type);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_communications ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_communications FROM authenticated, service_role;
|
||||||
|
-- Append-only: authenticated users can SELECT + INSERT, not UPDATE/DELETE
|
||||||
|
GRANT SELECT, INSERT ON public.member_communications TO authenticated;
|
||||||
|
GRANT ALL ON public.member_communications TO service_role;
|
||||||
|
|
||||||
|
-- Read: must have a role on the account
|
||||||
|
CREATE POLICY member_comms_select
|
||||||
|
ON public.member_communications FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- Insert: must have members.write permission
|
||||||
|
CREATE POLICY member_comms_insert
|
||||||
|
ON public.member_communications FOR INSERT TO authenticated
|
||||||
|
WITH CHECK (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- No UPDATE/DELETE policies for authenticated — communications are immutable
|
||||||
|
-- service_role can still delete via admin client when necessary
|
||||||
|
|
||||||
|
-- Auto-log to audit trail on communication INSERT
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_member_comm_audit_insert()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.member_audit_log (
|
||||||
|
member_id, account_id, user_id, action, metadata
|
||||||
|
) VALUES (
|
||||||
|
NEW.member_id,
|
||||||
|
NEW.account_id,
|
||||||
|
NEW.created_by,
|
||||||
|
'communication_logged',
|
||||||
|
jsonb_build_object(
|
||||||
|
'communication_id', NEW.id,
|
||||||
|
'type', NEW.type,
|
||||||
|
'direction', NEW.direction,
|
||||||
|
'subject', NEW.subject
|
||||||
|
)
|
||||||
|
);
|
||||||
|
RETURN NEW;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Audit failure should not block the insert
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_member_comms_audit_insert
|
||||||
|
AFTER INSERT ON public.member_communications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_member_comm_audit_insert();
|
||||||
|
|
||||||
|
-- Safe delete function for admin use — logs before deleting
|
||||||
|
CREATE OR REPLACE FUNCTION public.delete_member_communication(
|
||||||
|
p_communication_id uuid,
|
||||||
|
p_account_id uuid
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_comm record;
|
||||||
|
BEGIN
|
||||||
|
-- Verify caller has access
|
||||||
|
IF NOT public.has_permission(auth.uid(), p_account_id, 'members.write'::public.app_permissions) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Fetch the communication for audit
|
||||||
|
SELECT * INTO v_comm
|
||||||
|
FROM public.member_communications
|
||||||
|
WHERE id = p_communication_id AND account_id = p_account_id;
|
||||||
|
|
||||||
|
IF v_comm IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Communication not found' USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Log deletion to audit trail
|
||||||
|
INSERT INTO public.member_audit_log (
|
||||||
|
member_id, account_id, user_id, action, metadata
|
||||||
|
) VALUES (
|
||||||
|
v_comm.member_id,
|
||||||
|
v_comm.account_id,
|
||||||
|
auth.uid(),
|
||||||
|
'communication_logged',
|
||||||
|
jsonb_build_object(
|
||||||
|
'deleted_communication_id', v_comm.id,
|
||||||
|
'type', v_comm.type,
|
||||||
|
'direction', v_comm.direction,
|
||||||
|
'subject', v_comm.subject,
|
||||||
|
'action_detail', 'deleted'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Delete via service_role context (SECURITY DEFINER bypasses RLS)
|
||||||
|
DELETE FROM public.member_communications
|
||||||
|
WHERE id = p_communication_id AND account_id = p_account_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.delete_member_communication(uuid, uuid)
|
||||||
|
TO authenticated, service_role;
|
||||||
116
apps/web/supabase/migrations/20260416000009_member_tags.sql
Normal file
116
apps/web/supabase/migrations/20260416000009_member_tags.sql
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Member Tags / Labels System
|
||||||
|
--
|
||||||
|
-- Flexible, colored tags for member categorization
|
||||||
|
-- beyond departments (e.g., "Vorstand-Kandidat",
|
||||||
|
-- "Beitragsrückstand", "Newsletter-Opt-Out").
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Tag definitions (per account)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_tags (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
color text NOT NULL DEFAULT '#6B7280',
|
||||||
|
description text,
|
||||||
|
sort_order int NOT NULL DEFAULT 0,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(account_id, name)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.member_tags IS
|
||||||
|
'Colored labels for flexible member categorization';
|
||||||
|
|
||||||
|
CREATE INDEX ix_member_tags_account
|
||||||
|
ON public.member_tags(account_id, sort_order);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_tags FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_tags TO authenticated;
|
||||||
|
GRANT ALL ON public.member_tags TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY member_tags_select
|
||||||
|
ON public.member_tags FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY member_tags_mutate
|
||||||
|
ON public.member_tags FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- Tag assignments (member ↔ tag junction)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_tag_assignments (
|
||||||
|
member_id uuid NOT NULL REFERENCES public.members(id) ON DELETE CASCADE,
|
||||||
|
tag_id uuid NOT NULL REFERENCES public.member_tags(id) ON DELETE CASCADE,
|
||||||
|
assigned_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (member_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.member_tag_assignments IS
|
||||||
|
'Junction table linking members to tags';
|
||||||
|
|
||||||
|
CREATE INDEX ix_member_tag_assignments_tag
|
||||||
|
ON public.member_tag_assignments(tag_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_tag_assignments ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_tag_assignments FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, DELETE ON public.member_tag_assignments TO authenticated;
|
||||||
|
GRANT ALL ON public.member_tag_assignments TO service_role;
|
||||||
|
|
||||||
|
-- Read: via member's account
|
||||||
|
CREATE POLICY mta_select
|
||||||
|
ON public.member_tag_assignments FOR SELECT TO authenticated
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM public.members m
|
||||||
|
WHERE m.id = member_tag_assignments.member_id
|
||||||
|
AND public.has_role_on_account(m.account_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Write: via member's account with write permission
|
||||||
|
CREATE POLICY mta_mutate
|
||||||
|
ON public.member_tag_assignments FOR ALL TO authenticated
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM public.members m
|
||||||
|
WHERE m.id = member_tag_assignments.member_id
|
||||||
|
AND public.has_permission(auth.uid(), m.account_id, 'members.write'::public.app_permissions)
|
||||||
|
));
|
||||||
|
|
||||||
|
-- Audit triggers for tag assignment/removal
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_tag_assignment_audit()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_account_id uuid;
|
||||||
|
v_tag_name text;
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'INSERT' THEN
|
||||||
|
SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = NEW.member_id;
|
||||||
|
SELECT t.name INTO v_tag_name FROM public.member_tags t WHERE t.id = NEW.tag_id;
|
||||||
|
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (NEW.member_id, v_account_id, NEW.assigned_by, 'tag_added',
|
||||||
|
jsonb_build_object('tag_id', NEW.tag_id, 'tag_name', v_tag_name));
|
||||||
|
RETURN NEW;
|
||||||
|
ELSIF TG_OP = 'DELETE' THEN
|
||||||
|
SELECT m.account_id INTO v_account_id FROM public.members m WHERE m.id = OLD.member_id;
|
||||||
|
SELECT t.name INTO v_tag_name FROM public.member_tags t WHERE t.id = OLD.tag_id;
|
||||||
|
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (OLD.member_id, v_account_id, auth.uid(), 'tag_removed',
|
||||||
|
jsonb_build_object('tag_id', OLD.tag_id, 'tag_name', v_tag_name));
|
||||||
|
RETURN OLD;
|
||||||
|
END IF;
|
||||||
|
RETURN NULL;
|
||||||
|
EXCEPTION WHEN OTHERS THEN
|
||||||
|
-- Audit failure should not block the operation
|
||||||
|
IF TG_OP = 'INSERT' THEN RETURN NEW; ELSE RETURN OLD; END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_tag_assignment_audit
|
||||||
|
AFTER INSERT OR DELETE ON public.member_tag_assignments
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_tag_assignment_audit();
|
||||||
274
apps/web/supabase/migrations/20260416000010_member_merge.sql
Normal file
274
apps/web/supabase/migrations/20260416000010_member_merge.sql
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Member Merge / Deduplication
|
||||||
|
--
|
||||||
|
-- Atomic function to merge two member records:
|
||||||
|
-- picks field values, moves all references, archives secondary.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Merge log table for audit trail and potential undo
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_merges (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
primary_member_id uuid NOT NULL,
|
||||||
|
secondary_member_id uuid NOT NULL,
|
||||||
|
secondary_snapshot jsonb NOT NULL,
|
||||||
|
field_choices jsonb NOT NULL,
|
||||||
|
references_moved jsonb NOT NULL,
|
||||||
|
performed_by uuid NOT NULL REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
performed_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_member_merges_account ON public.member_merges(account_id);
|
||||||
|
|
||||||
|
ALTER TABLE public.member_merges ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_merges FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.member_merges TO authenticated;
|
||||||
|
GRANT ALL ON public.member_merges TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY member_merges_select
|
||||||
|
ON public.member_merges FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- Atomic merge function
|
||||||
|
CREATE OR REPLACE FUNCTION public.merge_members(
|
||||||
|
p_primary_id uuid,
|
||||||
|
p_secondary_id uuid,
|
||||||
|
p_field_choices jsonb DEFAULT '{}',
|
||||||
|
p_performed_by uuid DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_primary record;
|
||||||
|
v_secondary record;
|
||||||
|
v_account_id uuid;
|
||||||
|
v_user_id uuid;
|
||||||
|
v_refs_moved jsonb := '{}'::jsonb;
|
||||||
|
v_count int;
|
||||||
|
v_field text;
|
||||||
|
v_choice text;
|
||||||
|
v_update jsonb := '{}'::jsonb;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(p_performed_by, auth.uid());
|
||||||
|
|
||||||
|
-- 1. Fetch both members
|
||||||
|
SELECT * INTO v_primary FROM public.members WHERE id = p_primary_id;
|
||||||
|
SELECT * INTO v_secondary FROM public.members WHERE id = p_secondary_id;
|
||||||
|
|
||||||
|
IF v_primary IS NULL THEN RAISE EXCEPTION 'Primary member not found'; END IF;
|
||||||
|
IF v_secondary IS NULL THEN RAISE EXCEPTION 'Secondary member not found'; END IF;
|
||||||
|
|
||||||
|
IF v_primary.account_id != v_secondary.account_id THEN
|
||||||
|
RAISE EXCEPTION 'Members must belong to the same account';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_account_id := v_primary.account_id;
|
||||||
|
|
||||||
|
-- Verify caller access
|
||||||
|
IF NOT public.has_permission(auth.uid(), v_account_id, 'members.write'::public.app_permissions) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Apply field choices: for each conflicting field, pick primary or secondary value
|
||||||
|
FOR v_field, v_choice IN SELECT * FROM jsonb_each_text(p_field_choices)
|
||||||
|
LOOP
|
||||||
|
-- Validate choice value
|
||||||
|
IF v_choice NOT IN ('primary', 'secondary') THEN
|
||||||
|
RAISE EXCEPTION 'Invalid choice "%" for field "%". Must be "primary" or "secondary"', v_choice, v_field;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Whitelist of mergeable fields (no IDs, FKs, or system columns)
|
||||||
|
IF v_field NOT IN (
|
||||||
|
'first_name', 'last_name', 'email', 'phone', 'mobile', 'phone2', 'fax',
|
||||||
|
'street', 'house_number', 'street2', 'postal_code', 'city', 'country',
|
||||||
|
'date_of_birth', 'gender', 'title', 'salutation', 'birthplace', 'birth_country',
|
||||||
|
'notes', 'guardian_name', 'guardian_phone', 'guardian_email'
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Field "%" cannot be merged', v_field;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_choice = 'secondary' THEN
|
||||||
|
v_update := v_update || jsonb_build_object(v_field, to_jsonb(v_secondary) -> v_field);
|
||||||
|
END IF;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
-- Apply chosen fields to primary
|
||||||
|
IF v_update != '{}'::jsonb THEN
|
||||||
|
-- Build dynamic UPDATE
|
||||||
|
EXECUTE format(
|
||||||
|
'UPDATE public.members SET %s WHERE id = $1',
|
||||||
|
(SELECT string_agg(format('%I = %L', key, value #>> '{}'), ', ')
|
||||||
|
FROM jsonb_each(v_update))
|
||||||
|
) USING p_primary_id;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Move references from secondary to primary
|
||||||
|
|
||||||
|
-- Department assignments
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_department_assignments WHERE member_id = p_secondary_id;
|
||||||
|
INSERT INTO public.member_department_assignments (member_id, department_id)
|
||||||
|
SELECT p_primary_id, department_id
|
||||||
|
FROM public.member_department_assignments
|
||||||
|
WHERE member_id = p_secondary_id
|
||||||
|
ON CONFLICT (member_id, department_id) DO NOTHING;
|
||||||
|
DELETE FROM public.member_department_assignments WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('departments', v_count);
|
||||||
|
|
||||||
|
-- Roles
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_roles WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.member_roles SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('roles', v_count);
|
||||||
|
|
||||||
|
-- Honors
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_honors WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.member_honors SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('honors', v_count);
|
||||||
|
|
||||||
|
-- SEPA mandates
|
||||||
|
SELECT count(*) INTO v_count FROM public.sepa_mandates WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.sepa_mandates SET member_id = p_primary_id, is_primary = false WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('mandates', v_count);
|
||||||
|
|
||||||
|
-- Member cards
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_cards WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.member_cards SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('cards', v_count);
|
||||||
|
|
||||||
|
-- Portal invitations
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_portal_invitations WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.member_portal_invitations SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('invitations', v_count);
|
||||||
|
|
||||||
|
-- Tag assignments
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_tag_assignments WHERE member_id = p_secondary_id;
|
||||||
|
INSERT INTO public.member_tag_assignments (member_id, tag_id, assigned_by)
|
||||||
|
SELECT p_primary_id, tag_id, assigned_by
|
||||||
|
FROM public.member_tag_assignments
|
||||||
|
WHERE member_id = p_secondary_id
|
||||||
|
ON CONFLICT (member_id, tag_id) DO NOTHING;
|
||||||
|
DELETE FROM public.member_tag_assignments WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('tags', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL; -- tags table may not exist yet
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Event registrations (if member_id column exists)
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.event_registrations WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.event_registrations SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('events', v_count);
|
||||||
|
EXCEPTION WHEN undefined_column THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Communications
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.member_communications WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.member_communications SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('communications', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Course participants
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.course_participants WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.course_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('courses', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Catch books (Fischerei)
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.catch_books WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.catch_books SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('catch_books', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Catches
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.catches WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.catches SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('catches', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Water leases
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.water_leases WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.water_leases SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('water_leases', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Competition participants
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.competition_participants WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.competition_participants SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('competitions', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Invoices
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO v_count FROM public.invoices WHERE member_id = p_secondary_id;
|
||||||
|
UPDATE public.invoices SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
v_refs_moved := v_refs_moved || jsonb_build_object('invoices', v_count);
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Audit log entries
|
||||||
|
UPDATE public.member_audit_log SET member_id = p_primary_id WHERE member_id = p_secondary_id;
|
||||||
|
|
||||||
|
-- 4. Merge custom_data (union of keys, primary wins on conflicts)
|
||||||
|
UPDATE public.members
|
||||||
|
SET custom_data = v_secondary.custom_data || v_primary.custom_data
|
||||||
|
WHERE id = p_primary_id;
|
||||||
|
|
||||||
|
-- 5. Append merge note
|
||||||
|
UPDATE public.members
|
||||||
|
SET notes = COALESCE(notes, '') ||
|
||||||
|
E'\n[Zusammenführung ' || to_char(now(), 'YYYY-MM-DD') || '] ' ||
|
||||||
|
'Zusammengeführt mit ' || v_secondary.first_name || ' ' || v_secondary.last_name ||
|
||||||
|
COALESCE(' (Nr. ' || v_secondary.member_number || ')', '')
|
||||||
|
WHERE id = p_primary_id;
|
||||||
|
|
||||||
|
-- 6. Archive the secondary member
|
||||||
|
UPDATE public.members
|
||||||
|
SET status = 'resigned', is_archived = true,
|
||||||
|
exit_date = current_date, exit_reason = 'Zusammenführung mit Mitglied ' || p_primary_id::text,
|
||||||
|
notes = COALESCE(notes, '') || E'\n[Zusammenführung] Archiviert zugunsten von ' || v_primary.first_name || ' ' || v_primary.last_name
|
||||||
|
WHERE id = p_secondary_id;
|
||||||
|
|
||||||
|
-- 7. Create merge log entry
|
||||||
|
INSERT INTO public.member_merges (
|
||||||
|
account_id, primary_member_id, secondary_member_id,
|
||||||
|
secondary_snapshot, field_choices, references_moved, performed_by
|
||||||
|
) VALUES (
|
||||||
|
v_account_id, p_primary_id, p_secondary_id,
|
||||||
|
to_jsonb(v_secondary), p_field_choices, v_refs_moved, v_user_id
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 8. Audit log
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (p_primary_id, v_account_id, v_user_id, 'merged',
|
||||||
|
jsonb_build_object(
|
||||||
|
'secondary_member_id', p_secondary_id,
|
||||||
|
'secondary_name', v_secondary.first_name || ' ' || v_secondary.last_name,
|
||||||
|
'references_moved', v_refs_moved,
|
||||||
|
'field_choices', p_field_choices
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'primary_id', p_primary_id,
|
||||||
|
'secondary_id', p_secondary_id,
|
||||||
|
'references_moved', v_refs_moved
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.merge_members(uuid, uuid, jsonb, uuid)
|
||||||
|
TO authenticated, service_role;
|
||||||
170
apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
Normal file
170
apps/web/supabase/migrations/20260416000011_gdpr_retention.sql
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- GDPR Data Retention Automation
|
||||||
|
--
|
||||||
|
-- Configurable retention policies per account.
|
||||||
|
-- Automatic anonymization of resigned/excluded/deceased
|
||||||
|
-- members after retention period expires.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Retention policy configuration per account
|
||||||
|
CREATE TABLE IF NOT EXISTS public.gdpr_retention_policies (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
policy_name text NOT NULL DEFAULT 'Standard',
|
||||||
|
retention_days int NOT NULL DEFAULT 1095, -- 3 years
|
||||||
|
auto_anonymize boolean NOT NULL DEFAULT false,
|
||||||
|
applies_to_status text[] NOT NULL DEFAULT ARRAY['resigned', 'excluded', 'deceased'],
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(account_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.gdpr_retention_policies ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.gdpr_retention_policies FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.gdpr_retention_policies TO authenticated;
|
||||||
|
GRANT ALL ON public.gdpr_retention_policies TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY gdpr_retention_select
|
||||||
|
ON public.gdpr_retention_policies FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY gdpr_retention_mutate
|
||||||
|
ON public.gdpr_retention_policies FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- Anonymize a single member (replaces all PII with placeholder)
|
||||||
|
CREATE OR REPLACE FUNCTION public.anonymize_member(
|
||||||
|
p_member_id uuid,
|
||||||
|
p_performed_by uuid DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_member record;
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(p_performed_by, auth.uid());
|
||||||
|
|
||||||
|
SELECT * INTO v_member FROM public.members WHERE id = p_member_id;
|
||||||
|
IF v_member IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Member not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Verify caller access
|
||||||
|
IF v_user_id IS NOT NULL AND NOT public.has_permission(v_user_id, v_member.account_id, 'members.write'::public.app_permissions) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Snapshot full record to audit log before anonymization
|
||||||
|
INSERT INTO public.member_audit_log (member_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (
|
||||||
|
p_member_id, v_member.account_id, v_user_id, 'gdpr_anonymized',
|
||||||
|
jsonb_build_object(
|
||||||
|
'original_first_name', v_member.first_name,
|
||||||
|
'original_last_name', v_member.last_name,
|
||||||
|
'original_email', v_member.email,
|
||||||
|
'reason', 'GDPR retention policy'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Replace all PII with anonymized placeholders
|
||||||
|
UPDATE public.members SET
|
||||||
|
first_name = 'ANONYMISIERT',
|
||||||
|
last_name = 'ANONYMISIERT',
|
||||||
|
email = NULL,
|
||||||
|
phone = NULL,
|
||||||
|
mobile = NULL,
|
||||||
|
phone2 = NULL,
|
||||||
|
fax = NULL,
|
||||||
|
street = NULL,
|
||||||
|
house_number = NULL,
|
||||||
|
street2 = NULL,
|
||||||
|
postal_code = NULL,
|
||||||
|
city = NULL,
|
||||||
|
date_of_birth = NULL,
|
||||||
|
birthplace = NULL,
|
||||||
|
birth_country = NULL,
|
||||||
|
iban = NULL,
|
||||||
|
bic = NULL,
|
||||||
|
account_holder = NULL,
|
||||||
|
sepa_mandate_reference = NULL,
|
||||||
|
sepa_mandate_id = NULL,
|
||||||
|
primary_mandate_id = NULL,
|
||||||
|
guardian_name = NULL,
|
||||||
|
guardian_phone = NULL,
|
||||||
|
guardian_email = NULL,
|
||||||
|
notes = '[GDPR anonymisiert am ' || to_char(now(), 'YYYY-MM-DD') || ']',
|
||||||
|
custom_data = '{}'::jsonb,
|
||||||
|
online_access_key = NULL,
|
||||||
|
online_access_blocked = true,
|
||||||
|
gdpr_consent = false,
|
||||||
|
gdpr_newsletter = false,
|
||||||
|
gdpr_internet = false,
|
||||||
|
gdpr_print = false,
|
||||||
|
gdpr_birthday_info = false,
|
||||||
|
is_archived = true,
|
||||||
|
updated_by = v_user_id
|
||||||
|
WHERE id = p_member_id;
|
||||||
|
|
||||||
|
-- Anonymize SEPA mandates (can't DELETE due to ON DELETE RESTRICT from Phase 1)
|
||||||
|
-- primary_mandate_id already cleared above in the members UPDATE
|
||||||
|
-- Anonymize SEPA PII fields (keep row for audit, revoke mandate)
|
||||||
|
UPDATE public.sepa_mandates
|
||||||
|
SET iban = 'DE00ANON0000000000000', bic = NULL, account_holder = 'ANONYMISIERT',
|
||||||
|
mandate_reference = 'ANON-' || id::text, status = 'revoked',
|
||||||
|
notes = '[GDPR anonymisiert]'
|
||||||
|
WHERE member_id = p_member_id;
|
||||||
|
|
||||||
|
-- Remove communications (may contain PII)
|
||||||
|
BEGIN
|
||||||
|
DELETE FROM public.member_communications WHERE member_id = p_member_id;
|
||||||
|
EXCEPTION WHEN undefined_table THEN NULL;
|
||||||
|
END;
|
||||||
|
|
||||||
|
-- Remove portal invitations
|
||||||
|
DELETE FROM public.member_portal_invitations WHERE member_id = p_member_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.anonymize_member(uuid, uuid)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- Batch enforcement: find and anonymize members matching retention criteria
|
||||||
|
CREATE OR REPLACE FUNCTION public.enforce_gdpr_retention_policies()
|
||||||
|
RETURNS int
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_policy record;
|
||||||
|
v_member record;
|
||||||
|
v_count int := 0;
|
||||||
|
BEGIN
|
||||||
|
FOR v_policy IN
|
||||||
|
SELECT * FROM public.gdpr_retention_policies
|
||||||
|
WHERE auto_anonymize = true
|
||||||
|
LOOP
|
||||||
|
FOR v_member IN
|
||||||
|
SELECT m.id
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.account_id = v_policy.account_id
|
||||||
|
AND m.status = ANY(v_policy.applies_to_status::public.membership_status[])
|
||||||
|
AND m.first_name != 'ANONYMISIERT' -- not already anonymized
|
||||||
|
AND m.exit_date IS NOT NULL -- only retain based on actual exit date
|
||||||
|
AND m.exit_date + (v_policy.retention_days || ' days')::interval <= current_date
|
||||||
|
LOOP
|
||||||
|
PERFORM public.anonymize_member(v_member.id, NULL);
|
||||||
|
v_count := v_count + 1;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
RETURN v_count;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.enforce_gdpr_retention_policies()
|
||||||
|
TO service_role;
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Reporting & Analytics RPC Functions
|
||||||
|
--
|
||||||
|
-- Enterprise-grade reporting: demographics, retention,
|
||||||
|
-- geographic distribution, dues collection, membership
|
||||||
|
-- duration analysis.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. Age demographics by gender
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_member_demographics(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
age_group text,
|
||||||
|
male_count bigint,
|
||||||
|
female_count bigint,
|
||||||
|
diverse_count bigint,
|
||||||
|
unknown_count bigint,
|
||||||
|
total bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN age < 18 THEN 'Unter 18'
|
||||||
|
WHEN age BETWEEN 18 AND 30 THEN '18-30'
|
||||||
|
WHEN age BETWEEN 31 AND 50 THEN '31-50'
|
||||||
|
WHEN age BETWEEN 51 AND 65 THEN '51-65'
|
||||||
|
WHEN age > 65 THEN 'Über 65'
|
||||||
|
ELSE 'Unbekannt'
|
||||||
|
END AS age_group,
|
||||||
|
count(*) FILTER (WHERE m.gender = 'male') AS male_count,
|
||||||
|
count(*) FILTER (WHERE m.gender = 'female') AS female_count,
|
||||||
|
count(*) FILTER (WHERE m.gender = 'diverse') AS diverse_count,
|
||||||
|
count(*) FILTER (WHERE m.gender IS NULL OR m.gender NOT IN ('male', 'female', 'diverse')) AS unknown_count,
|
||||||
|
count(*) AS total
|
||||||
|
FROM public.members m
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT CASE
|
||||||
|
WHEN m.date_of_birth IS NOT NULL THEN
|
||||||
|
extract(year FROM age(current_date, m.date_of_birth))::int
|
||||||
|
ELSE NULL
|
||||||
|
END AS age
|
||||||
|
) ages ON true
|
||||||
|
WHERE m.account_id = p_account_id
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND m.is_archived = false
|
||||||
|
GROUP BY age_group
|
||||||
|
ORDER BY
|
||||||
|
CASE age_group
|
||||||
|
WHEN 'Unter 18' THEN 1
|
||||||
|
WHEN '18-30' THEN 2
|
||||||
|
WHEN '31-50' THEN 3
|
||||||
|
WHEN '51-65' THEN 4
|
||||||
|
WHEN 'Über 65' THEN 5
|
||||||
|
ELSE 6
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_member_demographics(uuid) TO authenticated;
|
||||||
|
|
||||||
|
-- 2. Year-over-year membership retention
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_member_retention(
|
||||||
|
p_account_id uuid,
|
||||||
|
p_years int DEFAULT 5
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
year int,
|
||||||
|
members_start bigint,
|
||||||
|
new_members bigint,
|
||||||
|
resigned_members bigint,
|
||||||
|
members_end bigint,
|
||||||
|
retention_rate numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH years AS (
|
||||||
|
SELECT generate_series(
|
||||||
|
extract(year FROM current_date)::int - p_years + 1,
|
||||||
|
extract(year FROM current_date)::int
|
||||||
|
) AS yr
|
||||||
|
),
|
||||||
|
stats AS (
|
||||||
|
SELECT
|
||||||
|
y.yr,
|
||||||
|
count(*) FILTER (WHERE m.entry_date < make_date(y.yr, 1, 1)
|
||||||
|
AND (m.exit_date IS NULL OR m.exit_date >= make_date(y.yr, 1, 1))) AS members_start,
|
||||||
|
count(*) FILTER (WHERE extract(year FROM m.entry_date) = y.yr) AS new_members,
|
||||||
|
count(*) FILTER (WHERE extract(year FROM m.exit_date) = y.yr) AS resigned_members,
|
||||||
|
count(*) FILTER (WHERE m.entry_date <= make_date(y.yr, 12, 31)
|
||||||
|
AND (m.exit_date IS NULL OR m.exit_date > make_date(y.yr, 12, 31))) AS members_end
|
||||||
|
FROM years y
|
||||||
|
CROSS JOIN public.members m
|
||||||
|
WHERE m.account_id = p_account_id AND m.is_archived = false
|
||||||
|
GROUP BY y.yr
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
s.yr AS year,
|
||||||
|
s.members_start,
|
||||||
|
s.new_members,
|
||||||
|
s.resigned_members,
|
||||||
|
s.members_end,
|
||||||
|
CASE WHEN s.members_start > 0
|
||||||
|
THEN round((s.members_end::numeric / s.members_start) * 100, 1)
|
||||||
|
ELSE 0
|
||||||
|
END AS retention_rate
|
||||||
|
FROM stats s
|
||||||
|
ORDER BY s.yr;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_member_retention(uuid, int) TO authenticated;
|
||||||
|
|
||||||
|
-- 3. Geographic distribution by postal code prefix
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_member_geographic_distribution(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
postal_prefix text,
|
||||||
|
city text,
|
||||||
|
member_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN m.postal_code IS NULL OR m.postal_code = '' THEN 'Keine Angabe'
|
||||||
|
ELSE left(m.postal_code, 2)
|
||||||
|
END AS postal_prefix,
|
||||||
|
COALESCE(NULLIF(m.city, ''), 'Keine Angabe') AS city,
|
||||||
|
count(*) AS member_count
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.account_id = p_account_id
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND m.is_archived = false
|
||||||
|
GROUP BY postal_prefix, m.city
|
||||||
|
ORDER BY member_count DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_member_geographic_distribution(uuid) TO authenticated;
|
||||||
|
|
||||||
|
-- 4. Dues collection rates by category
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_dues_collection_report(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
category_name text,
|
||||||
|
member_count bigint,
|
||||||
|
expected_amount numeric,
|
||||||
|
paid_count bigint,
|
||||||
|
collection_rate numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
COALESCE(dc.name, 'Keine Kategorie') AS category_name,
|
||||||
|
count(m.id) AS member_count,
|
||||||
|
COALESCE(sum(dc.amount), 0) AS expected_amount,
|
||||||
|
count(*) FILTER (WHERE m.dues_paid = true) AS paid_count,
|
||||||
|
CASE WHEN count(m.id) > 0
|
||||||
|
THEN round((count(*) FILTER (WHERE m.dues_paid = true)::numeric / count(m.id)) * 100, 1)
|
||||||
|
ELSE 0
|
||||||
|
END AS collection_rate
|
||||||
|
FROM public.members m
|
||||||
|
LEFT JOIN public.dues_categories dc ON dc.id = m.dues_category_id
|
||||||
|
WHERE m.account_id = p_account_id
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND m.is_archived = false
|
||||||
|
GROUP BY dc.name, dc.sort_order
|
||||||
|
ORDER BY dc.sort_order NULLS LAST;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_dues_collection_report(uuid) TO authenticated;
|
||||||
|
|
||||||
|
-- 5. Membership duration analysis
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_membership_duration_analysis(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
duration_bucket text,
|
||||||
|
member_count bigint,
|
||||||
|
percentage numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_total bigint;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.members
|
||||||
|
WHERE account_id = p_account_id AND status = 'active' AND is_archived = false;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN years < 1 THEN 'Unter 1 Jahr'
|
||||||
|
WHEN years BETWEEN 1 AND 5 THEN '1-5 Jahre'
|
||||||
|
WHEN years BETWEEN 6 AND 10 THEN '6-10 Jahre'
|
||||||
|
WHEN years BETWEEN 11 AND 25 THEN '11-25 Jahre'
|
||||||
|
WHEN years > 25 THEN 'Über 25 Jahre'
|
||||||
|
ELSE 'Unbekannt'
|
||||||
|
END AS duration_bucket,
|
||||||
|
count(*) AS member_count,
|
||||||
|
CASE WHEN v_total > 0
|
||||||
|
THEN round((count(*)::numeric / v_total) * 100, 1)
|
||||||
|
ELSE 0
|
||||||
|
END AS percentage
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
CASE WHEN m.entry_date IS NOT NULL
|
||||||
|
THEN extract(year FROM age(current_date, m.entry_date))::int
|
||||||
|
ELSE NULL
|
||||||
|
END AS years
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.account_id = p_account_id
|
||||||
|
AND m.status = 'active'
|
||||||
|
AND m.is_archived = false
|
||||||
|
) sub
|
||||||
|
GROUP BY duration_bucket
|
||||||
|
ORDER BY
|
||||||
|
CASE duration_bucket
|
||||||
|
WHEN 'Unter 1 Jahr' THEN 1
|
||||||
|
WHEN '1-5 Jahre' THEN 2
|
||||||
|
WHEN '6-10 Jahre' THEN 3
|
||||||
|
WHEN '11-25 Jahre' THEN 4
|
||||||
|
WHEN 'Über 25 Jahre' THEN 5
|
||||||
|
ELSE 6
|
||||||
|
END;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_membership_duration_analysis(uuid) TO authenticated;
|
||||||
|
|
||||||
|
-- 6. Department distribution
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_department_distribution(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
department_name text,
|
||||||
|
member_count bigint,
|
||||||
|
percentage numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_total bigint;
|
||||||
|
BEGIN
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT count(DISTINCT m.id) INTO v_total
|
||||||
|
FROM public.members m
|
||||||
|
WHERE m.account_id = p_account_id AND m.status = 'active' AND m.is_archived = false;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
d.name AS department_name,
|
||||||
|
count(DISTINCT mda.member_id) AS member_count,
|
||||||
|
CASE WHEN v_total > 0
|
||||||
|
THEN round((count(DISTINCT mda.member_id)::numeric / v_total) * 100, 1)
|
||||||
|
ELSE 0
|
||||||
|
END AS percentage
|
||||||
|
FROM public.member_departments d
|
||||||
|
LEFT JOIN public.member_department_assignments mda ON mda.department_id = d.id
|
||||||
|
LEFT JOIN public.members m ON m.id = mda.member_id
|
||||||
|
AND m.status = 'active' AND m.is_archived = false
|
||||||
|
WHERE d.account_id = p_account_id
|
||||||
|
GROUP BY d.name, d.sort_order
|
||||||
|
ORDER BY member_count DESC;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_department_distribution(uuid) TO authenticated;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Index Optimization
|
||||||
|
--
|
||||||
|
-- Adds partial indexes for common query patterns,
|
||||||
|
-- covers the advanced search filter combinations,
|
||||||
|
-- and optimizes reporting queries.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. Active members composite index (most common query pattern)
|
||||||
|
-- Covers: listMembers, searchMembers, all reporting functions
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_active_account_status
|
||||||
|
ON public.members(account_id, status, last_name, first_name)
|
||||||
|
WHERE is_archived = false;
|
||||||
|
|
||||||
|
-- 2. Entry date range queries (searchMembers with date filters)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_entry_date
|
||||||
|
ON public.members(account_id, entry_date)
|
||||||
|
WHERE entry_date IS NOT NULL;
|
||||||
|
|
||||||
|
-- 3. Dues category filter (searchMembers)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_dues_category
|
||||||
|
ON public.members(account_id, dues_category_id)
|
||||||
|
WHERE dues_category_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- 4. Boolean flag filters (searchMembers flag queries)
|
||||||
|
-- Partial indexes only store rows where the flag is true (very compact)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_honorary
|
||||||
|
ON public.members(account_id) WHERE is_honorary = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_youth
|
||||||
|
ON public.members(account_id) WHERE is_youth = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_founding
|
||||||
|
ON public.members(account_id) WHERE is_founding_member = true;
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_retiree
|
||||||
|
ON public.members(account_id) WHERE is_retiree = true;
|
||||||
|
|
||||||
|
-- 5. Active SEPA mandates lookup (finance integration)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_sepa_mandates_active_lookup
|
||||||
|
ON public.sepa_mandates(member_id, status)
|
||||||
|
WHERE status = 'active' AND is_primary = true;
|
||||||
|
|
||||||
|
-- 6. Communications per member (timeline queries)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_comms_member_date
|
||||||
|
ON public.member_communications(member_id, created_at DESC);
|
||||||
|
|
||||||
|
-- 7. Audit log: action-type filtering (timeline with action filter)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_audit_member_action
|
||||||
|
ON public.member_audit_log(member_id, action, created_at DESC);
|
||||||
|
|
||||||
|
-- 8. Tag assignments: member lookup (for search filter + detail view)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_tag_assignments_member
|
||||||
|
ON public.member_tag_assignments(member_id);
|
||||||
|
|
||||||
|
-- 9. Reporting: active members for retention/duration CROSS JOIN
|
||||||
|
-- Column order: account_id first (equality), then date columns (range scans)
|
||||||
|
-- is_archived excluded from key since it's in WHERE clause
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_active_reporting
|
||||||
|
ON public.members(account_id, entry_date, exit_date, status)
|
||||||
|
WHERE is_archived = false;
|
||||||
|
|
||||||
|
-- 10. Member merge log: primary member lookup
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_member_merges_primary
|
||||||
|
ON public.member_merges(primary_member_id);
|
||||||
|
|
||||||
|
-- 11. GDPR: candidates for anonymization (batch enforcement query)
|
||||||
|
-- status excluded from key since enforcement query uses dynamic ANY(array)
|
||||||
|
-- Covers: WHERE account_id = ? AND exit_date IS NOT NULL AND exit_date + interval <= current_date
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_members_gdpr_candidates
|
||||||
|
ON public.members(account_id, exit_date)
|
||||||
|
WHERE exit_date IS NOT NULL AND is_archived = false AND first_name != 'ANONYMISIERT';
|
||||||
|
|
||||||
|
-- 12. Portal invitations: account listing (listPortalInvitations query)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_portal_invitations_account_date
|
||||||
|
ON public.member_portal_invitations(account_id, created_at DESC);
|
||||||
|
|
||||||
|
-- 13. Department assignments by department (searchMembers department filter subquery)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_dept_assignments_department
|
||||||
|
ON public.member_department_assignments(department_id, member_id);
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Notification Rules + Scheduled Jobs
|
||||||
|
--
|
||||||
|
-- Configurable notification triggers per account.
|
||||||
|
-- Scheduled job runner with tracking.
|
||||||
|
-- Pending notifications queue for async dispatch.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- 1. Notification rules — configurable triggers per account
|
||||||
|
CREATE TABLE IF NOT EXISTS public.member_notification_rules (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
trigger_event text NOT NULL CHECK (trigger_event IN (
|
||||||
|
'application.submitted', 'application.approved', 'application.rejected',
|
||||||
|
'member.created', 'member.status_changed',
|
||||||
|
'member.birthday', 'member.anniversary',
|
||||||
|
'dues.unpaid', 'mandate.revoked'
|
||||||
|
)),
|
||||||
|
channel text NOT NULL DEFAULT 'in_app' CHECK (channel IN ('in_app', 'email', 'both')),
|
||||||
|
recipient_type text NOT NULL CHECK (recipient_type IN (
|
||||||
|
'admin', 'member', 'specific_user', 'role_holder'
|
||||||
|
)),
|
||||||
|
recipient_config jsonb NOT NULL DEFAULT '{}',
|
||||||
|
subject_template text,
|
||||||
|
message_template text NOT NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_notification_rules_account
|
||||||
|
ON public.member_notification_rules(account_id, trigger_event)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
ALTER TABLE public.member_notification_rules ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.member_notification_rules FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON public.member_notification_rules TO authenticated;
|
||||||
|
GRANT ALL ON public.member_notification_rules TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY notification_rules_select
|
||||||
|
ON public.member_notification_rules FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY notification_rules_mutate
|
||||||
|
ON public.member_notification_rules FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- 2. Scheduled job configuration per account
|
||||||
|
CREATE TABLE IF NOT EXISTS public.scheduled_job_configs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
job_type text NOT NULL CHECK (job_type IN (
|
||||||
|
'birthday_notification', 'anniversary_notification',
|
||||||
|
'dues_reminder', 'data_quality_check', 'gdpr_retention_check'
|
||||||
|
)),
|
||||||
|
is_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
config jsonb NOT NULL DEFAULT '{}',
|
||||||
|
last_run_at timestamptz,
|
||||||
|
next_run_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE(account_id, job_type)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE public.scheduled_job_configs ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.scheduled_job_configs FROM authenticated, service_role;
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON public.scheduled_job_configs TO authenticated;
|
||||||
|
GRANT ALL ON public.scheduled_job_configs TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY scheduled_jobs_select
|
||||||
|
ON public.scheduled_job_configs FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
CREATE POLICY scheduled_jobs_mutate
|
||||||
|
ON public.scheduled_job_configs FOR ALL TO authenticated
|
||||||
|
USING (public.has_permission(auth.uid(), account_id, 'members.write'::public.app_permissions));
|
||||||
|
|
||||||
|
-- 3. Job run history
|
||||||
|
CREATE TABLE IF NOT EXISTS public.scheduled_job_runs (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
job_config_id uuid NOT NULL REFERENCES public.scheduled_job_configs(id) ON DELETE CASCADE,
|
||||||
|
status text NOT NULL DEFAULT 'running' CHECK (status IN ('running', 'completed', 'failed')),
|
||||||
|
result jsonb,
|
||||||
|
started_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
completed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_job_runs_config
|
||||||
|
ON public.scheduled_job_runs(job_config_id, started_at DESC);
|
||||||
|
|
||||||
|
ALTER TABLE public.scheduled_job_runs ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.scheduled_job_runs FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.scheduled_job_runs TO authenticated;
|
||||||
|
GRANT ALL ON public.scheduled_job_runs TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY job_runs_select
|
||||||
|
ON public.scheduled_job_runs FOR SELECT TO authenticated
|
||||||
|
USING (EXISTS (
|
||||||
|
SELECT 1 FROM public.scheduled_job_configs jc
|
||||||
|
WHERE jc.id = scheduled_job_runs.job_config_id
|
||||||
|
AND public.has_role_on_account(jc.account_id)
|
||||||
|
));
|
||||||
|
|
||||||
|
-- 4. Pending notifications queue (lightweight, processed by cron)
|
||||||
|
CREATE TABLE IF NOT EXISTS public.pending_member_notifications (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
account_id uuid NOT NULL,
|
||||||
|
trigger_event text NOT NULL,
|
||||||
|
member_id uuid,
|
||||||
|
context jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
processed_at timestamptz
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX ix_pending_notifications_unprocessed
|
||||||
|
ON public.pending_member_notifications(created_at)
|
||||||
|
WHERE processed_at IS NULL;
|
||||||
|
|
||||||
|
-- No RLS — only service_role accesses this table
|
||||||
|
REVOKE ALL ON public.pending_member_notifications FROM authenticated;
|
||||||
|
GRANT ALL ON public.pending_member_notifications TO service_role;
|
||||||
|
|
||||||
|
-- 5. Trigger: queue notifications when audit events fire
|
||||||
|
CREATE OR REPLACE FUNCTION public.queue_notification_on_audit()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_event text;
|
||||||
|
BEGIN
|
||||||
|
-- Map audit action to notification trigger event
|
||||||
|
v_event := CASE NEW.action
|
||||||
|
WHEN 'created' THEN 'member.created'
|
||||||
|
WHEN 'status_changed' THEN 'member.status_changed'
|
||||||
|
WHEN 'application_approved' THEN 'application.approved'
|
||||||
|
WHEN 'application_rejected' THEN 'application.rejected'
|
||||||
|
ELSE NULL
|
||||||
|
END;
|
||||||
|
|
||||||
|
IF v_event IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Only queue if there are active rules for this event
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM public.member_notification_rules
|
||||||
|
WHERE account_id = NEW.account_id
|
||||||
|
AND trigger_event = v_event
|
||||||
|
AND is_active = true
|
||||||
|
) THEN
|
||||||
|
INSERT INTO public.pending_member_notifications (account_id, trigger_event, member_id, context)
|
||||||
|
VALUES (
|
||||||
|
NEW.account_id,
|
||||||
|
v_event,
|
||||||
|
NEW.member_id,
|
||||||
|
jsonb_build_object(
|
||||||
|
'audit_action', NEW.action,
|
||||||
|
'changes', NEW.changes,
|
||||||
|
'metadata', NEW.metadata,
|
||||||
|
'user_id', NEW.user_id
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_audit_queue_notifications
|
||||||
|
AFTER INSERT ON public.member_audit_log
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.queue_notification_on_audit();
|
||||||
|
|
||||||
|
-- 6. Queue trigger for application submissions (from membership_applications, not audit log)
|
||||||
|
CREATE OR REPLACE FUNCTION public.queue_notification_on_application()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF NEW.status = 'submitted' AND (TG_OP = 'INSERT' OR OLD.status IS DISTINCT FROM NEW.status) THEN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM public.member_notification_rules
|
||||||
|
WHERE account_id = NEW.account_id
|
||||||
|
AND trigger_event = 'application.submitted'
|
||||||
|
AND is_active = true
|
||||||
|
) THEN
|
||||||
|
INSERT INTO public.pending_member_notifications (account_id, trigger_event, context)
|
||||||
|
VALUES (
|
||||||
|
NEW.account_id,
|
||||||
|
'application.submitted',
|
||||||
|
jsonb_build_object(
|
||||||
|
'application_id', NEW.id,
|
||||||
|
'first_name', NEW.first_name,
|
||||||
|
'last_name', NEW.last_name,
|
||||||
|
'email', NEW.email
|
||||||
|
)
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_application_queue_notifications
|
||||||
|
AFTER INSERT OR UPDATE OF status ON public.membership_applications
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.queue_notification_on_application();
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Atomic Course Enrollment
|
||||||
|
--
|
||||||
|
-- Problem: Enrolling a participant in a course requires
|
||||||
|
-- multiple queries (check capacity, count enrolled, insert).
|
||||||
|
-- Race conditions can over-enroll a course.
|
||||||
|
--
|
||||||
|
-- Fix: Single transactional PG function that locks the
|
||||||
|
-- course row, validates capacity, and inserts with the
|
||||||
|
-- correct status (enrolled vs waitlisted).
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.enroll_course_participant(
|
||||||
|
p_course_id uuid,
|
||||||
|
p_member_id uuid DEFAULT NULL,
|
||||||
|
p_first_name text DEFAULT NULL,
|
||||||
|
p_last_name text DEFAULT NULL,
|
||||||
|
p_email text DEFAULT NULL,
|
||||||
|
p_phone text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_course record;
|
||||||
|
v_enrolled_count bigint;
|
||||||
|
v_status public.enrollment_status;
|
||||||
|
v_waitlist_position bigint;
|
||||||
|
v_participant_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Lock the course row to prevent concurrent enrollment races
|
||||||
|
SELECT * INTO v_course
|
||||||
|
FROM public.courses
|
||||||
|
WHERE id = p_course_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF v_course IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Course % not found', p_course_id
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Validate course status is open for enrollment
|
||||||
|
IF v_course.status != 'open' THEN
|
||||||
|
RAISE EXCEPTION 'Course is not open for enrollment (current status: %)', v_course.status
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Check registration deadline hasn't passed
|
||||||
|
IF v_course.registration_deadline IS NOT NULL AND v_course.registration_deadline < current_date THEN
|
||||||
|
RAISE EXCEPTION 'Registration deadline (%) has passed', v_course.registration_deadline
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Count currently enrolled participants
|
||||||
|
SELECT count(*) INTO v_enrolled_count
|
||||||
|
FROM public.course_participants
|
||||||
|
WHERE course_id = p_course_id
|
||||||
|
AND status = 'enrolled';
|
||||||
|
|
||||||
|
-- 5. Determine status based on capacity
|
||||||
|
IF v_enrolled_count >= v_course.capacity THEN
|
||||||
|
v_status := 'waitlisted';
|
||||||
|
ELSE
|
||||||
|
v_status := 'enrolled';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 6. Insert the participant
|
||||||
|
INSERT INTO public.course_participants (
|
||||||
|
course_id,
|
||||||
|
member_id,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
status,
|
||||||
|
enrolled_at
|
||||||
|
) VALUES (
|
||||||
|
p_course_id,
|
||||||
|
p_member_id,
|
||||||
|
p_first_name,
|
||||||
|
p_last_name,
|
||||||
|
p_email,
|
||||||
|
p_phone,
|
||||||
|
v_status,
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_participant_id;
|
||||||
|
|
||||||
|
-- 7. Calculate waitlist position if waitlisted
|
||||||
|
IF v_status = 'waitlisted' THEN
|
||||||
|
SELECT count(*) INTO v_waitlist_position
|
||||||
|
FROM public.course_participants
|
||||||
|
WHERE course_id = p_course_id
|
||||||
|
AND status = 'waitlisted';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 8. Return result
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'participant_id', v_participant_id,
|
||||||
|
'status', v_status::text,
|
||||||
|
'waitlist_position', CASE WHEN v_status = 'waitlisted' THEN v_waitlist_position ELSE NULL END
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.enroll_course_participant(uuid, uuid, text, text, text, text) TO service_role;
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Atomic Event Registration
|
||||||
|
--
|
||||||
|
-- Problem: Registering for an event requires multiple
|
||||||
|
-- queries (check capacity, validate age, count registrations,
|
||||||
|
-- insert). Race conditions can over-register an event.
|
||||||
|
--
|
||||||
|
-- Fix:
|
||||||
|
-- A) Ensure member_id FK column exists on event_registrations
|
||||||
|
-- (idempotent — may already exist from 20260416000006).
|
||||||
|
-- B) Single transactional PG function that locks the event
|
||||||
|
-- row, validates capacity/age, and inserts with the
|
||||||
|
-- correct status (confirmed vs waitlisted).
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- A) Add member_id column if not already present
|
||||||
|
ALTER TABLE public.event_registrations
|
||||||
|
ADD COLUMN IF NOT EXISTS member_id uuid
|
||||||
|
REFERENCES public.members(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- Ensure index exists (idempotent)
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_registrations_member
|
||||||
|
ON public.event_registrations(member_id)
|
||||||
|
WHERE member_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- The status CHECK constraint already includes 'waitlisted' in the
|
||||||
|
-- original schema: check (status in ('pending','confirmed','waitlisted','cancelled'))
|
||||||
|
-- No constraint modification needed.
|
||||||
|
|
||||||
|
-- B) Atomic registration function
|
||||||
|
CREATE OR REPLACE FUNCTION public.register_for_event(
|
||||||
|
p_event_id uuid,
|
||||||
|
p_member_id uuid DEFAULT NULL,
|
||||||
|
p_first_name text DEFAULT NULL,
|
||||||
|
p_last_name text DEFAULT NULL,
|
||||||
|
p_email text DEFAULT NULL,
|
||||||
|
p_phone text DEFAULT NULL,
|
||||||
|
p_date_of_birth date DEFAULT NULL,
|
||||||
|
p_parent_name text DEFAULT NULL,
|
||||||
|
p_parent_phone text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_event record;
|
||||||
|
v_reg_count bigint;
|
||||||
|
v_status text;
|
||||||
|
v_age integer;
|
||||||
|
v_registration_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Lock the event row to prevent concurrent registration races
|
||||||
|
SELECT * INTO v_event
|
||||||
|
FROM public.events
|
||||||
|
WHERE id = p_event_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF v_event IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Event % not found', p_event_id
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 2. Validate event status is open for registration
|
||||||
|
IF v_event.status != 'open' THEN
|
||||||
|
RAISE EXCEPTION 'Event is not open for registration (current status: %)', v_event.status
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Check registration deadline hasn't passed
|
||||||
|
IF v_event.registration_deadline IS NOT NULL AND v_event.registration_deadline < current_date THEN
|
||||||
|
RAISE EXCEPTION 'Registration deadline (%) has passed', v_event.registration_deadline
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Age validation: calculate age at event_date if date_of_birth provided
|
||||||
|
IF p_date_of_birth IS NOT NULL THEN
|
||||||
|
v_age := extract(year FROM age(v_event.event_date, p_date_of_birth))::integer;
|
||||||
|
|
||||||
|
IF v_event.min_age IS NOT NULL AND v_age < v_event.min_age THEN
|
||||||
|
RAISE EXCEPTION 'Participant age (%) is below the minimum age (%) for this event', v_age, v_event.min_age
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_event.max_age IS NOT NULL AND v_age > v_event.max_age THEN
|
||||||
|
RAISE EXCEPTION 'Participant age (%) exceeds the maximum age (%) for this event', v_age, v_event.max_age
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. Count confirmed + pending registrations
|
||||||
|
SELECT count(*) INTO v_reg_count
|
||||||
|
FROM public.event_registrations
|
||||||
|
WHERE event_id = p_event_id
|
||||||
|
AND status IN ('confirmed', 'pending');
|
||||||
|
|
||||||
|
-- 6. Determine status based on capacity
|
||||||
|
IF v_event.capacity IS NOT NULL AND v_reg_count >= v_event.capacity THEN
|
||||||
|
v_status := 'waitlisted';
|
||||||
|
ELSE
|
||||||
|
v_status := 'confirmed';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 7. Insert the registration
|
||||||
|
INSERT INTO public.event_registrations (
|
||||||
|
event_id,
|
||||||
|
member_id,
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
phone,
|
||||||
|
date_of_birth,
|
||||||
|
parent_name,
|
||||||
|
parent_phone,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
p_event_id,
|
||||||
|
p_member_id,
|
||||||
|
p_first_name,
|
||||||
|
p_last_name,
|
||||||
|
p_email,
|
||||||
|
p_phone,
|
||||||
|
p_date_of_birth,
|
||||||
|
p_parent_name,
|
||||||
|
p_parent_phone,
|
||||||
|
v_status
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_registration_id;
|
||||||
|
|
||||||
|
-- 8. Return result
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'registration_id', v_registration_id,
|
||||||
|
'status', v_status
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.register_for_event(uuid, uuid, text, text, text, text, date, text, text) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.register_for_event(uuid, uuid, text, text, text, text, date, text, text) TO service_role;
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Atomic Booking Creation with Overlap Prevention
|
||||||
|
--
|
||||||
|
-- Problem: Creating a booking requires checking room
|
||||||
|
-- availability, validating capacity, and inserting — all
|
||||||
|
-- as separate queries. Race conditions can double-book
|
||||||
|
-- a room for overlapping dates.
|
||||||
|
--
|
||||||
|
-- Fix:
|
||||||
|
-- A) Enable btree_gist extension for exclusion constraints.
|
||||||
|
-- B) Add GiST exclusion constraint to prevent overlapping
|
||||||
|
-- bookings for the same room (non-cancelled/no_show).
|
||||||
|
-- C) Single transactional PG function that locks the room,
|
||||||
|
-- validates inputs, calculates price, and inserts. The
|
||||||
|
-- exclusion constraint provides a final safety net.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- A) Enable btree_gist extension (required for exclusion constraints on non-GiST types)
|
||||||
|
CREATE EXTENSION IF NOT EXISTS btree_gist;
|
||||||
|
|
||||||
|
-- B) Add exclusion constraint to prevent overlapping bookings (idempotent)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'excl_booking_room_dates'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.bookings
|
||||||
|
ADD CONSTRAINT excl_booking_room_dates
|
||||||
|
EXCLUDE USING gist (
|
||||||
|
room_id WITH =,
|
||||||
|
daterange(check_in, check_out) WITH &&
|
||||||
|
) WHERE (status NOT IN ('cancelled', 'no_show'));
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- C) Atomic booking creation function
|
||||||
|
CREATE OR REPLACE FUNCTION public.create_booking_atomic(
|
||||||
|
p_account_id uuid,
|
||||||
|
p_room_id uuid,
|
||||||
|
p_guest_id uuid DEFAULT NULL,
|
||||||
|
p_check_in date DEFAULT NULL,
|
||||||
|
p_check_out date DEFAULT NULL,
|
||||||
|
p_adults integer DEFAULT 1,
|
||||||
|
p_children integer DEFAULT 0,
|
||||||
|
p_status text DEFAULT 'confirmed',
|
||||||
|
p_total_price numeric DEFAULT NULL,
|
||||||
|
p_notes text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS uuid
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_room record;
|
||||||
|
v_computed_price numeric(10,2);
|
||||||
|
v_booking_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- 1. Lock the room row to serialize booking attempts
|
||||||
|
SELECT * INTO v_room
|
||||||
|
FROM public.rooms
|
||||||
|
WHERE id = p_room_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
-- 2. Validate room exists
|
||||||
|
IF v_room IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Room % not found', p_room_id
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 3. Validate check_out > check_in
|
||||||
|
IF p_check_in IS NULL OR p_check_out IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'check_in and check_out dates are required'
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF p_check_out <= p_check_in THEN
|
||||||
|
RAISE EXCEPTION 'check_out (%) must be after check_in (%)', p_check_out, p_check_in
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 4. Validate total guests do not exceed room capacity
|
||||||
|
IF (p_adults + p_children) > v_room.capacity THEN
|
||||||
|
RAISE EXCEPTION 'Total guests (%) exceed room capacity (%)', (p_adults + p_children), v_room.capacity
|
||||||
|
USING ERRCODE = 'P0001';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 5. Calculate price if not provided
|
||||||
|
IF p_total_price IS NOT NULL THEN
|
||||||
|
v_computed_price := p_total_price;
|
||||||
|
ELSE
|
||||||
|
v_computed_price := v_room.price_per_night * (p_check_out - p_check_in);
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 6. Insert the booking (exclusion constraint prevents double-booking)
|
||||||
|
INSERT INTO public.bookings (
|
||||||
|
account_id,
|
||||||
|
room_id,
|
||||||
|
guest_id,
|
||||||
|
check_in,
|
||||||
|
check_out,
|
||||||
|
adults,
|
||||||
|
children,
|
||||||
|
status,
|
||||||
|
total_price,
|
||||||
|
notes
|
||||||
|
) VALUES (
|
||||||
|
p_account_id,
|
||||||
|
p_room_id,
|
||||||
|
p_guest_id,
|
||||||
|
p_check_in,
|
||||||
|
p_check_out,
|
||||||
|
p_adults,
|
||||||
|
p_children,
|
||||||
|
p_status,
|
||||||
|
v_computed_price,
|
||||||
|
p_notes
|
||||||
|
)
|
||||||
|
RETURNING id INTO v_booking_id;
|
||||||
|
|
||||||
|
-- 7. Return the new booking id
|
||||||
|
RETURN v_booking_id;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.create_booking_atomic(uuid, uuid, uuid, date, date, integer, integer, text, numeric, text) TO service_role;
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Data Integrity Constraints for Courses, Events, Bookings
|
||||||
|
--
|
||||||
|
-- Adds CHECK constraints and partial unique indexes to
|
||||||
|
-- enforce business rules at the database level.
|
||||||
|
--
|
||||||
|
-- All constraint additions are idempotent — wrapped in
|
||||||
|
-- DO blocks that check pg_constraint before adding.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- COURSES
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- reduced_fee must not exceed fee
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_reduced_fee_lte_fee'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.courses
|
||||||
|
ADD CONSTRAINT chk_courses_reduced_fee_lte_fee
|
||||||
|
CHECK (reduced_fee IS NULL OR reduced_fee <= fee);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- min_participants must not exceed capacity
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_min_lte_capacity'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.courses
|
||||||
|
ADD CONSTRAINT chk_courses_min_lte_capacity
|
||||||
|
CHECK (min_participants IS NULL OR min_participants <= capacity);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- end_date must be on or after start_date
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_date_range'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.courses
|
||||||
|
ADD CONSTRAINT chk_courses_date_range
|
||||||
|
CHECK (end_date IS NULL OR start_date IS NULL OR end_date >= start_date);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- registration_deadline must be on or before start_date
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_courses_deadline_before_start'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.courses
|
||||||
|
ADD CONSTRAINT chk_courses_deadline_before_start
|
||||||
|
CHECK (registration_deadline IS NULL OR start_date IS NULL OR registration_deadline <= start_date);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Unique course_number per account (partial index — allows NULLs and empty strings)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uix_courses_number_per_account
|
||||||
|
ON public.courses(account_id, course_number)
|
||||||
|
WHERE course_number IS NOT NULL AND course_number != '';
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- EVENTS
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- min_age must not exceed max_age
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_age_range'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.events
|
||||||
|
ADD CONSTRAINT chk_events_age_range
|
||||||
|
CHECK (min_age IS NULL OR max_age IS NULL OR min_age <= max_age);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- end_date must be on or after event_date
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_date_range'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.events
|
||||||
|
ADD CONSTRAINT chk_events_date_range
|
||||||
|
CHECK (end_date IS NULL OR end_date >= event_date);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- registration_deadline must be on or before event_date
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_events_deadline_before_event'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.events
|
||||||
|
ADD CONSTRAINT chk_events_deadline_before_event
|
||||||
|
CHECK (registration_deadline IS NULL OR registration_deadline <= event_date);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- BOOKINGS
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- At least 1 adult required
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_bookings_min_adults'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.bookings
|
||||||
|
ADD CONSTRAINT chk_bookings_min_adults
|
||||||
|
CHECK (adults >= 1);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- total_price must be non-negative
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'chk_bookings_price_non_negative'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE public.bookings
|
||||||
|
ADD CONSTRAINT chk_bookings_price_non_negative
|
||||||
|
CHECK (total_price >= 0);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Optimistic Locking for Courses, Events, Bookings
|
||||||
|
--
|
||||||
|
-- Problem: Concurrent edits to courses, events, or bookings
|
||||||
|
-- can silently overwrite each other (last write wins).
|
||||||
|
--
|
||||||
|
-- Fix: Add version column to each table with an auto-
|
||||||
|
-- increment trigger on update. API layer checks version
|
||||||
|
-- match before writing, preventing silent overwrites.
|
||||||
|
--
|
||||||
|
-- Reuses the same trigger function pattern established
|
||||||
|
-- in 20260416000005_member_versioning.sql but creates a
|
||||||
|
-- shared generic function instead of table-specific ones.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Shared version increment function (CREATE OR REPLACE is idempotent)
|
||||||
|
CREATE OR REPLACE FUNCTION public.increment_version()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.version := OLD.version + 1;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- COURSES
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE public.courses
|
||||||
|
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_courses_increment_version'
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER trg_courses_increment_version
|
||||||
|
BEFORE UPDATE ON public.courses
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.increment_version();
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- EVENTS
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE public.events
|
||||||
|
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_events_increment_version'
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER trg_events_increment_version
|
||||||
|
BEFORE UPDATE ON public.events
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.increment_version();
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- BOOKINGS
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE public.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS version integer NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bookings_increment_version'
|
||||||
|
) THEN
|
||||||
|
CREATE TRIGGER trg_bookings_increment_version
|
||||||
|
BEFORE UPDATE ON public.bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.increment_version();
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Audit Logging for Courses, Events, Bookings
|
||||||
|
--
|
||||||
|
-- Full change history for compliance: who changed what
|
||||||
|
-- field, old value -> new value, when. Mirrors the
|
||||||
|
-- member_audit_log pattern from 20260416000007.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- A) Add created_by / updated_by to main tables
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
ALTER TABLE public.courses
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.events
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE public.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- B) Audit log tables
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- B.1 Course audit log
|
||||||
|
CREATE TABLE IF NOT EXISTS public.course_audit_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
course_id uuid NOT NULL REFERENCES public.courses(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
action text NOT NULL CHECK (action IN (
|
||||||
|
'created', 'updated', 'status_changed', 'cancelled',
|
||||||
|
'participant_enrolled', 'participant_cancelled',
|
||||||
|
'participant_waitlisted', 'participant_promoted',
|
||||||
|
'session_created', 'session_cancelled',
|
||||||
|
'attendance_marked', 'instructor_changed', 'location_changed'
|
||||||
|
)),
|
||||||
|
changes jsonb NOT NULL DEFAULT '{}',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.course_audit_log IS
|
||||||
|
'Immutable audit trail for all course lifecycle events';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_course_audit_course
|
||||||
|
ON public.course_audit_log(course_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_course_audit_account
|
||||||
|
ON public.course_audit_log(account_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_course_audit_action
|
||||||
|
ON public.course_audit_log(account_id, action);
|
||||||
|
|
||||||
|
ALTER TABLE public.course_audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.course_audit_log FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.course_audit_log TO authenticated;
|
||||||
|
GRANT INSERT, SELECT ON public.course_audit_log TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY course_audit_log_select
|
||||||
|
ON public.course_audit_log FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- B.2 Event audit log
|
||||||
|
CREATE TABLE IF NOT EXISTS public.event_audit_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
event_id uuid NOT NULL REFERENCES public.events(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
action text NOT NULL CHECK (action IN (
|
||||||
|
'created', 'updated', 'status_changed', 'cancelled',
|
||||||
|
'registration_confirmed', 'registration_waitlisted',
|
||||||
|
'registration_cancelled', 'registration_promoted'
|
||||||
|
)),
|
||||||
|
changes jsonb NOT NULL DEFAULT '{}',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.event_audit_log IS
|
||||||
|
'Immutable audit trail for all event lifecycle events';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_audit_event
|
||||||
|
ON public.event_audit_log(event_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_audit_account
|
||||||
|
ON public.event_audit_log(account_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_audit_action
|
||||||
|
ON public.event_audit_log(account_id, action);
|
||||||
|
|
||||||
|
ALTER TABLE public.event_audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.event_audit_log FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.event_audit_log TO authenticated;
|
||||||
|
GRANT INSERT, SELECT ON public.event_audit_log TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY event_audit_log_select
|
||||||
|
ON public.event_audit_log FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- B.3 Booking audit log
|
||||||
|
CREATE TABLE IF NOT EXISTS public.booking_audit_log (
|
||||||
|
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
booking_id uuid NOT NULL REFERENCES public.bookings(id) ON DELETE CASCADE,
|
||||||
|
account_id uuid NOT NULL REFERENCES public.accounts(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||||
|
action text NOT NULL CHECK (action IN (
|
||||||
|
'created', 'updated', 'status_changed',
|
||||||
|
'checked_in', 'checked_out', 'cancelled',
|
||||||
|
'no_show', 'price_changed'
|
||||||
|
)),
|
||||||
|
changes jsonb NOT NULL DEFAULT '{}',
|
||||||
|
metadata jsonb NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE public.booking_audit_log IS
|
||||||
|
'Immutable audit trail for all booking lifecycle events';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_booking_audit_booking
|
||||||
|
ON public.booking_audit_log(booking_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_booking_audit_account
|
||||||
|
ON public.booking_audit_log(account_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_booking_audit_action
|
||||||
|
ON public.booking_audit_log(account_id, action);
|
||||||
|
|
||||||
|
ALTER TABLE public.booking_audit_log ENABLE ROW LEVEL SECURITY;
|
||||||
|
REVOKE ALL ON public.booking_audit_log FROM authenticated, service_role;
|
||||||
|
GRANT SELECT ON public.booking_audit_log TO authenticated;
|
||||||
|
GRANT INSERT, SELECT ON public.booking_audit_log TO service_role;
|
||||||
|
|
||||||
|
CREATE POLICY booking_audit_log_select
|
||||||
|
ON public.booking_audit_log FOR SELECT TO authenticated
|
||||||
|
USING (public.has_role_on_account(account_id));
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- C) Auto-audit triggers for UPDATE
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- C.1 Courses UPDATE trigger
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_course_audit_on_update()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_changes jsonb := '{}'::jsonb;
|
||||||
|
v_action text := 'updated';
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Build changes diff (field by field)
|
||||||
|
IF OLD.name IS DISTINCT FROM NEW.name THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name));
|
||||||
|
END IF;
|
||||||
|
IF OLD.description IS DISTINCT FROM NEW.description THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description));
|
||||||
|
END IF;
|
||||||
|
IF OLD.course_number IS DISTINCT FROM NEW.course_number THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('course_number', jsonb_build_object('old', OLD.course_number, 'new', NEW.course_number));
|
||||||
|
END IF;
|
||||||
|
IF OLD.category_id IS DISTINCT FROM NEW.category_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('category_id', jsonb_build_object('old', OLD.category_id, 'new', NEW.category_id));
|
||||||
|
END IF;
|
||||||
|
IF OLD.instructor_id IS DISTINCT FROM NEW.instructor_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('instructor_id', jsonb_build_object('old', OLD.instructor_id, 'new', NEW.instructor_id));
|
||||||
|
END IF;
|
||||||
|
IF OLD.location_id IS DISTINCT FROM NEW.location_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('location_id', jsonb_build_object('old', OLD.location_id, 'new', NEW.location_id));
|
||||||
|
END IF;
|
||||||
|
IF OLD.start_date IS DISTINCT FROM NEW.start_date THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('start_date', jsonb_build_object('old', OLD.start_date, 'new', NEW.start_date));
|
||||||
|
END IF;
|
||||||
|
IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date));
|
||||||
|
END IF;
|
||||||
|
IF OLD.fee IS DISTINCT FROM NEW.fee THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee));
|
||||||
|
END IF;
|
||||||
|
IF OLD.reduced_fee IS DISTINCT FROM NEW.reduced_fee THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('reduced_fee', jsonb_build_object('old', OLD.reduced_fee, 'new', NEW.reduced_fee));
|
||||||
|
END IF;
|
||||||
|
IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity));
|
||||||
|
END IF;
|
||||||
|
IF OLD.min_participants IS DISTINCT FROM NEW.min_participants THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('min_participants', jsonb_build_object('old', OLD.min_participants, 'new', NEW.min_participants));
|
||||||
|
END IF;
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status));
|
||||||
|
END IF;
|
||||||
|
IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline));
|
||||||
|
END IF;
|
||||||
|
IF OLD.notes IS DISTINCT FROM NEW.notes THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Skip if nothing actually changed
|
||||||
|
IF v_changes = '{}'::jsonb THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Classify the action
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
v_action := 'status_changed';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, changes)
|
||||||
|
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_courses_audit_on_update
|
||||||
|
AFTER UPDATE ON public.courses
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_course_audit_on_update();
|
||||||
|
|
||||||
|
-- C.2 Events UPDATE trigger
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_event_audit_on_update()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_changes jsonb := '{}'::jsonb;
|
||||||
|
v_action text := 'updated';
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Build changes diff (field by field)
|
||||||
|
IF OLD.name IS DISTINCT FROM NEW.name THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('name', jsonb_build_object('old', OLD.name, 'new', NEW.name));
|
||||||
|
END IF;
|
||||||
|
IF OLD.description IS DISTINCT FROM NEW.description THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('description', jsonb_build_object('old', OLD.description, 'new', NEW.description));
|
||||||
|
END IF;
|
||||||
|
IF OLD.event_date IS DISTINCT FROM NEW.event_date THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('event_date', jsonb_build_object('old', OLD.event_date, 'new', NEW.event_date));
|
||||||
|
END IF;
|
||||||
|
IF OLD.event_time IS DISTINCT FROM NEW.event_time THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('event_time', jsonb_build_object('old', OLD.event_time, 'new', NEW.event_time));
|
||||||
|
END IF;
|
||||||
|
IF OLD.end_date IS DISTINCT FROM NEW.end_date THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('end_date', jsonb_build_object('old', OLD.end_date, 'new', NEW.end_date));
|
||||||
|
END IF;
|
||||||
|
IF OLD.location IS DISTINCT FROM NEW.location THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('location', jsonb_build_object('old', OLD.location, 'new', NEW.location));
|
||||||
|
END IF;
|
||||||
|
IF OLD.capacity IS DISTINCT FROM NEW.capacity THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('capacity', jsonb_build_object('old', OLD.capacity, 'new', NEW.capacity));
|
||||||
|
END IF;
|
||||||
|
IF OLD.min_age IS DISTINCT FROM NEW.min_age THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('min_age', jsonb_build_object('old', OLD.min_age, 'new', NEW.min_age));
|
||||||
|
END IF;
|
||||||
|
IF OLD.max_age IS DISTINCT FROM NEW.max_age THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('max_age', jsonb_build_object('old', OLD.max_age, 'new', NEW.max_age));
|
||||||
|
END IF;
|
||||||
|
IF OLD.fee IS DISTINCT FROM NEW.fee THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('fee', jsonb_build_object('old', OLD.fee, 'new', NEW.fee));
|
||||||
|
END IF;
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status));
|
||||||
|
END IF;
|
||||||
|
IF OLD.registration_deadline IS DISTINCT FROM NEW.registration_deadline THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('registration_deadline', jsonb_build_object('old', OLD.registration_deadline, 'new', NEW.registration_deadline));
|
||||||
|
END IF;
|
||||||
|
IF OLD.contact_name IS DISTINCT FROM NEW.contact_name THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('contact_name', jsonb_build_object('old', OLD.contact_name, 'new', NEW.contact_name));
|
||||||
|
END IF;
|
||||||
|
IF OLD.contact_email IS DISTINCT FROM NEW.contact_email THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('contact_email', jsonb_build_object('old', OLD.contact_email, 'new', NEW.contact_email));
|
||||||
|
END IF;
|
||||||
|
IF OLD.contact_phone IS DISTINCT FROM NEW.contact_phone THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('contact_phone', jsonb_build_object('old', OLD.contact_phone, 'new', NEW.contact_phone));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Skip if nothing actually changed
|
||||||
|
IF v_changes = '{}'::jsonb THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Classify the action
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
v_action := 'status_changed';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, changes)
|
||||||
|
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_events_audit_on_update
|
||||||
|
AFTER UPDATE ON public.events
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_event_audit_on_update();
|
||||||
|
|
||||||
|
-- C.3 Bookings UPDATE trigger
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_update()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_changes jsonb := '{}'::jsonb;
|
||||||
|
v_action text := 'updated';
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
-- Build changes diff (field by field)
|
||||||
|
IF OLD.room_id IS DISTINCT FROM NEW.room_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('room_id', jsonb_build_object('old', OLD.room_id, 'new', NEW.room_id));
|
||||||
|
END IF;
|
||||||
|
IF OLD.guest_id IS DISTINCT FROM NEW.guest_id THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('guest_id', jsonb_build_object('old', OLD.guest_id, 'new', NEW.guest_id));
|
||||||
|
END IF;
|
||||||
|
IF OLD.check_in IS DISTINCT FROM NEW.check_in THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('check_in', jsonb_build_object('old', OLD.check_in, 'new', NEW.check_in));
|
||||||
|
END IF;
|
||||||
|
IF OLD.check_out IS DISTINCT FROM NEW.check_out THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('check_out', jsonb_build_object('old', OLD.check_out, 'new', NEW.check_out));
|
||||||
|
END IF;
|
||||||
|
IF OLD.adults IS DISTINCT FROM NEW.adults THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('adults', jsonb_build_object('old', OLD.adults, 'new', NEW.adults));
|
||||||
|
END IF;
|
||||||
|
IF OLD.children IS DISTINCT FROM NEW.children THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('children', jsonb_build_object('old', OLD.children, 'new', NEW.children));
|
||||||
|
END IF;
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('status', jsonb_build_object('old', OLD.status, 'new', NEW.status));
|
||||||
|
END IF;
|
||||||
|
IF OLD.total_price IS DISTINCT FROM NEW.total_price THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('total_price', jsonb_build_object('old', OLD.total_price, 'new', NEW.total_price));
|
||||||
|
END IF;
|
||||||
|
IF OLD.notes IS DISTINCT FROM NEW.notes THEN
|
||||||
|
v_changes := v_changes || jsonb_build_object('notes', jsonb_build_object('old', OLD.notes, 'new', NEW.notes));
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Skip if nothing actually changed
|
||||||
|
IF v_changes = '{}'::jsonb THEN
|
||||||
|
RETURN NULL;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Classify the action
|
||||||
|
IF OLD.status IS DISTINCT FROM NEW.status THEN
|
||||||
|
v_action := 'status_changed';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, changes)
|
||||||
|
VALUES (NEW.id, NEW.account_id, v_user_id, v_action, v_changes);
|
||||||
|
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_bookings_audit_on_update
|
||||||
|
AFTER UPDATE ON public.bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_booking_audit_on_update();
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- D) Auto-audit triggers for INSERT
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- D.1 Courses INSERT trigger
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_course_audit_on_insert()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
NEW.created_by
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.course_audit_log (course_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (
|
||||||
|
NEW.id, NEW.account_id, v_user_id, 'created',
|
||||||
|
jsonb_build_object(
|
||||||
|
'course_number', NEW.course_number,
|
||||||
|
'name', NEW.name,
|
||||||
|
'status', NEW.status,
|
||||||
|
'fee', NEW.fee,
|
||||||
|
'capacity', NEW.capacity,
|
||||||
|
'start_date', NEW.start_date,
|
||||||
|
'end_date', NEW.end_date
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_courses_audit_on_insert
|
||||||
|
AFTER INSERT ON public.courses
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_course_audit_on_insert();
|
||||||
|
|
||||||
|
-- D.2 Events INSERT trigger
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_event_audit_on_insert()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
NEW.created_by
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.event_audit_log (event_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (
|
||||||
|
NEW.id, NEW.account_id, v_user_id, 'created',
|
||||||
|
jsonb_build_object(
|
||||||
|
'name', NEW.name,
|
||||||
|
'status', NEW.status,
|
||||||
|
'event_date', NEW.event_date,
|
||||||
|
'location', NEW.location,
|
||||||
|
'capacity', NEW.capacity,
|
||||||
|
'fee', NEW.fee
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_events_audit_on_insert
|
||||||
|
AFTER INSERT ON public.events
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_event_audit_on_insert();
|
||||||
|
|
||||||
|
-- D.3 Bookings INSERT trigger
|
||||||
|
CREATE OR REPLACE FUNCTION public.trg_booking_audit_on_insert()
|
||||||
|
RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id uuid;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := COALESCE(
|
||||||
|
NULLIF(current_setting('app.current_user_id', true), '')::uuid,
|
||||||
|
NEW.created_by
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO public.booking_audit_log (booking_id, account_id, user_id, action, metadata)
|
||||||
|
VALUES (
|
||||||
|
NEW.id, NEW.account_id, v_user_id, 'created',
|
||||||
|
jsonb_build_object(
|
||||||
|
'room_id', NEW.room_id,
|
||||||
|
'guest_id', NEW.guest_id,
|
||||||
|
'check_in', NEW.check_in,
|
||||||
|
'check_out', NEW.check_out,
|
||||||
|
'status', NEW.status,
|
||||||
|
'total_price', NEW.total_price,
|
||||||
|
'adults', NEW.adults,
|
||||||
|
'children', NEW.children
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
CREATE OR REPLACE TRIGGER trg_bookings_audit_on_insert
|
||||||
|
AFTER INSERT ON public.bookings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION public.trg_booking_audit_on_insert();
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Audit Timeline RPCs for Courses, Events, Bookings
|
||||||
|
--
|
||||||
|
-- Paginated, filterable read layer on the audit logs.
|
||||||
|
-- Mirrors get_member_timeline from 20260416000007.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 1. Course timeline RPC
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_course_timeline(
|
||||||
|
p_course_id uuid,
|
||||||
|
p_page integer DEFAULT 1,
|
||||||
|
p_page_size integer DEFAULT 50,
|
||||||
|
p_action_filter text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id bigint,
|
||||||
|
action text,
|
||||||
|
changes jsonb,
|
||||||
|
metadata jsonb,
|
||||||
|
user_id uuid,
|
||||||
|
user_email text,
|
||||||
|
created_at timestamptz,
|
||||||
|
total_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_account_id uuid;
|
||||||
|
v_total bigint;
|
||||||
|
v_offset integer;
|
||||||
|
BEGIN
|
||||||
|
-- Get course's account for access check
|
||||||
|
SELECT c.account_id INTO v_account_id
|
||||||
|
FROM public.courses c WHERE c.id = p_course_id;
|
||||||
|
|
||||||
|
IF v_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Course not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT public.has_role_on_account(v_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Clamp page size to prevent unbounded queries
|
||||||
|
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
|
||||||
|
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
|
||||||
|
|
||||||
|
-- Get total count
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.course_audit_log cal
|
||||||
|
WHERE cal.course_id = p_course_id
|
||||||
|
AND (p_action_filter IS NULL OR cal.action = p_action_filter);
|
||||||
|
|
||||||
|
-- Return paginated results with user email
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
cal.id,
|
||||||
|
cal.action,
|
||||||
|
cal.changes,
|
||||||
|
cal.metadata,
|
||||||
|
cal.user_id,
|
||||||
|
u.email::text AS user_email,
|
||||||
|
cal.created_at,
|
||||||
|
v_total AS total_count
|
||||||
|
FROM public.course_audit_log cal
|
||||||
|
LEFT JOIN auth.users u ON u.id = cal.user_id
|
||||||
|
WHERE cal.course_id = p_course_id
|
||||||
|
AND (p_action_filter IS NULL OR cal.action = p_action_filter)
|
||||||
|
ORDER BY cal.created_at DESC
|
||||||
|
LIMIT p_page_size OFFSET v_offset;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_course_timeline(uuid, integer, integer, text)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 2. Event timeline RPC
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_event_timeline(
|
||||||
|
p_event_id uuid,
|
||||||
|
p_page integer DEFAULT 1,
|
||||||
|
p_page_size integer DEFAULT 50,
|
||||||
|
p_action_filter text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id bigint,
|
||||||
|
action text,
|
||||||
|
changes jsonb,
|
||||||
|
metadata jsonb,
|
||||||
|
user_id uuid,
|
||||||
|
user_email text,
|
||||||
|
created_at timestamptz,
|
||||||
|
total_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_account_id uuid;
|
||||||
|
v_total bigint;
|
||||||
|
v_offset integer;
|
||||||
|
BEGIN
|
||||||
|
-- Get event's account for access check
|
||||||
|
SELECT e.account_id INTO v_account_id
|
||||||
|
FROM public.events e WHERE e.id = p_event_id;
|
||||||
|
|
||||||
|
IF v_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Event not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT public.has_role_on_account(v_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Clamp page size to prevent unbounded queries
|
||||||
|
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
|
||||||
|
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
|
||||||
|
|
||||||
|
-- Get total count
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.event_audit_log eal
|
||||||
|
WHERE eal.event_id = p_event_id
|
||||||
|
AND (p_action_filter IS NULL OR eal.action = p_action_filter);
|
||||||
|
|
||||||
|
-- Return paginated results with user email
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
eal.id,
|
||||||
|
eal.action,
|
||||||
|
eal.changes,
|
||||||
|
eal.metadata,
|
||||||
|
eal.user_id,
|
||||||
|
u.email::text AS user_email,
|
||||||
|
eal.created_at,
|
||||||
|
v_total AS total_count
|
||||||
|
FROM public.event_audit_log eal
|
||||||
|
LEFT JOIN auth.users u ON u.id = eal.user_id
|
||||||
|
WHERE eal.event_id = p_event_id
|
||||||
|
AND (p_action_filter IS NULL OR eal.action = p_action_filter)
|
||||||
|
ORDER BY eal.created_at DESC
|
||||||
|
LIMIT p_page_size OFFSET v_offset;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_event_timeline(uuid, integer, integer, text)
|
||||||
|
TO authenticated, service_role;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 3. Booking timeline RPC
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_booking_timeline(
|
||||||
|
p_booking_id uuid,
|
||||||
|
p_page integer DEFAULT 1,
|
||||||
|
p_page_size integer DEFAULT 50,
|
||||||
|
p_action_filter text DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
id bigint,
|
||||||
|
action text,
|
||||||
|
changes jsonb,
|
||||||
|
metadata jsonb,
|
||||||
|
user_id uuid,
|
||||||
|
user_email text,
|
||||||
|
created_at timestamptz,
|
||||||
|
total_count bigint
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_account_id uuid;
|
||||||
|
v_total bigint;
|
||||||
|
v_offset integer;
|
||||||
|
BEGIN
|
||||||
|
-- Get booking's account for access check
|
||||||
|
SELECT b.account_id INTO v_account_id
|
||||||
|
FROM public.bookings b WHERE b.id = p_booking_id;
|
||||||
|
|
||||||
|
IF v_account_id IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Booking not found';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT public.has_role_on_account(v_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied' USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Clamp page size to prevent unbounded queries
|
||||||
|
p_page_size := LEAST(GREATEST(p_page_size, 1), 200);
|
||||||
|
v_offset := GREATEST(0, (p_page - 1)) * p_page_size;
|
||||||
|
|
||||||
|
-- Get total count
|
||||||
|
SELECT count(*) INTO v_total
|
||||||
|
FROM public.booking_audit_log bal
|
||||||
|
WHERE bal.booking_id = p_booking_id
|
||||||
|
AND (p_action_filter IS NULL OR bal.action = p_action_filter);
|
||||||
|
|
||||||
|
-- Return paginated results with user email
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
bal.id,
|
||||||
|
bal.action,
|
||||||
|
bal.changes,
|
||||||
|
bal.metadata,
|
||||||
|
bal.user_id,
|
||||||
|
u.email::text AS user_email,
|
||||||
|
bal.created_at,
|
||||||
|
v_total AS total_count
|
||||||
|
FROM public.booking_audit_log bal
|
||||||
|
LEFT JOIN auth.users u ON u.id = bal.user_id
|
||||||
|
WHERE bal.booking_id = p_booking_id
|
||||||
|
AND (p_action_filter IS NULL OR bal.action = p_action_filter)
|
||||||
|
ORDER BY bal.created_at DESC
|
||||||
|
LIMIT p_page_size OFFSET v_offset;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_booking_timeline(uuid, integer, integer, text)
|
||||||
|
TO authenticated, service_role;
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Waitlist Management
|
||||||
|
--
|
||||||
|
-- A) Course cancellation with automatic waitlist promotion
|
||||||
|
-- B) Event cancellation with automatic waitlist promotion
|
||||||
|
--
|
||||||
|
-- When an enrolled/confirmed participant is cancelled,
|
||||||
|
-- the oldest waitlisted entry is atomically promoted.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- A) Course waitlist promotion
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_course_enrollment(p_participant_id uuid)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_participant record;
|
||||||
|
v_course record;
|
||||||
|
v_promoted_id uuid;
|
||||||
|
v_promoted_name text;
|
||||||
|
BEGIN
|
||||||
|
-- Lock participant
|
||||||
|
SELECT * INTO v_participant
|
||||||
|
FROM public.course_participants
|
||||||
|
WHERE id = p_participant_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF v_participant IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Teilnehmer nicht gefunden'
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Lock course
|
||||||
|
SELECT * INTO v_course
|
||||||
|
FROM public.courses
|
||||||
|
WHERE id = v_participant.course_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UPDATE public.course_participants
|
||||||
|
SET status = 'cancelled'::public.enrollment_status,
|
||||||
|
cancelled_at = now()
|
||||||
|
WHERE id = p_participant_id;
|
||||||
|
|
||||||
|
-- If was enrolled (not already waitlisted/cancelled), promote oldest waitlisted
|
||||||
|
IF v_participant.status = 'enrolled' THEN
|
||||||
|
UPDATE public.course_participants
|
||||||
|
SET status = 'enrolled'::public.enrollment_status
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM public.course_participants
|
||||||
|
WHERE course_id = v_participant.course_id
|
||||||
|
AND status = 'waitlisted'
|
||||||
|
ORDER BY enrolled_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id, first_name || ' ' || last_name
|
||||||
|
INTO v_promoted_id, v_promoted_name;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'cancelled_id', p_participant_id,
|
||||||
|
'promoted_id', v_promoted_id,
|
||||||
|
'promoted_name', v_promoted_name
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.cancel_course_enrollment(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.cancel_course_enrollment(uuid) TO service_role;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- B) Event registration cancellation + waitlist promotion
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Add updated_at column if not present
|
||||||
|
ALTER TABLE public.event_registrations
|
||||||
|
ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.cancel_event_registration(p_registration_id uuid)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_registration record;
|
||||||
|
v_event record;
|
||||||
|
v_promoted_id uuid;
|
||||||
|
v_promoted_name text;
|
||||||
|
BEGIN
|
||||||
|
-- Lock registration
|
||||||
|
SELECT * INTO v_registration
|
||||||
|
FROM public.event_registrations
|
||||||
|
WHERE id = p_registration_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
IF v_registration IS NULL THEN
|
||||||
|
RAISE EXCEPTION 'Anmeldung nicht gefunden'
|
||||||
|
USING ERRCODE = 'P0002';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Lock event
|
||||||
|
SELECT * INTO v_event
|
||||||
|
FROM public.events
|
||||||
|
WHERE id = v_registration.event_id
|
||||||
|
FOR UPDATE;
|
||||||
|
|
||||||
|
-- Cancel
|
||||||
|
UPDATE public.event_registrations
|
||||||
|
SET status = 'cancelled',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = p_registration_id;
|
||||||
|
|
||||||
|
-- If was confirmed or pending, promote oldest waitlisted
|
||||||
|
IF v_registration.status IN ('confirmed', 'pending') THEN
|
||||||
|
UPDATE public.event_registrations
|
||||||
|
SET status = 'confirmed',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id FROM public.event_registrations
|
||||||
|
WHERE event_id = v_registration.event_id
|
||||||
|
AND status = 'waitlisted'
|
||||||
|
ORDER BY created_at ASC
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
RETURNING id, first_name || ' ' || last_name
|
||||||
|
INTO v_promoted_id, v_promoted_name;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN jsonb_build_object(
|
||||||
|
'cancelled_id', p_registration_id,
|
||||||
|
'promoted_id', v_promoted_id,
|
||||||
|
'promoted_name', v_promoted_name
|
||||||
|
);
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.cancel_event_registration(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.cancel_event_registration(uuid) TO service_role;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Attendance Rollup
|
||||||
|
--
|
||||||
|
-- RPC that returns a per-participant attendance summary
|
||||||
|
-- for a given course: total sessions, sessions attended,
|
||||||
|
-- and attendance rate (%).
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_course_attendance_summary(p_course_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
participant_id uuid,
|
||||||
|
participant_name text,
|
||||||
|
enrollment_status public.enrollment_status,
|
||||||
|
total_sessions bigint,
|
||||||
|
sessions_attended bigint,
|
||||||
|
attendance_rate numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Access check
|
||||||
|
IF NOT public.has_role_on_account(
|
||||||
|
(SELECT account_id FROM public.courses WHERE id = p_course_id)
|
||||||
|
) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH session_count AS (
|
||||||
|
SELECT count(*)::bigint AS cnt
|
||||||
|
FROM public.course_sessions
|
||||||
|
WHERE course_id = p_course_id
|
||||||
|
AND is_cancelled = false
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cp.id AS participant_id,
|
||||||
|
(cp.first_name || ' ' || cp.last_name)::text AS participant_name,
|
||||||
|
cp.status AS enrollment_status,
|
||||||
|
sc.cnt AS total_sessions,
|
||||||
|
COALESCE(count(ca.id) FILTER (WHERE ca.present = true), 0)::bigint AS sessions_attended,
|
||||||
|
CASE WHEN sc.cnt > 0 THEN
|
||||||
|
ROUND(
|
||||||
|
COALESCE(count(ca.id) FILTER (WHERE ca.present = true), 0)::numeric
|
||||||
|
/ sc.cnt * 100,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
ELSE 0 END AS attendance_rate
|
||||||
|
FROM public.course_participants cp
|
||||||
|
CROSS JOIN session_count sc
|
||||||
|
LEFT JOIN public.course_attendance ca ON ca.participant_id = cp.id
|
||||||
|
LEFT JOIN public.course_sessions cs
|
||||||
|
ON cs.id = ca.session_id
|
||||||
|
AND cs.is_cancelled = false
|
||||||
|
WHERE cp.course_id = p_course_id
|
||||||
|
AND cp.status IN ('enrolled', 'completed')
|
||||||
|
GROUP BY cp.id, cp.first_name, cp.last_name, cp.status, sc.cnt
|
||||||
|
ORDER BY cp.last_name, cp.first_name;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_course_attendance_summary(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_course_attendance_summary(uuid) TO service_role;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Instructor Availability Check
|
||||||
|
--
|
||||||
|
-- Returns TRUE if the instructor has no scheduling
|
||||||
|
-- conflicts for the requested date/time window.
|
||||||
|
-- Optionally excludes a specific session (for edits).
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.check_instructor_availability(
|
||||||
|
p_instructor_id uuid,
|
||||||
|
p_session_date date,
|
||||||
|
p_start_time time,
|
||||||
|
p_end_time time,
|
||||||
|
p_exclude_session_id uuid DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS boolean
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
SELECT NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM public.course_sessions cs
|
||||||
|
JOIN public.courses c ON c.id = cs.course_id
|
||||||
|
WHERE c.instructor_id = p_instructor_id
|
||||||
|
AND cs.session_date = p_session_date
|
||||||
|
AND cs.start_time < p_end_time
|
||||||
|
AND cs.end_time > p_start_time
|
||||||
|
AND (p_exclude_session_id IS NULL OR cs.id != p_exclude_session_id)
|
||||||
|
AND cs.is_cancelled = false
|
||||||
|
);
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.check_instructor_availability(uuid, date, time, time, uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.check_instructor_availability(uuid, date, time, time, uuid) TO service_role;
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Module Statistics RPCs
|
||||||
|
--
|
||||||
|
-- A) Course statistics — counts per status, participants,
|
||||||
|
-- average occupancy, total revenue
|
||||||
|
-- B) Event statistics — counts, upcoming/past, registrations,
|
||||||
|
-- average occupancy
|
||||||
|
-- C) Booking statistics — counts, revenue, avg stay,
|
||||||
|
-- occupancy rate for a date range
|
||||||
|
-- D) Event registration counts — batch lookup replacing
|
||||||
|
-- N+1 JS iteration
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- A) Course statistics
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_course_statistics(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_courses bigint,
|
||||||
|
open_courses bigint,
|
||||||
|
running_courses bigint,
|
||||||
|
completed_courses bigint,
|
||||||
|
cancelled_courses bigint,
|
||||||
|
total_participants bigint,
|
||||||
|
total_waitlisted bigint,
|
||||||
|
avg_occupancy_rate numeric,
|
||||||
|
total_revenue numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Access check
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH course_stats AS (
|
||||||
|
SELECT
|
||||||
|
count(*)::bigint AS total_courses,
|
||||||
|
count(*) FILTER (WHERE c.status = 'open')::bigint AS open_courses,
|
||||||
|
count(*) FILTER (WHERE c.status = 'running')::bigint AS running_courses,
|
||||||
|
count(*) FILTER (WHERE c.status = 'completed')::bigint AS completed_courses,
|
||||||
|
count(*) FILTER (WHERE c.status = 'cancelled')::bigint AS cancelled_courses
|
||||||
|
FROM public.courses c
|
||||||
|
WHERE c.account_id = p_account_id
|
||||||
|
),
|
||||||
|
participant_stats AS (
|
||||||
|
SELECT
|
||||||
|
count(*) FILTER (WHERE cp.status = 'enrolled')::bigint AS total_participants,
|
||||||
|
count(*) FILTER (WHERE cp.status = 'waitlisted')::bigint AS total_waitlisted
|
||||||
|
FROM public.course_participants cp
|
||||||
|
JOIN public.courses c ON c.id = cp.course_id
|
||||||
|
WHERE c.account_id = p_account_id
|
||||||
|
),
|
||||||
|
occupancy_stats AS (
|
||||||
|
SELECT
|
||||||
|
ROUND(
|
||||||
|
AVG(
|
||||||
|
CASE WHEN c.capacity > 0 THEN
|
||||||
|
enrolled_ct::numeric / c.capacity * 100
|
||||||
|
ELSE 0 END
|
||||||
|
),
|
||||||
|
1
|
||||||
|
) AS avg_occupancy_rate
|
||||||
|
FROM public.courses c
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*)::numeric AS enrolled_ct
|
||||||
|
FROM public.course_participants cp
|
||||||
|
WHERE cp.course_id = c.id AND cp.status = 'enrolled'
|
||||||
|
) ec ON true
|
||||||
|
WHERE c.account_id = p_account_id
|
||||||
|
AND c.status != 'cancelled'
|
||||||
|
),
|
||||||
|
revenue_stats AS (
|
||||||
|
SELECT
|
||||||
|
COALESCE(SUM(c.fee * enrolled_ct), 0)::numeric AS total_revenue
|
||||||
|
FROM public.courses c
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*)::numeric AS enrolled_ct
|
||||||
|
FROM public.course_participants cp
|
||||||
|
WHERE cp.course_id = c.id AND cp.status IN ('enrolled', 'completed')
|
||||||
|
) ec ON true
|
||||||
|
WHERE c.account_id = p_account_id
|
||||||
|
AND c.status != 'cancelled'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cs.total_courses,
|
||||||
|
cs.open_courses,
|
||||||
|
cs.running_courses,
|
||||||
|
cs.completed_courses,
|
||||||
|
cs.cancelled_courses,
|
||||||
|
ps.total_participants,
|
||||||
|
ps.total_waitlisted,
|
||||||
|
os.avg_occupancy_rate,
|
||||||
|
rs.total_revenue
|
||||||
|
FROM course_stats cs
|
||||||
|
CROSS JOIN participant_stats ps
|
||||||
|
CROSS JOIN occupancy_stats os
|
||||||
|
CROSS JOIN revenue_stats rs;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_course_statistics(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_course_statistics(uuid) TO service_role;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- B) Event statistics
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_event_statistics(p_account_id uuid)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_events bigint,
|
||||||
|
upcoming_events bigint,
|
||||||
|
past_events bigint,
|
||||||
|
total_registrations bigint,
|
||||||
|
avg_occupancy_rate numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Access check
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH event_counts AS (
|
||||||
|
SELECT
|
||||||
|
count(*)::bigint AS total_events,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE e.event_date >= current_date
|
||||||
|
AND e.status NOT IN ('cancelled', 'completed')
|
||||||
|
)::bigint AS upcoming_events,
|
||||||
|
count(*) FILTER (
|
||||||
|
WHERE e.event_date < current_date
|
||||||
|
OR e.status IN ('completed')
|
||||||
|
)::bigint AS past_events
|
||||||
|
FROM public.events e
|
||||||
|
WHERE e.account_id = p_account_id
|
||||||
|
),
|
||||||
|
reg_counts AS (
|
||||||
|
SELECT count(*)::bigint AS total_registrations
|
||||||
|
FROM public.event_registrations er
|
||||||
|
JOIN public.events e ON e.id = er.event_id
|
||||||
|
WHERE e.account_id = p_account_id
|
||||||
|
AND er.status IN ('confirmed', 'pending')
|
||||||
|
),
|
||||||
|
occupancy AS (
|
||||||
|
SELECT
|
||||||
|
ROUND(
|
||||||
|
AVG(
|
||||||
|
CASE WHEN e.capacity IS NOT NULL AND e.capacity > 0 THEN
|
||||||
|
reg_ct::numeric / e.capacity * 100
|
||||||
|
ELSE NULL END
|
||||||
|
),
|
||||||
|
1
|
||||||
|
) AS avg_occupancy_rate
|
||||||
|
FROM public.events e
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT count(*)::numeric AS reg_ct
|
||||||
|
FROM public.event_registrations er
|
||||||
|
WHERE er.event_id = e.id AND er.status IN ('confirmed', 'pending')
|
||||||
|
) rc ON true
|
||||||
|
WHERE e.account_id = p_account_id
|
||||||
|
AND e.status != 'cancelled'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
ec.total_events,
|
||||||
|
ec.upcoming_events,
|
||||||
|
ec.past_events,
|
||||||
|
rc.total_registrations,
|
||||||
|
COALESCE(occ.avg_occupancy_rate, 0)::numeric
|
||||||
|
FROM event_counts ec
|
||||||
|
CROSS JOIN reg_counts rc
|
||||||
|
CROSS JOIN occupancy occ;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_event_statistics(uuid) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_event_statistics(uuid) TO service_role;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- C) Booking statistics
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_booking_statistics(
|
||||||
|
p_account_id uuid,
|
||||||
|
p_from date DEFAULT NULL,
|
||||||
|
p_to date DEFAULT NULL
|
||||||
|
)
|
||||||
|
RETURNS TABLE (
|
||||||
|
total_bookings bigint,
|
||||||
|
active_bookings bigint,
|
||||||
|
checked_in_count bigint,
|
||||||
|
total_revenue numeric,
|
||||||
|
avg_stay_nights numeric,
|
||||||
|
occupancy_rate numeric
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_from date;
|
||||||
|
v_to date;
|
||||||
|
v_total_rooms bigint;
|
||||||
|
v_total_room_nights numeric;
|
||||||
|
v_booked_room_nights numeric;
|
||||||
|
BEGIN
|
||||||
|
-- Access check
|
||||||
|
IF NOT public.has_role_on_account(p_account_id) THEN
|
||||||
|
RAISE EXCEPTION 'Access denied'
|
||||||
|
USING ERRCODE = '42501';
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Default date range: current month
|
||||||
|
v_from := COALESCE(p_from, date_trunc('month', current_date)::date);
|
||||||
|
v_to := COALESCE(p_to, (date_trunc('month', current_date) + interval '1 month' - interval '1 day')::date);
|
||||||
|
|
||||||
|
-- Calculate total available room-nights
|
||||||
|
SELECT count(*)::bigint INTO v_total_rooms
|
||||||
|
FROM public.rooms
|
||||||
|
WHERE account_id = p_account_id
|
||||||
|
AND is_active = true;
|
||||||
|
|
||||||
|
v_total_room_nights := v_total_rooms::numeric * (v_to - v_from + 1);
|
||||||
|
|
||||||
|
-- Calculate booked room-nights in range (non-cancelled)
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
LEAST(b.check_out, v_to + 1) - GREATEST(b.check_in, v_from)
|
||||||
|
), 0)::numeric
|
||||||
|
INTO v_booked_room_nights
|
||||||
|
FROM public.bookings b
|
||||||
|
WHERE b.account_id = p_account_id
|
||||||
|
AND b.status NOT IN ('cancelled', 'no_show')
|
||||||
|
AND b.check_in <= v_to
|
||||||
|
AND b.check_out > v_from;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
SELECT
|
||||||
|
count(*)::bigint AS total_bookings,
|
||||||
|
count(*) FILTER (WHERE b.status IN ('confirmed', 'checked_in'))::bigint AS active_bookings,
|
||||||
|
count(*) FILTER (WHERE b.status = 'checked_in')::bigint AS checked_in_count,
|
||||||
|
COALESCE(SUM(b.total_price) FILTER (WHERE b.status != 'cancelled'), 0)::numeric AS total_revenue,
|
||||||
|
ROUND(
|
||||||
|
COALESCE(AVG((b.check_out - b.check_in)::numeric) FILTER (WHERE b.status != 'cancelled'), 0),
|
||||||
|
1
|
||||||
|
) AS avg_stay_nights,
|
||||||
|
CASE WHEN v_total_room_nights > 0 THEN
|
||||||
|
ROUND(v_booked_room_nights / v_total_room_nights * 100, 1)
|
||||||
|
ELSE 0 END AS occupancy_rate
|
||||||
|
FROM public.bookings b
|
||||||
|
WHERE b.account_id = p_account_id
|
||||||
|
AND b.check_in <= v_to
|
||||||
|
AND b.check_out > v_from;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_booking_statistics(uuid, date, date) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_booking_statistics(uuid, date, date) TO service_role;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- D) Event registration counts (batch lookup)
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.get_event_registration_counts(p_event_ids uuid[])
|
||||||
|
RETURNS TABLE (event_id uuid, registration_count bigint)
|
||||||
|
LANGUAGE sql
|
||||||
|
STABLE
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = ''
|
||||||
|
AS $$
|
||||||
|
SELECT
|
||||||
|
er.event_id,
|
||||||
|
count(*)::bigint AS registration_count
|
||||||
|
FROM public.event_registrations er
|
||||||
|
WHERE er.event_id = ANY(p_event_ids)
|
||||||
|
AND er.status IN ('confirmed', 'pending')
|
||||||
|
GROUP BY er.event_id;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_event_registration_counts(uuid[]) TO authenticated;
|
||||||
|
GRANT EXECUTE ON FUNCTION public.get_event_registration_counts(uuid[]) TO service_role;
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- =====================================================
|
||||||
|
-- Additional Indexes
|
||||||
|
--
|
||||||
|
-- Partial indexes for common query patterns across
|
||||||
|
-- course-management, event-management, and
|
||||||
|
-- booking-management modules.
|
||||||
|
-- =====================================================
|
||||||
|
|
||||||
|
-- Course participants: fast capacity counting
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_course_participants_active_status
|
||||||
|
ON public.course_participants(course_id, status)
|
||||||
|
WHERE status IN ('enrolled', 'waitlisted');
|
||||||
|
|
||||||
|
-- Event registrations: fast registration counting
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_registrations_active_status
|
||||||
|
ON public.event_registrations(event_id, status)
|
||||||
|
WHERE status IN ('confirmed', 'pending', 'waitlisted');
|
||||||
|
|
||||||
|
-- Bookings: active bookings for availability queries
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_bookings_active_dates
|
||||||
|
ON public.bookings(room_id, check_in, check_out)
|
||||||
|
WHERE status NOT IN ('cancelled', 'no_show');
|
||||||
|
|
||||||
|
-- Bookings: guest history lookup
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_bookings_guest_checkin
|
||||||
|
ON public.bookings(guest_id, check_in DESC)
|
||||||
|
WHERE guest_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Course sessions: instructor scheduling conflict checks
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_course_sessions_instructor_date
|
||||||
|
ON public.course_sessions(session_date, start_time, end_time)
|
||||||
|
WHERE is_cancelled = false;
|
||||||
|
|
||||||
|
-- Audit log indexes for timeline queries
|
||||||
|
-- Safety nets in case earlier migration did not cover them
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_course_audit_account_action
|
||||||
|
ON public.course_audit_log(account_id, action);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_event_audit_account_action
|
||||||
|
ON public.event_audit_log(account_id, action);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS ix_booking_audit_account_action
|
||||||
|
ON public.booking_audit_log(account_id, action);
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.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": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { BookingStatusEnum } from '../schema/booking.schema';
|
||||||
|
|
||||||
|
type BookingStatus = z.infer<typeof BookingStatusEnum>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booking status state machine.
|
||||||
|
*
|
||||||
|
* Defines valid transitions between booking statuses and their
|
||||||
|
* side effects. Enforced in booking update operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type StatusTransition = {
|
||||||
|
/** Fields to set automatically when this transition occurs */
|
||||||
|
sideEffects?: Partial<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITIONS: Record<
|
||||||
|
BookingStatus,
|
||||||
|
Partial<Record<BookingStatus, StatusTransition>>
|
||||||
|
> = {
|
||||||
|
pending: {
|
||||||
|
confirmed: {},
|
||||||
|
cancelled: {},
|
||||||
|
},
|
||||||
|
confirmed: {
|
||||||
|
checked_in: {},
|
||||||
|
cancelled: {},
|
||||||
|
no_show: {},
|
||||||
|
},
|
||||||
|
checked_in: {
|
||||||
|
checked_out: {},
|
||||||
|
},
|
||||||
|
// Terminal states — no transitions out
|
||||||
|
checked_out: {},
|
||||||
|
cancelled: {
|
||||||
|
pending: {},
|
||||||
|
},
|
||||||
|
no_show: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a status transition is valid.
|
||||||
|
*/
|
||||||
|
export function canTransition(from: BookingStatus, to: BookingStatus): boolean {
|
||||||
|
if (from === to) return true; // no-op is always valid
|
||||||
|
return to in (TRANSITIONS[from] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valid target statuses from a given status.
|
||||||
|
*/
|
||||||
|
export function getValidTransitions(from: BookingStatus): BookingStatus[] {
|
||||||
|
return Object.keys(TRANSITIONS[from] ?? {}) as BookingStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the side effects for a transition.
|
||||||
|
* Returns an object of field->value pairs to apply alongside the status change.
|
||||||
|
* Function values should be called to get the actual value.
|
||||||
|
*/
|
||||||
|
export function getTransitionSideEffects(
|
||||||
|
from: BookingStatus,
|
||||||
|
to: BookingStatus,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (from === to) return {};
|
||||||
|
|
||||||
|
const transition = TRANSITIONS[from]?.[to];
|
||||||
|
if (!transition?.sideEffects) return {};
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||||
|
result[key] = typeof value === 'function' ? value() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a status transition and return side effects.
|
||||||
|
* Throws if the transition is invalid.
|
||||||
|
*/
|
||||||
|
export function validateTransition(
|
||||||
|
from: BookingStatus,
|
||||||
|
to: BookingStatus,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (from === to) return {};
|
||||||
|
|
||||||
|
if (!canTransition(from, to)) {
|
||||||
|
throw new Error(
|
||||||
|
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getTransitionSideEffects(from, to);
|
||||||
|
}
|
||||||
131
packages/features/booking-management/src/lib/errors.ts
Normal file
131
packages/features/booking-management/src/lib/errors.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Standardized error codes and domain error classes
|
||||||
|
* for the booking management module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BookingErrorCodes = {
|
||||||
|
BOOKING_NOT_FOUND: 'BOOKING_NOT_FOUND',
|
||||||
|
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
|
||||||
|
ROOM_NOT_AVAILABLE: 'ROOM_NOT_AVAILABLE',
|
||||||
|
ROOM_CAPACITY_EXCEEDED: 'ROOM_CAPACITY_EXCEEDED',
|
||||||
|
INVALID_STATUS_TRANSITION: 'BOOKING_INVALID_TRANSITION',
|
||||||
|
CONCURRENCY_CONFLICT: 'BOOKING_CONCURRENCY_CONFLICT',
|
||||||
|
INVALID_DATE_RANGE: 'BOOKING_INVALID_DATE_RANGE',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BookingErrorCode =
|
||||||
|
(typeof BookingErrorCodes)[keyof typeof BookingErrorCodes];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base domain error for booking management operations.
|
||||||
|
*/
|
||||||
|
export class BookingDomainError extends Error {
|
||||||
|
readonly code: BookingErrorCode;
|
||||||
|
readonly statusCode: number;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: BookingErrorCode,
|
||||||
|
message: string,
|
||||||
|
statusCode = 400,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BookingDomainError';
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingNotFoundError extends BookingDomainError {
|
||||||
|
constructor(bookingId: string) {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.BOOKING_NOT_FOUND,
|
||||||
|
`Buchung ${bookingId} nicht gefunden`,
|
||||||
|
404,
|
||||||
|
{ bookingId },
|
||||||
|
);
|
||||||
|
this.name = 'BookingNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomNotFoundError extends BookingDomainError {
|
||||||
|
constructor(roomId: string) {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.ROOM_NOT_FOUND,
|
||||||
|
`Raum ${roomId} nicht gefunden`,
|
||||||
|
404,
|
||||||
|
{ roomId },
|
||||||
|
);
|
||||||
|
this.name = 'RoomNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomNotAvailableError extends BookingDomainError {
|
||||||
|
constructor(roomId: string, from: string, to: string) {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.ROOM_NOT_AVAILABLE,
|
||||||
|
`Raum ${roomId} ist im Zeitraum ${from} bis ${to} nicht verfügbar`,
|
||||||
|
409,
|
||||||
|
{ roomId, from, to },
|
||||||
|
);
|
||||||
|
this.name = 'RoomNotAvailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomCapacityExceededError extends BookingDomainError {
|
||||||
|
constructor(roomId: string, capacity: number) {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.ROOM_CAPACITY_EXCEEDED,
|
||||||
|
`Raum ${roomId} hat die maximale Kapazität (${capacity}) erreicht`,
|
||||||
|
422,
|
||||||
|
{ roomId, capacity },
|
||||||
|
);
|
||||||
|
this.name = 'RoomCapacityExceededError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidBookingStatusTransitionError extends BookingDomainError {
|
||||||
|
constructor(from: string, to: string, validTargets: string[]) {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.INVALID_STATUS_TRANSITION,
|
||||||
|
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
|
||||||
|
422,
|
||||||
|
{ from, to, validTargets },
|
||||||
|
);
|
||||||
|
this.name = 'InvalidBookingStatusTransitionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingConcurrencyConflictError extends BookingDomainError {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.CONCURRENCY_CONFLICT,
|
||||||
|
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
this.name = 'BookingConcurrencyConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BookingInvalidDateRangeError extends BookingDomainError {
|
||||||
|
constructor(from: string, to: string) {
|
||||||
|
super(
|
||||||
|
BookingErrorCodes.INVALID_DATE_RANGE,
|
||||||
|
`Ungültiger Zeitraum: ${from} bis ${to}. Das Enddatum muss nach dem Startdatum liegen.`,
|
||||||
|
422,
|
||||||
|
{ from, to },
|
||||||
|
);
|
||||||
|
this.name = 'BookingInvalidDateRangeError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a BookingDomainError.
|
||||||
|
*/
|
||||||
|
export function isBookingDomainError(
|
||||||
|
error: unknown,
|
||||||
|
): error is BookingDomainError {
|
||||||
|
return error instanceof BookingDomainError;
|
||||||
|
}
|
||||||
@@ -20,20 +20,31 @@ export const CreateRoomSchema = z.object({
|
|||||||
description: z.string().optional(),
|
description: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const CreateBookingSchema = z.object({
|
export const CreateBookingSchema = z
|
||||||
accountId: z.string().uuid(),
|
.object({
|
||||||
roomId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
guestId: z.string().uuid().optional(),
|
roomId: z.string().uuid(),
|
||||||
checkIn: z.string(),
|
guestId: z.string().uuid().optional(),
|
||||||
checkOut: z.string(),
|
checkIn: z.string(),
|
||||||
adults: z.number().int().min(1).default(1),
|
checkOut: z.string(),
|
||||||
children: z.number().int().min(0).default(0),
|
adults: z.number().int().min(1).default(1),
|
||||||
status: BookingStatusEnum.default('confirmed'),
|
children: z.number().int().min(0).default(0),
|
||||||
totalPrice: z.number().min(0).default(0),
|
status: BookingStatusEnum.default('confirmed'),
|
||||||
notes: z.string().optional(),
|
totalPrice: z.number().min(0).optional(),
|
||||||
});
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => d.checkOut > d.checkIn, {
|
||||||
|
message: 'Abreisedatum muss nach dem Anreisedatum liegen',
|
||||||
|
path: ['checkOut'],
|
||||||
|
});
|
||||||
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
|
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
|
||||||
|
|
||||||
|
export const UpdateBookingStatusSchema = z.object({
|
||||||
|
bookingId: z.string().uuid(),
|
||||||
|
status: BookingStatusEnum,
|
||||||
|
version: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const CreateGuestSchema = z.object({
|
export const CreateGuestSchema = z.object({
|
||||||
accountId: z.string().uuid(),
|
accountId: z.string().uuid(),
|
||||||
firstName: z.string().min(1),
|
firstName: z.string().min(1),
|
||||||
|
|||||||
@@ -1,71 +1,87 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
import { authActionClient } from '@kit/next/safe-action';
|
import { authActionClient } from '@kit/next/safe-action';
|
||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { isBookingDomainError } from '../../lib/errors';
|
||||||
import {
|
import {
|
||||||
CreateBookingSchema,
|
CreateBookingSchema,
|
||||||
CreateGuestSchema,
|
CreateGuestSchema,
|
||||||
CreateRoomSchema,
|
CreateRoomSchema,
|
||||||
|
UpdateBookingStatusSchema,
|
||||||
} from '../../schema/booking.schema';
|
} from '../../schema/booking.schema';
|
||||||
import { createBookingManagementApi } from '../api';
|
import { createBookingManagementApi } from '../api';
|
||||||
|
|
||||||
export const createBooking = authActionClient
|
export const createBooking = authActionClient
|
||||||
.inputSchema(CreateBookingSchema)
|
.inputSchema(CreateBookingSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'booking.create' }, 'Creating booking...');
|
try {
|
||||||
const result = await api.createBooking(input);
|
logger.info({ name: 'booking.create' }, 'Creating booking...');
|
||||||
logger.info({ name: 'booking.create' }, 'Booking created');
|
const result = await api.bookings.create(input);
|
||||||
return { success: true, data: result };
|
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
|
export const updateBookingStatus = authActionClient
|
||||||
.inputSchema(
|
.inputSchema(UpdateBookingStatusSchema)
|
||||||
z.object({
|
.action(async ({ parsedInput: input }) => {
|
||||||
bookingId: z.string().uuid(),
|
|
||||||
status: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
|
try {
|
||||||
const result = await api.updateBookingStatus(input.bookingId, input.status);
|
logger.info(
|
||||||
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
|
{ name: 'booking.updateStatus' },
|
||||||
return { success: true, data: result };
|
'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
|
export const createRoom = authActionClient
|
||||||
.inputSchema(CreateRoomSchema)
|
.inputSchema(CreateRoomSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'booking.createRoom' }, 'Creating room...');
|
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');
|
logger.info({ name: 'booking.createRoom' }, 'Room created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createGuest = authActionClient
|
export const createGuest = authActionClient
|
||||||
.inputSchema(CreateGuestSchema)
|
.inputSchema(CreateGuestSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createBookingManagementApi(client);
|
const api = createBookingManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
|
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');
|
logger.info({ name: 'booking.createGuest' }, 'Guest created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,173 +1,16 @@
|
|||||||
|
import 'server-only';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import type { Database } from '@kit/supabase/database';
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import type { CreateBookingInput } from '../schema/booking.schema';
|
import { createBookingCrudService } from './services/booking-crud.service';
|
||||||
|
import { createGuestService } from './services/guest.service';
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
import { createRoomService } from './services/room.service';
|
||||||
|
|
||||||
export function createBookingManagementApi(client: SupabaseClient<Database>) {
|
export function createBookingManagementApi(client: SupabaseClient<Database>) {
|
||||||
const _db = client;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// --- Rooms ---
|
rooms: createRoomService(client),
|
||||||
async listRooms(accountId: string) {
|
bookings: createBookingCrudService(client),
|
||||||
const { data, error } = await client
|
guests: createGuestService(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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getValidTransitions,
|
||||||
|
validateTransition,
|
||||||
|
} from '../../lib/booking-status-machine';
|
||||||
|
import {
|
||||||
|
BookingConcurrencyConflictError,
|
||||||
|
InvalidBookingStatusTransitionError,
|
||||||
|
} from '../../lib/errors';
|
||||||
|
import type { CreateBookingInput } from '../../schema/booking.schema';
|
||||||
|
|
||||||
|
const NAMESPACE = 'booking-crud';
|
||||||
|
|
||||||
|
export function createBookingCrudService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async list(
|
||||||
|
accountId: string,
|
||||||
|
opts?: {
|
||||||
|
status?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let query = client
|
||||||
|
.from('bookings')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('check_in', { ascending: false });
|
||||||
|
if (opts?.status) query = query.eq('status', opts.status);
|
||||||
|
if (opts?.from) query = query.gte('check_in', opts.from);
|
||||||
|
if (opts?.to) query = query.lte('check_out', opts.to);
|
||||||
|
const page = opts?.page ?? 1;
|
||||||
|
const pageSize = opts?.pageSize ?? 25;
|
||||||
|
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
const total = count ?? 0;
|
||||||
|
return {
|
||||||
|
data: data ?? [],
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: CreateBookingInput) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
logger.info({ name: NAMESPACE }, 'Creating booking...');
|
||||||
|
|
||||||
|
const { data, error } = await (client.rpc as CallableFunction)(
|
||||||
|
'create_booking_atomic',
|
||||||
|
{
|
||||||
|
p_account_id: input.accountId,
|
||||||
|
p_room_id: input.roomId,
|
||||||
|
p_guest_id: input.guestId ?? null,
|
||||||
|
p_check_in: input.checkIn,
|
||||||
|
p_check_out: input.checkOut,
|
||||||
|
p_adults: input.adults ?? 1,
|
||||||
|
p_children: input.children ?? 0,
|
||||||
|
p_status: input.status ?? 'confirmed',
|
||||||
|
p_total_price: input.totalPrice ?? null,
|
||||||
|
p_notes: input.notes ?? null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
// RPC returns the booking UUID; fetch the full row
|
||||||
|
const bookingId = data as unknown as string;
|
||||||
|
const { data: booking, error: fetchError } = await client
|
||||||
|
.from('bookings')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', bookingId)
|
||||||
|
.single();
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
return booking;
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateStatus(
|
||||||
|
bookingId: string,
|
||||||
|
status: string,
|
||||||
|
version?: number,
|
||||||
|
userId?: string,
|
||||||
|
) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
logger.info(
|
||||||
|
{ name: NAMESPACE, bookingId, status },
|
||||||
|
'Updating booking status...',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fetch current booking to get current status
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version column added via migration, not yet in generated types
|
||||||
|
const { data: current, error: fetchError } = await (
|
||||||
|
client.from('bookings').select('id, status, version') as any
|
||||||
|
)
|
||||||
|
.eq('id', bookingId)
|
||||||
|
.single();
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
|
||||||
|
const currentStatus = (current as Record<string, unknown>)
|
||||||
|
.status as string;
|
||||||
|
|
||||||
|
// Validate status transition using the state machine
|
||||||
|
try {
|
||||||
|
validateTransition(
|
||||||
|
currentStatus as Parameters<typeof validateTransition>[0],
|
||||||
|
status as Parameters<typeof validateTransition>[1],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
const validTargets = getValidTransitions(
|
||||||
|
currentStatus as Parameters<typeof getValidTransitions>[0],
|
||||||
|
);
|
||||||
|
throw new InvalidBookingStatusTransitionError(
|
||||||
|
currentStatus,
|
||||||
|
status,
|
||||||
|
validTargets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the update query
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- updated_by column added via migration
|
||||||
|
let query = client
|
||||||
|
.from('bookings')
|
||||||
|
.update({
|
||||||
|
status,
|
||||||
|
...(userId ? { updated_by: userId } : {}),
|
||||||
|
} as any)
|
||||||
|
.eq('id', bookingId);
|
||||||
|
|
||||||
|
// Optimistic concurrency control via version column (added by migration, not yet in generated types)
|
||||||
|
if (version !== undefined) {
|
||||||
|
query = (query as any).eq('version', version);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query.select('id').single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// If no rows matched, it's a concurrency conflict
|
||||||
|
if (error.code === 'PGRST116') {
|
||||||
|
throw new BookingConcurrencyConflictError();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new BookingConcurrencyConflictError();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createGuestService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async list(accountId: string, search?: string) {
|
||||||
|
let query = client
|
||||||
|
.from('guests')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('last_name');
|
||||||
|
if (search)
|
||||||
|
query = query.or(
|
||||||
|
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
|
||||||
|
);
|
||||||
|
const { data, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
accountId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
city?: string;
|
||||||
|
}) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('guests')
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
first_name: input.firstName,
|
||||||
|
last_name: input.lastName,
|
||||||
|
email: input.email || null,
|
||||||
|
phone: input.phone || null,
|
||||||
|
city: input.city || null,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHistory(guestId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('bookings')
|
||||||
|
.select('*')
|
||||||
|
.eq('guest_id', guestId)
|
||||||
|
.order('check_in', { ascending: false });
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import { createBookingCrudService } from './booking-crud.service';
|
||||||
|
import { createGuestService } from './guest.service';
|
||||||
|
import { createRoomService } from './room.service';
|
||||||
|
|
||||||
|
export { createBookingCrudService, createGuestService, createRoomService };
|
||||||
|
|
||||||
|
export function createBookingServices(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
rooms: createRoomService(client),
|
||||||
|
bookings: createBookingCrudService(client),
|
||||||
|
guests: createGuestService(client),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createRoomService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async list(accountId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('rooms')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.eq('is_active', true)
|
||||||
|
.order('room_number');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(roomId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('rooms')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', roomId)
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
|
||||||
|
const { count, error } = await client
|
||||||
|
.from('bookings')
|
||||||
|
.select('*', { count: 'exact', head: true })
|
||||||
|
.eq('room_id', roomId)
|
||||||
|
.not('status', 'in', '("cancelled","no_show")')
|
||||||
|
.lt('check_in', checkOut)
|
||||||
|
.gt('check_out', checkIn);
|
||||||
|
if (error) throw error;
|
||||||
|
return (count ?? 0) === 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
accountId: string;
|
||||||
|
roomNumber: string;
|
||||||
|
name?: string;
|
||||||
|
roomType?: string;
|
||||||
|
capacity?: number;
|
||||||
|
floor?: number;
|
||||||
|
pricePerNight: number;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('rooms')
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
room_number: input.roomNumber,
|
||||||
|
name: input.name,
|
||||||
|
room_type: input.roomType ?? 'standard',
|
||||||
|
capacity: input.capacity ?? 2,
|
||||||
|
floor: input.floor,
|
||||||
|
price_per_night: input.pricePerNight,
|
||||||
|
description: input.description,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.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": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { CourseStatusEnum } from '../schema/course.schema';
|
||||||
|
|
||||||
|
type CourseStatus = z.infer<typeof CourseStatusEnum>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Course status state machine.
|
||||||
|
*
|
||||||
|
* Defines valid transitions between course statuses and their
|
||||||
|
* side effects. Enforced in course update operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type StatusTransition = {
|
||||||
|
/** Fields to set automatically when this transition occurs */
|
||||||
|
sideEffects?: Partial<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITIONS: Record<
|
||||||
|
CourseStatus,
|
||||||
|
Partial<Record<CourseStatus, StatusTransition>>
|
||||||
|
> = {
|
||||||
|
planned: {
|
||||||
|
open: {},
|
||||||
|
cancelled: {},
|
||||||
|
},
|
||||||
|
open: {
|
||||||
|
running: {},
|
||||||
|
cancelled: {},
|
||||||
|
},
|
||||||
|
running: {
|
||||||
|
completed: {},
|
||||||
|
cancelled: {},
|
||||||
|
},
|
||||||
|
// Terminal state — no transitions out
|
||||||
|
completed: {},
|
||||||
|
cancelled: {
|
||||||
|
planned: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a status transition is valid.
|
||||||
|
*/
|
||||||
|
export function canTransition(from: CourseStatus, to: CourseStatus): boolean {
|
||||||
|
if (from === to) return true; // no-op is always valid
|
||||||
|
return to in (TRANSITIONS[from] ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all valid target statuses from a given status.
|
||||||
|
*/
|
||||||
|
export function getValidTransitions(from: CourseStatus): CourseStatus[] {
|
||||||
|
return Object.keys(TRANSITIONS[from] ?? {}) as CourseStatus[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the side effects for a transition.
|
||||||
|
* Returns an object of field->value pairs to apply alongside the status change.
|
||||||
|
* Function values should be called to get the actual value.
|
||||||
|
*/
|
||||||
|
export function getTransitionSideEffects(
|
||||||
|
from: CourseStatus,
|
||||||
|
to: CourseStatus,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (from === to) return {};
|
||||||
|
|
||||||
|
const transition = TRANSITIONS[from]?.[to];
|
||||||
|
if (!transition?.sideEffects) return {};
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||||
|
result[key] = typeof value === 'function' ? value() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a status transition and return side effects.
|
||||||
|
* Throws if the transition is invalid.
|
||||||
|
*/
|
||||||
|
export function validateTransition(
|
||||||
|
from: CourseStatus,
|
||||||
|
to: CourseStatus,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (from === to) return {};
|
||||||
|
|
||||||
|
if (!canTransition(from, to)) {
|
||||||
|
throw new Error(
|
||||||
|
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getTransitionSideEffects(from, to);
|
||||||
|
}
|
||||||
128
packages/features/course-management/src/lib/errors.ts
Normal file
128
packages/features/course-management/src/lib/errors.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
/**
|
||||||
|
* Standardized error codes and domain error classes
|
||||||
|
* for the course management module.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const CourseErrorCodes = {
|
||||||
|
NOT_FOUND: 'COURSE_NOT_FOUND',
|
||||||
|
CAPACITY_EXCEEDED: 'COURSE_CAPACITY_EXCEEDED',
|
||||||
|
INVALID_STATUS_TRANSITION: 'COURSE_INVALID_TRANSITION',
|
||||||
|
REGISTRATION_CLOSED: 'COURSE_REGISTRATION_CLOSED',
|
||||||
|
CONCURRENCY_CONFLICT: 'COURSE_CONCURRENCY_CONFLICT',
|
||||||
|
DUPLICATE_ENROLLMENT: 'COURSE_DUPLICATE_ENROLLMENT',
|
||||||
|
MIN_PARTICIPANTS_NOT_MET: 'COURSE_MIN_PARTICIPANTS_NOT_MET',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type CourseErrorCode =
|
||||||
|
(typeof CourseErrorCodes)[keyof typeof CourseErrorCodes];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base domain error for course management operations.
|
||||||
|
*/
|
||||||
|
export class CourseDomainError extends Error {
|
||||||
|
readonly code: CourseErrorCode;
|
||||||
|
readonly statusCode: number;
|
||||||
|
readonly details?: Record<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: CourseErrorCode,
|
||||||
|
message: string,
|
||||||
|
statusCode = 400,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'CourseDomainError';
|
||||||
|
this.code = code;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.details = details;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourseNotFoundError extends CourseDomainError {
|
||||||
|
constructor(courseId: string) {
|
||||||
|
super(CourseErrorCodes.NOT_FOUND, `Kurs ${courseId} nicht gefunden`, 404, {
|
||||||
|
courseId,
|
||||||
|
});
|
||||||
|
this.name = 'CourseNotFoundError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourseCapacityExceededError extends CourseDomainError {
|
||||||
|
constructor(courseId: string, capacity: number) {
|
||||||
|
super(
|
||||||
|
CourseErrorCodes.CAPACITY_EXCEEDED,
|
||||||
|
`Kurs ${courseId} hat die maximale Teilnehmerzahl (${capacity}) erreicht`,
|
||||||
|
409,
|
||||||
|
{ courseId, capacity },
|
||||||
|
);
|
||||||
|
this.name = 'CourseCapacityExceededError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvalidCourseStatusTransitionError extends CourseDomainError {
|
||||||
|
constructor(from: string, to: string, validTargets: string[]) {
|
||||||
|
super(
|
||||||
|
CourseErrorCodes.INVALID_STATUS_TRANSITION,
|
||||||
|
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
|
||||||
|
422,
|
||||||
|
{ from, to, validTargets },
|
||||||
|
);
|
||||||
|
this.name = 'InvalidCourseStatusTransitionError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourseRegistrationClosedError extends CourseDomainError {
|
||||||
|
constructor(courseId: string) {
|
||||||
|
super(
|
||||||
|
CourseErrorCodes.REGISTRATION_CLOSED,
|
||||||
|
`Anmeldung für Kurs ${courseId} ist geschlossen`,
|
||||||
|
422,
|
||||||
|
{ courseId },
|
||||||
|
);
|
||||||
|
this.name = 'CourseRegistrationClosedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourseConcurrencyConflictError extends CourseDomainError {
|
||||||
|
constructor() {
|
||||||
|
super(
|
||||||
|
CourseErrorCodes.CONCURRENCY_CONFLICT,
|
||||||
|
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
|
||||||
|
409,
|
||||||
|
);
|
||||||
|
this.name = 'CourseConcurrencyConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourseDuplicateEnrollmentError extends CourseDomainError {
|
||||||
|
constructor(courseId: string, memberId: string) {
|
||||||
|
super(
|
||||||
|
CourseErrorCodes.DUPLICATE_ENROLLMENT,
|
||||||
|
`Mitglied ${memberId} ist bereits für Kurs ${courseId} angemeldet`,
|
||||||
|
409,
|
||||||
|
{ courseId, memberId },
|
||||||
|
);
|
||||||
|
this.name = 'CourseDuplicateEnrollmentError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CourseMinParticipantsError extends CourseDomainError {
|
||||||
|
constructor(courseId: string, minParticipants: number, currentCount: number) {
|
||||||
|
super(
|
||||||
|
CourseErrorCodes.MIN_PARTICIPANTS_NOT_MET,
|
||||||
|
`Kurs ${courseId} benötigt mindestens ${minParticipants} Teilnehmer (aktuell: ${currentCount})`,
|
||||||
|
422,
|
||||||
|
{ courseId, minParticipants, currentCount },
|
||||||
|
);
|
||||||
|
this.name = 'CourseMinParticipantsError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if an error is a CourseDomainError.
|
||||||
|
*/
|
||||||
|
export function isCourseDomainError(
|
||||||
|
error: unknown,
|
||||||
|
): error is CourseDomainError {
|
||||||
|
return error instanceof CourseDomainError;
|
||||||
|
}
|
||||||
@@ -16,29 +16,100 @@ export const CourseStatusEnum = z.enum([
|
|||||||
'cancelled',
|
'cancelled',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const CreateCourseSchema = z.object({
|
export const CreateCourseSchema = z
|
||||||
accountId: z.string().uuid(),
|
.object({
|
||||||
courseNumber: z.string().optional(),
|
accountId: z.string().uuid(),
|
||||||
name: z.string().min(1).max(256),
|
courseNumber: z.string().optional(),
|
||||||
description: z.string().optional(),
|
name: z.string().min(1).max(256),
|
||||||
categoryId: z.string().uuid().optional(),
|
description: z.string().optional(),
|
||||||
instructorId: z.string().uuid().optional(),
|
categoryId: z.string().uuid().optional(),
|
||||||
locationId: z.string().uuid().optional(),
|
instructorId: z.string().uuid().optional(),
|
||||||
startDate: z.string().optional(),
|
locationId: z.string().uuid().optional(),
|
||||||
endDate: z.string().optional(),
|
startDate: z.string().optional(),
|
||||||
fee: z.number().min(0).default(0),
|
endDate: z.string().optional(),
|
||||||
reducedFee: z.number().min(0).optional(),
|
fee: z.number().min(0).default(0),
|
||||||
capacity: z.number().int().min(1).default(20),
|
reducedFee: z.number().min(0).optional(),
|
||||||
minParticipants: z.number().int().min(0).default(5),
|
capacity: z.number().int().min(1).default(20),
|
||||||
status: CourseStatusEnum.default('planned'),
|
minParticipants: z.number().int().min(0).default(5),
|
||||||
registrationDeadline: z.string().optional(),
|
status: CourseStatusEnum.default('planned'),
|
||||||
notes: z.string().optional(),
|
registrationDeadline: z.string().optional(),
|
||||||
});
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine((d) => d.reducedFee == null || d.reducedFee <= d.fee, {
|
||||||
|
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
|
||||||
|
path: ['reducedFee'],
|
||||||
|
})
|
||||||
|
.refine((d) => d.minParticipants == null || d.minParticipants <= d.capacity, {
|
||||||
|
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
|
||||||
|
path: ['minParticipants'],
|
||||||
|
})
|
||||||
|
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
|
||||||
|
message: 'Enddatum muss nach dem Startdatum liegen',
|
||||||
|
path: ['endDate'],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(d) =>
|
||||||
|
!d.registrationDeadline ||
|
||||||
|
!d.startDate ||
|
||||||
|
d.registrationDeadline <= d.startDate,
|
||||||
|
{
|
||||||
|
message: 'Anmeldefrist muss vor dem Startdatum liegen',
|
||||||
|
path: ['registrationDeadline'],
|
||||||
|
},
|
||||||
|
);
|
||||||
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
|
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
|
||||||
|
|
||||||
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
|
export const UpdateCourseSchema = z
|
||||||
courseId: z.string().uuid(),
|
.object({
|
||||||
});
|
courseId: z.string().uuid(),
|
||||||
|
version: z.number().int().optional(),
|
||||||
|
courseNumber: z.string().optional(),
|
||||||
|
name: z.string().min(1).max(256).optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
categoryId: z.string().uuid().optional(),
|
||||||
|
instructorId: z.string().uuid().optional(),
|
||||||
|
locationId: z.string().uuid().optional(),
|
||||||
|
startDate: z.string().optional(),
|
||||||
|
endDate: z.string().optional(),
|
||||||
|
fee: z.number().min(0).optional(),
|
||||||
|
reducedFee: z.number().min(0).optional().nullable(),
|
||||||
|
capacity: z.number().int().min(1).optional(),
|
||||||
|
minParticipants: z.number().int().min(0).optional(),
|
||||||
|
status: CourseStatusEnum.optional(),
|
||||||
|
registrationDeadline: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(d) => d.reducedFee == null || d.fee == null || d.reducedFee <= d.fee,
|
||||||
|
{
|
||||||
|
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
|
||||||
|
path: ['reducedFee'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(d) =>
|
||||||
|
d.minParticipants == null ||
|
||||||
|
d.capacity == null ||
|
||||||
|
d.minParticipants <= d.capacity,
|
||||||
|
{
|
||||||
|
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
|
||||||
|
path: ['minParticipants'],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
|
||||||
|
message: 'Enddatum muss nach dem Startdatum liegen',
|
||||||
|
path: ['endDate'],
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(d) =>
|
||||||
|
!d.registrationDeadline ||
|
||||||
|
!d.startDate ||
|
||||||
|
d.registrationDeadline <= d.startDate,
|
||||||
|
{
|
||||||
|
message: 'Anmeldefrist muss vor dem Startdatum liegen',
|
||||||
|
path: ['registrationDeadline'],
|
||||||
|
},
|
||||||
|
);
|
||||||
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
|
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
|
||||||
|
|
||||||
export const EnrollParticipantSchema = z.object({
|
export const EnrollParticipantSchema = z.object({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
|
|||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { isCourseDomainError } from '../../lib/errors';
|
||||||
import {
|
import {
|
||||||
CreateCourseSchema,
|
CreateCourseSchema,
|
||||||
UpdateCourseSchema,
|
UpdateCourseSchema,
|
||||||
@@ -25,7 +26,7 @@ export const createCourse = authActionClient
|
|||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.create' }, 'Creating course...');
|
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');
|
logger.info({ name: 'course.create' }, 'Course created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
@@ -37,39 +38,60 @@ export const updateCourse = authActionClient
|
|||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.update' }, 'Updating course...');
|
try {
|
||||||
const result = await api.updateCourse(input);
|
logger.info({ name: 'course.update' }, 'Updating course...');
|
||||||
logger.info({ name: 'course.update' }, 'Course updated');
|
const result = await api.courses.update(input, ctx.user.id);
|
||||||
return { success: true, data: result };
|
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
|
export const deleteCourse = authActionClient
|
||||||
.inputSchema(z.object({ courseId: z.string().uuid() }))
|
.inputSchema(z.object({ courseId: z.string().uuid() }))
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.delete' }, 'Archiving course...');
|
try {
|
||||||
await api.deleteCourse(input.courseId);
|
logger.info({ name: 'course.delete' }, 'Archiving course...');
|
||||||
logger.info({ name: 'course.delete' }, 'Course archived');
|
await api.courses.softDelete(input.courseId);
|
||||||
return { success: true };
|
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
|
export const enrollParticipant = authActionClient
|
||||||
.inputSchema(EnrollParticipantSchema)
|
.inputSchema(EnrollParticipantSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info(
|
try {
|
||||||
{ name: 'course.enrollParticipant' },
|
logger.info(
|
||||||
'Enrolling participant...',
|
{ name: 'course.enrollParticipant' },
|
||||||
);
|
'Enrolling participant...',
|
||||||
const result = await api.enrollParticipant(input);
|
);
|
||||||
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
|
const result = await api.enrollment.enroll(input);
|
||||||
return { success: true, data: result };
|
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
|
export const cancelEnrollment = authActionClient
|
||||||
@@ -78,7 +100,7 @@ export const cancelEnrollment = authActionClient
|
|||||||
participantId: z.string().uuid(),
|
participantId: z.string().uuid(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
@@ -87,9 +109,9 @@ export const cancelEnrollment = authActionClient
|
|||||||
{ name: 'course.cancelEnrollment' },
|
{ name: 'course.cancelEnrollment' },
|
||||||
'Cancelling enrollment...',
|
'Cancelling enrollment...',
|
||||||
);
|
);
|
||||||
const result = await api.cancelEnrollment(input.participantId);
|
await api.enrollment.cancel(input.participantId);
|
||||||
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
|
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
|
||||||
return { success: true, data: result };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const markAttendance = authActionClient
|
export const markAttendance = authActionClient
|
||||||
@@ -100,69 +122,69 @@ export const markAttendance = authActionClient
|
|||||||
present: z.boolean(),
|
present: z.boolean(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
|
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
|
||||||
const result = await api.markAttendance(
|
await api.attendance.mark(
|
||||||
input.sessionId,
|
input.sessionId,
|
||||||
input.participantId,
|
input.participantId,
|
||||||
input.present,
|
input.present,
|
||||||
);
|
);
|
||||||
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
|
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
|
||||||
return { success: true, data: result };
|
return { success: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createCategory = authActionClient
|
export const createCategory = authActionClient
|
||||||
.inputSchema(CreateCategorySchema)
|
.inputSchema(CreateCategorySchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.createCategory' }, 'Creating category...');
|
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');
|
logger.info({ name: 'course.createCategory' }, 'Category created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createInstructor = authActionClient
|
export const createInstructor = authActionClient
|
||||||
.inputSchema(CreateInstructorSchema)
|
.inputSchema(CreateInstructorSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
|
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');
|
logger.info({ name: 'course.createInstructor' }, 'Instructor created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createLocation = authActionClient
|
export const createLocation = authActionClient
|
||||||
.inputSchema(CreateLocationSchema)
|
.inputSchema(CreateLocationSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.createLocation' }, 'Creating location...');
|
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');
|
logger.info({ name: 'course.createLocation' }, 'Location created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const createSession = authActionClient
|
export const createSession = authActionClient
|
||||||
.inputSchema(CreateSessionSchema)
|
.inputSchema(CreateSessionSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input, ctx: _ctx }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createCourseManagementApi(client);
|
const api = createCourseManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'course.createSession' }, 'Creating session...');
|
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');
|
logger.info({ name: 'course.createSession' }, 'Session created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,361 +1,22 @@
|
|||||||
|
import 'server-only';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import type { Database } from '@kit/supabase/database';
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import type {
|
import { createAttendanceService } from './services/attendance.service';
|
||||||
CreateCourseInput,
|
import { createCourseCrudService } from './services/course-crud.service';
|
||||||
UpdateCourseInput,
|
import { createCourseReferenceDataService } from './services/course-reference-data.service';
|
||||||
EnrollParticipantInput,
|
import { createCourseStatisticsService } from './services/course-statistics.service';
|
||||||
} from '../schema/course.schema';
|
import { createEnrollmentService } from './services/enrollment.service';
|
||||||
|
import { createSessionService } from './services/session.service';
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
||||||
const _db = client;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// --- Courses ---
|
courses: createCourseCrudService(client),
|
||||||
async listCourses(
|
enrollment: createEnrollmentService(client),
|
||||||
accountId: string,
|
sessions: createSessionService(client),
|
||||||
opts?: {
|
attendance: createAttendanceService(client),
|
||||||
status?: string;
|
referenceData: createCourseReferenceDataService(client),
|
||||||
search?: string;
|
statistics: createCourseStatisticsService(client),
|
||||||
page?: number;
|
|
||||||
pageSize?: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
let query = client
|
|
||||||
.from('courses')
|
|
||||||
.select('*', { count: 'exact' })
|
|
||||||
.eq('account_id', accountId)
|
|
||||||
.order('start_date', { ascending: false });
|
|
||||||
if (opts?.status) query = query.eq('status', opts.status);
|
|
||||||
if (opts?.search)
|
|
||||||
query = query.or(
|
|
||||||
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
|
|
||||||
);
|
|
||||||
const page = opts?.page ?? 1;
|
|
||||||
const pageSize = opts?.pageSize ?? 25;
|
|
||||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
|
||||||
const { data, error, count } = await query;
|
|
||||||
if (error) throw error;
|
|
||||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCourse(courseId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('courses')
|
|
||||||
.select('*')
|
|
||||||
.eq('id', courseId)
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createCourse(input: CreateCourseInput) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('courses')
|
|
||||||
.insert({
|
|
||||||
account_id: input.accountId,
|
|
||||||
course_number: input.courseNumber || null,
|
|
||||||
name: input.name,
|
|
||||||
description: input.description || null,
|
|
||||||
category_id: input.categoryId || null,
|
|
||||||
instructor_id: input.instructorId || null,
|
|
||||||
location_id: input.locationId || null,
|
|
||||||
start_date: input.startDate || null,
|
|
||||||
end_date: input.endDate || null,
|
|
||||||
fee: input.fee,
|
|
||||||
reduced_fee: input.reducedFee ?? null,
|
|
||||||
capacity: input.capacity,
|
|
||||||
min_participants: input.minParticipants,
|
|
||||||
status: input.status,
|
|
||||||
registration_deadline: input.registrationDeadline || null,
|
|
||||||
notes: input.notes || null,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateCourse(input: UpdateCourseInput) {
|
|
||||||
const update: Record<string, unknown> = {};
|
|
||||||
if (input.name !== undefined) update.name = input.name;
|
|
||||||
if (input.courseNumber !== undefined)
|
|
||||||
update.course_number = input.courseNumber || null;
|
|
||||||
if (input.description !== undefined)
|
|
||||||
update.description = input.description || null;
|
|
||||||
if (input.categoryId !== undefined)
|
|
||||||
update.category_id = input.categoryId || null;
|
|
||||||
if (input.instructorId !== undefined)
|
|
||||||
update.instructor_id = input.instructorId || null;
|
|
||||||
if (input.locationId !== undefined)
|
|
||||||
update.location_id = input.locationId || null;
|
|
||||||
if (input.startDate !== undefined)
|
|
||||||
update.start_date = input.startDate || null;
|
|
||||||
if (input.endDate !== undefined) update.end_date = input.endDate || null;
|
|
||||||
if (input.fee !== undefined) update.fee = input.fee;
|
|
||||||
if (input.reducedFee !== undefined)
|
|
||||||
update.reduced_fee = input.reducedFee ?? null;
|
|
||||||
if (input.capacity !== undefined) update.capacity = input.capacity;
|
|
||||||
if (input.minParticipants !== undefined)
|
|
||||||
update.min_participants = input.minParticipants;
|
|
||||||
if (input.status !== undefined) update.status = input.status;
|
|
||||||
if (input.registrationDeadline !== undefined)
|
|
||||||
update.registration_deadline = input.registrationDeadline || null;
|
|
||||||
if (input.notes !== undefined) update.notes = input.notes || null;
|
|
||||||
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('courses')
|
|
||||||
.update(update)
|
|
||||||
.eq('id', input.courseId)
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteCourse(courseId: string) {
|
|
||||||
const { error } = await client
|
|
||||||
.from('courses')
|
|
||||||
.update({ status: 'cancelled' })
|
|
||||||
.eq('id', courseId);
|
|
||||||
if (error) throw error;
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Enrollment ---
|
|
||||||
async enrollParticipant(input: EnrollParticipantInput) {
|
|
||||||
// Check capacity
|
|
||||||
const { count } = await client
|
|
||||||
.from('course_participants')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.eq('course_id', input.courseId)
|
|
||||||
.in('status', ['enrolled']);
|
|
||||||
const course = await this.getCourse(input.courseId);
|
|
||||||
const status =
|
|
||||||
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
|
|
||||||
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_participants')
|
|
||||||
.insert({
|
|
||||||
course_id: input.courseId,
|
|
||||||
member_id: input.memberId,
|
|
||||||
first_name: input.firstName,
|
|
||||||
last_name: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
phone: input.phone,
|
|
||||||
status,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async cancelEnrollment(participantId: string) {
|
|
||||||
const { error } = await client
|
|
||||||
.from('course_participants')
|
|
||||||
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
|
|
||||||
.eq('id', participantId);
|
|
||||||
if (error) throw error;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getParticipants(courseId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_participants')
|
|
||||||
.select('*')
|
|
||||||
.eq('course_id', courseId)
|
|
||||||
.order('enrolled_at');
|
|
||||||
if (error) throw error;
|
|
||||||
return data ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Sessions ---
|
|
||||||
async getSessions(courseId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_sessions')
|
|
||||||
.select('*')
|
|
||||||
.eq('course_id', courseId)
|
|
||||||
.order('session_date');
|
|
||||||
if (error) throw error;
|
|
||||||
return data ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async createSession(input: {
|
|
||||||
courseId: string;
|
|
||||||
sessionDate: string;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
locationId?: string;
|
|
||||||
}) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_sessions')
|
|
||||||
.insert({
|
|
||||||
course_id: input.courseId,
|
|
||||||
session_date: input.sessionDate,
|
|
||||||
start_time: input.startTime,
|
|
||||||
end_time: input.endTime,
|
|
||||||
location_id: input.locationId,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Attendance ---
|
|
||||||
async getAttendance(sessionId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_attendance')
|
|
||||||
.select('*')
|
|
||||||
.eq('session_id', sessionId);
|
|
||||||
if (error) throw error;
|
|
||||||
return data ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async markAttendance(
|
|
||||||
sessionId: string,
|
|
||||||
participantId: string,
|
|
||||||
present: boolean,
|
|
||||||
) {
|
|
||||||
const { error } = await client.from('course_attendance').upsert(
|
|
||||||
{
|
|
||||||
session_id: sessionId,
|
|
||||||
participant_id: participantId,
|
|
||||||
present,
|
|
||||||
},
|
|
||||||
{ onConflict: 'session_id,participant_id' },
|
|
||||||
);
|
|
||||||
if (error) throw error;
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Categories, Instructors, Locations ---
|
|
||||||
async listCategories(accountId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_categories')
|
|
||||||
.select('*')
|
|
||||||
.eq('account_id', accountId)
|
|
||||||
.order('sort_order');
|
|
||||||
if (error) throw error;
|
|
||||||
return data ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async listInstructors(accountId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_instructors')
|
|
||||||
.select('*')
|
|
||||||
.eq('account_id', accountId)
|
|
||||||
.order('last_name');
|
|
||||||
if (error) throw error;
|
|
||||||
return data ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
async listLocations(accountId: string) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_locations')
|
|
||||||
.select('*')
|
|
||||||
.eq('account_id', accountId)
|
|
||||||
.order('name');
|
|
||||||
if (error) throw error;
|
|
||||||
return data ?? [];
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Statistics ---
|
|
||||||
async getStatistics(accountId: string) {
|
|
||||||
const { data: courses } = await client
|
|
||||||
.from('courses')
|
|
||||||
.select('status')
|
|
||||||
.eq('account_id', accountId);
|
|
||||||
const { count: totalParticipants } = await client
|
|
||||||
.from('course_participants')
|
|
||||||
.select('*', { count: 'exact', head: true })
|
|
||||||
.in(
|
|
||||||
'course_id',
|
|
||||||
(courses ?? []).map((c: any) => c.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
totalCourses: 0,
|
|
||||||
openCourses: 0,
|
|
||||||
completedCourses: 0,
|
|
||||||
totalParticipants: totalParticipants ?? 0,
|
|
||||||
};
|
|
||||||
for (const c of courses ?? []) {
|
|
||||||
stats.totalCourses++;
|
|
||||||
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
|
|
||||||
if (c.status === 'completed') stats.completedCourses++;
|
|
||||||
}
|
|
||||||
return stats;
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Create methods for CRUD ---
|
|
||||||
async createCategory(input: {
|
|
||||||
accountId: string;
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
parentId?: string;
|
|
||||||
}) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_categories')
|
|
||||||
.insert({
|
|
||||||
account_id: input.accountId,
|
|
||||||
name: input.name,
|
|
||||||
description: input.description,
|
|
||||||
parent_id: input.parentId,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createInstructor(input: {
|
|
||||||
accountId: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
email?: string;
|
|
||||||
phone?: string;
|
|
||||||
qualifications?: string;
|
|
||||||
hourlyRate?: number;
|
|
||||||
}) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_instructors')
|
|
||||||
.insert({
|
|
||||||
account_id: input.accountId,
|
|
||||||
first_name: input.firstName,
|
|
||||||
last_name: input.lastName,
|
|
||||||
email: input.email,
|
|
||||||
phone: input.phone,
|
|
||||||
qualifications: input.qualifications,
|
|
||||||
hourly_rate: input.hourlyRate,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
|
|
||||||
async createLocation(input: {
|
|
||||||
accountId: string;
|
|
||||||
name: string;
|
|
||||||
address?: string;
|
|
||||||
room?: string;
|
|
||||||
capacity?: number;
|
|
||||||
}) {
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('course_locations')
|
|
||||||
.insert({
|
|
||||||
account_id: input.accountId,
|
|
||||||
name: input.name,
|
|
||||||
address: input.address,
|
|
||||||
room: input.room,
|
|
||||||
capacity: input.capacity,
|
|
||||||
})
|
|
||||||
.select()
|
|
||||||
.single();
|
|
||||||
if (error) throw error;
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createAttendanceService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async getBySession(sessionId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_attendance')
|
||||||
|
.select('*')
|
||||||
|
.eq('session_id', sessionId);
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async mark(sessionId: string, participantId: string, present: boolean) {
|
||||||
|
const { error } = await client.from('course_attendance').upsert(
|
||||||
|
{
|
||||||
|
session_id: sessionId,
|
||||||
|
participant_id: participantId,
|
||||||
|
present,
|
||||||
|
},
|
||||||
|
{ onConflict: 'session_id,participant_id' },
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { getLogger } from '@kit/shared/logger';
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canTransition,
|
||||||
|
validateTransition,
|
||||||
|
getValidTransitions,
|
||||||
|
} from '../../lib/course-status-machine';
|
||||||
|
import {
|
||||||
|
CourseNotFoundError,
|
||||||
|
CourseConcurrencyConflictError,
|
||||||
|
InvalidCourseStatusTransitionError,
|
||||||
|
} from '../../lib/errors';
|
||||||
|
import type {
|
||||||
|
CreateCourseInput,
|
||||||
|
UpdateCourseInput,
|
||||||
|
} from '../../schema/course.schema';
|
||||||
|
|
||||||
|
const NAMESPACE = 'course-crud';
|
||||||
|
|
||||||
|
export function createCourseCrudService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async list(
|
||||||
|
accountId: string,
|
||||||
|
opts?: {
|
||||||
|
status?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
let query = client
|
||||||
|
.from('courses')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('start_date', { ascending: false });
|
||||||
|
|
||||||
|
if (opts?.status) query = query.eq('status', opts.status);
|
||||||
|
if (opts?.search)
|
||||||
|
query = query.or(
|
||||||
|
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = opts?.page ?? 1;
|
||||||
|
const pageSize = opts?.pageSize ?? 25;
|
||||||
|
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||||
|
|
||||||
|
const { data, error, count } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
const total = count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data ?? [],
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async getById(courseId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('courses')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', courseId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) throw error;
|
||||||
|
if (!data) throw new CourseNotFoundError(courseId);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: CreateCourseInput, userId?: string) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
logger.info({ name: NAMESPACE }, 'Creating course...');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- created_by/updated_by added via migration, not yet in generated types
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('courses')
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
course_number: input.courseNumber || null,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description || null,
|
||||||
|
category_id: input.categoryId || null,
|
||||||
|
instructor_id: input.instructorId || null,
|
||||||
|
location_id: input.locationId || null,
|
||||||
|
start_date: input.startDate || null,
|
||||||
|
end_date: input.endDate || null,
|
||||||
|
fee: input.fee,
|
||||||
|
reduced_fee: input.reducedFee ?? null,
|
||||||
|
capacity: input.capacity,
|
||||||
|
min_participants: input.minParticipants,
|
||||||
|
status: input.status,
|
||||||
|
registration_deadline: input.registrationDeadline || null,
|
||||||
|
notes: input.notes || null,
|
||||||
|
...(userId ? { created_by: userId, updated_by: userId } : {}),
|
||||||
|
} as any)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(input: UpdateCourseInput, userId?: string) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
logger.info(
|
||||||
|
{ name: NAMESPACE, courseId: input.courseId },
|
||||||
|
'Updating course...',
|
||||||
|
);
|
||||||
|
|
||||||
|
const update: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (input.name !== undefined) update.name = input.name;
|
||||||
|
if (input.courseNumber !== undefined)
|
||||||
|
update.course_number = input.courseNumber || null;
|
||||||
|
if (input.description !== undefined)
|
||||||
|
update.description = input.description || null;
|
||||||
|
if (input.categoryId !== undefined)
|
||||||
|
update.category_id = input.categoryId || null;
|
||||||
|
if (input.instructorId !== undefined)
|
||||||
|
update.instructor_id = input.instructorId || null;
|
||||||
|
if (input.locationId !== undefined)
|
||||||
|
update.location_id = input.locationId || null;
|
||||||
|
if (input.startDate !== undefined)
|
||||||
|
update.start_date = input.startDate || null;
|
||||||
|
if (input.endDate !== undefined) update.end_date = input.endDate || null;
|
||||||
|
if (input.fee !== undefined) update.fee = input.fee;
|
||||||
|
if (input.reducedFee !== undefined)
|
||||||
|
update.reduced_fee = input.reducedFee ?? null;
|
||||||
|
if (input.capacity !== undefined) update.capacity = input.capacity;
|
||||||
|
if (input.minParticipants !== undefined)
|
||||||
|
update.min_participants = input.minParticipants;
|
||||||
|
if (input.registrationDeadline !== undefined)
|
||||||
|
update.registration_deadline = input.registrationDeadline || null;
|
||||||
|
if (input.notes !== undefined) update.notes = input.notes || null;
|
||||||
|
|
||||||
|
// Status transition validation
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
const { data: current, error: fetchError } = await client
|
||||||
|
.from('courses')
|
||||||
|
.select('status')
|
||||||
|
.eq('id', input.courseId)
|
||||||
|
.single();
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
|
||||||
|
const currentStatus = current.status as string;
|
||||||
|
if (currentStatus !== input.status) {
|
||||||
|
try {
|
||||||
|
const sideEffects = validateTransition(
|
||||||
|
currentStatus as Parameters<typeof validateTransition>[0],
|
||||||
|
input.status as Parameters<typeof validateTransition>[1],
|
||||||
|
);
|
||||||
|
Object.assign(update, sideEffects);
|
||||||
|
} catch {
|
||||||
|
throw new InvalidCourseStatusTransitionError(
|
||||||
|
currentStatus,
|
||||||
|
input.status,
|
||||||
|
getValidTransitions(
|
||||||
|
currentStatus as Parameters<typeof getValidTransitions>[0],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update.status = input.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
update.updated_by = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version/updated_by columns added via migration, not yet in generated types
|
||||||
|
let query = client
|
||||||
|
.from('courses')
|
||||||
|
.update(update as any)
|
||||||
|
.eq('id', input.courseId);
|
||||||
|
|
||||||
|
// Optimistic locking via version column
|
||||||
|
if (input.version !== undefined) {
|
||||||
|
query = (query as any).eq('version', input.version);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await query.select().single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (error.code === 'PGRST116' && input.version !== undefined) {
|
||||||
|
throw new CourseConcurrencyConflictError();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async softDelete(courseId: string) {
|
||||||
|
const logger = await getLogger();
|
||||||
|
logger.info({ name: NAMESPACE, courseId }, 'Cancelling course...');
|
||||||
|
|
||||||
|
const { data: current, error: fetchError } = await client
|
||||||
|
.from('courses')
|
||||||
|
.select('status')
|
||||||
|
.eq('id', courseId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
if (!current) throw new CourseNotFoundError(courseId);
|
||||||
|
|
||||||
|
type CourseStatus = Parameters<typeof canTransition>[0];
|
||||||
|
if (!canTransition(current.status as CourseStatus, 'cancelled')) {
|
||||||
|
throw new InvalidCourseStatusTransitionError(
|
||||||
|
current.status,
|
||||||
|
'cancelled',
|
||||||
|
getValidTransitions(current.status as CourseStatus),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await client
|
||||||
|
.from('courses')
|
||||||
|
.update({ status: 'cancelled' })
|
||||||
|
.eq('id', courseId);
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createCourseReferenceDataService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async listCategories(accountId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_categories')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('sort_order');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async listInstructors(accountId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_instructors')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('last_name');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async listLocations(accountId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_locations')
|
||||||
|
.select('*')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.order('name');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCategory(input: {
|
||||||
|
accountId: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
parentId?: string;
|
||||||
|
}) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_categories')
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description,
|
||||||
|
parent_id: input.parentId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createInstructor(input: {
|
||||||
|
accountId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
qualifications?: string;
|
||||||
|
hourlyRate?: number;
|
||||||
|
}) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_instructors')
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
first_name: input.firstName,
|
||||||
|
last_name: input.lastName,
|
||||||
|
email: input.email,
|
||||||
|
phone: input.phone,
|
||||||
|
qualifications: input.qualifications,
|
||||||
|
hourly_rate: input.hourlyRate,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createLocation(input: {
|
||||||
|
accountId: string;
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
room?: string;
|
||||||
|
capacity?: number;
|
||||||
|
}) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_locations')
|
||||||
|
.insert({
|
||||||
|
account_id: input.accountId,
|
||||||
|
name: input.name,
|
||||||
|
address: input.address,
|
||||||
|
room: input.room,
|
||||||
|
capacity: input.capacity,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createCourseStatisticsService(
|
||||||
|
client: SupabaseClient<Database>,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
async getQuickStats(accountId: string) {
|
||||||
|
const { data, error } = await (client.rpc as CallableFunction)(
|
||||||
|
'get_course_statistics',
|
||||||
|
{ p_account_id: accountId },
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
// RPC returns a single row as an array
|
||||||
|
const stats = Array.isArray(data) ? data[0] : data;
|
||||||
|
return (
|
||||||
|
stats ?? {
|
||||||
|
total_courses: 0,
|
||||||
|
open_courses: 0,
|
||||||
|
running_courses: 0,
|
||||||
|
completed_courses: 0,
|
||||||
|
cancelled_courses: 0,
|
||||||
|
total_participants: 0,
|
||||||
|
total_waitlisted: 0,
|
||||||
|
avg_occupancy_rate: 0,
|
||||||
|
total_revenue: 0,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getAttendanceSummary(courseId: string) {
|
||||||
|
const { data, error } = await (client.rpc as CallableFunction)(
|
||||||
|
'get_course_attendance_summary',
|
||||||
|
{ p_course_id: courseId },
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import type { EnrollParticipantInput } from '../../schema/course.schema';
|
||||||
|
|
||||||
|
export function createEnrollmentService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async enroll(input: EnrollParticipantInput) {
|
||||||
|
// Uses the enroll_course_participant RPC which handles capacity checks
|
||||||
|
// and waitlisting atomically, avoiding race conditions.
|
||||||
|
const { data, error } = await (client.rpc as CallableFunction)(
|
||||||
|
'enroll_course_participant',
|
||||||
|
{
|
||||||
|
p_course_id: input.courseId,
|
||||||
|
p_member_id: input.memberId ?? null,
|
||||||
|
p_first_name: input.firstName,
|
||||||
|
p_last_name: input.lastName,
|
||||||
|
p_email: input.email || null,
|
||||||
|
p_phone: input.phone || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel(participantId: string) {
|
||||||
|
const { data, error } = await (client.rpc as CallableFunction)(
|
||||||
|
'cancel_course_enrollment',
|
||||||
|
{ p_participant_id: participantId },
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
return data as {
|
||||||
|
cancelled_id: string;
|
||||||
|
promoted_id: string | null;
|
||||||
|
promoted_name: string | null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listParticipants(courseId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_participants')
|
||||||
|
.select('*')
|
||||||
|
.eq('course_id', courseId)
|
||||||
|
.order('enrolled_at');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
import { createAttendanceService } from './attendance.service';
|
||||||
|
import { createCourseCrudService } from './course-crud.service';
|
||||||
|
import { createCourseReferenceDataService } from './course-reference-data.service';
|
||||||
|
import { createCourseStatisticsService } from './course-statistics.service';
|
||||||
|
import { createEnrollmentService } from './enrollment.service';
|
||||||
|
import { createSessionService } from './session.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
createAttendanceService,
|
||||||
|
createCourseCrudService,
|
||||||
|
createCourseReferenceDataService,
|
||||||
|
createCourseStatisticsService,
|
||||||
|
createEnrollmentService,
|
||||||
|
createSessionService,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCourseServices(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
courses: createCourseCrudService(client),
|
||||||
|
enrollment: createEnrollmentService(client),
|
||||||
|
sessions: createSessionService(client),
|
||||||
|
attendance: createAttendanceService(client),
|
||||||
|
referenceData: createCourseReferenceDataService(client),
|
||||||
|
statistics: createCourseStatisticsService(client),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'server-only';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
|
export function createSessionService(client: SupabaseClient<Database>) {
|
||||||
|
return {
|
||||||
|
async list(courseId: string) {
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_sessions')
|
||||||
|
.select('*')
|
||||||
|
.eq('course_id', courseId)
|
||||||
|
.order('session_date');
|
||||||
|
if (error) throw error;
|
||||||
|
return data ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(input: {
|
||||||
|
courseId: string;
|
||||||
|
sessionDate: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
locationId?: string;
|
||||||
|
}) {
|
||||||
|
// Check instructor availability if course has an instructor
|
||||||
|
const { data: course, error: courseError } = await client
|
||||||
|
.from('courses')
|
||||||
|
.select('instructor_id')
|
||||||
|
.eq('id', input.courseId)
|
||||||
|
.single();
|
||||||
|
if (courseError) throw courseError;
|
||||||
|
|
||||||
|
if (course?.instructor_id) {
|
||||||
|
const { data: isAvailable, error: availError } = await (
|
||||||
|
client.rpc as CallableFunction
|
||||||
|
)('check_instructor_availability', {
|
||||||
|
p_instructor_id: course.instructor_id,
|
||||||
|
p_session_date: input.sessionDate,
|
||||||
|
p_start_time: input.startTime,
|
||||||
|
p_end_time: input.endTime,
|
||||||
|
});
|
||||||
|
if (availError) throw availError;
|
||||||
|
if (!isAvailable) {
|
||||||
|
throw new Error('Kursleiter ist zu diesem Zeitpunkt nicht verfügbar');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('course_sessions')
|
||||||
|
.insert({
|
||||||
|
course_id: input.courseId,
|
||||||
|
session_date: input.sessionDate,
|
||||||
|
start_time: input.startTime,
|
||||||
|
end_time: input.endTime,
|
||||||
|
location_id: input.locationId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"./api": "./src/server/api.ts",
|
"./api": "./src/server/api.ts",
|
||||||
"./schema/*": "./src/schema/*.ts",
|
"./schema/*": "./src/schema/*.ts",
|
||||||
"./components": "./src/components/index.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": {
|
"scripts": {
|
||||||
"clean": "git clean -xdf .turbo node_modules",
|
"clean": "git clean -xdf .turbo node_modules",
|
||||||
|
|||||||
123
packages/features/event-management/src/lib/errors.ts
Normal file
123
packages/features/event-management/src/lib/errors.ts
Normal file
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
code: EventErrorCode,
|
||||||
|
message: string,
|
||||||
|
statusCode = 400,
|
||||||
|
details?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import type { EventStatusEnum } from '../schema/event.schema';
|
||||||
|
|
||||||
|
type EventStatus = z.infer<typeof EventStatusEnum>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSITIONS: Record<
|
||||||
|
EventStatus,
|
||||||
|
Partial<Record<EventStatus, StatusTransition>>
|
||||||
|
> = {
|
||||||
|
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<string, unknown> {
|
||||||
|
if (from === to) return {};
|
||||||
|
|
||||||
|
const transition = TRANSITIONS[from]?.[to];
|
||||||
|
if (!transition?.sideEffects) return {};
|
||||||
|
|
||||||
|
const result: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||||
|
result[key] = typeof value === 'function' ? value() : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a status transition and return side effects.
|
||||||
|
* Throws if the transition is invalid.
|
||||||
|
*/
|
||||||
|
export function validateTransition(
|
||||||
|
from: EventStatus,
|
||||||
|
to: EventStatus,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (from === to) return {};
|
||||||
|
|
||||||
|
if (!canTransition(from, to)) {
|
||||||
|
throw new Error(
|
||||||
|
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getTransitionSideEffects(from, to);
|
||||||
|
}
|
||||||
@@ -9,29 +9,80 @@ export const EventStatusEnum = z.enum([
|
|||||||
'cancelled',
|
'cancelled',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const CreateEventSchema = z.object({
|
export const CreateEventSchema = z
|
||||||
accountId: z.string().uuid(),
|
.object({
|
||||||
name: z.string().min(1).max(256),
|
accountId: z.string().uuid(),
|
||||||
description: z.string().optional(),
|
name: z.string().min(1).max(256),
|
||||||
eventDate: z.string(),
|
description: z.string().optional(),
|
||||||
eventTime: z.string().optional(),
|
eventDate: z.string(),
|
||||||
endDate: z.string().optional(),
|
eventTime: z.string().optional(),
|
||||||
location: z.string().optional(),
|
endDate: z.string().optional(),
|
||||||
capacity: z.number().int().optional(),
|
location: z.string().optional(),
|
||||||
minAge: z.number().int().optional(),
|
capacity: z.number().int().optional(),
|
||||||
maxAge: z.number().int().optional(),
|
minAge: z.number().int().optional(),
|
||||||
fee: z.number().min(0).default(0),
|
maxAge: z.number().int().optional(),
|
||||||
status: EventStatusEnum.default('planned'),
|
fee: z.number().min(0).default(0),
|
||||||
registrationDeadline: z.string().optional(),
|
status: EventStatusEnum.default('planned'),
|
||||||
contactName: z.string().optional(),
|
registrationDeadline: z.string().optional(),
|
||||||
contactEmail: z.string().email().optional().or(z.literal('')),
|
contactName: z.string().optional(),
|
||||||
contactPhone: 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<typeof CreateEventSchema>;
|
export type CreateEventInput = z.infer<typeof CreateEventSchema>;
|
||||||
|
|
||||||
export const UpdateEventSchema = CreateEventSchema.partial().extend({
|
export const UpdateEventSchema = z
|
||||||
eventId: z.string().uuid(),
|
.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<typeof UpdateEventSchema>;
|
export type UpdateEventInput = z.infer<typeof UpdateEventSchema>;
|
||||||
|
|
||||||
export const EventRegistrationSchema = z.object({
|
export const EventRegistrationSchema = z.object({
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
|
|||||||
import { getLogger } from '@kit/shared/logger';
|
import { getLogger } from '@kit/shared/logger';
|
||||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||||
|
|
||||||
|
import { isEventDomainError } from '../../lib/errors';
|
||||||
import {
|
import {
|
||||||
CreateEventSchema,
|
CreateEventSchema,
|
||||||
UpdateEventSchema,
|
UpdateEventSchema,
|
||||||
@@ -22,7 +23,7 @@ export const createEvent = authActionClient
|
|||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'event.create' }, 'Creating event...');
|
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');
|
logger.info({ name: 'event.create' }, 'Event created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
@@ -34,41 +35,62 @@ export const updateEvent = authActionClient
|
|||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'event.update' }, 'Updating event...');
|
try {
|
||||||
const result = await api.updateEvent(input);
|
logger.info({ name: 'event.update' }, 'Updating event...');
|
||||||
logger.info({ name: 'event.update' }, 'Event updated');
|
const result = await api.events.update(input, ctx.user.id);
|
||||||
return { success: true, data: result };
|
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
|
export const deleteEvent = authActionClient
|
||||||
.inputSchema(z.object({ eventId: z.string().uuid() }))
|
.inputSchema(z.object({ eventId: z.string().uuid() }))
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'event.delete' }, 'Cancelling event...');
|
try {
|
||||||
await api.deleteEvent(input.eventId);
|
logger.info({ name: 'event.delete' }, 'Cancelling event...');
|
||||||
logger.info({ name: 'event.delete' }, 'Event cancelled');
|
await api.events.softDelete(input.eventId);
|
||||||
return { success: true };
|
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
|
export const registerForEvent = authActionClient
|
||||||
.inputSchema(EventRegistrationSchema)
|
.inputSchema(EventRegistrationSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
|
|
||||||
logger.info({ name: 'event.register' }, 'Registering for event...');
|
try {
|
||||||
const result = await api.registerForEvent(input);
|
logger.info({ name: 'event.register' }, 'Registering for event...');
|
||||||
logger.info({ name: 'event.register' }, 'Registered for event');
|
const result = await api.registrations.register(input);
|
||||||
return { success: true, data: result };
|
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
|
export const createHolidayPass = authActionClient
|
||||||
.inputSchema(CreateHolidayPassSchema)
|
.inputSchema(CreateHolidayPassSchema)
|
||||||
.action(async ({ parsedInput: input, ctx }) => {
|
.action(async ({ parsedInput: input }) => {
|
||||||
const client = getSupabaseServerClient();
|
const client = getSupabaseServerClient();
|
||||||
const logger = await getLogger();
|
const logger = await getLogger();
|
||||||
const api = createEventManagementApi(client);
|
const api = createEventManagementApi(client);
|
||||||
@@ -77,7 +99,7 @@ export const createHolidayPass = authActionClient
|
|||||||
{ name: 'event.createHolidayPass' },
|
{ name: 'event.createHolidayPass' },
|
||||||
'Creating holiday pass...',
|
'Creating holiday pass...',
|
||||||
);
|
);
|
||||||
const result = await api.createHolidayPass(input);
|
const result = await api.holidayPasses.create(input);
|
||||||
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
|
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
|
||||||
return { success: true, data: result };
|
return { success: true, data: result };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,232 +1,16 @@
|
|||||||
|
import 'server-only';
|
||||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
import type { Database } from '@kit/supabase/database';
|
import type { Database } from '@kit/supabase/database';
|
||||||
|
|
||||||
import type {
|
import { createEventCrudService } from './services/event-crud.service';
|
||||||
CreateEventInput,
|
import { createEventRegistrationService } from './services/event-registration.service';
|
||||||
UpdateEventInput,
|
import { createHolidayPassService } from './services/holiday-pass.service';
|
||||||
} from '../schema/event.schema';
|
|
||||||
|
|
||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
|
|
||||||
export function createEventManagementApi(client: SupabaseClient<Database>) {
|
export function createEventManagementApi(client: SupabaseClient<Database>) {
|
||||||
const PAGE_SIZE = 25;
|
|
||||||
const _db = client;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async listEvents(
|
events: createEventCrudService(client),
|
||||||
accountId: string,
|
registrations: createEventRegistrationService(client),
|
||||||
opts?: { status?: string; page?: number },
|
holidayPasses: createHolidayPassService(client),
|
||||||
) {
|
|
||||||
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<string, number>;
|
|
||||||
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<string, number> = {};
|
|
||||||
for (const row of data ?? []) {
|
|
||||||
const eid = (row as Record<string, unknown>).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<string, unknown> = {};
|
|
||||||
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;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Database>) {
|
||||||
|
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<string, number>;
|
||||||
|
const { data, error } = await (client.rpc as CallableFunction)(
|
||||||
|
'get_event_registration_counts',
|
||||||
|
{ p_event_ids: eventIds },
|
||||||
|
);
|
||||||
|
if (error) throw error;
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
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<string, unknown> = {};
|
||||||
|
|
||||||
|
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<string, unknown>)
|
||||||
|
.status as string;
|
||||||
|
|
||||||
|
if (currentStatus !== input.status) {
|
||||||
|
try {
|
||||||
|
const sideEffects = validateTransition(
|
||||||
|
currentStatus as Parameters<typeof validateTransition>[0],
|
||||||
|
input.status as Parameters<typeof validateTransition>[1],
|
||||||
|
);
|
||||||
|
Object.assign(update, sideEffects);
|
||||||
|
} catch {
|
||||||
|
throw new InvalidEventStatusTransitionError(
|
||||||
|
currentStatus,
|
||||||
|
input.status,
|
||||||
|
getValidTransitions(
|
||||||
|
currentStatus as Parameters<typeof getValidTransitions>[0],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update.status = input.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId) {
|
||||||
|
update.updated_by = userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 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<typeof canTransition>[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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<Database>,
|
||||||
|
) {
|
||||||
|
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 ?? [];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user