import { and, eq, isNull } from 'drizzle-orm'; import { db } from '@/lib/db'; import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations'; import { berths } from '@/lib/db/schema/berths'; import { clients } from '@/lib/db/schema/clients'; import { yachts } from '@/lib/db/schema/yachts'; import { companyMemberships } from '@/lib/db/schema/companies'; import { buildListQuery } from '@/lib/db/query-builder'; import { createAuditLog } from '@/lib/audit'; import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors'; import { emitToRoom } from '@/lib/socket/server'; import type { z } from 'zod'; import type { createPendingSchema, ActivateInput, EndReservationInput, CancelInput, ListReservationsInput, } from '@/lib/validators/reservations'; type CreatePendingInput = z.input; export type { BerthReservation }; interface AuditMeta { userId: string; portId: string; ipAddress: string; userAgent: string; } // ─── Helpers ───────────────────────────────────────────────────────────────── /** * Returns true if the error is a Postgres unique-violation (SQLSTATE 23505) * raised specifically on the `idx_br_active` partial unique index. Narrowing * to this constraint name prevents us from swallowing unrelated unique * violations. */ function isBerthActiveConflict(err: unknown): boolean { if (!err || typeof err !== 'object') return false; const e = err as { code?: unknown; constraint_name?: unknown; constraint?: unknown; cause?: { code?: unknown; constraint_name?: unknown; constraint?: unknown }; }; const code = e.code ?? e.cause?.code; if (code !== '23505') return false; const constraint = e.constraint_name ?? e.constraint ?? e.cause?.constraint_name ?? e.cause?.constraint; return constraint === 'idx_br_active'; } /** * Cross-references the reservation's client against the yacht's current owner. * Either the yacht is directly owned by the client, OR the client has an * active (endDate IS NULL) company_membership on the owning company. */ async function assertClientOwnsOrRepresentsYacht( yacht: { currentOwnerType: string; currentOwnerId: string }, clientId: string, ): Promise { if (yacht.currentOwnerType === 'client' && yacht.currentOwnerId === clientId) { return; } if (yacht.currentOwnerType === 'company') { const membership = await db.query.companyMemberships.findFirst({ where: and( eq(companyMemberships.companyId, yacht.currentOwnerId), eq(companyMemberships.clientId, clientId), isNull(companyMemberships.endDate), ), }); if (membership) return; } throw new ValidationError('yacht does not belong to reservation client'); } async function loadScoped(id: string, portId: string): Promise { const row = await db.query.berthReservations.findFirst({ where: and(eq(berthReservations.id, id), eq(berthReservations.portId, portId)), }); if (!row) throw new NotFoundError('Reservation'); return row; } // ─── Create (pending) ──────────────────────────────────────────────────────── export async function createPending( portId: string, data: CreatePendingInput, meta: AuditMeta, ): Promise { // Tenant-scoped existence checks (berth, client, yacht). const berth = await db.query.berths.findFirst({ where: and(eq(berths.id, data.berthId), eq(berths.portId, portId)), }); if (!berth) throw new ValidationError('berth not found'); const client = await db.query.clients.findFirst({ where: and(eq(clients.id, data.clientId), eq(clients.portId, portId)), }); if (!client) throw new ValidationError('client not found'); const yacht = await db.query.yachts.findFirst({ where: and(eq(yachts.id, data.yachtId), eq(yachts.portId, portId)), }); if (!yacht) throw new ValidationError('yacht not found'); // Client must own the yacht directly OR be an active member of the owning company. await assertClientOwnsOrRepresentsYacht( { currentOwnerType: yacht.currentOwnerType, currentOwnerId: yacht.currentOwnerId }, data.clientId, ); const [reservation] = await db .insert(berthReservations) .values({ portId, berthId: data.berthId, clientId: data.clientId, yachtId: data.yachtId, interestId: data.interestId ?? null, status: 'pending', startDate: data.startDate, tenureType: data.tenureType ?? 'permanent', notes: data.notes ?? null, createdBy: meta.userId, }) .returning(); void createAuditLog({ userId: meta.userId, portId, action: 'create', entityType: 'berth_reservation', entityId: reservation!.id, newValue: { berthId: reservation!.berthId, clientId: reservation!.clientId, yachtId: reservation!.yachtId, status: reservation!.status, startDate: reservation!.startDate, }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'berth_reservation:created', { reservationId: reservation!.id, berthId: reservation!.berthId, }); return reservation!; } // ─── Activate (pending → active) ───────────────────────────────────────────── export async function activate( reservationId: string, portId: string, data: ActivateInput, meta: AuditMeta, ): Promise { const existing = await loadScoped(reservationId, portId); if (existing.status !== 'pending') { throw new ValidationError(`invalid transition: ${existing.status} → active`); } const patch: Partial = { status: 'active', updatedAt: new Date(), }; if (data.contractFileId !== undefined) { patch.contractFileId = data.contractFileId; } if (data.effectiveDate !== undefined) { patch.startDate = data.effectiveDate; } let updated: BerthReservation | undefined; try { const rows = await db .update(berthReservations) .set(patch) .where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId))) .returning(); updated = rows[0]; } catch (err) { if (isBerthActiveConflict(err)) { const conflicting = await db.query.berthReservations.findFirst({ where: and( eq(berthReservations.berthId, existing.berthId), eq(berthReservations.status, 'active'), eq(berthReservations.portId, portId), ), }); throw new ConflictError( conflicting ? `berth already has active reservation (conflictingReservationId: ${conflicting.id})` : 'berth already has active reservation', ); } throw err; } void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'berth_reservation', entityId: reservationId, oldValue: { status: existing.status }, newValue: { status: 'active', contractFileId: updated!.contractFileId }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'berth_reservation:activated', { reservationId, berthId: updated!.berthId, }); return updated!; } // ─── End (active → ended) ──────────────────────────────────────────────────── export async function endReservation( reservationId: string, portId: string, data: EndReservationInput, meta: AuditMeta, ): Promise { const existing = await loadScoped(reservationId, portId); if (existing.status !== 'active') { throw new ValidationError(`invalid transition: ${existing.status} → ended`); } const rows = await db .update(berthReservations) .set({ status: 'ended', endDate: data.endDate, notes: data.notes ?? existing.notes, updatedAt: new Date(), }) .where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId))) .returning(); const updated = rows[0]!; void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'berth_reservation', entityId: reservationId, oldValue: { status: existing.status }, newValue: { status: 'ended', endDate: updated.endDate }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'berth_reservation:ended', { reservationId, berthId: updated.berthId, }); return updated; } // ─── Cancel (pending|active → cancelled) ───────────────────────────────────── export async function cancel( reservationId: string, portId: string, data: CancelInput, meta: AuditMeta, ): Promise { const existing = await loadScoped(reservationId, portId); if (existing.status !== 'pending' && existing.status !== 'active') { throw new ValidationError(`invalid transition: ${existing.status} → cancelled`); } const rows = await db .update(berthReservations) .set({ status: 'cancelled', notes: data.reason ? `${existing.notes ? `${existing.notes}\n` : ''}Cancelled: ${data.reason}` : existing.notes, updatedAt: new Date(), }) .where(and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId))) .returning(); const updated = rows[0]!; void createAuditLog({ userId: meta.userId, portId, action: 'update', entityType: 'berth_reservation', entityId: reservationId, oldValue: { status: existing.status }, newValue: { status: 'cancelled', reason: data.reason ?? null }, ipAddress: meta.ipAddress, userAgent: meta.userAgent, }); emitToRoom(`port:${portId}`, 'berth_reservation:cancelled', { reservationId, berthId: updated.berthId, }); return updated; } // ─── Get ───────────────────────────────────────────────────────────────────── export async function getById(id: string, portId: string): Promise { return loadScoped(id, portId); } // ─── List ──────────────────────────────────────────────────────────────────── export async function listReservations( portId: string, query: ListReservationsInput, ): Promise<{ data: BerthReservation[]; total: number }> { const { page, limit, sort, order, search, status, berthId, clientId, yachtId } = query; const filters = []; if (status) filters.push(eq(berthReservations.status, status)); if (berthId) filters.push(eq(berthReservations.berthId, berthId)); if (clientId) filters.push(eq(berthReservations.clientId, clientId)); if (yachtId) filters.push(eq(berthReservations.yachtId, yachtId)); let sortColumn: | typeof berthReservations.startDate | typeof berthReservations.createdAt | typeof berthReservations.updatedAt = berthReservations.updatedAt; if (sort === 'startDate') sortColumn = berthReservations.startDate; else if (sort === 'createdAt') sortColumn = berthReservations.createdAt; const result = await buildListQuery({ table: berthReservations, portIdColumn: berthReservations.portId, portId, idColumn: berthReservations.id, updatedAtColumn: berthReservations.updatedAt, searchColumns: search ? [berthReservations.notes] : [], searchTerm: search, filters, sort: sort ? { column: sortColumn, direction: order } : undefined, page, pageSize: limit, includeArchived: true, }); return result; }