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,123 @@
/**
* Standardized error codes and domain error classes
* for the event management module.
*/
export const EventErrorCodes = {
NOT_FOUND: 'EVENT_NOT_FOUND',
FULL: 'EVENT_FULL',
INVALID_STATUS_TRANSITION: 'EVENT_INVALID_TRANSITION',
REGISTRATION_CLOSED: 'EVENT_REGISTRATION_CLOSED',
AGE_RESTRICTION: 'EVENT_AGE_RESTRICTION',
CONCURRENCY_CONFLICT: 'EVENT_CONCURRENCY_CONFLICT',
} as const;
export type EventErrorCode =
(typeof EventErrorCodes)[keyof typeof EventErrorCodes];
/**
* Base domain error for event management operations.
*/
export class EventDomainError extends Error {
readonly code: EventErrorCode;
readonly statusCode: number;
readonly details?: Record<string, unknown>;
constructor(
code: EventErrorCode,
message: string,
statusCode = 400,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'EventDomainError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class EventNotFoundError extends EventDomainError {
constructor(eventId: string) {
super(
EventErrorCodes.NOT_FOUND,
`Veranstaltung ${eventId} nicht gefunden`,
404,
{ eventId },
);
this.name = 'EventNotFoundError';
}
}
export class EventFullError extends EventDomainError {
constructor(eventId: string, capacity: number) {
super(
EventErrorCodes.FULL,
`Veranstaltung ${eventId} ist ausgebucht (max. ${capacity} Teilnehmer)`,
409,
{ eventId, capacity },
);
this.name = 'EventFullError';
}
}
export class InvalidEventStatusTransitionError extends EventDomainError {
constructor(from: string, to: string, validTargets: string[]) {
super(
EventErrorCodes.INVALID_STATUS_TRANSITION,
`Ungültiger Statuswechsel: ${from}${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
422,
{ from, to, validTargets },
);
this.name = 'InvalidEventStatusTransitionError';
}
}
export class EventRegistrationClosedError extends EventDomainError {
constructor(eventId: string) {
super(
EventErrorCodes.REGISTRATION_CLOSED,
`Anmeldung für Veranstaltung ${eventId} ist geschlossen`,
422,
{ eventId },
);
this.name = 'EventRegistrationClosedError';
}
}
export class EventAgeRestrictionError extends EventDomainError {
constructor(eventId: string, minAge?: number, maxAge?: number) {
const ageRange =
minAge && maxAge
? `${minAge}${maxAge} Jahre`
: minAge
? `ab ${minAge} Jahre`
: `bis ${maxAge} Jahre`;
super(
EventErrorCodes.AGE_RESTRICTION,
`Altersbeschränkung für Veranstaltung ${eventId}: ${ageRange}`,
422,
{ eventId, minAge, maxAge },
);
this.name = 'EventAgeRestrictionError';
}
}
export class EventConcurrencyConflictError extends EventDomainError {
constructor() {
super(
EventErrorCodes.CONCURRENCY_CONFLICT,
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
409,
);
this.name = 'EventConcurrencyConflictError';
}
}
/**
* Check if an error is an EventDomainError.
*/
export function isEventDomainError(error: unknown): error is EventDomainError {
return error instanceof EventDomainError;
}

View File

@@ -0,0 +1,103 @@
import type { z } from 'zod';
import type { EventStatusEnum } from '../schema/event.schema';
type EventStatus = z.infer<typeof EventStatusEnum>;
/**
* Event status state machine.
*
* Defines valid transitions between event statuses and their
* side effects. Enforced in event update operations.
*/
type StatusTransition = {
/** Fields to set automatically when this transition occurs */
sideEffects?: Partial<Record<string, unknown>>;
};
const TRANSITIONS: Record<
EventStatus,
Partial<Record<EventStatus, StatusTransition>>
> = {
planned: {
open: {},
cancelled: {},
},
open: {
full: {},
running: {},
cancelled: {},
},
full: {
open: {},
running: {},
cancelled: {},
},
running: {
completed: {},
cancelled: {},
},
// Terminal state — no transitions out
completed: {},
cancelled: {
planned: {},
},
};
/**
* Check if a status transition is valid.
*/
export function canTransition(from: EventStatus, to: EventStatus): boolean {
if (from === to) return true; // no-op is always valid
return to in (TRANSITIONS[from] ?? {});
}
/**
* Get all valid target statuses from a given status.
*/
export function getValidTransitions(from: EventStatus): EventStatus[] {
return Object.keys(TRANSITIONS[from] ?? {}) as EventStatus[];
}
/**
* Get the side effects for a transition.
* Returns an object of field->value pairs to apply alongside the status change.
* Function values should be called to get the actual value.
*/
export function getTransitionSideEffects(
from: EventStatus,
to: EventStatus,
): Record<string, unknown> {
if (from === to) return {};
const transition = TRANSITIONS[from]?.[to];
if (!transition?.sideEffects) return {};
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(transition.sideEffects)) {
result[key] = typeof value === 'function' ? value() : value;
}
return result;
}
/**
* Validate a status transition and return side effects.
* Throws if the transition is invalid.
*/
export function validateTransition(
from: EventStatus,
to: EventStatus,
): Record<string, unknown> {
if (from === to) return {};
if (!canTransition(from, to)) {
throw new Error(
`Ungültiger Statuswechsel: ${from}${to}. Erlaubte Übergänge: ${getValidTransitions(from).join(', ') || 'keine'}`,
);
}
return getTransitionSideEffects(from, to);
}

View File

@@ -9,29 +9,80 @@ export const EventStatusEnum = z.enum([
'cancelled',
]);
export const CreateEventSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(256),
description: z.string().optional(),
eventDate: z.string(),
eventTime: z.string().optional(),
endDate: z.string().optional(),
location: z.string().optional(),
capacity: z.number().int().optional(),
minAge: z.number().int().optional(),
maxAge: z.number().int().optional(),
fee: z.number().min(0).default(0),
status: EventStatusEnum.default('planned'),
registrationDeadline: z.string().optional(),
contactName: z.string().optional(),
contactEmail: z.string().email().optional().or(z.literal('')),
contactPhone: z.string().optional(),
});
export const CreateEventSchema = z
.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(256),
description: z.string().optional(),
eventDate: z.string(),
eventTime: z.string().optional(),
endDate: z.string().optional(),
location: z.string().optional(),
capacity: z.number().int().optional(),
minAge: z.number().int().optional(),
maxAge: z.number().int().optional(),
fee: z.number().min(0).default(0),
status: EventStatusEnum.default('planned'),
registrationDeadline: z.string().optional(),
contactName: z.string().optional(),
contactEmail: z.string().email().optional().or(z.literal('')),
contactPhone: z.string().optional(),
})
.refine((d) => d.minAge == null || d.maxAge == null || d.minAge <= d.maxAge, {
message: 'Mindestalter darf das Höchstalter nicht übersteigen',
path: ['minAge'],
})
.refine((d) => !d.endDate || d.endDate >= d.eventDate, {
message: 'Enddatum muss nach dem Veranstaltungsdatum liegen',
path: ['endDate'],
})
.refine(
(d) => !d.registrationDeadline || d.registrationDeadline <= d.eventDate,
{
message: 'Anmeldefrist muss vor dem Veranstaltungsdatum liegen',
path: ['registrationDeadline'],
},
);
export type CreateEventInput = z.infer<typeof CreateEventSchema>;
export const UpdateEventSchema = CreateEventSchema.partial().extend({
eventId: z.string().uuid(),
});
export const UpdateEventSchema = z
.object({
eventId: z.string().uuid(),
version: z.number().int().optional(),
name: z.string().min(1).max(256).optional(),
description: z.string().optional(),
eventDate: z.string().optional(),
eventTime: z.string().optional(),
endDate: z.string().optional(),
location: z.string().optional(),
capacity: z.number().int().optional(),
minAge: z.number().int().optional().nullable(),
maxAge: z.number().int().optional().nullable(),
fee: z.number().min(0).optional(),
status: EventStatusEnum.optional(),
registrationDeadline: z.string().optional(),
contactName: z.string().optional(),
contactEmail: z.string().email().optional().or(z.literal('')),
contactPhone: z.string().optional(),
})
.refine((d) => d.minAge == null || d.maxAge == null || d.minAge <= d.maxAge, {
message: 'Mindestalter darf das Höchstalter nicht übersteigen',
path: ['minAge'],
})
.refine((d) => !d.endDate || !d.eventDate || d.endDate >= d.eventDate, {
message: 'Enddatum muss nach dem Veranstaltungsdatum liegen',
path: ['endDate'],
})
.refine(
(d) =>
!d.registrationDeadline ||
!d.eventDate ||
d.registrationDeadline <= d.eventDate,
{
message: 'Anmeldefrist muss vor dem Veranstaltungsdatum liegen',
path: ['registrationDeadline'],
},
);
export type UpdateEventInput = z.infer<typeof UpdateEventSchema>;
export const EventRegistrationSchema = 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 { isEventDomainError } from '../../lib/errors';
import {
CreateEventSchema,
UpdateEventSchema,
@@ -22,7 +23,7 @@ export const createEvent = authActionClient
const api = createEventManagementApi(client);
logger.info({ name: 'event.create' }, 'Creating event...');
const result = await api.createEvent(input);
const result = await api.events.create(input, ctx.user.id);
logger.info({ name: 'event.create' }, 'Event created');
return { success: true, data: result };
});
@@ -34,41 +35,62 @@ export const updateEvent = authActionClient
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.update' }, 'Updating event...');
const result = await api.updateEvent(input);
logger.info({ name: 'event.update' }, 'Event updated');
return { success: true, data: result };
try {
logger.info({ name: 'event.update' }, 'Updating event...');
const result = await api.events.update(input, ctx.user.id);
logger.info({ name: 'event.update' }, 'Event updated');
return { success: true, data: result };
} catch (e) {
if (isEventDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const deleteEvent = authActionClient
.inputSchema(z.object({ eventId: z.string().uuid() }))
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.delete' }, 'Cancelling event...');
await api.deleteEvent(input.eventId);
logger.info({ name: 'event.delete' }, 'Event cancelled');
return { success: true };
try {
logger.info({ name: 'event.delete' }, 'Cancelling event...');
await api.events.softDelete(input.eventId);
logger.info({ name: 'event.delete' }, 'Event cancelled');
return { success: true };
} catch (e) {
if (isEventDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const registerForEvent = authActionClient
.inputSchema(EventRegistrationSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
logger.info({ name: 'event.register' }, 'Registering for event...');
const result = await api.registerForEvent(input);
logger.info({ name: 'event.register' }, 'Registered for event');
return { success: true, data: result };
try {
logger.info({ name: 'event.register' }, 'Registering for event...');
const result = await api.registrations.register(input);
logger.info({ name: 'event.register' }, 'Registered for event');
return { success: true, data: result };
} catch (e) {
if (isEventDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const createHolidayPass = authActionClient
.inputSchema(CreateHolidayPassSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createEventManagementApi(client);
@@ -77,7 +99,7 @@ export const createHolidayPass = authActionClient
{ name: 'event.createHolidayPass' },
'Creating holiday pass...',
);
const result = await api.createHolidayPass(input);
const result = await api.holidayPasses.create(input);
logger.info({ name: 'event.createHolidayPass' }, 'Holiday pass created');
return { success: true, data: result };
});

View File

@@ -1,232 +1,16 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CreateEventInput,
UpdateEventInput,
} from '../schema/event.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createEventCrudService } from './services/event-crud.service';
import { createEventRegistrationService } from './services/event-registration.service';
import { createHolidayPassService } from './services/holiday-pass.service';
export function createEventManagementApi(client: SupabaseClient<Database>) {
const PAGE_SIZE = 25;
const _db = client;
return {
async listEvents(
accountId: string,
opts?: { status?: string; page?: number },
) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
},
async getRegistrationCounts(eventIds: string[]) {
if (eventIds.length === 0) return {} as Record<string, number>;
const { data, error } = await client
.from('event_registrations')
.select('event_id', { count: 'exact', head: false })
.in('event_id', eventIds)
.in('status', ['pending', 'confirmed']);
if (error) throw error;
const counts: Record<string, number> = {};
for (const row of data ?? []) {
const eid = (row as Record<string, unknown>).event_id as string;
counts[eid] = (counts[eid] ?? 0) + 1;
}
return counts;
},
async getEvent(eventId: string) {
const { data, error } = await client
.from('events')
.select('*')
.eq('id', eventId)
.single();
if (error) throw error;
return data;
},
async createEvent(input: CreateEventInput) {
const { data, error } = await client
.from('events')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description || null,
event_date: input.eventDate,
event_time: input.eventTime || null,
end_date: input.endDate || null,
location: input.location || null,
capacity: input.capacity,
min_age: input.minAge ?? null,
max_age: input.maxAge ?? null,
fee: input.fee,
status: input.status,
registration_deadline: input.registrationDeadline || null,
contact_name: input.contactName || null,
contact_email: input.contactEmail || null,
contact_phone: input.contactPhone || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateEvent(input: UpdateEventInput) {
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.description !== undefined)
update.description = input.description || null;
if (input.eventDate !== undefined)
update.event_date = input.eventDate || null;
if (input.eventTime !== undefined)
update.event_time = input.eventTime || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.location !== undefined)
update.location = input.location || null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minAge !== undefined) update.min_age = input.minAge ?? null;
if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.status !== undefined) update.status = input.status;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.contactName !== undefined)
update.contact_name = input.contactName || null;
if (input.contactEmail !== undefined)
update.contact_email = input.contactEmail || null;
if (input.contactPhone !== undefined)
update.contact_phone = input.contactPhone || null;
const { data, error } = await client
.from('events')
.update(update)
.eq('id', input.eventId)
.select()
.single();
if (error) throw error;
return data;
},
async deleteEvent(eventId: string) {
const { error } = await client
.from('events')
.update({ status: 'cancelled' })
.eq('id', eventId);
if (error) throw error;
},
async registerForEvent(input: {
eventId: string;
firstName: string;
lastName: string;
email?: string;
parentName?: string;
}) {
// Check capacity
const event = await this.getEvent(input.eventId);
if (event.capacity) {
const { count } = await client
.from('event_registrations')
.select('*', { count: 'exact', head: true })
.eq('event_id', input.eventId)
.in('status', ['pending', 'confirmed']);
if ((count ?? 0) >= event.capacity) {
throw new Error('Event is full');
}
}
const { data, error } = await client
.from('event_registrations')
.insert({
event_id: input.eventId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
parent_name: input.parentName,
status: 'confirmed',
})
.select()
.single();
if (error) throw error;
return data;
},
async getRegistrations(eventId: string) {
const { data, error } = await client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
// Holiday passes
async listHolidayPasses(accountId: string) {
const { data, error } = await client
.from('holiday_passes')
.select('*')
.eq('account_id', accountId)
.order('year', { ascending: false });
if (error) throw error;
return data ?? [];
},
async getPassActivities(passId: string) {
const { data, error } = await client
.from('holiday_pass_activities')
.select('*')
.eq('pass_id', passId)
.order('activity_date');
if (error) throw error;
return data ?? [];
},
async createHolidayPass(input: {
accountId: string;
name: string;
year: number;
description?: string;
price?: number;
validFrom?: string;
validUntil?: string;
}) {
const { data, error } = await client
.from('holiday_passes')
.insert({
account_id: input.accountId,
name: input.name,
year: input.year,
description: input.description,
price: input.price ?? 0,
valid_from: input.validFrom,
valid_until: input.validUntil,
})
.select()
.single();
if (error) throw error;
return data;
},
events: createEventCrudService(client),
registrations: createEventRegistrationService(client),
holidayPasses: createHolidayPassService(client),
};
}

View File

@@ -0,0 +1,237 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import {
EventConcurrencyConflictError,
EventNotFoundError,
InvalidEventStatusTransitionError,
} from '../../lib/errors';
import {
canTransition,
validateTransition,
getValidTransitions,
} from '../../lib/event-status-machine';
import type {
CreateEventInput,
UpdateEventInput,
} from '../../schema/event.schema';
const PAGE_SIZE = 25;
const NAMESPACE = 'event-crud';
export function createEventCrudService(client: SupabaseClient<Database>) {
return {
async list(accountId: string, opts?: { status?: string; page?: number }) {
let query = client
.from('events')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('event_date', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
const page = opts?.page ?? 1;
query = query.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize: PAGE_SIZE,
totalPages: Math.max(1, Math.ceil(total / PAGE_SIZE)),
};
},
async getById(eventId: string) {
const { data, error } = await client
.from('events')
.select('*')
.eq('id', eventId)
.maybeSingle();
if (error) throw error;
if (!data) throw new EventNotFoundError(eventId);
return data;
},
async getRegistrationCounts(eventIds: string[]) {
if (eventIds.length === 0) return {} as Record<string, number>;
const { data, error } = await (client.rpc as CallableFunction)(
'get_event_registration_counts',
{ p_event_ids: eventIds },
);
if (error) throw error;
const counts: Record<string, number> = {};
for (const row of (data ?? []) as Array<{
event_id: string;
registration_count: number;
}>) {
counts[row.event_id] = Number(row.registration_count);
}
return counts;
},
async create(input: CreateEventInput, userId?: string) {
const logger = await getLogger();
logger.info({ name: NAMESPACE }, 'Creating event...');
const { data, error } = await client
.from('events')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description || null,
event_date: input.eventDate,
event_time: input.eventTime || null,
end_date: input.endDate || null,
location: input.location || null,
capacity: input.capacity,
min_age: input.minAge ?? null,
max_age: input.maxAge ?? null,
fee: input.fee,
status: input.status,
registration_deadline: input.registrationDeadline || null,
contact_name: input.contactName || null,
contact_email: input.contactEmail || null,
contact_phone: input.contactPhone || null,
...(userId ? { created_by: userId, updated_by: userId } : {}),
} as any)
.select()
.single();
if (error) throw error;
return data;
},
async update(input: UpdateEventInput, userId?: string) {
const logger = await getLogger();
logger.info(
{ name: NAMESPACE, eventId: input.eventId },
'Updating event...',
);
const update: Record<string, unknown> = {};
if (input.name !== undefined) update.name = input.name;
if (input.description !== undefined)
update.description = input.description || null;
if (input.eventDate !== undefined)
update.event_date = input.eventDate || null;
if (input.eventTime !== undefined)
update.event_time = input.eventTime || null;
if (input.endDate !== undefined) update.end_date = input.endDate || null;
if (input.location !== undefined)
update.location = input.location || null;
if (input.capacity !== undefined) update.capacity = input.capacity;
if (input.minAge !== undefined) update.min_age = input.minAge ?? null;
if (input.maxAge !== undefined) update.max_age = input.maxAge ?? null;
if (input.fee !== undefined) update.fee = input.fee;
if (input.registrationDeadline !== undefined)
update.registration_deadline = input.registrationDeadline || null;
if (input.contactName !== undefined)
update.contact_name = input.contactName || null;
if (input.contactEmail !== undefined)
update.contact_email = input.contactEmail || null;
if (input.contactPhone !== undefined)
update.contact_phone = input.contactPhone || null;
// Status machine validation
if (input.status !== undefined) {
// Fetch current event to validate transition
const { data: current, error: fetchError } = await client
.from('events')
.select('status')
.eq('id', input.eventId)
.single();
if (fetchError) throw fetchError;
const currentStatus = (current as Record<string, unknown>)
.status as string;
if (currentStatus !== input.status) {
try {
const sideEffects = validateTransition(
currentStatus as Parameters<typeof validateTransition>[0],
input.status as Parameters<typeof validateTransition>[1],
);
Object.assign(update, sideEffects);
} catch {
throw new InvalidEventStatusTransitionError(
currentStatus,
input.status,
getValidTransitions(
currentStatus as Parameters<typeof getValidTransitions>[0],
),
);
}
}
update.status = input.status;
}
if (userId) {
update.updated_by = userId;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic fields + future version column
let query = client
.from('events')
.update(update as any)
.eq('id', input.eventId);
// Optimistic locking — version column may be added via migration
if (input.version !== undefined) {
query = (query as any).eq('version', input.version);
}
const { data, error } = await query.select().single();
// If version was provided and no rows matched, it's a concurrency conflict
if (error && input.version !== undefined) {
// PGRST116 = "JSON object requested, multiple (or no) rows returned"
if (error.code === 'PGRST116') {
throw new EventConcurrencyConflictError();
}
}
if (error) throw error;
return data;
},
async softDelete(eventId: string) {
const logger = await getLogger();
logger.info({ name: NAMESPACE, eventId }, 'Cancelling event...');
const { data: current, error: fetchError } = await client
.from('events')
.select('status')
.eq('id', eventId)
.maybeSingle();
if (fetchError) throw fetchError;
if (!current) throw new EventNotFoundError(eventId);
type EventStatus = Parameters<typeof canTransition>[0];
if (!canTransition(current.status as EventStatus, 'cancelled')) {
throw new InvalidEventStatusTransitionError(
current.status,
'cancelled',
getValidTransitions(current.status as EventStatus),
);
}
const { error } = await client
.from('events')
.update({ status: 'cancelled' })
.eq('id', eventId);
if (error) throw error;
},
};
}

View File

@@ -0,0 +1,64 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createEventRegistrationService(
client: SupabaseClient<Database>,
) {
return {
async register(input: {
eventId: string;
memberId?: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
dateOfBirth?: string;
parentName?: string;
parentPhone?: string;
}) {
// RPC function defined in migration; cast to bypass generated types
// until `pnpm supabase:web:typegen` is re-run.
const { data, error } = await (client.rpc as CallableFunction)(
'register_for_event',
{
p_event_id: input.eventId,
p_member_id: input.memberId ?? null,
p_first_name: input.firstName,
p_last_name: input.lastName,
p_email: input.email || null,
p_phone: input.phone || null,
p_date_of_birth: input.dateOfBirth ?? null,
p_parent_name: input.parentName ?? null,
p_parent_phone: input.parentPhone ?? null,
},
);
if (error) throw error;
return data;
},
async cancel(registrationId: string) {
const { data, error } = await (client.rpc as CallableFunction)(
'cancel_event_registration',
{ p_registration_id: registrationId },
);
if (error) throw error;
return data as {
cancelled_id: string;
promoted_id: string | null;
promoted_name: string | null;
};
},
async list(eventId: string) {
const { data, error } = await client
.from('event_registrations')
.select('*')
.eq('event_id', eventId)
.order('created_at');
if (error) throw error;
return data ?? [];
},
};
}

View File

@@ -0,0 +1,54 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createHolidayPassService(client: SupabaseClient<Database>) {
return {
async list(accountId: string) {
const { data, error } = await client
.from('holiday_passes')
.select('*')
.eq('account_id', accountId)
.order('year', { ascending: false });
if (error) throw error;
return data ?? [];
},
async getActivities(passId: string) {
const { data, error } = await client
.from('holiday_pass_activities')
.select('*')
.eq('pass_id', passId)
.order('activity_date');
if (error) throw error;
return data ?? [];
},
async create(input: {
accountId: string;
name: string;
year: number;
description?: string;
price?: number;
validFrom?: string;
validUntil?: string;
}) {
const { data, error } = await client
.from('holiday_passes')
.insert({
account_id: input.accountId,
name: input.name,
year: input.year,
description: input.description,
price: input.price ?? 0,
valid_from: input.validFrom,
valid_until: input.validUntil,
})
.select()
.single();
if (error) throw error;
return data;
},
};
}

View File

@@ -0,0 +1,22 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createEventCrudService } from './event-crud.service';
import { createEventRegistrationService } from './event-registration.service';
import { createHolidayPassService } from './holiday-pass.service';
export {
createEventCrudService,
createEventRegistrationService,
createHolidayPassService,
};
export function createEventServices(client: SupabaseClient<Database>) {
return {
events: createEventCrudService(client),
registrations: createEventRegistrationService(client),
holidayPasses: createHolidayPassService(client),
};
}