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

View File

@@ -0,0 +1,131 @@
/**
* Standardized error codes and domain error classes
* for the booking management module.
*/
export const BookingErrorCodes = {
BOOKING_NOT_FOUND: 'BOOKING_NOT_FOUND',
ROOM_NOT_FOUND: 'ROOM_NOT_FOUND',
ROOM_NOT_AVAILABLE: 'ROOM_NOT_AVAILABLE',
ROOM_CAPACITY_EXCEEDED: 'ROOM_CAPACITY_EXCEEDED',
INVALID_STATUS_TRANSITION: 'BOOKING_INVALID_TRANSITION',
CONCURRENCY_CONFLICT: 'BOOKING_CONCURRENCY_CONFLICT',
INVALID_DATE_RANGE: 'BOOKING_INVALID_DATE_RANGE',
} as const;
export type BookingErrorCode =
(typeof BookingErrorCodes)[keyof typeof BookingErrorCodes];
/**
* Base domain error for booking management operations.
*/
export class BookingDomainError extends Error {
readonly code: BookingErrorCode;
readonly statusCode: number;
readonly details?: Record<string, unknown>;
constructor(
code: BookingErrorCode,
message: string,
statusCode = 400,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'BookingDomainError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class BookingNotFoundError extends BookingDomainError {
constructor(bookingId: string) {
super(
BookingErrorCodes.BOOKING_NOT_FOUND,
`Buchung ${bookingId} nicht gefunden`,
404,
{ bookingId },
);
this.name = 'BookingNotFoundError';
}
}
export class RoomNotFoundError extends BookingDomainError {
constructor(roomId: string) {
super(
BookingErrorCodes.ROOM_NOT_FOUND,
`Raum ${roomId} nicht gefunden`,
404,
{ roomId },
);
this.name = 'RoomNotFoundError';
}
}
export class RoomNotAvailableError extends BookingDomainError {
constructor(roomId: string, from: string, to: string) {
super(
BookingErrorCodes.ROOM_NOT_AVAILABLE,
`Raum ${roomId} ist im Zeitraum ${from} bis ${to} nicht verfügbar`,
409,
{ roomId, from, to },
);
this.name = 'RoomNotAvailableError';
}
}
export class RoomCapacityExceededError extends BookingDomainError {
constructor(roomId: string, capacity: number) {
super(
BookingErrorCodes.ROOM_CAPACITY_EXCEEDED,
`Raum ${roomId} hat die maximale Kapazität (${capacity}) erreicht`,
422,
{ roomId, capacity },
);
this.name = 'RoomCapacityExceededError';
}
}
export class InvalidBookingStatusTransitionError extends BookingDomainError {
constructor(from: string, to: string, validTargets: string[]) {
super(
BookingErrorCodes.INVALID_STATUS_TRANSITION,
`Ungültiger Statuswechsel: ${from}${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
422,
{ from, to, validTargets },
);
this.name = 'InvalidBookingStatusTransitionError';
}
}
export class BookingConcurrencyConflictError extends BookingDomainError {
constructor() {
super(
BookingErrorCodes.CONCURRENCY_CONFLICT,
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
409,
);
this.name = 'BookingConcurrencyConflictError';
}
}
export class BookingInvalidDateRangeError extends BookingDomainError {
constructor(from: string, to: string) {
super(
BookingErrorCodes.INVALID_DATE_RANGE,
`Ungültiger Zeitraum: ${from} bis ${to}. Das Enddatum muss nach dem Startdatum liegen.`,
422,
{ from, to },
);
this.name = 'BookingInvalidDateRangeError';
}
}
/**
* Check if an error is a BookingDomainError.
*/
export function isBookingDomainError(
error: unknown,
): error is BookingDomainError {
return error instanceof BookingDomainError;
}

View File

@@ -20,20 +20,31 @@ export const CreateRoomSchema = z.object({
description: z.string().optional(),
});
export const CreateBookingSchema = z.object({
accountId: z.string().uuid(),
roomId: z.string().uuid(),
guestId: z.string().uuid().optional(),
checkIn: z.string(),
checkOut: z.string(),
adults: z.number().int().min(1).default(1),
children: z.number().int().min(0).default(0),
status: BookingStatusEnum.default('confirmed'),
totalPrice: z.number().min(0).default(0),
notes: z.string().optional(),
});
export const CreateBookingSchema = z
.object({
accountId: z.string().uuid(),
roomId: z.string().uuid(),
guestId: z.string().uuid().optional(),
checkIn: z.string(),
checkOut: z.string(),
adults: z.number().int().min(1).default(1),
children: z.number().int().min(0).default(0),
status: BookingStatusEnum.default('confirmed'),
totalPrice: z.number().min(0).optional(),
notes: z.string().optional(),
})
.refine((d) => d.checkOut > d.checkIn, {
message: 'Abreisedatum muss nach dem Anreisedatum liegen',
path: ['checkOut'],
});
export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
export const UpdateBookingStatusSchema = z.object({
bookingId: z.string().uuid(),
status: BookingStatusEnum,
version: z.number().int().optional(),
});
export const CreateGuestSchema = z.object({
accountId: z.string().uuid(),
firstName: z.string().min(1),

View File

@@ -1,71 +1,87 @@
'use server';
import { z } from 'zod';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { isBookingDomainError } from '../../lib/errors';
import {
CreateBookingSchema,
CreateGuestSchema,
CreateRoomSchema,
UpdateBookingStatusSchema,
} from '../../schema/booking.schema';
import { createBookingManagementApi } from '../api';
export const createBooking = authActionClient
.inputSchema(CreateBookingSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.create' }, 'Creating booking...');
const result = await api.createBooking(input);
logger.info({ name: 'booking.create' }, 'Booking created');
return { success: true, data: result };
try {
logger.info({ name: 'booking.create' }, 'Creating booking...');
const result = await api.bookings.create(input);
logger.info({ name: 'booking.create' }, 'Booking created');
return { success: true, data: result };
} catch (e) {
if (isBookingDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const updateBookingStatus = authActionClient
.inputSchema(
z.object({
bookingId: z.string().uuid(),
status: z.string(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.inputSchema(UpdateBookingStatusSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.updateStatus' }, 'Updating booking status...');
const result = await api.updateBookingStatus(input.bookingId, input.status);
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
return { success: true, data: result };
try {
logger.info(
{ name: 'booking.updateStatus' },
'Updating booking status...',
);
await api.bookings.updateStatus(
input.bookingId,
input.status,
input.version,
);
logger.info({ name: 'booking.updateStatus' }, 'Booking status updated');
return { success: true };
} catch (e) {
if (isBookingDomainError(e)) {
return { success: false, error: e.message, code: e.code };
}
throw e;
}
});
export const createRoom = authActionClient
.inputSchema(CreateRoomSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.createRoom' }, 'Creating room...');
const result = await api.createRoom(input);
const result = await api.rooms.create(input);
logger.info({ name: 'booking.createRoom' }, 'Room created');
return { success: true, data: result };
});
export const createGuest = authActionClient
.inputSchema(CreateGuestSchema)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createBookingManagementApi(client);
logger.info({ name: 'booking.createGuest' }, 'Creating guest...');
const result = await api.createGuest(input);
const result = await api.guests.create(input);
logger.info({ name: 'booking.createGuest' }, 'Guest created');
return { success: true, data: result };
});

View File

@@ -1,173 +1,16 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type { CreateBookingInput } from '../schema/booking.schema';
/* eslint-disable @typescript-eslint/no-explicit-any */
import { createBookingCrudService } from './services/booking-crud.service';
import { createGuestService } from './services/guest.service';
import { createRoomService } from './services/room.service';
export function createBookingManagementApi(client: SupabaseClient<Database>) {
const _db = client;
return {
// --- Rooms ---
async listRooms(accountId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('account_id', accountId)
.eq('is_active', true)
.order('room_number');
if (error) throw error;
return data ?? [];
},
async getRoom(roomId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('id', roomId)
.single();
if (error) throw error;
return data;
},
// --- Availability ---
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
const { count, error } = await client
.from('bookings')
.select('*', { count: 'exact', head: true })
.eq('room_id', roomId)
.not('status', 'in', '("cancelled","no_show")')
.lt('check_in', checkOut)
.gt('check_out', checkIn);
if (error) throw error;
return (count ?? 0) === 0;
},
// --- Bookings ---
async listBookings(
accountId: string,
opts?: { status?: string; from?: string; to?: string; page?: number },
) {
let query = client
.from('bookings')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.from) query = query.gte('check_in', opts.from);
if (opts?.to) query = query.lte('check_out', opts.to);
const page = opts?.page ?? 1;
query = query.range((page - 1) * 25, page * 25 - 1);
const { data, error, count } = await query;
if (error) throw error;
return { data: data ?? [], total: count ?? 0 };
},
async createBooking(input: CreateBookingInput) {
const available = await this.checkAvailability(
input.roomId,
input.checkIn,
input.checkOut,
);
if (!available)
throw new Error('Room is not available for the selected dates');
const { data, error } = await client
.from('bookings')
.insert({
account_id: input.accountId,
room_id: input.roomId,
guest_id: input.guestId,
check_in: input.checkIn,
check_out: input.checkOut,
adults: input.adults,
children: input.children,
status: input.status,
total_price: input.totalPrice,
notes: input.notes,
})
.select()
.single();
if (error) throw error;
return data;
},
async updateBookingStatus(bookingId: string, status: string) {
const { error } = await client
.from('bookings')
.update({ status })
.eq('id', bookingId);
if (error) throw error;
},
// --- Guests ---
async listGuests(accountId: string, search?: string) {
let query = client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (search)
query = query.or(
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async createGuest(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
city?: string;
}) {
const { data, error } = await client
.from('guests')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email,
phone: input.phone,
city: input.city,
})
.select()
.single();
if (error) throw error;
return data;
},
async createRoom(input: {
accountId: string;
roomNumber: string;
name?: string;
roomType?: string;
capacity?: number;
floor?: number;
pricePerNight: number;
description?: string;
}) {
const { data, error } = await client
.from('rooms')
.insert({
account_id: input.accountId,
room_number: input.roomNumber,
name: input.name,
room_type: input.roomType ?? 'standard',
capacity: input.capacity ?? 2,
floor: input.floor,
price_per_night: input.pricePerNight,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},
rooms: createRoomService(client),
bookings: createBookingCrudService(client),
guests: createGuestService(client),
};
}

View File

@@ -0,0 +1,157 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import {
getValidTransitions,
validateTransition,
} from '../../lib/booking-status-machine';
import {
BookingConcurrencyConflictError,
InvalidBookingStatusTransitionError,
} from '../../lib/errors';
import type { CreateBookingInput } from '../../schema/booking.schema';
const NAMESPACE = 'booking-crud';
export function createBookingCrudService(client: SupabaseClient<Database>) {
return {
async list(
accountId: string,
opts?: {
status?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
},
) {
let query = client
.from('bookings')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('check_in', { ascending: false });
if (opts?.status) query = query.eq('status', opts.status);
if (opts?.from) query = query.gte('check_in', opts.from);
if (opts?.to) query = query.lte('check_out', opts.to);
const page = opts?.page ?? 1;
const pageSize = opts?.pageSize ?? 25;
query = query.range((page - 1) * pageSize, page * pageSize - 1);
const { data, error, count } = await query;
if (error) throw error;
const total = count ?? 0;
return {
data: data ?? [],
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
};
},
async create(input: CreateBookingInput) {
const logger = await getLogger();
logger.info({ name: NAMESPACE }, 'Creating booking...');
const { data, error } = await (client.rpc as CallableFunction)(
'create_booking_atomic',
{
p_account_id: input.accountId,
p_room_id: input.roomId,
p_guest_id: input.guestId ?? null,
p_check_in: input.checkIn,
p_check_out: input.checkOut,
p_adults: input.adults ?? 1,
p_children: input.children ?? 0,
p_status: input.status ?? 'confirmed',
p_total_price: input.totalPrice ?? null,
p_notes: input.notes ?? null,
},
);
if (error) throw error;
// RPC returns the booking UUID; fetch the full row
const bookingId = data as unknown as string;
const { data: booking, error: fetchError } = await client
.from('bookings')
.select('*')
.eq('id', bookingId)
.single();
if (fetchError) throw fetchError;
return booking;
},
async updateStatus(
bookingId: string,
status: string,
version?: number,
userId?: string,
) {
const logger = await getLogger();
logger.info(
{ name: NAMESPACE, bookingId, status },
'Updating booking status...',
);
// Fetch current booking to get current status
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- version column added via migration, not yet in generated types
const { data: current, error: fetchError } = await (
client.from('bookings').select('id, status, version') as any
)
.eq('id', bookingId)
.single();
if (fetchError) throw fetchError;
const currentStatus = (current as Record<string, unknown>)
.status as string;
// Validate status transition using the state machine
try {
validateTransition(
currentStatus as Parameters<typeof validateTransition>[0],
status as Parameters<typeof validateTransition>[1],
);
} catch {
const validTargets = getValidTransitions(
currentStatus as Parameters<typeof getValidTransitions>[0],
);
throw new InvalidBookingStatusTransitionError(
currentStatus,
status,
validTargets,
);
}
// Build the update query
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- updated_by column added via migration
let query = client
.from('bookings')
.update({
status,
...(userId ? { updated_by: userId } : {}),
} as any)
.eq('id', bookingId);
// Optimistic concurrency control via version column (added by migration, not yet in generated types)
if (version !== undefined) {
query = (query as any).eq('version', version);
}
const { data, error } = await query.select('id').single();
if (error) {
// If no rows matched, it's a concurrency conflict
if (error.code === 'PGRST116') {
throw new BookingConcurrencyConflictError();
}
throw error;
}
if (!data) {
throw new BookingConcurrencyConflictError();
}
},
};
}

View File

@@ -0,0 +1,57 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createGuestService(client: SupabaseClient<Database>) {
return {
async list(accountId: string, search?: string) {
let query = client
.from('guests')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (search)
query = query.or(
`last_name.ilike.%${search}%,first_name.ilike.%${search}%,email.ilike.%${search}%`,
);
const { data, error } = await query;
if (error) throw error;
return data ?? [];
},
async create(input: {
accountId: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
city?: string;
}) {
const { data, error } = await client
.from('guests')
.insert({
account_id: input.accountId,
first_name: input.firstName,
last_name: input.lastName,
email: input.email || null,
phone: input.phone || null,
city: input.city || null,
})
.select()
.single();
if (error) throw error;
return data;
},
async getHistory(guestId: string) {
const { data, error } = await client
.from('bookings')
.select('*')
.eq('guest_id', guestId)
.order('check_in', { ascending: false });
if (error) throw error;
return data ?? [];
},
};
}

View File

@@ -0,0 +1,18 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createBookingCrudService } from './booking-crud.service';
import { createGuestService } from './guest.service';
import { createRoomService } from './room.service';
export { createBookingCrudService, createGuestService, createRoomService };
export function createBookingServices(client: SupabaseClient<Database>) {
return {
rooms: createRoomService(client),
bookings: createBookingCrudService(client),
guests: createGuestService(client),
};
}

View File

@@ -0,0 +1,69 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createRoomService(client: SupabaseClient<Database>) {
return {
async list(accountId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('account_id', accountId)
.eq('is_active', true)
.order('room_number');
if (error) throw error;
return data ?? [];
},
async getById(roomId: string) {
const { data, error } = await client
.from('rooms')
.select('*')
.eq('id', roomId)
.single();
if (error) throw error;
return data;
},
async checkAvailability(roomId: string, checkIn: string, checkOut: string) {
const { count, error } = await client
.from('bookings')
.select('*', { count: 'exact', head: true })
.eq('room_id', roomId)
.not('status', 'in', '("cancelled","no_show")')
.lt('check_in', checkOut)
.gt('check_out', checkIn);
if (error) throw error;
return (count ?? 0) === 0;
},
async create(input: {
accountId: string;
roomNumber: string;
name?: string;
roomType?: string;
capacity?: number;
floor?: number;
pricePerNight: number;
description?: string;
}) {
const { data, error } = await client
.from('rooms')
.insert({
account_id: input.accountId,
room_number: input.roomNumber,
name: input.name,
room_type: input.roomType ?? 'standard',
capacity: input.capacity ?? 2,
floor: input.floor,
price_per_night: input.pricePerNight,
description: input.description,
})
.select()
.single();
if (error) throw error;
return data;
},
};
}

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

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

View File

@@ -10,7 +10,8 @@
}
},
"exports": {
"./api": "./src/server/api.ts",
"./services": "./src/server/services/index.ts",
"./services/*": "./src/server/services/*.ts",
"./schema/*": "./src/schema/*.ts",
"./components": "./src/components/index.ts",
"./actions/*": "./src/server/actions/*.ts",
@@ -22,7 +23,9 @@
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/mailers": "workspace:*",
"@kit/next": "workspace:*",
"@kit/notifications": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",

View File

@@ -1,16 +1,13 @@
export { CreateMemberForm } from './create-member-form';
export { EditMemberForm } from './edit-member-form';
export { MembersDataTable } from './members-data-table';
export { MemberDetailView } from './member-detail-view';
export { ApplicationWorkflow } from './application-workflow';
export { DuesCategoryManager } from './dues-category-manager';
export { MandateManager } from './mandate-manager';
export { MemberImportWizard } from './member-import-wizard';
// New v2 components
export { MemberAvatar } from './member-avatar';
export { MemberStatsBar } from './member-stats-bar';
export { MembersListView } from './members-list-view';
export { MemberDetailTabs } from './member-detail-tabs';
export { MemberCreateWizard } from './member-create-wizard';
export { MemberCommandPalette } from './member-command-palette';
export { TagsManager } from './tags-manager';

View File

@@ -1,299 +0,0 @@
'use client';
import { useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { Download } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { formatDate } from '@kit/shared/dates';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Input } from '@kit/ui/input';
import { useFileDownloadAction } from '@kit/ui/use-file-download-action';
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
import {
exportMembers,
exportMembersExcel,
} from '../server/actions/member-actions';
interface MembersDataTableProps {
data: Array<Record<string, unknown>>;
total: number;
page: number;
pageSize: number;
account: string;
accountId: string;
duesCategories: Array<{ id: string; name: string }>;
}
const STATUS_OPTIONS = [
{ value: '', label: 'Alle' },
{ value: 'active', label: 'Aktiv' },
{ value: 'inactive', label: 'Inaktiv' },
{ value: 'pending', label: 'Ausstehend' },
{ value: 'resigned', label: 'Ausgetreten' },
] as const;
export function MembersDataTable({
data,
total,
page,
pageSize,
account,
accountId,
duesCategories: _duesCategories,
}: MembersDataTableProps) {
const router = useRouter();
const searchParams = useSearchParams();
const currentSearch = searchParams.get('search') ?? '';
const currentStatus = searchParams.get('status') ?? '';
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const form = useForm({
defaultValues: {
search: currentSearch,
},
});
const updateParams = useCallback(
(updates: Record<string, string>) => {
const params = new URLSearchParams(searchParams.toString());
for (const [key, value] of Object.entries(updates)) {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
}
// Reset to page 1 on filter change
if (!('page' in updates)) {
params.delete('page');
}
router.push(`?${params.toString()}`);
},
[router, searchParams],
);
const handleSearch = useCallback(
(e: React.FormEvent) => {
e.preventDefault();
const search = form.getValues('search');
updateParams({ search });
},
[form, updateParams],
);
const handleStatusChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
updateParams({ status: e.target.value });
},
[updateParams],
);
const handlePageChange = useCallback(
(newPage: number) => {
updateParams({ page: String(newPage) });
},
[updateParams],
);
const handleRowClick = useCallback(
(memberId: string) => {
router.push(`/home/${account}/members-cms/${memberId}`);
},
[router, account],
);
const { execute: execCsvExport, isPending: isCsvExporting } =
useFileDownloadAction(exportMembers, {
successMessage: 'CSV-Export heruntergeladen',
errorMessage: 'CSV-Export fehlgeschlagen',
});
const { execute: execExcelExport, isPending: isExcelExporting } =
useFileDownloadAction(exportMembersExcel, {
successMessage: 'Excel-Export heruntergeladen',
errorMessage: 'Excel-Export fehlgeschlagen',
});
const isExporting = isCsvExporting || isExcelExporting;
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form onSubmit={handleSearch} className="flex gap-2">
<Input
placeholder="Mitglied suchen..."
className="w-64"
data-test="members-search-input"
{...form.register('search')}
/>
<Button
type="submit"
variant="outline"
size="sm"
data-test="members-search-btn"
>
Suchen
</Button>
</form>
<div className="flex items-center gap-3">
<select
value={currentStatus}
onChange={handleStatusChange}
data-test="members-status-filter"
className="border-input bg-background flex h-9 rounded-md border px-3 py-1 text-sm shadow-sm"
>
{STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<Button
variant="outline"
size="sm"
disabled={isExporting}
onClick={() =>
execCsvExport({
accountId,
status: currentStatus || undefined,
})
}
>
<Download className="mr-1 h-4 w-4" />
CSV
</Button>
<Button
variant="outline"
size="sm"
disabled={isExporting}
onClick={() =>
execExcelExport({
accountId,
status: currentStatus || undefined,
})
}
>
<Download className="mr-1 h-4 w-4" />
Excel
</Button>
<Button
size="sm"
data-test="members-new-btn"
onClick={() => router.push(`/home/${account}/members-cms/new`)}
>
Neues Mitglied
</Button>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/50 border-b">
<th scope="col" className="px-4 py-3 text-left font-medium">
Nr
</th>
<th scope="col" className="px-4 py-3 text-left font-medium">
Name
</th>
<th scope="col" className="px-4 py-3 text-left font-medium">
E-Mail
</th>
<th scope="col" className="px-4 py-3 text-left font-medium">
Ort
</th>
<th scope="col" className="px-4 py-3 text-left font-medium">
Status
</th>
<th scope="col" className="px-4 py-3 text-left font-medium">
Eintritt
</th>
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={6}
className="text-muted-foreground px-4 py-8 text-center"
>
Keine Mitglieder gefunden.
</td>
</tr>
) : (
data.map((member) => {
const memberId = String(member.id ?? '');
const status = String(member.status ?? 'active');
return (
<tr
key={memberId}
onClick={() => handleRowClick(memberId)}
className="hover:bg-muted/50 cursor-pointer border-b transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">
{String(member.member_number ?? '—')}
</td>
<td className="px-4 py-3">
{String(member.last_name ?? '')},{' '}
{String(member.first_name ?? '')}
</td>
<td className="text-muted-foreground px-4 py-3">
{String(member.email ?? '—')}
</td>
<td className="px-4 py-3">{String(member.city ?? '—')}</td>
<td className="px-4 py-3">
<Badge variant={getMemberStatusColor(status)}>
{STATUS_LABELS[status] ?? status}
</Badge>
</td>
<td className="text-muted-foreground px-4 py-3">
{formatDate(member.entry_date as string)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{total} Mitglied{total !== 1 ? 'er' : ''} insgesamt
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => handlePageChange(page - 1)}
>
Zurück
</Button>
<span className="text-sm">
Seite {page} von {totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => handlePageChange(page + 1)}
>
Weiter
</Button>
</div>
</div>
</div>
);
}

View File

@@ -36,6 +36,11 @@ interface MembersListViewProps {
accountId: string;
duesCategories: Array<{ id: string; name: string }>;
departments: Array<{ id: string; name: string; memberCount: number }>;
tags?: Array<{ id: string; name: string; color: string }>;
memberTags?: Record<
string,
Array<{ id: string; name: string; color: string }>
>;
}
export function MembersListView({
@@ -47,6 +52,8 @@ export function MembersListView({
accountId,
duesCategories,
departments,
tags = [],
memberTags = {},
}: MembersListViewProps) {
const router = useRouter();
const searchParams = useSearchParams();
@@ -67,6 +74,7 @@ export function MembersListView({
isHonorary: Boolean(m.is_honorary),
isFoundingMember: Boolean(m.is_founding_member),
isYouth: Boolean(m.is_youth),
tags: memberTags[String(m.id)] ?? [],
}));
const columns = createMembersColumns({
@@ -120,6 +128,7 @@ export function MembersListView({
selectedIds={selectedIds}
departments={departments}
duesCategories={duesCategories}
tags={tags}
/>
{/* Table or empty state */}

View File

@@ -17,6 +17,12 @@ import {
import { STATUS_LABELS, getMemberStatusColor } from '../lib/member-utils';
import { MemberAvatar } from './member-avatar';
export interface MemberTag {
id: string;
name: string;
color: string;
}
export interface MemberRow {
id: string;
firstName: string;
@@ -31,6 +37,7 @@ export interface MemberRow {
isHonorary: boolean;
isFoundingMember: boolean;
isYouth: boolean;
tags: MemberTag[];
}
interface ColumnOptions {
@@ -211,6 +218,36 @@ export function createMembersColumns({
size: 80,
},
// Tags
{
id: 'tags',
header: 'Tags',
cell: ({ row }) => {
const m = row.original;
if (!m.tags || m.tags.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{m.tags.slice(0, 3).map((tag) => (
<Badge
key={tag.id}
variant="outline"
className="border-0 px-1.5 py-0 text-[10px] whitespace-nowrap text-white"
style={{ backgroundColor: tag.color }}
>
{tag.name}
</Badge>
))}
{m.tags.length > 3 && (
<Badge variant="secondary" className="px-1.5 py-0 text-[10px]">
+{m.tags.length - 3}
</Badge>
)}
</div>
);
},
size: 160,
},
// Row actions
{
id: 'actions',

View File

@@ -59,6 +59,7 @@ interface MembersToolbarProps {
selectedIds: string[];
departments: Array<{ id: string; name: string; memberCount: number }>;
duesCategories: Array<{ id: string; name: string }>;
tags?: Array<{ id: string; name: string; color: string }>;
}
export function MembersToolbar({
@@ -72,6 +73,7 @@ export function MembersToolbar({
selectedIds,
departments,
duesCategories,
tags = [],
}: MembersToolbarProps) {
const router = useRouter();
const [, startTransition] = useTransition();

View File

@@ -0,0 +1,267 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Trash2, Pencil } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { useForm } from 'react-hook-form';
import { Badge } from '@kit/ui/badge';
import { Button } from '@kit/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@kit/ui/card';
import { Input } from '@kit/ui/input';
import { Label } from '@kit/ui/label';
import { toast } from '@kit/ui/sonner';
import { Textarea } from '@kit/ui/textarea';
import { createTag, deleteTag, updateTag } from '../server/actions/tag-actions';
interface TagsManagerProps {
tags: Array<{
id: string;
name: string;
color: string;
description: string | null;
sort_order: number;
}>;
accountId: string;
}
interface TagFormValues {
name: string;
color: string;
description: string;
}
export function TagsManager({ tags, accountId }: TagsManagerProps) {
const router = useRouter();
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const form = useForm<TagFormValues>({
defaultValues: { name: '', color: '#6B7280', description: '' },
});
const { execute: executeCreate, isPending: isCreating } = useAction(
createTag,
{
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Tag erstellt');
form.reset();
setShowForm(false);
router.refresh();
}
},
onError: () => toast.error('Fehler beim Erstellen'),
},
);
const { execute: executeUpdate } = useAction(updateTag, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Tag aktualisiert');
setEditingId(null);
form.reset();
router.refresh();
}
},
onError: () => toast.error('Fehler beim Aktualisieren'),
});
const { execute: executeDelete } = useAction(deleteTag, {
onSuccess: ({ data }) => {
if (data?.success) {
toast.success('Tag gelöscht');
router.refresh();
}
},
onError: () => toast.error('Fehler beim Löschen'),
});
const handleSubmit = form.handleSubmit((values) => {
if (editingId) {
executeUpdate({
tagId: editingId,
name: values.name,
color: values.color,
description: values.description || undefined,
});
} else {
executeCreate({
accountId,
name: values.name,
color: values.color,
description: values.description || undefined,
});
}
});
const startEditing = (tag: TagsManagerProps['tags'][number]) => {
setEditingId(tag.id);
setShowForm(true);
form.setValue('name', tag.name);
form.setValue('color', tag.color);
form.setValue('description', tag.description ?? '');
};
const cancelForm = () => {
setShowForm(false);
setEditingId(null);
form.reset();
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-sm">
{tags.length} {tags.length === 1 ? 'Tag' : 'Tags'} erstellt
</p>
{!showForm && (
<Button size="sm" onClick={() => setShowForm(true)}>
<Plus className="mr-1.5 size-4" />
Neues Tag
</Button>
)}
</div>
{showForm && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">
{editingId ? 'Tag bearbeiten' : 'Neues Tag erstellen'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="tag-name">Name</Label>
<Input
id="tag-name"
placeholder="z.B. Vorstand-Kandidat"
{...form.register('name', { required: true })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="tag-color">Farbe</Label>
<div className="flex items-center gap-2">
<input
type="color"
id="tag-color"
className="h-9 w-12 cursor-pointer rounded border p-0.5"
{...form.register('color')}
/>
<Input
className="flex-1"
placeholder="#6B7280"
{...form.register('color')}
/>
</div>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="tag-description">Beschreibung (optional)</Label>
<Textarea
id="tag-description"
placeholder="Beschreibung des Tags..."
rows={2}
{...form.register('description')}
/>
</div>
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-xs">
Vorschau:{' '}
<Badge
className="text-white"
style={{ backgroundColor: form.watch('color') }}
>
{form.watch('name') || 'Tag-Name'}
</Badge>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={cancelForm}
>
Abbrechen
</Button>
<Button type="submit" size="sm" disabled={isCreating}>
{editingId ? 'Speichern' : 'Erstellen'}
</Button>
</div>
</div>
</form>
</CardContent>
</Card>
)}
{tags.length === 0 && !showForm ? (
<Card>
<CardContent className="py-8 text-center">
<p className="text-muted-foreground">
Keine Tags vorhanden. Erstellen Sie Ihr erstes Tag.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-2">
{tags.map((tag) => (
<Card key={tag.id}>
<CardContent className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3">
<span
className="size-4 rounded"
style={{ backgroundColor: tag.color }}
/>
<div>
<span className="font-medium">{tag.name}</span>
{tag.description && (
<p className="text-muted-foreground text-xs">
{tag.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
onClick={() => startEditing(tag)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive size-8"
onClick={() => {
if (
window.confirm(`Tag "${tag.name}" wirklich löschen?`)
) {
executeDelete({ tagId: tag.id });
}
}}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
/**
* Standardized error codes and domain error classes
* for the member management module.
*/
export const MemberErrorCodes = {
NOT_FOUND: 'MEMBER_NOT_FOUND',
DUPLICATE: 'MEMBER_DUPLICATE',
INVALID_STATUS_TRANSITION: 'MEMBER_INVALID_STATUS_TRANSITION',
CONCURRENCY_CONFLICT: 'MEMBER_CONCURRENCY_CONFLICT',
MERGE_CONFLICT: 'MEMBER_MERGE_CONFLICT',
IMPORT_VALIDATION_FAILED: 'MEMBER_IMPORT_VALIDATION_FAILED',
PERMISSION_DENIED: 'MEMBER_PERMISSION_DENIED',
RATE_LIMITED: 'MEMBER_RATE_LIMITED',
INVALID_IBAN: 'MEMBER_INVALID_IBAN',
APPLICATION_NOT_REVIEWABLE: 'MEMBER_APPLICATION_NOT_REVIEWABLE',
} as const;
export type MemberErrorCode =
(typeof MemberErrorCodes)[keyof typeof MemberErrorCodes];
/**
* Base domain error for member management operations.
*/
export class MemberDomainError extends Error {
readonly code: MemberErrorCode;
readonly statusCode: number;
readonly details?: Record<string, unknown>;
constructor(
code: MemberErrorCode,
message: string,
statusCode = 400,
details?: Record<string, unknown>,
) {
super(message);
this.name = 'MemberDomainError';
this.code = code;
this.statusCode = statusCode;
this.details = details;
}
}
export class MemberNotFoundError extends MemberDomainError {
constructor(memberId: string) {
super(
MemberErrorCodes.NOT_FOUND,
`Mitglied ${memberId} nicht gefunden`,
404,
{ memberId },
);
this.name = 'MemberNotFoundError';
}
}
export class DuplicateMemberError extends MemberDomainError {
constructor(
duplicates: Array<{ id: string; name: string; memberNumber?: string }>,
) {
super(MemberErrorCodes.DUPLICATE, 'Mögliche Duplikate gefunden', 409, {
duplicates,
});
this.name = 'DuplicateMemberError';
}
}
export class InvalidStatusTransitionError extends MemberDomainError {
constructor(from: string, to: string, validTargets: string[]) {
super(
MemberErrorCodes.INVALID_STATUS_TRANSITION,
`Ungültiger Statuswechsel: ${from}${to}. Erlaubt: ${validTargets.join(', ') || 'keine'}`,
422,
{ from, to, validTargets },
);
this.name = 'InvalidStatusTransitionError';
}
}
export class ConcurrencyConflictError extends MemberDomainError {
constructor() {
super(
MemberErrorCodes.CONCURRENCY_CONFLICT,
'Dieser Datensatz wurde zwischenzeitlich von einem anderen Benutzer geändert. Bitte laden Sie die Seite neu.',
409,
);
this.name = 'ConcurrencyConflictError';
}
}
export class MergeConflictError extends MemberDomainError {
constructor(
conflicts: Record<string, { primary: unknown; secondary: unknown }>,
) {
super(
MemberErrorCodes.MERGE_CONFLICT,
'Konflikte beim Zusammenführen der Mitgliedsdaten',
409,
{ conflicts },
);
this.name = 'MergeConflictError';
}
}
export class ImportValidationError extends MemberDomainError {
constructor(errors: Array<{ row: number; field: string; message: string }>) {
super(
MemberErrorCodes.IMPORT_VALIDATION_FAILED,
`${errors.length} Validierungsfehler beim Import`,
422,
{ errors },
);
this.name = 'ImportValidationError';
}
}
/**
* Check if an error is a MemberDomainError.
*/
export function isMemberDomainError(
error: unknown,
): error is MemberDomainError {
return error instanceof MemberDomainError;
}

View File

@@ -0,0 +1,139 @@
import type { MembershipStatus } from '../schema/member.schema';
/**
* Member status state machine.
*
* Defines valid transitions between membership statuses and their
* side effects. Enforced in updateMember and bulkUpdateStatus.
*/
type StatusTransition = {
/** Fields to set automatically when this transition occurs */
sideEffects?: Partial<Record<string, unknown>>;
};
const TRANSITIONS: Record<
MembershipStatus,
Partial<Record<MembershipStatus, StatusTransition>>
> = {
pending: {
active: {},
inactive: {},
resigned: {
sideEffects: { exit_date: () => todayISO() },
},
excluded: {
sideEffects: { exit_date: () => todayISO() },
},
},
active: {
inactive: {},
resigned: {
sideEffects: { exit_date: () => todayISO() },
},
excluded: {
sideEffects: { exit_date: () => todayISO() },
},
deceased: {
sideEffects: {
exit_date: () => todayISO(),
is_archived: true,
},
},
},
inactive: {
active: {
sideEffects: { exit_date: null, exit_reason: null },
},
resigned: {
sideEffects: { exit_date: () => todayISO() },
},
excluded: {
sideEffects: { exit_date: () => todayISO() },
},
deceased: {
sideEffects: {
exit_date: () => todayISO(),
is_archived: true,
},
},
},
resigned: {
active: {
sideEffects: { exit_date: null, exit_reason: null },
},
},
excluded: {
active: {
sideEffects: { exit_date: null, exit_reason: null },
},
},
// Terminal state — no transitions out
deceased: {},
};
function todayISO(): string {
return new Date().toISOString().split('T')[0]!;
}
/**
* Check if a status transition is valid.
*/
export function canTransition(
from: MembershipStatus,
to: MembershipStatus,
): 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: MembershipStatus,
): MembershipStatus[] {
return Object.keys(TRANSITIONS[from] ?? {}) as MembershipStatus[];
}
/**
* 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: MembershipStatus,
to: MembershipStatus,
): 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: MembershipStatus,
to: MembershipStatus,
): 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,68 @@
import { z } from 'zod';
export const CommunicationTypeEnum = z.enum([
'email',
'phone',
'letter',
'meeting',
'note',
'sms',
]);
export type CommunicationType = z.infer<typeof CommunicationTypeEnum>;
export const CommunicationDirectionEnum = z.enum([
'inbound',
'outbound',
'internal',
]);
export type CommunicationDirection = z.infer<typeof CommunicationDirectionEnum>;
export const CreateCommunicationSchema = z
.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
type: CommunicationTypeEnum,
direction: CommunicationDirectionEnum.default('outbound'),
subject: z.string().max(500).optional(),
body: z.string().max(50000).optional(),
emailTo: z.string().email().optional().or(z.literal('')),
emailCc: z.string().max(1000).optional(),
emailMessageId: z.string().max(256).optional(),
attachmentPaths: z.array(z.string().max(512)).max(10).optional(),
})
.superRefine((data, ctx) => {
// Email type requires a recipient
if (
data.type === 'email' &&
(!data.emailTo || data.emailTo.trim() === '')
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'E-Mail-Empfänger ist für den Typ "E-Mail" erforderlich',
path: ['emailTo'],
});
}
});
export type CreateCommunicationInput = z.infer<
typeof CreateCommunicationSchema
>;
export const CommunicationListFiltersSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
type: CommunicationTypeEnum.optional(),
direction: CommunicationDirectionEnum.optional(),
search: z.string().max(256).optional(),
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(100).default(25),
});
export type CommunicationListFilters = z.infer<
typeof CommunicationListFiltersSchema
>;
export const DeleteCommunicationSchema = z.object({
communicationId: z.string().uuid(),
accountId: z.string().uuid(),
});

View File

@@ -17,66 +17,140 @@ export const SepaMandateStatusEnum = z.enum([
'expired',
]);
export const CreateMemberSchema = z.object({
accountId: z.string().uuid(),
memberNumber: z.string().optional(),
firstName: z.string().min(1).max(128),
lastName: z.string().min(1).max(128),
dateOfBirth: z.string().optional(),
gender: z.enum(['male', 'female', 'diverse']).optional(),
title: z.string().max(32).optional(),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().max(32).optional(),
mobile: z.string().max(32).optional(),
street: z.string().max(256).optional(),
houseNumber: z.string().max(16).optional(),
postalCode: z.string().max(10).optional(),
city: z.string().max(128).optional(),
country: z.string().max(2).default('DE'),
status: MembershipStatusEnum.default('active'),
entryDate: z.string().default(() => new Date().toISOString().split('T')[0]!),
duesCategoryId: z.string().uuid().optional(),
iban: z.string().max(34).optional(),
bic: z.string().max(11).optional(),
accountHolder: z.string().max(128).optional(),
gdprConsent: z.boolean().default(false),
notes: z.string().optional(),
// New optional fields
salutation: z.string().optional(),
street2: z.string().optional(),
phone2: z.string().optional(),
fax: z.string().optional(),
birthplace: z.string().optional(),
birthCountry: z.string().default('DE'),
isHonorary: z.boolean().default(false),
isFoundingMember: z.boolean().default(false),
isYouth: z.boolean().default(false),
isRetiree: z.boolean().default(false),
isProbationary: z.boolean().default(false),
isTransferred: z.boolean().default(false),
exitDate: z.string().optional(),
exitReason: z.string().optional(),
guardianName: z.string().optional(),
guardianPhone: z.string().optional(),
guardianEmail: z.string().optional(),
duesYear: z.number().int().optional(),
duesPaid: z.boolean().default(false),
additionalFees: z.number().default(0),
exemptionType: z.string().optional(),
exemptionReason: z.string().optional(),
exemptionAmount: z.number().optional(),
gdprNewsletter: z.boolean().default(false),
gdprInternet: z.boolean().default(false),
gdprPrint: z.boolean().default(false),
gdprBirthdayInfo: z.boolean().default(false),
sepaMandateReference: z.string().optional(),
});
// --- Shared validators ---
/** IBAN validation with mod-97 checksum (ISO 13616) */
export function validateIban(iban: string): boolean {
const cleaned = iban.replace(/\s/g, '').toUpperCase();
if (!/^[A-Z]{2}\d{2}[A-Z0-9]{4,30}$/.test(cleaned)) return false;
const rearranged = cleaned.slice(4) + cleaned.slice(0, 4);
let numStr = '';
for (const char of rearranged) {
const code = char.charCodeAt(0);
numStr += code >= 65 && code <= 90 ? (code - 55).toString() : char;
}
let remainder = 0;
for (const digit of numStr) {
remainder = (remainder * 10 + parseInt(digit, 10)) % 97;
}
return remainder === 1;
}
const ibanSchema = z
.string()
.max(34)
.optional()
.refine((v) => !v || v.trim() === '' || validateIban(v), {
message: 'Ungültige IBAN (Prüfsumme fehlerhaft)',
});
const dateNotFutureSchema = (fieldName: string) =>
z
.string()
.optional()
.refine((v) => !v || new Date(v) <= new Date(), {
message: `${fieldName} darf nicht in der Zukunft liegen`,
});
// --- Main schemas ---
export const CreateMemberSchema = z
.object({
accountId: z.string().uuid(),
memberNumber: z.string().optional(),
firstName: z.string().min(1).max(128),
lastName: z.string().min(1).max(128),
dateOfBirth: dateNotFutureSchema('Geburtsdatum'),
gender: z.enum(['male', 'female', 'diverse']).optional(),
title: z.string().max(32).optional(),
email: z.string().email().optional().or(z.literal('')),
phone: z.string().max(32).optional(),
mobile: z.string().max(32).optional(),
street: z.string().max(256).optional(),
houseNumber: z.string().max(16).optional(),
postalCode: z.string().max(10).optional(),
city: z.string().max(128).optional(),
country: z.string().max(2).default('DE'),
status: MembershipStatusEnum.default('active'),
entryDate: z
.string()
.default(() => new Date().toISOString().split('T')[0]!),
duesCategoryId: z.string().uuid().optional(),
iban: ibanSchema,
bic: z.string().max(11).optional(),
accountHolder: z.string().max(128).optional(),
gdprConsent: z.boolean().default(false),
notes: z.string().optional(),
salutation: z.string().optional(),
street2: z.string().optional(),
phone2: z.string().optional(),
fax: z.string().optional(),
birthplace: z.string().optional(),
birthCountry: z.string().default('DE'),
isHonorary: z.boolean().default(false),
isFoundingMember: z.boolean().default(false),
isYouth: z.boolean().default(false),
isRetiree: z.boolean().default(false),
isProbationary: z.boolean().default(false),
isTransferred: z.boolean().default(false),
exitDate: z.string().optional(),
exitReason: z.string().optional(),
guardianName: z.string().optional(),
guardianPhone: z.string().optional(),
guardianEmail: z.string().optional(),
duesYear: z.number().int().optional(),
duesPaid: z.boolean().default(false),
additionalFees: z.number().default(0),
exemptionType: z.string().optional(),
exemptionReason: z.string().optional(),
exemptionAmount: z.number().optional(),
gdprNewsletter: z.boolean().default(false),
gdprInternet: z.boolean().default(false),
gdprPrint: z.boolean().default(false),
gdprBirthdayInfo: z.boolean().default(false),
sepaMandateReference: z.string().optional(),
})
.superRefine((data, ctx) => {
// Cross-field: exit_date must be after entry_date
if (data.exitDate && data.entryDate && data.exitDate < data.entryDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Austrittsdatum muss nach dem Eintrittsdatum liegen',
path: ['exitDate'],
});
}
// Cross-field: entry_date must be after date_of_birth
if (
data.dateOfBirth &&
data.entryDate &&
data.entryDate < data.dateOfBirth
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Eintrittsdatum muss nach dem Geburtsdatum liegen',
path: ['entryDate'],
});
}
// Cross-field: youth members should have guardian info
if (data.isYouth && !data.guardianName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Jugendmitglieder benötigen einen Erziehungsberechtigten',
path: ['guardianName'],
});
}
});
export type CreateMemberInput = z.infer<typeof CreateMemberSchema>;
export const UpdateMemberSchema = CreateMemberSchema.partial().extend({
memberId: z.string().uuid(),
isArchived: z.boolean().optional(),
version: z.number().int().optional(),
});
export type UpdateMemberInput = z.infer<typeof UpdateMemberSchema>;
@@ -128,7 +202,13 @@ export const CreateSepaMandateSchema = z.object({
memberId: z.string().uuid(),
accountId: z.string().uuid(),
mandateReference: z.string().min(1),
iban: z.string().min(15).max(34),
iban: z
.string()
.min(15)
.max(34)
.refine((v) => validateIban(v), {
message: 'Ungültige IBAN (Prüfsumme fehlerhaft)',
}),
bic: z.string().optional(),
accountHolder: z.string().min(1),
mandateDate: z.string(),
@@ -149,7 +229,14 @@ export type UpdateDuesCategoryInput = z.infer<typeof UpdateDuesCategorySchema>;
export const UpdateMandateSchema = z.object({
mandateId: z.string().uuid(),
iban: z.string().min(15).max(34).optional(),
iban: z
.string()
.min(15)
.max(34)
.refine((v) => validateIban(v), {
message: 'Ungültige IBAN (Prüfsumme fehlerhaft)',
})
.optional(),
bic: z.string().optional(),
accountHolder: z.string().optional(),
sequence: z.enum(['FRST', 'RCUR', 'FNAL', 'OOFF']).optional(),
@@ -191,6 +278,7 @@ export const MemberSearchFiltersSchema = z.object({
search: z.string().optional(),
status: z.array(MembershipStatusEnum).optional(),
departmentIds: z.array(z.string().uuid()).optional(),
tagIds: z.array(z.string().uuid()).optional(),
duesCategoryId: z.string().uuid().optional(),
flags: z
.array(

View File

@@ -0,0 +1,76 @@
import { z } from 'zod';
export const TriggerEventEnum = z.enum([
'application.submitted',
'application.approved',
'application.rejected',
'member.created',
'member.status_changed',
'member.birthday',
'member.anniversary',
'dues.unpaid',
'mandate.revoked',
]);
export const NotificationChannelEnum = z.enum(['in_app', 'email', 'both']);
export const RecipientTypeEnum = z.enum([
'admin',
'member',
'specific_user',
'role_holder',
]);
export const CreateNotificationRuleSchema = z.object({
accountId: z.string().uuid(),
triggerEvent: TriggerEventEnum,
channel: NotificationChannelEnum.default('in_app'),
recipientType: RecipientTypeEnum,
recipientConfig: z.record(z.string(), z.unknown()).default({}),
subjectTemplate: z.string().max(256).optional(),
messageTemplate: z.string().min(1).max(2000),
isActive: z.boolean().default(true),
});
export type CreateNotificationRuleInput = z.infer<
typeof CreateNotificationRuleSchema
>;
export const UpdateNotificationRuleSchema = z.object({
ruleId: z.string().uuid(),
triggerEvent: TriggerEventEnum.optional(),
channel: NotificationChannelEnum.optional(),
recipientType: RecipientTypeEnum.optional(),
recipientConfig: z.record(z.string(), z.unknown()).optional(),
subjectTemplate: z.string().max(256).optional(),
messageTemplate: z.string().min(1).max(2000).optional(),
isActive: z.boolean().optional(),
});
export const DeleteNotificationRuleSchema = z.object({
ruleId: z.string().uuid(),
});
export const ListNotificationRulesSchema = z.object({
accountId: z.string().uuid(),
});
// Scheduled jobs
export const JobTypeEnum = z.enum([
'birthday_notification',
'anniversary_notification',
'dues_reminder',
'data_quality_check',
'gdpr_retention_check',
]);
export const ConfigureScheduledJobSchema = z.object({
accountId: z.string().uuid(),
jobType: JobTypeEnum,
isEnabled: z.boolean(),
config: z.record(z.string(), z.unknown()).default({}),
});
export const ListScheduledJobsSchema = z.object({
accountId: z.string().uuid(),
});

View File

@@ -0,0 +1,41 @@
import { z } from 'zod';
const hexColorRegex = /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
export const CreateTagSchema = z.object({
accountId: z.string().uuid(),
name: z.string().min(1).max(64),
color: z
.string()
.regex(hexColorRegex, 'Ungültiger Hex-Farbcode')
.default('#6B7280'),
description: z.string().max(256).optional(),
});
export type CreateTagInput = z.infer<typeof CreateTagSchema>;
export const UpdateTagSchema = z.object({
tagId: z.string().uuid(),
name: z.string().min(1).max(64).optional(),
color: z.string().regex(hexColorRegex, 'Ungültiger Hex-Farbcode').optional(),
description: z.string().max(256).optional(),
sortOrder: z.number().int().min(0).optional(),
});
export type UpdateTagInput = z.infer<typeof UpdateTagSchema>;
export const DeleteTagSchema = z.object({
tagId: z.string().uuid(),
});
export const AssignTagSchema = z.object({
memberId: z.string().uuid(),
tagId: z.string().uuid(),
});
export const BulkAssignTagSchema = z.object({
memberIds: z.array(z.string().uuid()).min(1),
tagId: z.string().uuid(),
});
export const ListTagsSchema = z.object({
accountId: z.string().uuid(),
});

View File

@@ -0,0 +1,66 @@
'use server';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
CommunicationListFiltersSchema,
CreateCommunicationSchema,
DeleteCommunicationSchema,
} from '../../schema/communication.schema';
import { createMemberServices } from '../services';
export const listCommunications = authActionClient
.inputSchema(CommunicationListFiltersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { communication } = createMemberServices(client);
const result = await communication.list(input);
return { success: true, data: result };
});
export const createCommunication = authActionClient
.inputSchema(CreateCommunicationSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const { communication } = createMemberServices(client);
const userId = ctx.user.id;
logger.info(
{
name: 'communication.create',
memberId: input.memberId,
type: input.type,
},
'Logging communication...',
);
const result = await communication.create(input, userId);
logger.info(
{ name: 'communication.create', communicationId: result.id },
'Communication logged',
);
return { success: true, data: result };
});
export const deleteCommunication = authActionClient
.inputSchema(DeleteCommunicationSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const { communication } = createMemberServices(client);
logger.info(
{ name: 'communication.delete', communicationId: input.communicationId },
'Deleting communication...',
);
await communication.delete(input.communicationId, input.accountId);
return { success: true };
});

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 { DuplicateMemberError, isMemberDomainError } from '../../lib/errors';
import {
CreateMemberSchema,
UpdateMemberSchema,
@@ -24,40 +25,43 @@ import {
BulkArchiveSchema,
QuickSearchSchema,
} from '../../schema/member.schema';
import { createMemberManagementApi } from '../api';
import { createMemberServices } from '../services';
// --- Member CRUD (via MemberMutationService) ---
export const createMember = authActionClient
.inputSchema(CreateMemberSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { mutation } = createMemberServices(client);
const userId = ctx.user.id;
// Check for duplicates before creating
const duplicates = await api.checkDuplicate(
input.accountId,
input.firstName,
input.lastName,
input.dateOfBirth,
);
if (duplicates.length > 0) {
return {
success: false,
error: 'Mögliche Duplikate gefunden',
validationErrors: duplicates.map((d: Record<string, unknown>) => ({
field: 'name',
message: `${d.first_name} ${d.last_name}${d.member_number ? ` (Nr. ${d.member_number})` : ''}`,
id: String(d.id),
})),
};
try {
logger.info({ name: 'member.create' }, 'Creating member...');
const result = await mutation.create(input, userId);
logger.info({ name: 'member.create' }, 'Member created');
return { success: true, data: result };
} catch (e) {
if (e instanceof DuplicateMemberError) {
return {
success: false,
error: e.message,
validationErrors: (
e.details?.duplicates as Array<{
id: string;
name: string;
memberNumber?: string;
}>
)?.map((d) => ({
field: 'name',
message: `${d.name}${d.memberNumber ? ` (Nr. ${d.memberNumber})` : ''}`,
id: d.id,
})),
};
}
throw e;
}
logger.info({ name: 'member.create' }, 'Creating member...');
const result = await api.createMember(input, userId);
logger.info({ name: 'member.create' }, 'Member created');
return { success: true, data: result };
});
export const updateMember = authActionClient
@@ -65,11 +69,11 @@ export const updateMember = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { mutation } = createMemberServices(client);
const userId = ctx.user.id;
logger.info({ name: 'member.update' }, 'Updating member...');
const result = await api.updateMember(input, userId);
const result = await mutation.update(input, userId);
logger.info({ name: 'member.update' }, 'Member updated');
return { success: true, data: result };
});
@@ -81,17 +85,19 @@ export const deleteMember = authActionClient
accountId: z.string().uuid(),
}),
)
.action(async ({ parsedInput: input, ctx }) => {
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { mutation } = createMemberServices(client);
logger.info({ name: 'member.delete' }, 'Deleting member...');
const result = await api.deleteMember(input.memberId);
await mutation.softDelete(input.memberId);
logger.info({ name: 'member.delete' }, 'Member deleted');
return { success: true, data: result };
return { success: true };
});
// --- Application Workflow (via MemberWorkflowService) ---
export const approveApplication = authActionClient
.inputSchema(
z.object({
@@ -102,14 +108,17 @@ export const approveApplication = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { workflow } = createMemberServices(client);
const userId = ctx.user.id;
logger.info(
{ name: 'member.approveApplication' },
'Approving application...',
);
const result = await api.approveApplication(input.applicationId, userId);
const result = await workflow.approveApplication(
input.applicationId,
userId,
);
logger.info({ name: 'member.approveApplication' }, 'Application approved');
return { success: true, data: result };
});
@@ -119,12 +128,13 @@ export const rejectApplication = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { workflow } = createMemberServices(client);
logger.info(
{ name: 'members.reject-application' },
'Rejecting application...',
);
await api.rejectApplication(
await workflow.rejectApplication(
input.applicationId,
ctx.user.id,
input.reviewNotes,
@@ -132,12 +142,14 @@ export const rejectApplication = authActionClient
return { success: true };
});
// --- Organization (via MemberOrganizationService) ---
export const createDuesCategory = authActionClient
.inputSchema(CreateDuesCategorySchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createDuesCategory(input);
const { organization } = createMemberServices(client);
const data = await organization.createDuesCategory(input);
return { success: true, data };
});
@@ -145,8 +157,8 @@ export const deleteDuesCategory = authActionClient
.inputSchema(z.object({ categoryId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteDuesCategory(input.categoryId);
const { organization } = createMemberServices(client);
await organization.deleteDuesCategory(input.categoryId);
return { success: true };
});
@@ -154,8 +166,8 @@ export const createDepartment = authActionClient
.inputSchema(CreateDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createDepartment(input);
const { organization } = createMemberServices(client);
const data = await organization.createDepartment(input);
return { success: true, data };
});
@@ -163,8 +175,8 @@ export const createMemberRole = authActionClient
.inputSchema(CreateMemberRoleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMemberRole(input);
const { organization } = createMemberServices(client);
const data = await organization.createMemberRole(input);
return { success: true, data };
});
@@ -172,8 +184,8 @@ export const deleteMemberRole = authActionClient
.inputSchema(z.object({ roleId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteMemberRole(input.roleId);
const { organization } = createMemberServices(client);
await organization.deleteMemberRole(input.roleId);
return { success: true };
});
@@ -181,8 +193,8 @@ export const createMemberHonor = authActionClient
.inputSchema(CreateMemberHonorSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMemberHonor(input);
const { organization } = createMemberServices(client);
const data = await organization.createMemberHonor(input);
return { success: true, data };
});
@@ -190,8 +202,8 @@ export const deleteMemberHonor = authActionClient
.inputSchema(z.object({ honorId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.deleteMemberHonor(input.honorId);
const { organization } = createMemberServices(client);
await organization.deleteMemberHonor(input.honorId);
return { success: true };
});
@@ -199,8 +211,8 @@ export const createMandate = authActionClient
.inputSchema(CreateSepaMandateSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.createMandate(input);
const { organization } = createMemberServices(client);
const data = await organization.createMandate(input);
return { success: true, data };
});
@@ -208,18 +220,17 @@ export const revokeMandate = authActionClient
.inputSchema(z.object({ mandateId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.revokeMandate(input.mandateId);
const { organization } = createMemberServices(client);
await organization.revokeMandate(input.mandateId);
return { success: true };
});
// Gap 1: Update operations
export const updateDuesCategory = authActionClient
.inputSchema(UpdateDuesCategorySchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.updateDuesCategory(input);
const { organization } = createMemberServices(client);
const data = await organization.updateDuesCategory(input);
return { success: true, data };
});
@@ -227,18 +238,19 @@ export const updateMandate = authActionClient
.inputSchema(UpdateMandateSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const data = await api.updateMandate(input);
const { organization } = createMemberServices(client);
const data = await organization.updateMandate(input);
return { success: true, data };
});
// Gap 2: Export
// --- Export (stays on api.ts — export logic not in services) ---
export const exportMembers = authActionClient
.inputSchema(ExportMembersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const csv = await api.exportMembersCsv(input.accountId, {
const { export: exportService } = createMemberServices(client);
const csv = await exportService.exportCsv(input.accountId, {
status: input.status,
});
return {
@@ -251,32 +263,12 @@ export const exportMembers = authActionClient
};
});
// Gap 5: Department assignments
export const assignDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.assignDepartment(input.memberId, input.departmentId);
return { success: true };
});
export const removeDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.removeDepartment(input.memberId, input.departmentId);
return { success: true };
});
// Gap 2: Excel export
export const exportMembersExcel = authActionClient
.inputSchema(ExportMembersSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const buffer = await api.exportMembersExcel(input.accountId, {
const { export: exportService } = createMemberServices(client);
const buffer = await exportService.exportExcel(input.accountId, {
status: input.status,
});
return {
@@ -290,7 +282,28 @@ export const exportMembersExcel = authActionClient
};
});
// Gap 6: Member card PDF generation
// --- Department assignments (via MemberOrganizationService) ---
export const assignDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { organization } = createMemberServices(client);
await organization.assignDepartment(input.memberId, input.departmentId);
return { success: true };
});
export const removeDepartment = authActionClient
.inputSchema(AssignDepartmentSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { organization } = createMemberServices(client);
await organization.removeDepartment(input.memberId, input.departmentId);
return { success: true };
});
// --- Member cards (uses separate card generator service) ---
export const generateMemberCards = authActionClient
.inputSchema(
z.object({
@@ -301,7 +314,6 @@ export const generateMemberCards = authActionClient
)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const _api = createMemberManagementApi(client);
let query = client
.from('members')
@@ -337,7 +349,8 @@ export const generateMemberCards = authActionClient
};
});
// Portal Invitations
// --- Portal (via MemberWorkflowService) ---
export const inviteMemberToPortal = authActionClient
.inputSchema(
z.object({
@@ -349,18 +362,18 @@ export const inviteMemberToPortal = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { workflow } = createMemberServices(client);
logger.info(
{ name: 'portal.invite', memberId: input.memberId },
'Sending portal invitation...',
);
const invitation = await api.inviteMemberToPortal(input, ctx.user.id);
const invitation = await workflow.inviteMemberToPortal({
...input,
userId: ctx.user.id,
});
// Create auth user for the member if not exists
// In production: send invitation email with the token link
// For now: create the user directly via admin API
logger.info(
{ name: 'portal.invite', invitationId: invitation.id },
'Invitation created',
@@ -373,8 +386,8 @@ export const revokePortalInvitation = authActionClient
.inputSchema(z.object({ invitationId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
await api.revokePortalInvitation(input.invitationId);
const { workflow } = createMemberServices(client);
await workflow.revokePortalInvitation(input.invitationId);
return { success: true };
});
@@ -385,13 +398,13 @@ export const bulkUpdateStatus = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { mutation } = createMemberServices(client);
logger.info(
{ name: 'member.bulkStatus', count: input.memberIds.length },
`Bulk updating status to ${input.status}...`,
);
await api.bulkUpdateStatus(input.memberIds, input.status, ctx.user.id);
await mutation.bulkUpdateStatus(input.memberIds, input.status, ctx.user.id);
return { success: true };
});
@@ -400,13 +413,16 @@ export const bulkAssignDepartment = authActionClient
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { organization } = createMemberServices(client);
logger.info(
{ name: 'member.bulkDepartment', count: input.memberIds.length },
'Bulk assigning department...',
);
await api.bulkAssignDepartment(input.memberIds, input.departmentId);
await organization.bulkAssignDepartment(
input.memberIds,
input.departmentId,
);
return { success: true };
});
@@ -415,22 +431,24 @@ export const bulkArchiveMembers = authActionClient
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
const api = createMemberManagementApi(client);
const { mutation } = createMemberServices(client);
logger.info(
{ name: 'member.bulkArchive', count: input.memberIds.length },
'Bulk archiving members...',
);
await api.bulkArchiveMembers(input.memberIds, ctx.user.id);
await mutation.archive(input.memberIds, ctx.user.id);
return { success: true };
});
// --- Query (via MemberQueryService) ---
export const quickSearchMembers = authActionClient
.inputSchema(QuickSearchSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const results = await api.quickSearchMembers(
const { query } = createMemberServices(client);
const results = await query.quickSearch(
input.accountId,
input.query,
input.limit,
@@ -442,7 +460,7 @@ export const getNextMemberNumber = authActionClient
.inputSchema(z.object({ accountId: z.string().uuid() }))
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const api = createMemberManagementApi(client);
const next = await api.getNextMemberNumber(input.accountId);
const { query } = createMemberServices(client);
const next = await query.getNextMemberNumber(input.accountId);
return { success: true, data: next };
});

View File

@@ -0,0 +1,155 @@
'use server';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
ConfigureScheduledJobSchema,
CreateNotificationRuleSchema,
DeleteNotificationRuleSchema,
ListNotificationRulesSchema,
ListScheduledJobsSchema,
UpdateNotificationRuleSchema,
} from '../../schema/notification-rule.schema';
// --- Notification Rules CRUD ---
export const listNotificationRules = authActionClient
.inputSchema(ListNotificationRulesSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { data, error } = await (client.from as any)(
'member_notification_rules',
)
.select('*')
.eq('account_id', input.accountId)
.order('trigger_event');
if (error) throw error;
return { success: true, data: data ?? [] };
});
export const createNotificationRule = authActionClient
.inputSchema(CreateNotificationRuleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
logger.info(
{ name: 'notification-rule.create', event: input.triggerEvent },
'Creating notification rule...',
);
const { data, error } = await (client.from as any)(
'member_notification_rules',
)
.insert({
account_id: input.accountId,
trigger_event: input.triggerEvent,
channel: input.channel,
recipient_type: input.recipientType,
recipient_config: input.recipientConfig,
subject_template: input.subjectTemplate ?? null,
message_template: input.messageTemplate,
is_active: input.isActive,
})
.select()
.single();
if (error) throw error;
return { success: true, data };
});
export const updateNotificationRule = authActionClient
.inputSchema(UpdateNotificationRuleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const updateData: Record<string, unknown> = {};
if (input.triggerEvent !== undefined)
updateData.trigger_event = input.triggerEvent;
if (input.channel !== undefined) updateData.channel = input.channel;
if (input.recipientType !== undefined)
updateData.recipient_type = input.recipientType;
if (input.recipientConfig !== undefined)
updateData.recipient_config = input.recipientConfig;
if (input.subjectTemplate !== undefined)
updateData.subject_template = input.subjectTemplate;
if (input.messageTemplate !== undefined)
updateData.message_template = input.messageTemplate;
if (input.isActive !== undefined) updateData.is_active = input.isActive;
const { data, error } = await (client.from as any)(
'member_notification_rules',
)
.update(updateData)
.eq('id', input.ruleId)
.select()
.single();
if (error) throw error;
return { success: true, data };
});
export const deleteNotificationRule = authActionClient
.inputSchema(DeleteNotificationRuleSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { error } = await (client.from as any)('member_notification_rules')
.delete()
.eq('id', input.ruleId);
if (error) throw error;
return { success: true };
});
// --- Scheduled Jobs ---
export const listScheduledJobs = authActionClient
.inputSchema(ListScheduledJobsSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { data, error } = await (client.from as any)('scheduled_job_configs')
.select('*')
.eq('account_id', input.accountId)
.order('job_type');
if (error) throw error;
return { success: true, data: data ?? [] };
});
export const configureScheduledJob = authActionClient
.inputSchema(ConfigureScheduledJobSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
logger.info(
{
name: 'scheduled-job.configure',
jobType: input.jobType,
enabled: input.isEnabled,
},
'Configuring scheduled job...',
);
const { data, error } = await (client.from as any)('scheduled_job_configs')
.upsert(
{
account_id: input.accountId,
job_type: input.jobType,
is_enabled: input.isEnabled,
config: input.config,
},
{ onConflict: 'account_id,job_type' },
)
.select()
.single();
if (error) throw error;
return { success: true, data };
});

View File

@@ -0,0 +1,147 @@
'use server';
import { authActionClient } from '@kit/next/safe-action';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import {
AssignTagSchema,
BulkAssignTagSchema,
CreateTagSchema,
DeleteTagSchema,
ListTagsSchema,
UpdateTagSchema,
} from '../../schema/tag.schema';
export const listTags = authActionClient
.inputSchema(ListTagsSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { data, error } = await (client.from as any)('member_tags')
.select('*')
.eq('account_id', input.accountId)
.order('sort_order');
if (error) throw error;
return { success: true, data: data ?? [] };
});
export const createTag = authActionClient
.inputSchema(CreateTagSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
logger.info({ name: 'tag.create', tag: input.name }, 'Creating tag...');
const { data, error } = await (client.from as any)('member_tags')
.insert({
account_id: input.accountId,
name: input.name,
color: input.color,
description: input.description ?? null,
})
.select()
.single();
if (error) throw error;
return { success: true, data };
});
export const updateTag = authActionClient
.inputSchema(UpdateTagSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
logger.info({ name: 'tag.update', tagId: input.tagId }, 'Updating tag...');
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.color !== undefined) updateData.color = input.color;
if (input.description !== undefined)
updateData.description = input.description;
if (input.sortOrder !== undefined) updateData.sort_order = input.sortOrder;
const { data, error } = await (client.from as any)('member_tags')
.update(updateData)
.eq('id', input.tagId)
.select()
.single();
if (error) throw error;
return { success: true, data };
});
export const deleteTag = authActionClient
.inputSchema(DeleteTagSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const logger = await getLogger();
logger.info({ name: 'tag.delete', tagId: input.tagId }, 'Deleting tag...');
const { error } = await (client.from as any)('member_tags')
.delete()
.eq('id', input.tagId);
if (error) throw error;
logger.info({ name: 'tag.delete', tagId: input.tagId }, 'Tag deleted');
return { success: true };
});
export const assignTag = authActionClient
.inputSchema(AssignTagSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const { error } = await (client.from as any)(
'member_tag_assignments',
).upsert(
{
member_id: input.memberId,
tag_id: input.tagId,
assigned_by: ctx.user.id,
},
{ onConflict: 'member_id,tag_id' },
);
if (error) throw error;
return { success: true };
});
export const removeTag = authActionClient
.inputSchema(AssignTagSchema)
.action(async ({ parsedInput: input }) => {
const client = getSupabaseServerClient();
const { error } = await (client.from as any)('member_tag_assignments')
.delete()
.eq('member_id', input.memberId)
.eq('tag_id', input.tagId);
if (error) throw error;
return { success: true };
});
export const bulkAssignTag = authActionClient
.inputSchema(BulkAssignTagSchema)
.action(async ({ parsedInput: input, ctx }) => {
const client = getSupabaseServerClient();
const rows = input.memberIds.map((memberId) => ({
member_id: memberId,
tag_id: input.tagId,
assigned_by: ctx.user.id,
}));
const { error } = await (client.from as any)(
'member_tag_assignments',
).upsert(rows, { onConflict: 'member_id,tag_id' });
if (error) throw error;
return { success: true };
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import { createMemberCommunicationService } from './member-communication.service';
import { createMemberExportService } from './member-export.service';
import { createMemberMutationService } from './member-mutation.service';
import { createMemberNotificationService } from './member-notification.service';
import { createMemberOrganizationService } from './member-organization.service';
import { createMemberQueryService } from './member-query.service';
import { createMemberWorkflowService } from './member-workflow.service';
export {
createMemberCommunicationService,
createMemberExportService,
createMemberMutationService,
createMemberNotificationService,
createMemberOrganizationService,
createMemberQueryService,
createMemberWorkflowService,
};
/**
* Convenience factory that creates all member services at once.
* Use when a server action or route handler needs multiple services.
*/
export function createMemberServices(client: SupabaseClient<Database>) {
return {
query: createMemberQueryService(client),
mutation: createMemberMutationService(client),
workflow: createMemberWorkflowService(client),
organization: createMemberOrganizationService(client),
export: createMemberExportService(client),
communication: createMemberCommunicationService(client),
};
}

View File

@@ -0,0 +1,78 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
import type {
CommunicationListFilters,
CreateCommunicationInput,
} from '../../schema/communication.schema';
export function createMemberCommunicationService(
client: SupabaseClient<Database>,
) {
return new MemberCommunicationService(client);
}
class MemberCommunicationService {
constructor(private readonly client: SupabaseClient<Database>) {}
async list(filters: CommunicationListFilters) {
let query = (this.client.from as any)('member_communications')
.select('*', { count: 'exact' })
.eq('member_id', filters.memberId)
.eq('account_id', filters.accountId)
.order('created_at', { ascending: false });
if (filters.type) query = query.eq('type', filters.type);
if (filters.direction) query = query.eq('direction', filters.direction);
if (filters.search) {
const escaped = filters.search.replace(/[%_\\]/g, '\\$&');
query = query.or(`subject.ilike.%${escaped}%,body.ilike.%${escaped}%`);
}
const page = filters.page ?? 1;
const pageSize = filters.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 create(input: CreateCommunicationInput, userId: string) {
const { data, error } = await (this.client.from as any)(
'member_communications',
)
.insert({
member_id: input.memberId,
account_id: input.accountId,
type: input.type,
direction: input.direction,
subject: input.subject ?? null,
body: input.body ?? null,
email_to: input.emailTo ?? null,
email_cc: input.emailCc ?? null,
email_message_id: input.emailMessageId ?? null,
attachment_paths: input.attachmentPaths ?? null,
created_by: userId,
})
.select(
'id, member_id, account_id, type, direction, subject, email_to, created_at, created_by',
)
.single();
if (error) throw error;
return data;
}
async delete(communicationId: string, accountId: string) {
const { error } = await (this.client.rpc as any)(
'delete_member_communication',
{
p_communication_id: communicationId,
p_account_id: accountId,
},
);
if (error) throw error;
}
}

View File

@@ -0,0 +1,127 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { Database } from '@kit/supabase/database';
export function createMemberExportService(client: SupabaseClient<Database>) {
return new MemberExportService(client);
}
class MemberExportService {
constructor(private readonly client: SupabaseClient<Database>) {}
async exportCsv(
accountId: string,
filters?: { status?: string },
): Promise<string> {
const members = await this.fetchMembers(accountId, filters);
if (members.length === 0) return '';
const headers = [
'Mitgliedsnr.',
'Anrede',
'Vorname',
'Nachname',
'Geburtsdatum',
'E-Mail',
'Telefon',
'Mobil',
'Straße',
'Hausnummer',
'PLZ',
'Ort',
'Status',
'Eintrittsdatum',
'IBAN',
'BIC',
'Kontoinhaber',
];
const rows = members.map((m) =>
[
m.member_number ?? '',
m.salutation ?? '',
m.first_name,
m.last_name,
m.date_of_birth ?? '',
m.email ?? '',
m.phone ?? '',
m.mobile ?? '',
m.street ?? '',
m.house_number ?? '',
m.postal_code ?? '',
m.city ?? '',
m.status,
m.entry_date ?? '',
m.iban ?? '',
m.bic ?? '',
m.account_holder ?? '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(';'),
);
return [headers.join(';'), ...rows].join('\n');
}
async exportExcel(
accountId: string,
filters?: { status?: string },
): Promise<Buffer> {
const members = await this.fetchMembers(accountId, filters);
const ExcelJS = (await import('exceljs')).default;
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('Mitglieder');
sheet.columns = [
{ header: 'Mitgliedsnr.', key: 'member_number', width: 15 },
{ header: 'Anrede', key: 'salutation', width: 10 },
{ header: 'Vorname', key: 'first_name', width: 20 },
{ header: 'Nachname', key: 'last_name', width: 20 },
{ header: 'Geburtsdatum', key: 'date_of_birth', width: 15 },
{ header: 'E-Mail', key: 'email', width: 30 },
{ header: 'Telefon', key: 'phone', width: 18 },
{ header: 'Mobil', key: 'mobile', width: 18 },
{ header: 'Straße', key: 'street', width: 25 },
{ header: 'Hausnummer', key: 'house_number', width: 12 },
{ header: 'PLZ', key: 'postal_code', width: 10 },
{ header: 'Ort', key: 'city', width: 20 },
{ header: 'Status', key: 'status', width: 12 },
{ header: 'Eintrittsdatum', key: 'entry_date', width: 15 },
{ header: 'IBAN', key: 'iban', width: 30 },
{ header: 'BIC', key: 'bic', width: 15 },
{ header: 'Kontoinhaber', key: 'account_holder', width: 25 },
];
sheet.getRow(1).font = { bold: true };
sheet.getRow(1).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: 'FFE8F5E9' },
};
for (const m of members) {
sheet.addRow(m);
}
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
private async fetchMembers(accountId: string, filters?: { status?: string }) {
let query = this.client
.from('members')
.select('*')
.eq('account_id', accountId)
.order('last_name');
if (filters?.status) {
query = query.eq('status', filters.status as any);
}
const { data, error } = await query;
if (error) throw error;
return data ?? [];
}
}

View File

@@ -0,0 +1,350 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { todayISO } from '@kit/shared/dates';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import {
ConcurrencyConflictError,
DuplicateMemberError,
} from '../../lib/errors';
import {
getTransitionSideEffects,
validateTransition,
} from '../../lib/status-machine';
import type {
CreateMemberInput,
MembershipStatus,
UpdateMemberInput,
} from '../../schema/member.schema';
export function createMemberMutationService(client: SupabaseClient<Database>) {
return new MemberMutationService(client);
}
class MemberMutationService {
private readonly namespace = 'member-mutation';
constructor(private readonly client: SupabaseClient<Database>) {}
async create(input: CreateMemberInput, userId: string) {
const logger = await getLogger();
// Check for duplicates
const { data: dupes } = await (this.client.rpc as any)(
'check_duplicate_member',
{
p_account_id: input.accountId,
p_first_name: input.firstName,
p_last_name: input.lastName,
p_date_of_birth: input.dateOfBirth ?? null,
},
);
if (dupes && dupes.length > 0) {
throw new DuplicateMemberError(
dupes.map((d: any) => ({
id: d.id,
name: `${d.first_name} ${d.last_name}`,
memberNumber: d.member_number,
})),
);
}
logger.info({ namespace: this.namespace }, 'Creating member...');
const { data, error } = await this.client
.from('members')
.insert({
account_id: input.accountId,
member_number: input.memberNumber,
first_name: input.firstName,
last_name: input.lastName,
date_of_birth: input.dateOfBirth,
gender: input.gender,
title: input.title,
email: input.email,
phone: input.phone,
mobile: input.mobile,
street: input.street,
house_number: input.houseNumber,
postal_code: input.postalCode,
city: input.city,
country: input.country,
status: input.status,
entry_date: input.entryDate,
dues_category_id: input.duesCategoryId,
iban: input.iban,
bic: input.bic,
account_holder: input.accountHolder,
sepa_mandate_reference: input.sepaMandateReference,
gdpr_consent: input.gdprConsent,
gdpr_consent_date: input.gdprConsent ? new Date().toISOString() : null,
notes: input.notes,
salutation: input.salutation,
street2: input.street2,
phone2: input.phone2,
fax: input.fax,
birthplace: input.birthplace,
birth_country: input.birthCountry,
is_honorary: input.isHonorary,
is_founding_member: input.isFoundingMember,
is_youth: input.isYouth,
is_retiree: input.isRetiree,
is_probationary: input.isProbationary,
is_transferred: input.isTransferred,
guardian_name: input.guardianName,
guardian_phone: input.guardianPhone,
guardian_email: input.guardianEmail,
dues_year: input.duesYear,
dues_paid: input.duesPaid,
additional_fees: input.additionalFees,
exemption_type: input.exemptionType,
exemption_reason: input.exemptionReason,
exemption_amount: input.exemptionAmount,
gdpr_newsletter: input.gdprNewsletter,
gdpr_internet: input.gdprInternet,
gdpr_print: input.gdprPrint,
gdpr_birthday_info: input.gdprBirthdayInfo,
created_by: userId,
updated_by: userId,
})
.select()
.single();
if (error) throw error;
// Create SEPA mandate if bank data provided
if (input.iban && input.iban.trim()) {
await this.createMandateForMember(data.id, input, data.member_number);
}
logger.info(
{ namespace: this.namespace, memberId: data.id },
'Member created',
);
return data;
}
async update(input: UpdateMemberInput, userId: string) {
const updateData: Record<string, unknown> = { updated_by: userId };
// Map all camelCase fields to snake_case
const fieldMap: Record<string, string> = {
firstName: 'first_name',
lastName: 'last_name',
email: 'email',
phone: 'phone',
mobile: 'mobile',
street: 'street',
houseNumber: 'house_number',
postalCode: 'postal_code',
city: 'city',
status: 'status',
duesCategoryId: 'dues_category_id',
iban: 'iban',
bic: 'bic',
accountHolder: 'account_holder',
notes: 'notes',
isArchived: 'is_archived',
salutation: 'salutation',
street2: 'street2',
phone2: 'phone2',
fax: 'fax',
birthplace: 'birthplace',
birthCountry: 'birth_country',
title: 'title',
dateOfBirth: 'date_of_birth',
gender: 'gender',
country: 'country',
entryDate: 'entry_date',
exitDate: 'exit_date',
exitReason: 'exit_reason',
isHonorary: 'is_honorary',
isFoundingMember: 'is_founding_member',
isYouth: 'is_youth',
isRetiree: 'is_retiree',
isProbationary: 'is_probationary',
isTransferred: 'is_transferred',
guardianName: 'guardian_name',
guardianPhone: 'guardian_phone',
guardianEmail: 'guardian_email',
duesYear: 'dues_year',
duesPaid: 'dues_paid',
additionalFees: 'additional_fees',
exemptionType: 'exemption_type',
exemptionReason: 'exemption_reason',
exemptionAmount: 'exemption_amount',
gdprConsent: 'gdpr_consent',
gdprNewsletter: 'gdpr_newsletter',
gdprInternet: 'gdpr_internet',
gdprPrint: 'gdpr_print',
gdprBirthdayInfo: 'gdpr_birthday_info',
sepaMandateReference: 'sepa_mandate_reference',
};
for (const [camel, snake] of Object.entries(fieldMap)) {
const value = (input as Record<string, unknown>)[camel];
if (value !== undefined) {
updateData[snake] = value;
}
}
// Validate status transition if status is being changed
if (input.status !== undefined) {
const { data: current } = await this.client
.from('members')
.select('status')
.eq('id', input.memberId)
.single();
if (current && current.status !== input.status) {
const sideEffects = validateTransition(
current.status as MembershipStatus,
input.status as MembershipStatus,
);
Object.assign(updateData, sideEffects);
}
}
let query = this.client
.from('members')
.update(updateData)
.eq('id', input.memberId);
// Optimistic locking
if (input.version !== undefined) {
query = query.eq('version' as any, input.version);
}
const { data, error } = await query.select().single();
if (error) {
if (error.code === 'PGRST116' && input.version !== undefined) {
throw new ConcurrencyConflictError();
}
throw error;
}
return data;
}
async softDelete(memberId: string) {
const { error } = await this.client
.from('members')
.update({ status: 'resigned', exit_date: todayISO() })
.eq('id', memberId);
if (error) throw error;
}
async archive(memberIds: string[], userId: string) {
const { error } = await this.client
.from('members')
.update({ is_archived: true, updated_by: userId })
.in('id', memberIds);
if (error) throw error;
}
async bulkUpdateStatus(
memberIds: string[],
targetStatus: MembershipStatus,
userId: string,
) {
// Fetch current statuses to validate transitions
const { data: members, error: fetchError } = await this.client
.from('members')
.select('id, status')
.in('id', memberIds);
if (fetchError) throw fetchError;
const validIds: string[] = [];
const errors: string[] = [];
for (const member of members ?? []) {
try {
validateTransition(member.status as MembershipStatus, targetStatus);
validIds.push(member.id);
} catch (e) {
errors.push(
`${member.id}: ${e instanceof Error ? e.message : 'Ungültiger Statuswechsel'}`,
);
}
}
if (validIds.length === 0 && errors.length > 0) {
throw new Error(`Kein Mitglied konnte aktualisiert werden: ${errors[0]}`);
}
// Group by source status for correct side effects
const bySourceStatus = new Map<string, string[]>();
for (const member of members ?? []) {
if (!validIds.includes(member.id)) continue;
const group = bySourceStatus.get(member.status) ?? [];
group.push(member.id);
bySourceStatus.set(member.status, group);
}
for (const [sourceStatus, ids] of bySourceStatus) {
const sideEffects = getTransitionSideEffects(
sourceStatus as MembershipStatus,
targetStatus,
);
const { error } = await this.client
.from('members')
.update({
status: targetStatus as any,
updated_by: userId,
...sideEffects,
})
.in('id', ids);
if (error) throw error;
}
}
private async createMandateForMember(
memberId: string,
input: CreateMemberInput,
memberNumber: string | null,
) {
const logger = await getLogger();
const { data: mandate, error: mandateError } = await this.client
.from('sepa_mandates')
.insert({
member_id: memberId,
account_id: input.accountId,
mandate_reference:
input.sepaMandateReference || `M-${memberNumber || memberId}`,
iban: input.iban!,
bic: input.bic ?? null,
account_holder:
input.accountHolder || `${input.firstName} ${input.lastName}`,
mandate_date: new Date().toISOString().split('T')[0]!,
status: 'active',
sequence: 'FRST',
is_primary: true,
})
.select()
.single();
if (mandateError) {
logger.error(
{ error: mandateError, memberId, namespace: this.namespace },
'Failed to create SEPA mandate during member creation',
);
return;
}
if (mandate) {
await this.client
.from('members')
.update({ primary_mandate_id: mandate.id } as any)
.eq('id', memberId);
}
}
}

View File

@@ -0,0 +1,516 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
interface NotificationRule {
id: string;
account_id: string;
trigger_event: string;
channel: 'in_app' | 'email' | 'both';
recipient_type: string;
recipient_config: Record<string, unknown>;
subject_template: string | null;
message_template: string;
}
interface PendingNotification {
id: number;
account_id: string;
trigger_event: string;
member_id: string | null;
context: Record<string, unknown>;
}
interface JobRunResult {
processed: number;
notifications: number;
errors: string[];
}
export function createMemberNotificationService(
client: SupabaseClient<Database>,
) {
return new MemberNotificationService(client);
}
class MemberNotificationService {
private readonly namespace = 'member-notification';
constructor(private readonly client: SupabaseClient<Database>) {}
/**
* Process all pending notifications in the queue.
* Called by the cron route.
*/
async processPendingNotifications(): Promise<{
processed: number;
sent: number;
}> {
const logger = await getLogger();
// Fetch unprocessed notifications (limit batch size)
const { data: pending, error } = await (this.client.from as any)(
'pending_member_notifications',
)
.select('*')
.is('processed_at', null)
.order('created_at')
.limit(100);
if (error) {
logger.error(
{ namespace: this.namespace, error },
'Failed to fetch pending notifications',
);
return { processed: 0, sent: 0 };
}
if (!pending || pending.length === 0) {
return { processed: 0, sent: 0 };
}
let sent = 0;
for (const notification of pending as PendingNotification[]) {
try {
const dispatched = await this.dispatchForEvent(
notification.account_id,
notification.trigger_event,
notification.member_id,
notification.context,
);
sent += dispatched;
} catch (e) {
logger.error(
{
namespace: this.namespace,
notificationId: notification.id,
error: e,
},
'Failed to dispatch notification',
);
}
// Mark as processed regardless of success/failure
await (this.client.from as any)('pending_member_notifications')
.update({ processed_at: new Date().toISOString() })
.eq('id', notification.id);
}
logger.info(
{ namespace: this.namespace, processed: pending.length, sent },
'Pending notifications processed',
);
return { processed: pending.length, sent };
}
/**
* Dispatch notifications for a specific event.
* Looks up matching rules and sends in-app + email as configured.
*/
async dispatchForEvent(
accountId: string,
triggerEvent: string,
memberId: string | null,
context: Record<string, unknown>,
): Promise<number> {
const logger = await getLogger();
// Find matching active rules
const { data: rules, error } = await (this.client.from as any)(
'member_notification_rules',
)
.select('*')
.eq('account_id', accountId)
.eq('trigger_event', triggerEvent)
.eq('is_active', true);
if (error || !rules || rules.length === 0) return 0;
let sent = 0;
for (const rule of rules as NotificationRule[]) {
try {
const message = this.renderTemplate(rule.message_template, context);
const subject = rule.subject_template
? this.renderTemplate(rule.subject_template, context)
: undefined;
// In-app notification
if (rule.channel === 'in_app' || rule.channel === 'both') {
await this.sendInAppNotification(accountId, message, rule);
sent++;
}
// Email notification
if (rule.channel === 'email' || rule.channel === 'both') {
const recipientEmail = await this.resolveRecipientEmail(
accountId,
memberId,
rule,
);
if (recipientEmail) {
await this.sendEmailNotification(
recipientEmail,
subject ?? triggerEvent,
message,
);
sent++;
}
}
} catch (e) {
logger.error(
{ namespace: this.namespace, ruleId: rule.id, error: e },
'Failed to dispatch notification for rule',
);
}
}
return sent;
}
/**
* Run all due scheduled jobs for a specific account.
*/
async runScheduledJobs(accountId: string): Promise<JobRunResult> {
const logger = await getLogger();
const result: JobRunResult = { processed: 0, notifications: 0, errors: [] };
// Fetch due jobs
const { data: jobs, error } = await (this.client.from as any)(
'scheduled_job_configs',
)
.select('*')
.eq('account_id', accountId)
.eq('is_enabled', true)
.or(`next_run_at.is.null,next_run_at.lte.${new Date().toISOString()}`);
if (error || !jobs || jobs.length === 0) return result;
for (const job of jobs) {
const runId = await this.startJobRun(job.id);
try {
const jobResult = await this.executeJob(
accountId,
job.job_type,
job.config ?? {},
);
result.processed++;
result.notifications += jobResult.notifications;
await this.completeJobRun(runId, 'completed', jobResult);
await this.updateJobSchedule(job.id);
} catch (e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
result.errors.push(`${job.job_type}: ${errorMsg}`);
logger.error(
{ namespace: this.namespace, jobType: job.job_type, error: e },
'Scheduled job failed',
);
await this.completeJobRun(runId, 'failed', { error: errorMsg });
await this.updateJobSchedule(job.id);
}
}
return result;
}
// --- Private helpers ---
private async executeJob(
accountId: string,
jobType: string,
config: Record<string, unknown>,
): Promise<{ notifications: number }> {
switch (jobType) {
case 'birthday_notification':
return this.runBirthdayJob(accountId, config);
case 'anniversary_notification':
return this.runAnniversaryJob(accountId, config);
case 'dues_reminder':
return this.runDuesReminderJob(accountId);
case 'data_quality_check':
return this.runDataQualityJob(accountId);
case 'gdpr_retention_check':
return this.runGdprRetentionJob();
default:
throw new Error(`Unknown job type: ${jobType}`);
}
}
private async runBirthdayJob(
accountId: string,
config: Record<string, unknown>,
): Promise<{ notifications: number }> {
const daysBefore = (config.days_before as number) ?? 7;
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + daysBefore);
const month = targetDate.getMonth() + 1;
const day = targetDate.getDate();
const { data: members } = await this.client
.from('members')
.select('id, first_name, last_name, date_of_birth')
.eq('account_id', accountId)
.eq('status', 'active')
.eq('is_archived', false)
.not('date_of_birth', 'is', null);
let count = 0;
for (const m of members ?? []) {
if (!m.date_of_birth) continue;
const dob = new Date(m.date_of_birth);
if (dob.getMonth() + 1 === month && dob.getDate() === day) {
const age = targetDate.getFullYear() - dob.getFullYear();
await this.dispatchForEvent(accountId, 'member.birthday', m.id, {
first_name: m.first_name,
last_name: m.last_name,
age,
birthday_date: m.date_of_birth,
});
count++;
}
}
return { notifications: count };
}
private async runAnniversaryJob(
accountId: string,
config: Record<string, unknown>,
): Promise<{ notifications: number }> {
const daysBefore = (config.days_before as number) ?? 7;
const milestoneYears = (config.milestone_years as number[]) ?? [
5, 10, 25, 50,
];
const targetDate = new Date();
targetDate.setDate(targetDate.getDate() + daysBefore);
const { data: members } = await this.client
.from('members')
.select('id, first_name, last_name, entry_date')
.eq('account_id', accountId)
.eq('status', 'active')
.eq('is_archived', false)
.not('entry_date', 'is', null);
let count = 0;
for (const m of members ?? []) {
if (!m.entry_date) continue;
const entry = new Date(m.entry_date);
const years = targetDate.getFullYear() - entry.getFullYear();
if (
milestoneYears.includes(years) &&
entry.getMonth() === targetDate.getMonth() &&
entry.getDate() === targetDate.getDate()
) {
await this.dispatchForEvent(accountId, 'member.anniversary', m.id, {
first_name: m.first_name,
last_name: m.last_name,
years,
entry_date: m.entry_date,
});
count++;
}
}
return { notifications: count };
}
private async runDuesReminderJob(
accountId: string,
): Promise<{ notifications: number }> {
const { data: members } = await this.client
.from('members')
.select('id, first_name, last_name, email')
.eq('account_id', accountId)
.eq('status', 'active')
.eq('is_archived', false)
.eq('dues_paid', false);
let count = 0;
for (const m of members ?? []) {
await this.dispatchForEvent(accountId, 'dues.unpaid', m.id, {
first_name: m.first_name,
last_name: m.last_name,
email: m.email,
});
count++;
}
return { notifications: count };
}
private async runDataQualityJob(
accountId: string,
): Promise<{ notifications: number }> {
const { count } = await this.client
.from('members')
.select('id', { count: 'exact', head: true })
.eq('account_id', accountId)
.eq('status', 'active')
.eq('is_archived', false)
.or('email.is.null,email.eq.,data_reconciliation_needed.eq.true');
if (count && count > 0) {
await this.sendInAppNotification(
accountId,
`${count} Mitglieder mit fehlenden oder ungültigen Daten gefunden. Bitte überprüfen.`,
{ recipient_type: 'admin' } as NotificationRule,
);
return { notifications: 1 };
}
return { notifications: 0 };
}
private async runGdprRetentionJob(): Promise<{ notifications: number }> {
const { data, error } = await (this.client.rpc as any)(
'enforce_gdpr_retention_policies',
);
if (error) throw error;
const anonymized = typeof data === 'number' ? data : 0;
return { notifications: anonymized };
}
private renderTemplate(
template: string,
context: Record<string, unknown>,
): string {
let result = template;
for (const [key, value] of Object.entries(context)) {
result = result.replace(
new RegExp(`\\{\\{${key}\\}\\}`, 'g'),
String(value ?? ''),
);
}
return result;
}
private async sendInAppNotification(
accountId: string,
body: string,
rule: Pick<NotificationRule, 'recipient_type'>,
): Promise<void> {
// Use the existing notifications API to create in-app notifications
const { createNotificationsApi } = await import('@kit/notifications/api');
const notificationsApi = createNotificationsApi(this.client);
await notificationsApi.createNotification({
account_id: accountId,
body,
type: 'info',
channel: 'in_app',
});
}
private async resolveRecipientEmail(
accountId: string,
memberId: string | null,
rule: NotificationRule,
): Promise<string | null> {
if (rule.recipient_type === 'member' && memberId) {
const { data } = await this.client
.from('members')
.select('email')
.eq('id', memberId)
.single();
return data?.email ?? null;
}
if (
rule.recipient_type === 'specific_user' &&
rule.recipient_config.email
) {
return String(rule.recipient_config.email);
}
// For 'admin' type: get account owner email
if (rule.recipient_type === 'admin') {
const { data } = await this.client
.from('accounts_memberships')
.select('user_id, account_role')
.eq('account_id', accountId)
.eq('account_role', 'owner')
.limit(1)
.single();
if (data?.user_id) {
const { data: user } = await this.client.auth.admin.getUserById(
data.user_id,
);
return user?.user?.email ?? null;
}
}
return null;
}
private async sendEmailNotification(
to: string,
subject: string,
body: string,
): Promise<void> {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
await mailer.sendEmail({
to,
from: process.env.EMAIL_SENDER ?? 'noreply@example.com',
subject,
html: `<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">${body}</div>`,
});
}
private async startJobRun(jobConfigId: string): Promise<string> {
const { data } = await (this.client.from as any)('scheduled_job_runs')
.insert({ job_config_id: jobConfigId, status: 'running' })
.select('id')
.single();
return data?.id;
}
private async completeJobRun(
runId: string,
status: 'completed' | 'failed',
result: Record<string, unknown>,
): Promise<void> {
await (this.client.from as any)('scheduled_job_runs')
.update({
status,
result,
completed_at: new Date().toISOString(),
})
.eq('id', runId);
}
private async updateJobSchedule(jobConfigId: string): Promise<void> {
// Next run: 1 day from now (daily jobs)
const nextRun = new Date();
nextRun.setDate(nextRun.getDate() + 1);
nextRun.setHours(8, 0, 0, 0); // 8:00 AM
await (this.client.from as any)('scheduled_job_configs')
.update({
last_run_at: new Date().toISOString(),
next_run_at: nextRun.toISOString(),
})
.eq('id', jobConfigId);
}
}

