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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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