refactor: improve code readability and consistency in api.ts and common.json

This commit is contained in:
T. Zehetbauer
2026-04-01 13:33:43 +02:00
parent 2a9d543ee4
commit c98cada7f6
7 changed files with 1032 additions and 234 deletions

View File

@@ -1,17 +1,65 @@
import {
CreditCard,
LayoutDashboard,
Settings,
Users,
Database,
UserCog,
CreditCard,
// People (Members + Access)
UserCheck,
UserPlus,
IdCard,
ClipboardList,
KeyRound,
// Courses
GraduationCap,
CalendarDays,
MapPin,
UserRound,
// Events
CalendarHeart,
Ticket,
PartyPopper,
// Bookings
Hotel,
Calendar,
BedDouble,
Contact,
CalendarRange,
// Finance
Wallet,
Receipt,
Landmark,
BarChart3,
// Documents
FileText,
FilePlus,
FileStack,
// Newsletter
Mail,
Globe,
MailPlus,
FileCode,
// Site Builder
PanelTop,
Newspaper,
Palette,
// Fisheries
Fish,
Waves,
Anchor,
BookOpen,
ShieldCheck,
Trophy,
// Meetings
BookMarked,
ListChecks,
ScrollText,
// Association (Verband)
Building2,
Network,
SearchCheck,
Share2,
PieChart,
LayoutTemplate,
// Modules
Database,
} from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
@@ -21,101 +69,499 @@ import pathsConfig from '~/config/paths.config';
const iconClasses = 'w-4';
const getRoutes = (account: string) => [
const getRoutes = (account: string) => {
const routes: Array<
| {
label: string;
collapsible?: boolean;
collapsed?: boolean;
children: Array<
| {
label: string;
path: string;
Icon: React.ReactNode;
highlightMatch?: string;
}
| undefined
>;
}
| { divider: true }
> = [
// ── Dashboard ──
{
label: 'common:routes.dashboard',
children: [
{
label: 'common:routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
},
],
},
];
// ── People (Members + Access) ──
{
label: 'common.routes.application',
const peopleChildren: Array<{
label: string;
path: string;
Icon: React.ReactNode;
}> = [];
if (featureFlagsConfig.enableMemberManagement) {
peopleChildren.push(
{
label: 'common:routes.clubMembers',
path: createPath(pathsConfig.app.accountCmsMembers, account),
Icon: <UserCheck className={iconClasses} />,
},
{
label: 'common:routes.memberApplications',
path: createPath(
pathsConfig.app.accountCmsMembers + '/applications',
account,
),
Icon: <UserPlus className={iconClasses} />,
},
{
label: 'common:routes.memberPortal',
path: createPath(
pathsConfig.app.accountCmsMembers + '/portal',
account,
),
Icon: <KeyRound className={iconClasses} />,
},
{
label: 'common:routes.memberCards',
path: createPath(
pathsConfig.app.accountCmsMembers + '/cards',
account,
),
Icon: <IdCard className={iconClasses} />,
},
{
label: 'common:routes.memberDues',
path: createPath(
pathsConfig.app.accountCmsMembers + '/dues',
account,
),
Icon: <ClipboardList className={iconClasses} />,
},
);
}
// Admin users who can log in — always visible
peopleChildren.push({
label: 'common:routes.accessAndRoles',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <UserCog className={iconClasses} />,
});
routes.push({
label: 'common:routes.people',
collapsible: true,
children: peopleChildren,
});
}
// ── Courses ──
if (featureFlagsConfig.enableCourseManagement) {
routes.push({
label: 'common:routes.courseManagement',
collapsible: true,
children: [
{
label: 'common:routes.courseList',
path: createPath(pathsConfig.app.accountCourses, account),
Icon: <GraduationCap className={iconClasses} />,
},
{
label: 'common:routes.courseCalendar',
path: createPath(
pathsConfig.app.accountCourses + '/calendar',
account,
),
Icon: <CalendarDays className={iconClasses} />,
},
{
label: 'common:routes.courseInstructors',
path: createPath(
pathsConfig.app.accountCourses + '/instructors',
account,
),
Icon: <UserRound className={iconClasses} />,
},
{
label: 'common:routes.courseLocations',
path: createPath(
pathsConfig.app.accountCourses + '/locations',
account,
),
Icon: <MapPin className={iconClasses} />,
},
],
});
}
// ── Events ──
routes.push({
label: 'common:routes.eventManagement',
collapsible: true,
children: [
{
label: 'common.routes.dashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
highlightMatch: `${pathsConfig.app.home}$`,
label: 'common:routes.eventList',
path: createPath('/home/[account]/events', account),
Icon: <CalendarHeart className={iconClasses} />,
},
featureFlagsConfig.enableModuleBuilder
? {
label: 'common.routes.modules',
path: createPath(pathsConfig.app.accountModules, account),
Icon: <Database className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableMemberManagement
? {
label: 'common.routes.cmsMembers',
path: createPath(pathsConfig.app.accountCmsMembers, account),
Icon: <UserCheck className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableCourseManagement
? {
label: 'common.routes.courses',
path: createPath(pathsConfig.app.accountCourses, account),
Icon: <GraduationCap className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableBookingManagement
? {
label: 'common.routes.bookings',
path: createPath(pathsConfig.app.accountBookings, account),
Icon: <Hotel className={iconClasses} />,
}
: undefined,
{
label: 'common.routes.events',
path: createPath(`/home/[account]/events`, account),
Icon: <Calendar className={iconClasses} />,
label: 'common:routes.eventRegistrations',
path: createPath('/home/[account]/events/registrations', account),
Icon: <Ticket className={iconClasses} />,
},
featureFlagsConfig.enableSepaPayments
? {
label: 'common.routes.finance',
path: createPath(pathsConfig.app.accountFinance, account),
Icon: <Wallet className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableDocumentGeneration
? {
label: 'common.routes.documents',
path: createPath(pathsConfig.app.accountDocuments, account),
Icon: <FileText className={iconClasses} />,
}
: undefined,
featureFlagsConfig.enableNewsletter
? {
label: 'common.routes.newsletter',
path: createPath(pathsConfig.app.accountNewsletter, account),
Icon: <Mail className={iconClasses} />,
}
: undefined,
{
label: 'common.routes.siteBuilder',
path: createPath(`/home/[account]/site-builder`, account),
Icon: <Globe className={iconClasses} />,
label: 'common:routes.holidayPasses',
path: createPath('/home/[account]/events/holiday-passes', account),
Icon: <PartyPopper className={iconClasses} />,
},
].filter(Boolean),
},
{
label: 'common.routes.settings',
],
});
// ── Bookings ──
if (featureFlagsConfig.enableBookingManagement) {
routes.push({
label: 'common:routes.bookingManagement',
collapsible: true,
children: [
{
label: 'common:routes.bookingList',
path: createPath(pathsConfig.app.accountBookings, account),
Icon: <Hotel className={iconClasses} />,
},
{
label: 'common:routes.bookingCalendar',
path: createPath(
pathsConfig.app.accountBookings + '/calendar',
account,
),
Icon: <CalendarRange className={iconClasses} />,
},
{
label: 'common:routes.bookingRooms',
path: createPath(pathsConfig.app.accountBookings + '/rooms', account),
Icon: <BedDouble className={iconClasses} />,
},
{
label: 'common:routes.bookingGuests',
path: createPath(
pathsConfig.app.accountBookings + '/guests',
account,
),
Icon: <Contact className={iconClasses} />,
},
],
});
}
// ── Finance ──
if (featureFlagsConfig.enableSepaPayments) {
routes.push({
label: 'common:routes.financeManagement',
collapsible: true,
children: [
{
label: 'common:routes.financeOverview',
path: createPath(pathsConfig.app.accountFinance, account),
Icon: <Wallet className={iconClasses} />,
},
{
label: 'common:routes.financeInvoices',
path: createPath(
pathsConfig.app.accountFinance + '/invoices',
account,
),
Icon: <Receipt className={iconClasses} />,
},
{
label: 'common:routes.financeSepa',
path: createPath(pathsConfig.app.accountFinance + '/sepa', account),
Icon: <Landmark className={iconClasses} />,
},
{
label: 'common:routes.financePayments',
path: createPath(
pathsConfig.app.accountFinance + '/payments',
account,
),
Icon: <BarChart3 className={iconClasses} />,
},
],
});
}
// ── Documents ──
if (featureFlagsConfig.enableDocumentGeneration) {
routes.push({
label: 'common:routes.documentManagement',
collapsible: true,
children: [
{
label: 'common:routes.documentOverview',
path: createPath(pathsConfig.app.accountDocuments, account),
Icon: <FileText className={iconClasses} />,
},
{
label: 'common:routes.documentGenerate',
path: createPath(
pathsConfig.app.accountDocuments + '/generate',
account,
),
Icon: <FilePlus className={iconClasses} />,
},
{
label: 'common:routes.documentTemplates',
path: createPath(
pathsConfig.app.accountDocuments + '/templates',
account,
),
Icon: <FileStack className={iconClasses} />,
},
],
});
}
// ── Newsletter ──
if (featureFlagsConfig.enableNewsletter) {
routes.push({
label: 'common:routes.newsletterManagement',
collapsible: true,
children: [
{
label: 'common:routes.newsletterCampaigns',
path: createPath(pathsConfig.app.accountNewsletter, account),
Icon: <Mail className={iconClasses} />,
},
{
label: 'common:routes.newsletterNew',
path: createPath(pathsConfig.app.accountNewsletter + '/new', account),
Icon: <MailPlus className={iconClasses} />,
},
{
label: 'common:routes.newsletterTemplates',
path: createPath(
pathsConfig.app.accountNewsletter + '/templates',
account,
),
Icon: <FileCode className={iconClasses} />,
},
],
});
}
// ── Site Builder ──
if (featureFlagsConfig.enableSiteBuilder) {
routes.push({
label: 'common:routes.siteBuilder',
collapsible: true,
children: [
{
label: 'common:routes.sitePages',
path: createPath(pathsConfig.app.accountSiteBuilder, account),
Icon: <PanelTop className={iconClasses} />,
},
{
label: 'common:routes.sitePosts',
path: createPath(
pathsConfig.app.accountSiteBuilder + '/posts',
account,
),
Icon: <Newspaper className={iconClasses} />,
},
{
label: 'common:routes.siteSettings',
path: createPath(
pathsConfig.app.accountSiteBuilder + '/settings',
account,
),
Icon: <Palette className={iconClasses} />,
},
],
});
}
// ── Custom Modules ──
if (featureFlagsConfig.enableModuleBuilder) {
routes.push({
label: 'common:routes.customModules',
collapsible: true,
collapsed: true,
children: [
{
label: 'common:routes.moduleList',
path: createPath(pathsConfig.app.accountModules, account),
Icon: <Database className={iconClasses} />,
},
],
});
}
// ── Fisheries ──
if (featureFlagsConfig.enableFischerei) {
routes.push({
label: 'common:routes.fisheriesManagement',
collapsible: true,
children: [
{
label: 'common:routes.fisheriesOverview',
path: createPath(pathsConfig.app.accountFischerei, account),
Icon: <Fish className={iconClasses} />,
},
{
label: 'common:routes.fisheriesWaters',
path: createPath(
pathsConfig.app.accountFischerei + '/waters',
account,
),
Icon: <Waves className={iconClasses} />,
},
{
label: 'common:routes.fisheriesLeases',
path: createPath(
pathsConfig.app.accountFischerei + '/leases',
account,
),
Icon: <Anchor className={iconClasses} />,
},
{
label: 'common:routes.fisheriesCatchBooks',
path: createPath(
pathsConfig.app.accountFischerei + '/catch-books',
account,
),
Icon: <BookOpen className={iconClasses} />,
},
{
label: 'common:routes.fisheriesPermits',
path: createPath(
pathsConfig.app.accountFischerei + '/permits',
account,
),
Icon: <ShieldCheck className={iconClasses} />,
},
{
label: 'common:routes.fisheriesCompetitions',
path: createPath(
pathsConfig.app.accountFischerei + '/competitions',
account,
),
Icon: <Trophy className={iconClasses} />,
},
],
});
}
// ── Meeting Protocols ──
if (featureFlagsConfig.enableMeetingProtocols) {
routes.push({
label: 'common:routes.meetingProtocols',
collapsible: true,
children: [
{
label: 'common:routes.meetingsOverview',
path: createPath(pathsConfig.app.accountMeetings, account),
Icon: <BookMarked className={iconClasses} />,
},
{
label: 'common:routes.meetingsProtocols',
path: createPath(
pathsConfig.app.accountMeetings + '/protocols',
account,
),
Icon: <ScrollText className={iconClasses} />,
},
{
label: 'common:routes.meetingsTasks',
path: createPath(pathsConfig.app.accountMeetings + '/tasks', account),
Icon: <ListChecks className={iconClasses} />,
},
],
});
}
// ── Association Management (Verband) ──
if (featureFlagsConfig.enableVerbandsverwaltung) {
routes.push({
label: 'common:routes.associationManagement',
collapsible: true,
children: [
{
label: 'common:routes.associationOverview',
path: createPath(pathsConfig.app.accountVerband, account),
Icon: <Building2 className={iconClasses} />,
},
{
label: 'common:routes.associationHierarchy',
path: createPath(
pathsConfig.app.accountVerband + '/hierarchy',
account,
),
Icon: <Network className={iconClasses} />,
},
{
label: 'common:routes.associationMemberSearch',
path: createPath(
pathsConfig.app.accountVerband + '/members',
account,
),
Icon: <SearchCheck className={iconClasses} />,
},
{
label: 'common:routes.associationEvents',
path: createPath(pathsConfig.app.accountVerband + '/events', account),
Icon: <Share2 className={iconClasses} />,
},
{
label: 'common:routes.associationReporting',
path: createPath(
pathsConfig.app.accountVerband + '/reporting',
account,
),
Icon: <PieChart className={iconClasses} />,
},
{
label: 'common:routes.associationTemplates',
path: createPath(
pathsConfig.app.accountVerband + '/templates',
account,
),
Icon: <LayoutTemplate className={iconClasses} />,
},
],
});
}
// ── Administration ──
routes.push({
label: 'common:routes.administration',
collapsible: false,
children: [
{
label: 'common.routes.settings',
label: 'common:routes.accountSettings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
{
label: 'common.routes.members',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />,
},
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common.routes.billing',
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />,
}
: undefined,
].filter(Boolean),
},
];
],
});
return routes;
};
export function getTeamAccountSidebarConfig(account: string) {
return NavigationConfigSchema.parse({

View File

@@ -61,24 +61,83 @@
"routes": {
"home": "Startseite",
"account": "Konto",
"members": "Mitglieder",
"billing": "Abrechnung",
"dashboard": "Dashboard",
"settings": "Einstellungen",
"profile": "Profil",
"application": "Anwendung",
"modules": "Module",
"cmsMembers": "Mitglieder",
"courses": "Kurse",
"bookings": "Buchungen",
"finance": "Finanzen",
"documents": "Dokumente",
"newsletter": "Newsletter",
"events": "Veranstaltungen",
"people": "Personen",
"clubMembers": "Vereinsmitglieder",
"memberApplications": "Aufnahmeanträge",
"memberPortal": "Mitgliederportal",
"memberCards": "Mitgliedsausweise",
"memberDues": "Beitragskategorien",
"accessAndRoles": "Zugänge & Rollen",
"courseManagement": "Kursverwaltung",
"courseList": "Alle Kurse",
"courseCalendar": "Kurskalender",
"courseInstructors": "Kursleiter",
"courseLocations": "Standorte",
"eventManagement": "Veranstaltungen",
"eventList": "Alle Veranstaltungen",
"eventRegistrations": "Anmeldungen",
"holidayPasses": "Ferienpässe",
"bookingManagement": "Buchungsverwaltung",
"bookingList": "Alle Buchungen",
"bookingCalendar": "Belegungskalender",
"bookingRooms": "Zimmer",
"bookingGuests": "Gäste",
"financeManagement": "Finanzen",
"financeOverview": "Übersicht",
"financeInvoices": "Rechnungen",
"financeSepa": "SEPA-Einzüge",
"financePayments": "Zahlungen",
"documentManagement": "Dokumente",
"documentOverview": "Übersicht",
"documentGenerate": "Generieren",
"documentTemplates": "Vorlagen",
"newsletterManagement": "Newsletter",
"newsletterCampaigns": "Kampagnen",
"newsletterNew": "Neuer Newsletter",
"newsletterTemplates": "Vorlagen",
"siteBuilder": "Website",
"fischerei": "Fischerei",
"meetings": "Sitzungsprotokolle",
"verband": "Verbandsverwaltung"
"sitePages": "Seiten",
"sitePosts": "Beiträge",
"siteSettings": "Einstellungen",
"customModules": "Benutzerdefinierte Module",
"moduleList": "Alle Module",
"fisheriesManagement": "Fischerei",
"fisheriesOverview": "Übersicht",
"fisheriesWaters": "Gewässer",
"fisheriesLeases": "Pachten",
"fisheriesCatchBooks": "Fangbücher",
"fisheriesPermits": "Erlaubnisscheine",
"fisheriesCompetitions": "Wettbewerbe",
"meetingProtocols": "Sitzungsprotokolle",
"meetingsOverview": "Übersicht",
"meetingsProtocols": "Protokolle",
"meetingsTasks": "Offene Aufgaben",
"associationManagement": "Verbandsverwaltung",
"associationOverview": "Übersicht",
"associationHierarchy": "Organisationsstruktur",
"associationMemberSearch": "Verbandsweite Suche",
"associationEvents": "Geteilte Veranstaltungen",
"associationReporting": "Berichte",
"associationTemplates": "Geteilte Vorlagen",
"administration": "Administration",
"accountSettings": "Kontoeinstellungen"
},
"roles": {
"owner": {

View File

@@ -61,24 +61,83 @@
"routes": {
"home": "Home",
"account": "Account",
"members": "Members",
"billing": "Billing",
"dashboard": "Dashboard",
"settings": "Settings",
"profile": "Profile",
"application": "Application",
"modules": "Modules",
"cmsMembers": "Members",
"courses": "Courses",
"bookings": "Bookings",
"events": "Events",
"people": "People",
"clubMembers": "Club Members",
"memberApplications": "Applications",
"memberPortal": "Member Portal",
"memberCards": "Member Cards",
"memberDues": "Dues Categories",
"accessAndRoles": "Access & Roles",
"courseManagement": "Courses",
"courseList": "All Courses",
"courseCalendar": "Calendar",
"courseInstructors": "Instructors",
"courseLocations": "Locations",
"eventManagement": "Events",
"eventList": "All Events",
"eventRegistrations": "Registrations",
"holidayPasses": "Holiday Passes",
"bookingManagement": "Bookings",
"bookingList": "All Bookings",
"bookingCalendar": "Availability Calendar",
"bookingRooms": "Rooms",
"bookingGuests": "Guests",
"financeManagement": "Finance",
"financeOverview": "Overview",
"financeInvoices": "Invoices",
"financeSepa": "SEPA Batches",
"financePayments": "Payments",
"documentManagement": "Documents",
"documentOverview": "Overview",
"documentGenerate": "Generate",
"documentTemplates": "Templates",
"newsletterManagement": "Newsletter",
"newsletterCampaigns": "Campaigns",
"newsletterNew": "New Newsletter",
"newsletterTemplates": "Templates",
"siteBuilder": "Website",
"finance": "Finance",
"documents": "Documents",
"newsletter": "Newsletter",
"fischerei": "Fisheries",
"meetings": "Meeting Protocols",
"verband": "Federation Management"
"sitePages": "Pages",
"sitePosts": "Posts",
"siteSettings": "Settings",
"customModules": "Custom Modules",
"moduleList": "All Modules",
"fisheriesManagement": "Fisheries",
"fisheriesOverview": "Overview",
"fisheriesWaters": "Waters",
"fisheriesLeases": "Leases",
"fisheriesCatchBooks": "Catch Books",
"fisheriesPermits": "Permits",
"fisheriesCompetitions": "Competitions",
"meetingProtocols": "Meeting Protocols",
"meetingsOverview": "Overview",
"meetingsProtocols": "Protocols",
"meetingsTasks": "Open Tasks",
"associationManagement": "Association Management",
"associationOverview": "Overview",
"associationHierarchy": "Organization Structure",
"associationMemberSearch": "Association-wide Search",
"associationEvents": "Shared Events",
"associationReporting": "Reports",
"associationTemplates": "Shared Templates",
"administration": "Administration",
"accountSettings": "Account Settings"
},
"roles": {
"owner": {

View File

@@ -165,7 +165,9 @@ async function getPatterns() {
} catch {
// Supabase unreachable — redirect to sign in
const signIn = pathsConfig.auth.signIn;
return NextResponse.redirect(new URL(signIn, req.nextUrl.origin).href);
return NextResponse.redirect(
new URL(signIn, req.nextUrl.origin).href,
);
}
const { origin, pathname: next } = req.nextUrl;

View File

@@ -28,7 +28,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: postgres
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
test: ['CMD-SHELL', 'pg_isready -U postgres -d postgres']
interval: 10s
timeout: 5s
retries: 10
@@ -49,7 +49,7 @@ services:
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
entrypoint: ["/bin/sh", "-c"]
entrypoint: ['/bin/sh', '-c']
command:
- |
echo "🔑 Ensuring role passwords are set (idempotent)..."
@@ -63,7 +63,7 @@ services:
echo "✅ App migrations complete."
echo ""
sh /app-seed/dev-bootstrap.sh
restart: "no"
restart: 'no'
# =====================================================
# Supabase Auth (GoTrue)
@@ -102,7 +102,15 @@ services:
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:9999/health"]
test:
[
'CMD',
'wget',
'--no-verbose',
'--tries=1',
'--spider',
'http://localhost:9999/health',
]
interval: 10s
timeout: 5s
retries: 5
@@ -123,9 +131,9 @@ services:
PGRST_DB_SCHEMAS: public,storage,graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_DB_USE_LEGACY_GUCS: 'false'
healthcheck:
test: ["CMD-SHELL", "head -c0 </dev/tcp/localhost/3000 || exit 1"]
test: ['CMD-SHELL', 'head -c0 </dev/tcp/localhost/3000 || exit 1']
interval: 10s
timeout: 5s
retries: 5
@@ -149,20 +157,20 @@ services:
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: postgres
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE:-UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq}
ERL_AFLAGS: "-proto_dist inet_tcp"
ERL_AFLAGS: '-proto_dist inet_tcp'
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
RLIMIT_NOFILE: '10000'
APP_NAME: realtime
SEED_SELF_HOST: "true"
SEED_SELF_HOST: 'true'
REPLICATION_MODE: RLS
REPLICATION_POLL_INTERVAL: 100
SECURE_CHANNELS: "true"
SECURE_CHANNELS: 'true'
SLOT_NAME: supabase_realtime_rls
TEMPORARY_SLOT: "true"
TEMPORARY_SLOT: 'true'
MAX_RECORD_BYTES: 1048576
# =====================================================
@@ -196,7 +204,11 @@ services:
GLOBAL_S3_BUCKET: stub
IMGPROXY_URL: http://supabase-imgproxy:8080
healthcheck:
test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1"]
test:
[
'CMD-SHELL',
'wget --no-verbose --tries=1 --spider http://localhost:5000/status || exit 1',
]
interval: 10s
timeout: 5s
retries: 5
@@ -209,10 +221,10 @@ services:
image: darthsim/imgproxy:v3.8.0
restart: unless-stopped
environment:
IMGPROXY_BIND: ":8080"
IMGPROXY_BIND: ':8080'
IMGPROXY_LOCAL_FILESYSTEM_ROOT: /
IMGPROXY_USE_ETAG: "true"
IMGPROXY_ENABLE_WEBP_DETECTION: "true"
IMGPROXY_USE_ETAG: 'true'
IMGPROXY_ENABLE_WEBP_DETECTION: 'true'
# =====================================================
# Supabase pg_meta (DB introspection for Studio)
@@ -252,10 +264,16 @@ services:
SUPABASE_ANON_KEY: ${SUPABASE_ANON_KEY}
SUPABASE_SERVICE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
NEXT_PUBLIC_ENABLE_LOGS: "true"
NEXT_PUBLIC_ENABLE_LOGS: 'true'
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/profile', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))"]
test:
[
'CMD',
'node',
'-e',
"require('http').get('http://localhost:3000/api/profile', (r) => r.statusCode === 200 ? process.exit(0) : process.exit(1))",
]
interval: 10s
timeout: 5s
retries: 5
@@ -284,7 +302,7 @@ services:
entrypoint: >
sh -c "sed 's|\$${SUPABASE_ANON_KEY}|'\"$$SUPABASE_ANON_KEY\"'|g; s|\$${SUPABASE_SERVICE_KEY}|'\"$$SUPABASE_SERVICE_KEY\"'|g' /var/lib/kong/kong.yml.tpl > /tmp/kong.yml && KONG_DECLARATIVE_CONFIG=/tmp/kong.yml /docker-entrypoint.sh kong docker-start"
environment:
KONG_DATABASE: "off"
KONG_DATABASE: 'off'
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
@@ -295,7 +313,7 @@ services:
volumes:
- ./docker/kong.yml:/var/lib/kong/kong.yml.tpl:ro
healthcheck:
test: ["CMD", "kong", "health"]
test: ['CMD', 'kong', 'health']
interval: 10s
timeout: 5s
retries: 5
@@ -329,15 +347,15 @@ services:
SUPABASE_DB_WEBHOOK_SECRET: ${DB_WEBHOOK_SECRET:-webhooksecret}
EMAIL_SENDER: ${EMAIL_SENDER:-noreply@myeasycms.de}
NEXT_PUBLIC_PRODUCT_NAME: MyEasyCMS
NEXT_PUBLIC_ENABLE_THEME_TOGGLE: "true"
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: "true"
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: "true"
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: "false"
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: "false"
NEXT_PUBLIC_ENABLE_NOTIFICATIONS: "true"
NEXT_PUBLIC_ENABLE_FISCHEREI: "true"
NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS: "true"
NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG: "true"
NEXT_PUBLIC_ENABLE_THEME_TOGGLE: 'true'
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS: 'true'
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION: 'true'
NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING: 'false'
NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING: 'false'
NEXT_PUBLIC_ENABLE_NOTIFICATIONS: 'true'
NEXT_PUBLIC_ENABLE_FISCHEREI: 'true'
NEXT_PUBLIC_ENABLE_MEETING_PROTOCOLS: 'true'
NEXT_PUBLIC_ENABLE_VERBANDSVERWALTUNG: 'true'
volumes:
supabase-db-data:

View File

@@ -1,7 +1,11 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { CreateCourseInput, EnrollParticipantInput } from '../schema/course.schema';
import type { Database } from '@kit/supabase/database';
import type {
CreateCourseInput,
EnrollParticipantInput,
} from '../schema/course.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -10,11 +14,25 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
return {
// --- Courses ---
async listCourses(accountId: string, opts?: { status?: string; search?: string; page?: number; pageSize?: number }) {
let query = client.from('courses').select('*', { count: 'exact' })
.eq('account_id', accountId).order('start_date', { ascending: false });
async listCourses(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('courses')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.search) query = query.or(`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`);
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);
@@ -24,20 +42,38 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
},
async getCourse(courseId: string) {
const { data, error } = await client.from('courses').select('*').eq('id', courseId).single();
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();
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;
},
@@ -45,96 +81,161 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
// --- 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 { 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 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();
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')
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');
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');
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();
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);
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' });
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');
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');
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');
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')
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));
.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 ?? [])) {
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++;
@@ -143,30 +244,70 @@ export function createCourseManagementApi(client: SupabaseClient<Database>) {
},
// --- 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();
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();
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();
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;
},

View File

@@ -1,6 +1,7 @@
import type { Database } from '@kit/supabase/database';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateEventInput } from '../schema/event.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -10,16 +11,28 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
const db = client;
return {
async listEvents(accountId: string, opts?: { status?: string; page?: number }) {
let query = client.from('events').select('*', { count: 'exact' })
.eq('account_id', accountId).order('event_date', { ascending: false });
async listEvents(
accountId: string,
opts?: { status?: string; page?: number },
) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return { data: data ?? [], total, page, pageSize: PAGE_SIZE, totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)) };
return {
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
},
async getRegistrationCounts(eventIds: string[]) {
@@ -40,71 +53,131 @@ export function createEventManagementApi(client: SupabaseClient<Database>) {
},
async getEvent(eventId: string) {
const { data, error } = await client.from('events').select('*').eq('id', eventId).single();
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 || null, 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();
const { data, error } = await client
.from('events')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description || null,
event_date: input.eventDate || null,
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 registerForEvent(input: { eventId: string; firstName: string; lastName: string; email?: string; parentName?: string }) {
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']);
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();
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');
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 });
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');
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();
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;
},