View File

@@ -0,0 +1,371 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
export function createMemberOrganizationService(
client: SupabaseClient<Database>,
) {
return new MemberOrganizationService(client);
}
class MemberOrganizationService {
private readonly namespace = 'member-organization';
constructor(private readonly client: SupabaseClient<Database>) {}
// --- Departments ---
async listDepartments(accountId: string) {
const { data, error } = await this.client
.from('member_departments')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
}
async listDepartmentsWithCounts(accountId: string) {
const { data: departments, error: deptError } = await this.client
.from('member_departments')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (deptError) throw deptError;
const deptIds = (departments ?? []).map((d) => d.id);
if (deptIds.length === 0) return [];
const { data: assignments, error: assignError } = await this.client
.from('member_department_assignments')
.select('department_id')
.in('department_id', deptIds);
if (assignError) throw assignError;
const counts = new Map<string, number>();
for (const a of assignments ?? []) {
counts.set(a.department_id, (counts.get(a.department_id) ?? 0) + 1);
}
return (departments ?? []).map((d) => ({
...d,
memberCount: counts.get(d.id) ?? 0,
}));
}
async createDepartment(input: {
accountId: string;
name: string;
description?: string;
}) {
const logger = await getLogger();
logger.info(
{ namespace: this.namespace, name: input.name },
'Creating department...',
);
const { data, error } = await this.client
.from('member_departments')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description ?? null,
})
.select()
.single();
if (error) throw error;
return data;
}
async deleteDepartment(departmentId: string) {
const { error } = await this.client
.from('member_departments')
.delete()
.eq('id', departmentId);
if (error) throw error;
}
async assignDepartment(memberId: string, departmentId: string) {
const { error } = await this.client
.from('member_department_assignments')
.upsert(
{ member_id: memberId, department_id: departmentId },
{ onConflict: 'member_id,department_id' },
);
if (error) throw error;
}
async removeDepartment(memberId: string, departmentId: string) {
const { error } = await this.client
.from('member_department_assignments')
.delete()
.eq('member_id', memberId)
.eq('department_id', departmentId);
if (error) throw error;
}
async getDepartmentAssignments(memberId: string) {
const { data, error } = await this.client
.from('member_department_assignments')
.select('department_id, member_departments(id, name)')
.eq('member_id', memberId);
if (error) throw error;
return data ?? [];
}
async bulkAssignDepartment(memberIds: string[], departmentId: string) {
const rows = memberIds.map((memberId) => ({
member_id: memberId,
department_id: departmentId,
}));
const { error } = await this.client
.from('member_department_assignments')
.upsert(rows, { onConflict: 'member_id,department_id' });
if (error) throw error;
}
// --- Roles (Board positions / Funktionen) ---
async listMemberRoles(memberId: string) {
const { data, error } = await this.client
.from('member_roles')
.select('*')
.eq('member_id', memberId)
.order('from_date', { ascending: false });
if (error) throw error;
return data ?? [];
}
async createMemberRole(input: {
memberId: string;
accountId: string;
roleName: string;
fromDate?: string;
untilDate?: string;
}) {
const { data, error } = await this.client
.from('member_roles')
.insert({
member_id: input.memberId,
account_id: input.accountId,
role_name: input.roleName,
from_date: input.fromDate ?? null,
until_date: input.untilDate ?? null,
is_active: true,
})
.select()
.single();
if (error) throw error;
return data;
}
async deleteMemberRole(roleId: string) {
const { error } = await this.client
.from('member_roles')
.delete()
.eq('id', roleId);
if (error) throw error;
}
// --- Honors (Awards / Ehrungen) ---
async listMemberHonors(memberId: string) {
const { data, error } = await this.client
.from('member_honors')
.select('*')
.eq('member_id', memberId)
.order('honor_date', { ascending: false });
if (error) throw error;
return data ?? [];
}
async createMemberHonor(input: {
memberId: string;
accountId: string;
honorName: string;
honorDate?: string;
description?: string;
}) {
const { data, error } = await this.client
.from('member_honors')
.insert({
member_id: input.memberId,
account_id: input.accountId,
honor_name: input.honorName,
honor_date: input.honorDate ?? null,
description: input.description ?? null,
})
.select()
.single();
if (error) throw error;
return data;
}
async deleteMemberHonor(honorId: string) {
const { error } = await this.client
.from('member_honors')
.delete()
.eq('id', honorId);
if (error) throw error;
}
// --- Dues Categories ---
async listDuesCategories(accountId: string) {
const { data, error } = await this.client
.from('dues_categories')
.select('*')
.eq('account_id', accountId)
.order('sort_order');
if (error) throw error;
return data ?? [];
}
async createDuesCategory(input: {
accountId: string;
name: string;
description?: string;
amount: number;
interval?: string;
isDefault?: boolean;
isYouth?: boolean;
isExit?: boolean;
}) {
const { data, error } = await this.client
.from('dues_categories')
.insert({
account_id: input.accountId,
name: input.name,
description: input.description ?? null,
amount: input.amount,
interval: (input.interval ?? 'yearly') as any,
is_default: input.isDefault ?? false,
is_youth: input.isYouth ?? false,
is_exit: input.isExit ?? false,
})
.select()
.single();
if (error) throw error;
return data;
}
async updateDuesCategory(input: {
categoryId: string;
name?: string;
description?: string;
amount?: number;
interval?: string;
isDefault?: boolean;
}) {
const updateData: Record<string, unknown> = {};
if (input.name !== undefined) updateData.name = input.name;
if (input.description !== undefined)
updateData.description = input.description;
if (input.amount !== undefined) updateData.amount = input.amount;
if (input.interval !== undefined) updateData.interval = input.interval;
if (input.isDefault !== undefined) updateData.is_default = input.isDefault;
const { data, error } = await this.client
.from('dues_categories')
.update(updateData)
.eq('id', input.categoryId)
.select()
.single();
if (error) throw error;
return data;
}
async deleteDuesCategory(categoryId: string) {
const { error } = await this.client
.from('dues_categories')
.delete()
.eq('id', categoryId);
if (error) throw error;
}
// --- SEPA Mandates ---
async listMandates(memberId: string) {
const { data, error } = await this.client
.from('sepa_mandates')
.select('*')
.eq('member_id', memberId)
.order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
}
async createMandate(input: {
memberId: string;
accountId: string;
mandateReference: string;
iban: string;
bic?: string;
accountHolder: string;
mandateDate: string;
sequence?: string;
}) {
const logger = await getLogger();
logger.info(
{ namespace: this.namespace, memberId: input.memberId },
'Creating SEPA mandate...',
);
const { data, error } = await this.client
.from('sepa_mandates')
.insert({
member_id: input.memberId,
account_id: input.accountId,
mandate_reference: input.mandateReference,
iban: input.iban,
bic: input.bic ?? null,
account_holder: input.accountHolder,
mandate_date: input.mandateDate,
sequence: (input.sequence ?? 'RCUR') as any,
is_primary: true,
status: 'active',
})
.select()
.single();
if (error) throw error;
return data;
}
async updateMandate(input: {
mandateId: string;
iban?: string;
bic?: string;
accountHolder?: string;
sequence?: string;
}) {
const updateData: Record<string, unknown> = {};
if (input.iban !== undefined) updateData.iban = input.iban;
if (input.bic !== undefined) updateData.bic = input.bic;
if (input.accountHolder !== undefined)
updateData.account_holder = input.accountHolder;
if (input.sequence !== undefined) updateData.sequence = input.sequence;
const { data, error } = await this.client
.from('sepa_mandates')
.update(updateData)
.eq('id', input.mandateId)
.select()
.single();
if (error) throw error;
return data;
}
async revokeMandate(mandateId: string) {
const { error } = await this.client
.from('sepa_mandates')
.update({ status: 'revoked' as any })
.eq('id', mandateId);
if (error) throw error;
}
}

