refactor: remove obsolete member management API module
Some checks failed
Workflow / ʦ TypeScript (pull_request) Failing after 5m57s
Workflow / ⚫️ Test (pull_request) Has been skipped

This commit is contained in:
T. Zehetbauer
2026-04-03 14:08:31 +02:00
parent 124c6a632a
commit 5c5aaabae5
132 changed files with 10107 additions and 3442 deletions

View File

@@ -13,7 +13,9 @@
"./api": "./src/server/api.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts"
"./actions/*": "./src/server/actions/*.ts",
"./services/*": "./src/server/services/*.ts",
"./lib/*": "./src/lib/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",

View File

@@ -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);
}

View 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;
}

View File

@@ -16,29 +16,100 @@ export const CourseStatusEnum = z.enum([
'cancelled',
]);
export const CreateCourseSchema = z.object({
accountId: z.string().uuid(),
courseNumber: z.string().optional(),
name: z.string().min(1).max(256),
description: z.string().optional(),
categoryId: z.string().uuid().optional(),
instructorId: z.string().uuid().optional(),
locationId: z.string().uuid().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
fee: z.number().min(0).default(0),
reducedFee: z.number().min(0).optional(),
capacity: z.number().int().min(1).default(20),
minParticipants: z.number().int().min(0).default(5),
status: CourseStatusEnum.default('planned'),
registrationDeadline: z.string().optional(),
notes: z.string().optional(),
});
export const CreateCourseSchema = z
.object({
accountId: z.string().uuid(),
courseNumber: z.string().optional(),
name: z.string().min(1).max(256),
description: z.string().optional(),
categoryId: z.string().uuid().optional(),
instructorId: z.string().uuid().optional(),
locationId: z.string().uuid().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
fee: z.number().min(0).default(0),
reducedFee: z.number().min(0).optional(),
capacity: z.number().int().min(1).default(20),
minParticipants: z.number().int().min(0).default(5),
status: CourseStatusEnum.default('planned'),
registrationDeadline: z.string().optional(),
notes: z.string().optional(),
})
.refine((d) => d.reducedFee == null || d.reducedFee <= d.fee, {
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
path: ['reducedFee'],
})
.refine((d) => d.minParticipants == null || d.minParticipants <= d.capacity, {
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
path: ['minParticipants'],
})
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
message: 'Enddatum muss nach dem Startdatum liegen',
path: ['endDate'],
})
.refine(
(d) =>
!d.registrationDeadline ||
!d.startDate ||
d.registrationDeadline <= d.startDate,
{
message: 'Anmeldefrist muss vor dem Startdatum liegen',
path: ['registrationDeadline'],
},
);
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>;
export const UpdateCourseSchema = CreateCourseSchema.partial().extend({
courseId: z.string().uuid(),
});
export const UpdateCourseSchema = z
.object({
courseId: z.string().uuid(),
version: z.number().int().optional(),
courseNumber: z.string().optional(),
name: z.string().min(1).max(256).optional(),
description: z.string().optional(),
categoryId: z.string().uuid().optional(),
instructorId: z.string().uuid().optional(),
locationId: z.string().uuid().optional(),
startDate: z.string().optional(),
endDate: z.string().optional(),
fee: z.number().min(0).optional(),
reducedFee: z.number().min(0).optional().nullable(),
capacity: z.number().int().min(1).optional(),
minParticipants: z.number().int().min(0).optional(),
status: CourseStatusEnum.optional(),
registrationDeadline: z.string().optional(),
notes: z.string().optional(),
})
.refine(
(d) => d.reducedFee == null || d.fee == null || d.reducedFee <= d.fee,
{
message: 'Ermäßigte Gebühr darf die reguläre Gebühr nicht übersteigen',
path: ['reducedFee'],
},
)
.refine(
(d) =>
d.minParticipants == null ||
d.capacity == null ||
d.minParticipants <= d.capacity,
{
message: 'Mindestteilnehmerzahl darf die Kapazität nicht übersteigen',
path: ['minParticipants'],
},
)
.refine((d) => !d.startDate || !d.endDate || d.endDate >= d.startDate, {
message: 'Enddatum muss nach dem Startdatum liegen',
path: ['endDate'],
})
.refine(
(d) =>
!d.registrationDeadline ||
!d.startDate ||
d.registrationDeadline <= d.startDate,
{
message: 'Anmeldefrist muss vor dem Startdatum liegen',
path: ['registrationDeadline'],
},
);
export type UpdateCourseInput = z.infer<typeof UpdateCourseSchema>;
export const EnrollParticipantSchema = z.object({

View File

@@ -6,6 +6,7 @@ import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isCourseDomainError } from '../../lib/errors';
import {
CreateCourseSchema,
UpdateCourseSchema,
@@ -25,7 +26,7 @@ export const createCourse = authActionClient
const api = createCourseManagementApi(client);
logger.info({ name: 'course.create' }, 'Creating course...');
const result = await api.createCourse(input);
const result = await api.courses.create(input, ctx.user.id);
logger.info({ name: 'course.create' }, 'Course created');
return { success: true, data: result };
});
@@ -37,39 +38,60 @@ export const updateCourse = authActionClient
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.update' }, 'Updating course...');
const result = await api.updateCourse(input);
logger.info({ name: 'course.update' }, 'Course updated');
return { success: true, data: result };
try {
logger.info({ name: 'course.update' }, 'Updating course...');
const result = await api.courses.update(input, ctx.user.id);
logger.info({ name: 'course.update' }, 'Course updated');
return { success: true, data: result };
} catch (e) {
if (isCourseDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const deleteCourse = authActionClient
.inputSchema(z.object({ courseId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.delete' }, 'Archiving course...');
await api.deleteCourse(input.courseId);
logger.info({ name: 'course.delete' }, 'Course archived');
return { success: true };
try {
logger.info({ name: 'course.delete' }, 'Archiving course...');
await api.courses.softDelete(input.courseId);
logger.info({ name: 'course.delete' }, 'Course archived');
return { success: true };
} catch (e) {
if (isCourseDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const enrollParticipant = authActionClient
.inputSchema(EnrollParticipantSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info(
{ name: 'course.enrollParticipant' },
'Enrolling participant...',
);
const result = await api.enrollParticipant(input);
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
return { success: true, data: result };
try {
logger.info(
{ name: 'course.enrollParticipant' },
'Enrolling participant...',
);
const result = await api.enrollment.enroll(input);
logger.info({ name: 'course.enrollParticipant' }, 'Participant enrolled');
return { success: true, data: result };
} catch (e) {
if (isCourseDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const cancelEnrollment = authActionClient
@@ -78,7 +100,7 @@ export const cancelEnrollment = authActionClient
participantId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
@@ -87,9 +109,9 @@ export const cancelEnrollment = authActionClient
{ name: 'course.cancelEnrollment' },
'Cancelling enrollment...',
);
const result = await api.cancelEnrollment(input.participantId);
await api.enrollment.cancel(input.participantId);
logger.info({ name: 'course.cancelEnrollment' }, 'Enrollment cancelled');
return { success: true, data: result };
return { success: true };
});
export const markAttendance = authActionClient
@@ -100,69 +122,69 @@ export const markAttendance = authActionClient
present: z.boolean(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.markAttendance' }, 'Marking attendance...');
const result = await api.markAttendance(
await api.attendance.mark(
input.sessionId,
input.participantId,
input.present,
);
logger.info({ name: 'course.markAttendance' }, 'Attendance marked');
return { success: true, data: result };
return { success: true };
});
export const createCategory = authActionClient
.inputSchema(CreateCategorySchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createCategory' }, 'Creating category...');
const result = await api.createCategory(input);
const result = await api.referenceData.createCategory(input);
logger.info({ name: 'course.createCategory' }, 'Category created');
return { success: true, data: result };
});
export const createInstructor = authActionClient
.inputSchema(CreateInstructorSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createInstructor' }, 'Creating instructor...');
const result = await api.createInstructor(input);
const result = await api.referenceData.createInstructor(input);
logger.info({ name: 'course.createInstructor' }, 'Instructor created');
return { success: true, data: result };
});
export const createLocation = authActionClient
.inputSchema(CreateLocationSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createLocation' }, 'Creating location...');
const result = await api.createLocation(input);
const result = await api.referenceData.createLocation(input);
logger.info({ name: 'course.createLocation' }, 'Location created');
return { success: true, data: result };
});
export const createSession = authActionClient
.inputSchema(CreateSessionSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input, ctx: _ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createCourseManagementApi(client);
logger.info({ name: 'course.createSession' }, 'Creating session...');
const result = await api.createSession(input);
const result = await api.sessions.create(input);
logger.info({ name: 'course.createSession' }, 'Session created');
return { success: true, data: result };
});

View File

@@ -1,361 +1,22 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CreateCourseInput,
UpdateCourseInput,
EnrollParticipantInput,
} from '../schema/course.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createAttendanceService } from './services/attendance.service';
import { createCourseCrudService } from './services/course-crud.service';
import { createCourseReferenceDataService } from './services/course-reference-data.service';
import { createCourseStatisticsService } from './services/course-statistics.service';
import { createEnrollmentService } from './services/enrollment.service';
import { createSessionService } from './services/session.service';
export function createCourseManagementApi(client: SupabaseClient<Database>) {
const _db = client;
return {
// --- Courses ---
async listCourses(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('courses')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('start_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.search)
query = query.or(
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
);
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0, page, pageSize };
},
async getCourse(courseId: string) {
const { data, error } = await client
.from('courses')
.select('*')
.eq('id', courseId)
.single();
if (error) throw error;
return data;
},
async createCourse(input: CreateCourseInput) {
const { data, error } = await client
.from('courses')
.insert({
account_id: input.accountId,
course_number: input.courseNumber || null,
name: input.name,
description: input.description || null,
category_id: input.categoryId || null,
instructor_id: input.instructorId || null,
location_id: input.locationId || null,
start_date: input.startDate || null,
end_date: input.endDate || null,
fee: input.fee,
reduced_fee: input.reducedFee ?? null,
capacity: input.capacity,
min_participants: input.minParticipants,
status: input.status,
registration_deadline: input.registrationDeadline || null,
notes: input.notes || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateCourse(input: UpdateCourseInput) {
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.courseNumber !== undefined)
update.course_number = input.courseNumber || null;
if (input.description !== undefined)
update.description = input.description || null;
if (input.categoryId !== undefined)
update.category_id = input.categoryId || null;
if (input.instructorId !== undefined)
update.instructor_id = input.instructorId || null;
if (input.locationId !== undefined)
update.location_id = input.locationId || null;
if (input.startDate !== undefined)
update.start_date = input.startDate || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.reducedFee !== undefined)
update.reduced_fee = input.reducedFee ?? null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minParticipants !== undefined)
update.min_participants = input.minParticipants;
if (input.status !== undefined) update.status = input.status;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.notes !== undefined) update.notes = input.notes || null;
const { data, error } = await client
.from('courses')
.update(update)
.eq('id', input.courseId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteCourse(courseId: string) {
const { error } = await client
.from('courses')
.update({ status: 'cancelled' })
.eq('id', courseId);
if (error) throw error;
},
// --- Enrollment ---
async enrollParticipant(input: EnrollParticipantInput) {
// Check capacity
const { count } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true })
.eq('course_id', input.courseId)
.in('status', ['enrolled']);
const course = await this.getCourse(input.courseId);
const status =
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
const { data, error } = await client
.from('course_participants')
.insert({
course_id: input.courseId,
member_id: input.memberId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
status,
})
.select()
.single();
if (error) throw error;
return data;
},
async cancelEnrollment(participantId: string) {
const { error } = await client
.from('course_participants')
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
.eq('id', participantId);
if (error) throw error;
},
async getParticipants(courseId: string) {
const { data, error } = await client
.from('course_participants')
.select('*')
.eq('course_id', courseId)
.order('enrolled_at');
if (error) throw error;
return data ?? [];
},
// --- Sessions ---
async getSessions(courseId: string) {
const { data, error } = await client
.from('course_sessions')
.select('*')
.eq('course_id', courseId)
.order('session_date');
if (error) throw error;
return data ?? [];
},
async createSession(input: {
courseId: string;
sessionDate: string;
startTime: string;
endTime: string;
locationId?: string;
}) {
const { data, error } = await client
.from('course_sessions')
.insert({
course_id: input.courseId,
session_date: input.sessionDate,
start_time: input.startTime,
end_time: input.endTime,
location_id: input.locationId,
})
.select()
.single();
if (error) throw error;
return data;
},
// --- Attendance ---
async getAttendance(sessionId: string) {
const { data, error } = await client
.from('course_attendance')
.select('*')
.eq('session_id', sessionId);
if (error) throw error;
return data ?? [];
},
async markAttendance(
sessionId: string,
participantId: string,
present: boolean,
) {
const { error } = await client.from('course_attendance').upsert(
{
session_id: sessionId,
participant_id: participantId,
present,
},
{ onConflict: 'session_id,participant_id' },
);
if (error) throw error;
},
// --- Categories, Instructors, Locations ---
async listCategories(accountId: string) {
const { data, error } = await client
.from('course_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
},
async listInstructors(accountId: string) {
const { data, error } = await client
.from('course_instructors')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (error) throw error;
return data ?? [];
},
async listLocations(accountId: string) {
const { data, error } = await client
.from('course_locations')
.select('*')
.eq('account_id', accountId)
.order('name');
if (error) throw error;
return data ?? [];
},
// --- Statistics ---
async getStatistics(accountId: string) {
const { data: courses } = await client
.from('courses')
.select('status')
.eq('account_id', accountId);
const { count: totalParticipants } = await client
.from('course_participants')
.select('*', { count: 'exact', head: true })
.in(
'course_id',
(courses ?? []).map((c: any) => c.id),
);
const stats = {
totalCourses: 0,
openCourses: 0,
completedCourses: 0,
totalParticipants: totalParticipants ?? 0,
};
for (const c of courses ?? []) {
stats.totalCourses++;
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
if (c.status === 'completed') stats.completedCourses++;
}
return stats;
},
// --- Create methods for CRUD ---
async createCategory(input: {
accountId: string;
name: string;
description?: string;
parentId?: string;
}) {
const { data, error } = await client
.from('course_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description,
parent_id: input.parentId,
})
.select()
.single();
if (error) throw error;
return data;
},
async createInstructor(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
qualifications?: string;
hourlyRate?: number;
}) {
const { data, error } = await client
.from('course_instructors')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
qualifications: input.qualifications,
hourly_rate: input.hourlyRate,
})
.select()
.single();
if (error) throw error;
return data;
},
async createLocation(input: {
accountId: string;
name: string;
address?: string;
room?: string;
capacity?: number;
}) {
const { data, error } = await client
.from('course_locations')
.insert({
account_id: input.accountId,
name: input.name,
address: input.address,
room: input.room,
capacity: input.capacity,
})
.select()
.single();
if (error) throw error;
return data;
},
courses: createCourseCrudService(client),
enrollment: createEnrollmentService(client),
sessions: createSessionService(client),
attendance: createAttendanceService(client),
referenceData: createCourseReferenceDataService(client),
statistics: createCourseStatisticsService(client),
};
}

View File

@@ -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;
},
};
}

View File

@@ -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;
},
};
}

View File

@@ -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;
},
};
}

View File

@@ -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 ?? [];
},
};
}

View File

@@ -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 ?? [];
},
};
}

View File

@@ -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),
};
}

View File

@@ -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;
},
};
}