158 lines
4.8 KiB
TypeScript
158 lines
4.8 KiB
TypeScript
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();
|
|
}
|
|
},
|
|
};
|
|
}
|