View File

@@ -0,0 +1,245 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
import type { MemberSearchFilters } from '../../schema/member.schema';
export function createMemberQueryService(client: SupabaseClient<Database>) {
return new MemberQueryService(client);
}
class MemberQueryService {
private readonly namespace = 'member-query';
constructor(private readonly client: SupabaseClient<Database>) {}
async list(
accountId: string,
opts?: {
status?: string;
search?: string;
page?: number;
pageSize?: number;
excludeArchived?: boolean;
},
) {
let query = this.client
.from('members')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('last_name')
.order('first_name');
// Opt-in archived filtering (matches original api.ts behavior)
if (opts?.excludeArchived) {
query = query.eq('is_archived', false);
}
if (opts?.status) {
query = query.eq(
'status',
opts.status as Database['public']['Enums']['membership_status'],
);
}
if (opts?.search) {
const escaped = opts.search.replace(/[%_\\]/g, '\\$&');
query = query.or(
`last_name.ilike.%${escaped}%,first_name.ilike.%${escaped}%,email.ilike.%${escaped}%,member_number.ilike.%${escaped}%`,
);
}
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 getById(accountId: string, memberId: string) {
const { data, error } = await this.client
.from('members')
.select('*')
.eq('id', memberId)
.eq('account_id', accountId)
.single();
if (error) {
const logger = await getLogger();
logger.warn(
{ namespace: this.namespace, memberId, accountId, error },
'Member lookup failed',
);
throw error;
}
return data;
}
async search(filters: MemberSearchFilters) {
const {
accountId,
search,
status,
departmentIds,
tagIds,
duesCategoryId,
flags,
entryDateFrom,
entryDateTo,
hasEmail,
sortBy,
sortDirection,
page,
pageSize,
} = filters;
let query = this.client
.from('members')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.eq('is_archived', false);
if (status && status.length > 0) {
query = query.in('status', status);
}
if (search) {
const escaped = search.replace(/[%_\\]/g, '\\$&');
query = query.or(
`last_name.ilike.%${escaped}%,first_name.ilike.%${escaped}%,email.ilike.%${escaped}%,member_number.ilike.%${escaped}%,city.ilike.%${escaped}%`,
);
}
if (duesCategoryId) {
query = query.eq('dues_category_id', duesCategoryId);
}
if (flags && flags.length > 0) {
for (const flag of flags) {
const col = `is_${flag === 'founding' ? 'founding_member' : flag}`;
query = query.eq(col, true);
}
}
if (entryDateFrom) query = query.gte('entry_date', entryDateFrom);
if (entryDateTo) query = query.lte('entry_date', entryDateTo);
if (hasEmail === true) {
query = query.not('email', 'is', null).neq('email', '');
} else if (hasEmail === false) {
query = query.or('email.is.null,email.eq.');
}
// Department filter via subquery
if (departmentIds && departmentIds.length > 0) {
const { data: deptMemberIds } = await this.client
.from('member_department_assignments')
.select('member_id')
.in('department_id', departmentIds);
const ids = (deptMemberIds ?? []).map((d) => d.member_id);
if (ids.length === 0) {
return { data: [], total: 0, page, pageSize };
}
query = query.in('id', ids);
}
// Tag filter via subquery (same pattern as departments)
if (tagIds && tagIds.length > 0) {
const { data: tagMemberIds } = await (this.client.from as any)(
'member_tag_assignments',
)
.select('member_id')
.in('tag_id', tagIds);
const ids = (tagMemberIds ?? []).map((d: any) => d.member_id);
if (ids.length === 0) {
return { data: [], total: 0, page, pageSize };
}
query = query.in('id', ids);
}
// Sorting
const ascending = sortDirection === 'asc';
const sortColumn =
sortBy === 'first_name'
? 'first_name'
: sortBy === 'entry_date'
? 'entry_date'
: sortBy === 'member_number'
? 'member_number'
: sortBy === 'city'
? 'city'
: sortBy === 'status'
? 'status'
: 'last_name';
query = query.order(sortColumn, { ascending });
if (sortColumn !== 'first_name') {
query = query.order('first_name', { ascending: true });
}
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 quickSearch(accountId: string, searchQuery: string, limit = 8) {
const escaped = searchQuery.replace(/[%_\\]/g, '\\$&');
const { data, error } = await this.client
.from('members')
.select('id, first_name, last_name, email, member_number, status')
.eq('account_id', accountId)
.eq('is_archived', false)
.or(
`last_name.ilike.%${escaped}%,first_name.ilike.%${escaped}%,email.ilike.%${escaped}%,member_number.ilike.%${escaped}%`,
)
.order('last_name')
.limit(limit);
if (error) throw error;
return data ?? [];
}
async getStatistics(accountId: string) {
const { data, error } = await this.client
.from('members')
.select('status', { count: 'exact' })
.eq('account_id', accountId)
.eq('is_archived', false);
if (error) throw error;
const counts: Record<string, number> = {};
for (const row of data ?? []) {
counts[row.status] = (counts[row.status] ?? 0) + 1;
}
return counts;
}
async getQuickStats(accountId: string) {
const { data, error } = await (this.client.rpc as any)(
'get_member_quick_stats',
{ p_account_id: accountId },
);
if (error) throw error;
return data?.[0] ?? data;
}
async getNextMemberNumber(accountId: string) {
const { data, error } = await (this.client.rpc as any)(
'get_next_member_number',
{ p_account_id: accountId },
);
if (error) throw error;
return String(data ?? '0001');
}
}

View File

@@ -0,0 +1,152 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import type { Database } from '@kit/supabase/database';
export function createMemberWorkflowService(client: SupabaseClient<Database>) {
return new MemberWorkflowService(client);
}
class MemberWorkflowService {
private readonly namespace = 'member-workflow';
constructor(private readonly client: SupabaseClient<Database>) {}
async approveApplication(applicationId: string, userId: string) {
const logger = await getLogger();
logger.info(
{ namespace: this.namespace, applicationId },
'Approving application...',
);
// Atomic RPC: validates status, creates member, updates application
const { data: memberId, error } = await (this.client.rpc as any)(
'approve_application',
{
p_application_id: applicationId,
p_user_id: userId,
},
);
if (error) throw error;
// Fetch the created member to return full data
const { data: member, error: fetchError } = await this.client
.from('members')
.select('*')
.eq('id', memberId)
.single();
if (fetchError) throw fetchError;
logger.info(
{ namespace: this.namespace, applicationId, memberId: member.id },
'Application approved, member created',
);
return member;
}
async rejectApplication(
applicationId: string,
userId: string,
reviewNotes?: string,
) {
const logger = await getLogger();
logger.info(
{ namespace: this.namespace, applicationId },
'Rejecting application...',
);
const { error } = await (this.client.rpc as any)('reject_application', {
p_application_id: applicationId,
p_user_id: userId,
p_review_notes: reviewNotes ?? null,
});
if (error) throw error;
logger.info(
{ namespace: this.namespace, applicationId },
'Application rejected',
);
}
async listApplications(accountId: string, status?: string) {
let query = this.client
.from('membership_applications')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (status) {
query = query.eq(
'status',
status as Database['public']['Enums']['application_status'],
);
}
const { data, error } = await query;
if (error) throw error;
return data ?? [];
}
async inviteMemberToPortal(input: {
memberId: string;
accountId: string;
email: string;
userId: string;
}) {
const logger = await getLogger();
logger.info(
{ namespace: this.namespace, memberId: input.memberId },
'Sending portal invitation...',
);
const token = crypto.randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
const { data, error } = await this.client
.from('member_portal_invitations')
.insert({
account_id: input.accountId,
member_id: input.memberId,
email: input.email,
invite_token: token,
status: 'pending',
invited_by: input.userId,
expires_at: expiresAt.toISOString(),
} as any)
.select()
.single();
if (error) throw error;
logger.info(
{ namespace: this.namespace, invitationId: data?.id },
'Portal invitation created',
);
return data;
}
async listPortalInvitations(accountId: string) {
const { data, error } = await this.client
.from('member_portal_invitations')
.select('*')
.eq('account_id', accountId)
.order('created_at', { ascending: false });
if (error) throw error;
return data ?? [];
}
async revokePortalInvitation(invitationId: string) {
const { error } = await this.client
.from('member_portal_invitations')
.update({ status: 'revoked' as any })
.eq('id', invitationId);
if (error) throw error;
}
}

View File

@@ -13,6 +13,7 @@
"./actions": "./src/actions/index.ts",
"./safe-action": "./src/actions/safe-action-client.ts",
"./routes": "./src/routes/index.ts",
"./routes/rate-limit": "./src/routes/rate-limit.ts",
"./route-helpers": "./src/routes/api-helpers.ts"
},
"scripts": {

View File

@@ -0,0 +1,102 @@
import 'server-only';
/**
* Simple in-memory rate limiter for API routes.
* Tracks request counts per key within sliding time windows.
*
* For production at scale, consider Upstash Redis or Vercel Edge Config.
* This is sufficient for moderate traffic and single-instance deployments.
*/
interface RateLimitEntry {
count: number;
resetAt: number;
}
const store = new Map<string, RateLimitEntry>();
// Cleanup stale entries every 2 minutes
const CLEANUP_INTERVAL = 2 * 60 * 1000;
// Hard cap to prevent memory exhaustion under attack
const MAX_STORE_SIZE = 10_000;
let lastCleanup = Date.now();
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
for (const [key, entry] of store) {
if (entry.resetAt <= now) {
store.delete(key);
}
}
// If still over limit after expiry cleanup, evict oldest entries
if (store.size > MAX_STORE_SIZE) {
const sorted = [...store.entries()].sort(
(a, b) => a[1].resetAt - b[1].resetAt,
);
const toDelete = sorted.slice(0, store.size - MAX_STORE_SIZE);
for (const [key] of toDelete) {
store.delete(key);
}
}
}
/**
* Check rate limit for a given key.
*
* @param key - Unique identifier (e.g., IP address, account ID)
* @param maxRequests - Maximum requests allowed within the window
* @param windowMs - Time window in milliseconds
* @returns Object with `allowed` boolean and `remaining` count
*/
export function checkRateLimit(
key: string,
maxRequests: number,
windowMs: number,
): { allowed: boolean; remaining: number; resetAt: number } {
cleanup();
const now = Date.now();
const entry = store.get(key);
if (!entry || entry.resetAt <= now) {
// New window
store.set(key, { count: 1, resetAt: now + windowMs });
return {
allowed: true,
remaining: maxRequests - 1,
resetAt: now + windowMs,
};
}
if (entry.count >= maxRequests) {
return { allowed: false, remaining: 0, resetAt: entry.resetAt };
}
entry.count++;
return {
allowed: true,
remaining: maxRequests - entry.count,
resetAt: entry.resetAt,
};
}
/**
* Extract client IP from request headers.
* Works with Vercel, Cloudflare, and standard proxies.
*/
export function getClientIp(request: Request): string {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
request.headers.get('x-real-ip') ??
request.headers.get('cf-connecting-ip') ??
'unknown'
);
}