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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user