refactor: remove obsolete member management API module
This commit is contained in:
@@ -13,7 +13,9 @@
|
||||
"./api": "./src/server/api.ts",
|
||||
"./schema/*": "./src/schema/*.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./actions/*": "./src/server/actions/*.ts"
|
||||
"./actions/*": "./src/server/actions/*.ts",
|
||||
"./services/*": "./src/server/services/*.ts",
|
||||
"./lib/*": "./src/lib/*.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { z } from 'zod';
|
||||
|
||||
import type { CourseStatusEnum } from '../schema/course.schema';
|
||||
|
||||
type CourseStatus = z.infer<typeof CourseStatusEnum>;
|
||||
|
||||
/**
|
||||
* Course status state machine.
|
||||
*
|
||||
* Defines valid transitions between course statuses and their
|
||||
* side effects. Enforced in course update operations.
|
||||
*/
|
||||
|
||||
type StatusTransition = {
|
||||
/** Fields to set automatically when this transition occurs */
|
||||
sideEffects?: Partial<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const TRANSITIONS: Record<
|
||||
CourseStatus,
|
||||
Partial<Record<CourseStatus, StatusTransition>>
|
||||
> = {
|
||||
planned: {
|
||||
open: {},
|
||||
cancelled: {},
|
||||
},
|
||||
open: {
|
||||
running: {},
|
||||
cancelled: {},
|
||||
},
|
||||
running: {
|
||||
completed: {},
|
||||
cancelled: {},
|
||||
},
|
||||
// Terminal state — no transitions out
|
||||
completed: {},
|
||||
cancelled: {
|
||||
planned: {},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a status transition is valid.
|
||||
*/
|
||||
export function canTransition(from: CourseStatus, to: CourseStatus): boolean {
|
||||
if (from === to) return true; // no-op is always valid
|
||||
return to in (TRANSITIONS[from] ?? {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all valid target statuses from a given status.
|
||||
*/
|
||||
export function getValidTransitions(from: CourseStatus): CourseStatus[] {
|
||||
return Object.keys(TRANSITIONS[from] ?? {}) as CourseStatus[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the side effects for a transition.
|
||||
* Returns an object of field->value pairs to apply alongside the status change.
|
||||
* Function values should be called to get the actual value.
|
||||
*/
|
||||
export function getTransitionSideEffects(
|
||||
from: CourseStatus,
|
||||
to: CourseStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
const transition = TRANSITIONS[from]?.[to];
|
||||
if (!transition?.sideEffects) return {};
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(transition.sideEffects)) {
|
||||
result[key] = typeof value === 'function' ? value() : value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a status transition and return side effects.
|
||||
* Throws if the transition is invalid.
|
||||
*/
|
||||
export function validateTransition(
|
||||
from: CourseStatus,
|
||||
to: CourseStatus,
|
||||
): Record<string, unknown> {
|
||||
if (from === to) return {};
|
||||
|
||||
if (!canTransition(from, to)) {
|
||||
throw new Error(
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return getTransitionSideEffects(from, to);
|
||||
}
|
||||
128
packages/features/course-management/src/lib/errors.ts
Normal file
128
packages/features/course-management/src/lib/errors.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Standardized error codes and domain error classes
|
||||
* for the course management module.
|
||||
*/
|
||||
|
||||
export const CourseErrorCodes = {
|
||||
NOT_FOUND: 'COURSE_NOT_FOUND',
|
||||
CAPACITY_EXCEEDED: 'COURSE_CAPACITY_EXCEEDED',
|
||||
INVALID_STATUS_TRANSITION: 'COURSE_INVALID_TRANSITION',
|
||||
REGISTRATION_CLOSED: 'COURSE_REGISTRATION_CLOSED',
|
||||
CONCURRENCY_CONFLICT: 'COURSE_CONCURRENCY_CONFLICT',
|
||||
DUPLICATE_ENROLLMENT: 'COURSE_DUPLICATE_ENROLLMENT',
|
||||
MIN_PARTICIPANTS_NOT_MET: 'COURSE_MIN_PARTICIPANTS_NOT_MET',
|
||||
} as const;
|
||||
|
||||
export type CourseErrorCode =
|
||||
(typeof CourseErrorCodes)[keyof typeof CourseErrorCodes];
|
||||
|
||||
/**
|
||||
* Base domain error for course management operations.
|
||||
*/
|
||||
export class CourseDomainError extends Error {
|
||||
readonly code: CourseErrorCode;
|
||||
readonly statusCode: number;
|
||||
readonly details?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: CourseErrorCode,
|
||||
message: string,
|
||||
statusCode = 400,
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'CourseDomainError';
|
||||
this.code = code;
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseNotFoundError extends CourseDomainError {
|
||||
constructor(courseId: string) {
|
||||
super(CourseErrorCodes.NOT_FOUND, `Kurs ${courseId} nicht gefunden`, 404, {
|
||||
courseId,
|
||||
});
|
||||
this.name = 'CourseNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseCapacityExceededError extends CourseDomainError {
|
||||
constructor(courseId: string, capacity: number) {
|
||||
super(
|
||||
CourseErrorCodes.CAPACITY_EXCEEDED,
|
||||
`Kurs ${courseId} hat die maximale Teilnehmerzahl (${capacity}) erreicht`,
|
||||
409,
|
||||
{ courseId, capacity },
|
||||
);
|
||||
this.name = 'CourseCapacityExceededError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidCourseStatusTransitionError extends CourseDomainError {
|
||||
constructor(from: string, to: string, validTargets: string[]) {
|
||||
super(
|
||||
CourseErrorCodes.INVALID_STATUS_TRANSITION,
|
||||
`Ungültiger Statuswechsel: ${from} → ${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
|
||||
422,
|
||||
{ from, to, validTargets },
|
||||
);
|
||||
this.name = 'InvalidCourseStatusTransitionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseRegistrationClosedError extends CourseDomainError {
|
||||
constructor(courseId: string) {
|
||||
super(
|
||||
CourseErrorCodes.REGISTRATION_CLOSED,
|
||||
`Anmeldung für Kurs ${courseId} ist geschlossen`,
|
||||
422,
|
||||
{ courseId },
|
||||
);
|
||||
this.name = 'CourseRegistrationClosedError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseConcurrencyConflictError extends CourseDomainError {
|
||||
constructor() {
|
||||
super(
|
||||
CourseErrorCodes.CONCURRENCY_CONFLICT,
|
||||
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
|
||||
409,
|
||||
);
|
||||
this.name = 'CourseConcurrencyConflictError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseDuplicateEnrollmentError extends CourseDomainError {
|
||||
constructor(courseId: string, memberId: string) {
|
||||
super(
|
||||
CourseErrorCodes.DUPLICATE_ENROLLMENT,
|
||||
`Mitglied ${memberId} ist bereits für Kurs ${courseId} angemeldet`,
|
||||
409,
|
||||
{ courseId, memberId },
|
||||
);
|
||||
this.name = 'CourseDuplicateEnrollmentError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CourseMinParticipantsError extends CourseDomainError {
|
||||
constructor(courseId: string, minParticipants: number, currentCount: number) {
|
||||
super(
|
||||
CourseErrorCodes.MIN_PARTICIPANTS_NOT_MET,
|
||||
`Kurs ${courseId} benötigt mindestens ${minParticipants} Teilnehmer (aktuell: ${currentCount})`,
|
||||
422,
|
||||
{ courseId, minParticipants, currentCount },
|
||||
);
|
||||
this.name = 'CourseMinParticipantsError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a CourseDomainError.
|
||||
*/
|
||||
export function isCourseDomainError(
|
||||
error: unknown,
|
||||
): error is CourseDomainError {
|
||||
return error instanceof CourseDomainError;
|
||||
}
|
||||
@@ -16,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({
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -1,361 +1,22 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type {
|
||||
CreateCourseInput,
|
||||
UpdateCourseInput,
|
||||
EnrollParticipantInput,
|
||||
} from '../schema/course.schema';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { createAttendanceService } from './services/attendance.service';
|
||||
import { createCourseCrudService } from './services/course-crud.service';
|
||||
import { createCourseReferenceDataService } from './services/course-reference-data.service';
|
||||
import { createCourseStatisticsService } from './services/course-statistics.service';
|
||||
import { createEnrollmentService } from './services/enrollment.service';
|
||||
import { createSessionService } from './services/session.service';
|
||||
|
||||
export function createCourseManagementApi(client: SupabaseClient<Database>) {
|
||||
const _db = client;
|
||||
|
||||
return {
|
||||
// --- Courses ---
|
||||
async listCourses(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('courses')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('start_date', { ascending: false });
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.search)
|
||||
query = query.or(
|
||||
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
|
||||
);
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
return { data: data ?? [], total: count ?? 0, page, pageSize };
|
||||
},
|
||||
|
||||
async getCourse(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.select('*')
|
||||
.eq('id', courseId)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createCourse(input: CreateCourseInput) {
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
course_number: input.courseNumber || null,
|
||||
name: input.name,
|
||||
description: input.description || null,
|
||||
category_id: input.categoryId || null,
|
||||
instructor_id: input.instructorId || null,
|
||||
location_id: input.locationId || null,
|
||||
start_date: input.startDate || null,
|
||||
end_date: input.endDate || null,
|
||||
fee: input.fee,
|
||||
reduced_fee: input.reducedFee ?? null,
|
||||
capacity: input.capacity,
|
||||
min_participants: input.minParticipants,
|
||||
status: input.status,
|
||||
registration_deadline: input.registrationDeadline || null,
|
||||
notes: input.notes || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async updateCourse(input: UpdateCourseInput) {
|
||||
const update: Record<string, unknown> = {};
|
||||
if (input.name !== undefined) update.name = input.name;
|
||||
if (input.courseNumber !== undefined)
|
||||
update.course_number = input.courseNumber || null;
|
||||
if (input.description !== undefined)
|
||||
update.description = input.description || null;
|
||||
if (input.categoryId !== undefined)
|
||||
update.category_id = input.categoryId || null;
|
||||
if (input.instructorId !== undefined)
|
||||
update.instructor_id = input.instructorId || null;
|
||||
if (input.locationId !== undefined)
|
||||
update.location_id = input.locationId || null;
|
||||
if (input.startDate !== undefined)
|
||||
update.start_date = input.startDate || null;
|
||||
if (input.endDate !== undefined) update.end_date = input.endDate || null;
|
||||
if (input.fee !== undefined) update.fee = input.fee;
|
||||
if (input.reducedFee !== undefined)
|
||||
update.reduced_fee = input.reducedFee ?? null;
|
||||
if (input.capacity !== undefined) update.capacity = input.capacity;
|
||||
if (input.minParticipants !== undefined)
|
||||
update.min_participants = input.minParticipants;
|
||||
if (input.status !== undefined) update.status = input.status;
|
||||
if (input.registrationDeadline !== undefined)
|
||||
update.registration_deadline = input.registrationDeadline || null;
|
||||
if (input.notes !== undefined) update.notes = input.notes || null;
|
||||
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.update(update)
|
||||
.eq('id', input.courseId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async deleteCourse(courseId: string) {
|
||||
const { error } = await client
|
||||
.from('courses')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', courseId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Enrollment ---
|
||||
async enrollParticipant(input: EnrollParticipantInput) {
|
||||
// Check capacity
|
||||
const { count } = await client
|
||||
.from('course_participants')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('course_id', input.courseId)
|
||||
.in('status', ['enrolled']);
|
||||
const course = await this.getCourse(input.courseId);
|
||||
const status =
|
||||
(count ?? 0) >= course.capacity ? 'waitlisted' : 'enrolled';
|
||||
|
||||
const { data, error } = await client
|
||||
.from('course_participants')
|
||||
.insert({
|
||||
course_id: input.courseId,
|
||||
member_id: input.memberId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
status,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancelEnrollment(participantId: string) {
|
||||
const { error } = await client
|
||||
.from('course_participants')
|
||||
.update({ status: 'cancelled', cancelled_at: new Date().toISOString() })
|
||||
.eq('id', participantId);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
async getParticipants(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_participants')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('enrolled_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// --- Sessions ---
|
||||
async getSessions(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('session_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createSession(input: {
|
||||
courseId: string;
|
||||
sessionDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
locationId?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.insert({
|
||||
course_id: input.courseId,
|
||||
session_date: input.sessionDate,
|
||||
start_time: input.startTime,
|
||||
end_time: input.endTime,
|
||||
location_id: input.locationId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
// --- Attendance ---
|
||||
async getAttendance(sessionId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_attendance')
|
||||
.select('*')
|
||||
.eq('session_id', sessionId);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async markAttendance(
|
||||
sessionId: string,
|
||||
participantId: string,
|
||||
present: boolean,
|
||||
) {
|
||||
const { error } = await client.from('course_attendance').upsert(
|
||||
{
|
||||
session_id: sessionId,
|
||||
participant_id: participantId,
|
||||
present,
|
||||
},
|
||||
{ onConflict: 'session_id,participant_id' },
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
|
||||
// --- Categories, Instructors, Locations ---
|
||||
async listCategories(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listInstructors(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listLocations(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
// --- Statistics ---
|
||||
async getStatistics(accountId: string) {
|
||||
const { data: courses } = await client
|
||||
.from('courses')
|
||||
.select('status')
|
||||
.eq('account_id', accountId);
|
||||
const { count: totalParticipants } = await client
|
||||
.from('course_participants')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.in(
|
||||
'course_id',
|
||||
(courses ?? []).map((c: any) => c.id),
|
||||
);
|
||||
|
||||
const stats = {
|
||||
totalCourses: 0,
|
||||
openCourses: 0,
|
||||
completedCourses: 0,
|
||||
totalParticipants: totalParticipants ?? 0,
|
||||
};
|
||||
for (const c of courses ?? []) {
|
||||
stats.totalCourses++;
|
||||
if (c.status === 'open' || c.status === 'running') stats.openCourses++;
|
||||
if (c.status === 'completed') stats.completedCourses++;
|
||||
}
|
||||
return stats;
|
||||
},
|
||||
|
||||
// --- Create methods for CRUD ---
|
||||
async createCategory(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
parent_id: input.parentId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createInstructor(input: {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
qualifications?: string;
|
||||
hourlyRate?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
qualifications: input.qualifications,
|
||||
hourly_rate: input.hourlyRate,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createLocation(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
room?: string;
|
||||
capacity?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
address: input.address,
|
||||
room: input.room,
|
||||
capacity: input.capacity,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
courses: createCourseCrudService(client),
|
||||
enrollment: createEnrollmentService(client),
|
||||
sessions: createSessionService(client),
|
||||
attendance: createAttendanceService(client),
|
||||
referenceData: createCourseReferenceDataService(client),
|
||||
statistics: createCourseStatisticsService(client),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createAttendanceService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async getBySession(sessionId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_attendance')
|
||||
.select('*')
|
||||
.eq('session_id', sessionId);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async mark(sessionId: string, participantId: string, present: boolean) {
|
||||
const { error } = await client.from('course_attendance').upsert(
|
||||
{
|
||||
session_id: sessionId,
|
||||
participant_id: participantId,
|
||||
present,
|
||||
},
|
||||
{ onConflict: 'session_id,participant_id' },
|
||||
);
|
||||
if (error) throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import {
|
||||
canTransition,
|
||||
validateTransition,
|
||||
getValidTransitions,
|
||||
} from '../../lib/course-status-machine';
|
||||
import {
|
||||
CourseNotFoundError,
|
||||
CourseConcurrencyConflictError,
|
||||
InvalidCourseStatusTransitionError,
|
||||
} from '../../lib/errors';
|
||||
import type {
|
||||
CreateCourseInput,
|
||||
UpdateCourseInput,
|
||||
} from '../../schema/course.schema';
|
||||
|
||||
const NAMESPACE = 'course-crud';
|
||||
|
||||
export function createCourseCrudService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(
|
||||
accountId: string,
|
||||
opts?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
) {
|
||||
let query = client
|
||||
.from('courses')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('account_id', accountId)
|
||||
.order('start_date', { ascending: false });
|
||||
|
||||
if (opts?.status) query = query.eq('status', opts.status);
|
||||
if (opts?.search)
|
||||
query = query.or(
|
||||
`name.ilike.%${opts.search}%,course_number.ilike.%${opts.search}%`,
|
||||
);
|
||||
|
||||
const page = opts?.page ?? 1;
|
||||
const pageSize = opts?.pageSize ?? 25;
|
||||
query = query.range((page - 1) * pageSize, page * pageSize - 1);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
if (error) throw error;
|
||||
|
||||
const total = count ?? 0;
|
||||
|
||||
return {
|
||||
data: data ?? [],
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||
};
|
||||
},
|
||||
|
||||
async getById(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.select('*')
|
||||
.eq('id', courseId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
if (!data) throw new CourseNotFoundError(courseId);
|
||||
return data;
|
||||
},
|
||||
|
||||
async create(input: CreateCourseInput, userId?: string) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: NAMESPACE }, 'Creating course...');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- created_by/updated_by added via migration, not yet in generated types
|
||||
const { data, error } = await client
|
||||
.from('courses')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
course_number: input.courseNumber || null,
|
||||
name: input.name,
|
||||
description: input.description || null,
|
||||
category_id: input.categoryId || null,
|
||||
instructor_id: input.instructorId || null,
|
||||
location_id: input.locationId || null,
|
||||
start_date: input.startDate || null,
|
||||
end_date: input.endDate || null,
|
||||
fee: input.fee,
|
||||
reduced_fee: input.reducedFee ?? null,
|
||||
capacity: input.capacity,
|
||||
min_participants: input.minParticipants,
|
||||
status: input.status,
|
||||
registration_deadline: input.registrationDeadline || null,
|
||||
notes: input.notes || null,
|
||||
...(userId ? { created_by: userId, updated_by: userId } : {}),
|
||||
} as any)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async update(input: UpdateCourseInput, userId?: string) {
|
||||
const logger = await getLogger();
|
||||
logger.info(
|
||||
{ name: NAMESPACE, courseId: input.courseId },
|
||||
'Updating course...',
|
||||
);
|
||||
|
||||
const update: Record<string, unknown> = {};
|
||||
|
||||
if (input.name !== undefined) update.name = input.name;
|
||||
if (input.courseNumber !== undefined)
|
||||
update.course_number = input.courseNumber || null;
|
||||
if (input.description !== undefined)
|
||||
update.description = input.description || null;
|
||||
if (input.categoryId !== undefined)
|
||||
update.category_id = input.categoryId || null;
|
||||
if (input.instructorId !== undefined)
|
||||
update.instructor_id = input.instructorId || null;
|
||||
if (input.locationId !== undefined)
|
||||
update.location_id = input.locationId || null;
|
||||
if (input.startDate !== undefined)
|
||||
update.start_date = input.startDate || null;
|
||||
if (input.endDate !== undefined) update.end_date = input.endDate || null;
|
||||
if (input.fee !== undefined) update.fee = input.fee;
|
||||
if (input.reducedFee !== undefined)
|
||||
update.reduced_fee = input.reducedFee ?? null;
|
||||
if (input.capacity !== undefined) update.capacity = input.capacity;
|
||||
if (input.minParticipants !== undefined)
|
||||
update.min_participants = input.minParticipants;
|
||||
if (input.registrationDeadline !== undefined)
|
||||
update.registration_deadline = input.registrationDeadline || null;
|
||||
if (input.notes !== undefined) update.notes = input.notes || null;
|
||||
|
||||
// Status transition validation
|
||||
if (input.status !== undefined) {
|
||||
const { data: current, error: fetchError } = await client
|
||||
.from('courses')
|
||||
.select('status')
|
||||
.eq('id', input.courseId)
|
||||
.single();
|
||||
if (fetchError) throw fetchError;
|
||||
|
||||
const currentStatus = current.status as string;
|
||||
if (currentStatus !== input.status) {
|
||||
try {
|
||||
const sideEffects = validateTransition(
|
||||
currentStatus as Parameters<typeof validateTransition>[0],
|
||||
input.status as Parameters<typeof validateTransition>[1],
|
||||
);
|
||||
Object.assign(update, sideEffects);
|
||||
} catch {
|
||||
throw new InvalidCourseStatusTransitionError(
|
||||
currentStatus,
|
||||
input.status,
|
||||
getValidTransitions(
|
||||
currentStatus as Parameters<typeof getValidTransitions>[0],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
update.status = input.status;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
update.updated_by = userId;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version/updated_by columns added via migration, not yet in generated types
|
||||
let query = client
|
||||
.from('courses')
|
||||
.update(update as any)
|
||||
.eq('id', input.courseId);
|
||||
|
||||
// Optimistic locking via version column
|
||||
if (input.version !== undefined) {
|
||||
query = (query as any).eq('version', input.version);
|
||||
}
|
||||
|
||||
const { data, error } = await query.select().single();
|
||||
|
||||
if (error) {
|
||||
if (error.code === 'PGRST116' && input.version !== undefined) {
|
||||
throw new CourseConcurrencyConflictError();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
async softDelete(courseId: string) {
|
||||
const logger = await getLogger();
|
||||
logger.info({ name: NAMESPACE, courseId }, 'Cancelling course...');
|
||||
|
||||
const { data: current, error: fetchError } = await client
|
||||
.from('courses')
|
||||
.select('status')
|
||||
.eq('id', courseId)
|
||||
.maybeSingle();
|
||||
if (fetchError) throw fetchError;
|
||||
if (!current) throw new CourseNotFoundError(courseId);
|
||||
|
||||
type CourseStatus = Parameters<typeof canTransition>[0];
|
||||
if (!canTransition(current.status as CourseStatus, 'cancelled')) {
|
||||
throw new InvalidCourseStatusTransitionError(
|
||||
current.status,
|
||||
'cancelled',
|
||||
getValidTransitions(current.status as CourseStatus),
|
||||
);
|
||||
}
|
||||
|
||||
const { error } = await client
|
||||
.from('courses')
|
||||
.update({ status: 'cancelled' })
|
||||
.eq('id', courseId);
|
||||
if (error) throw error;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createCourseReferenceDataService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return {
|
||||
async listCategories(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('sort_order');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listInstructors(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('last_name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async listLocations(accountId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.select('*')
|
||||
.eq('account_id', accountId)
|
||||
.order('name');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async createCategory(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_categories')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
parent_id: input.parentId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createInstructor(input: {
|
||||
accountId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
qualifications?: string;
|
||||
hourlyRate?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_instructors')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
first_name: input.firstName,
|
||||
last_name: input.lastName,
|
||||
email: input.email,
|
||||
phone: input.phone,
|
||||
qualifications: input.qualifications,
|
||||
hourly_rate: input.hourlyRate,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async createLocation(input: {
|
||||
accountId: string;
|
||||
name: string;
|
||||
address?: string;
|
||||
room?: string;
|
||||
capacity?: number;
|
||||
}) {
|
||||
const { data, error } = await client
|
||||
.from('course_locations')
|
||||
.insert({
|
||||
account_id: input.accountId,
|
||||
name: input.name,
|
||||
address: input.address,
|
||||
room: input.room,
|
||||
capacity: input.capacity,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createCourseStatisticsService(
|
||||
client: SupabaseClient<Database>,
|
||||
) {
|
||||
return {
|
||||
async getQuickStats(accountId: string) {
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'get_course_statistics',
|
||||
{ p_account_id: accountId },
|
||||
);
|
||||
if (error) throw error;
|
||||
// RPC returns a single row as an array
|
||||
const stats = Array.isArray(data) ? data[0] : data;
|
||||
return (
|
||||
stats ?? {
|
||||
total_courses: 0,
|
||||
open_courses: 0,
|
||||
running_courses: 0,
|
||||
completed_courses: 0,
|
||||
cancelled_courses: 0,
|
||||
total_participants: 0,
|
||||
total_waitlisted: 0,
|
||||
avg_occupancy_rate: 0,
|
||||
total_revenue: 0,
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async getAttendanceSummary(courseId: string) {
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'get_course_attendance_summary',
|
||||
{ p_course_id: courseId },
|
||||
);
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import type { EnrollParticipantInput } from '../../schema/course.schema';
|
||||
|
||||
export function createEnrollmentService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async enroll(input: EnrollParticipantInput) {
|
||||
// Uses the enroll_course_participant RPC which handles capacity checks
|
||||
// and waitlisting atomically, avoiding race conditions.
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'enroll_course_participant',
|
||||
{
|
||||
p_course_id: input.courseId,
|
||||
p_member_id: input.memberId ?? null,
|
||||
p_first_name: input.firstName,
|
||||
p_last_name: input.lastName,
|
||||
p_email: input.email || null,
|
||||
p_phone: input.phone || null,
|
||||
},
|
||||
);
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
|
||||
async cancel(participantId: string) {
|
||||
const { data, error } = await (client.rpc as CallableFunction)(
|
||||
'cancel_course_enrollment',
|
||||
{ p_participant_id: participantId },
|
||||
);
|
||||
if (error) throw error;
|
||||
return data as {
|
||||
cancelled_id: string;
|
||||
promoted_id: string | null;
|
||||
promoted_name: string | null;
|
||||
};
|
||||
},
|
||||
|
||||
async listParticipants(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_participants')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('enrolled_at');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createAttendanceService } from './attendance.service';
|
||||
import { createCourseCrudService } from './course-crud.service';
|
||||
import { createCourseReferenceDataService } from './course-reference-data.service';
|
||||
import { createCourseStatisticsService } from './course-statistics.service';
|
||||
import { createEnrollmentService } from './enrollment.service';
|
||||
import { createSessionService } from './session.service';
|
||||
|
||||
export {
|
||||
createAttendanceService,
|
||||
createCourseCrudService,
|
||||
createCourseReferenceDataService,
|
||||
createCourseStatisticsService,
|
||||
createEnrollmentService,
|
||||
createSessionService,
|
||||
};
|
||||
|
||||
export function createCourseServices(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
courses: createCourseCrudService(client),
|
||||
enrollment: createEnrollmentService(client),
|
||||
sessions: createSessionService(client),
|
||||
attendance: createAttendanceService(client),
|
||||
referenceData: createCourseReferenceDataService(client),
|
||||
statistics: createCourseStatisticsService(client),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'server-only';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
|
||||
export function createSessionService(client: SupabaseClient<Database>) {
|
||||
return {
|
||||
async list(courseId: string) {
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.select('*')
|
||||
.eq('course_id', courseId)
|
||||
.order('session_date');
|
||||
if (error) throw error;
|
||||
return data ?? [];
|
||||
},
|
||||
|
||||
async create(input: {
|
||||
courseId: string;
|
||||
sessionDate: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
locationId?: string;
|
||||
}) {
|
||||
// Check instructor availability if course has an instructor
|
||||
const { data: course, error: courseError } = await client
|
||||
.from('courses')
|
||||
.select('instructor_id')
|
||||
.eq('id', input.courseId)
|
||||
.single();
|
||||
if (courseError) throw courseError;
|
||||
|
||||
if (course?.instructor_id) {
|
||||
const { data: isAvailable, error: availError } = await (
|
||||
client.rpc as CallableFunction
|
||||
)('check_instructor_availability', {
|
||||
p_instructor_id: course.instructor_id,
|
||||
p_session_date: input.sessionDate,
|
||||
p_start_time: input.startTime,
|
||||
p_end_time: input.endTime,
|
||||
});
|
||||
if (availError) throw availError;
|
||||
if (!isAvailable) {
|
||||
throw new Error('Kursleiter ist zu diesem Zeitpunkt nicht verfügbar');
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await client
|
||||
.from('course_sessions')
|
||||
.insert({
|
||||
course_id: input.courseId,
|
||||
session_date: input.sessionDate,
|
||||
start_time: input.startTime,
|
||||
end_time: input.endTime,
|
||||
location_id: input.locationId,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user