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