import { and, eq, gte, lt, inArray, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { bookings, bookingObjects, bookableObjects, users } from "@/lib/db/schema"; import { createCalendarEvent, updateCalendarEvent, deleteCalendarEvent } from "@/lib/google/calendar"; export interface BookableObjectDTO { id: string; name: string; active: boolean; sortOrder: number; } export async function listBookableObjects(includeInactive = false): Promise { const rows = await db.query.bookableObjects.findMany({ where: includeInactive ? undefined : eq(bookableObjects.active, true), orderBy: (t, { asc }) => [asc(t.sortOrder), asc(t.name)], }); return rows; } export async function createBookableObject(name: string): Promise { const maxOrder = await db .select({ max: sql`coalesce(max(${bookableObjects.sortOrder}), 0)` }) .from(bookableObjects); const [row] = await db .insert(bookableObjects) .values({ name, sortOrder: (maxOrder[0]?.max ?? 0) + 1 }) .returning(); return row; } export async function setBookableObjectActive(id: string, active: boolean): Promise { await db.update(bookableObjects).set({ active }).where(eq(bookableObjects.id, id)); } export async function deleteBookableObject(id: string): Promise { await db.delete(bookableObjects).where(eq(bookableObjects.id, id)); } export interface BookingDTO { id: string; bookingNumber: number; contactName: string; phone: string; date: Date; checkIn: string; checkOut: string; guestCount: number; cost: number; prepayment: number; comment: string | null; source: string | null; status: "confirmed" | "cancelled"; objects: { id: string; name: string }[]; bookedByName: string; googleEventId: string | null; createdAt: Date; } async function toBookingDTO(row: typeof bookings.$inferSelect): Promise { const [objRows, bookedBy] = await Promise.all([ db .select({ id: bookableObjects.id, name: bookableObjects.name }) .from(bookingObjects) .innerJoin(bookableObjects, eq(bookingObjects.objectId, bookableObjects.id)) .where(eq(bookingObjects.bookingId, row.id)), db.query.users.findFirst({ where: eq(users.id, row.bookedByUserId), columns: { name: true } }), ]); return { id: row.id, bookingNumber: row.bookingNumber, contactName: row.contactName, phone: row.phone, date: row.date, checkIn: row.checkIn, checkOut: row.checkOut, guestCount: row.guestCount, cost: row.cost, prepayment: row.prepayment, comment: row.comment, source: row.source, status: row.status, objects: objRows, bookedByName: bookedBy?.name ?? "—", googleEventId: row.googleEventId, createdAt: row.createdAt, }; } export interface CreateBookingInput { contactName: string; phone: string; objectIds: string[]; date: Date; checkIn: string; checkOut: string; guestCount: number; cost: number; prepayment: number; comment?: string | null; source?: string | null; bookedByUserId: string; } export async function createBooking(input: CreateBookingInput): Promise { const maxRow = await db.select({ max: sql`coalesce(max(${bookings.bookingNumber}), 0)` }).from(bookings); const bookingNumber = (maxRow[0]?.max ?? 0) + 1; const [row] = await db .insert(bookings) .values({ bookingNumber, contactName: input.contactName, phone: input.phone, date: input.date, checkIn: input.checkIn, checkOut: input.checkOut, guestCount: input.guestCount, cost: input.cost, prepayment: input.prepayment, comment: input.comment ?? null, source: input.source ?? null, bookedByUserId: input.bookedByUserId, }) .returning(); if (input.objectIds.length > 0) { await db.insert(bookingObjects).values(input.objectIds.map((objectId) => ({ bookingId: row.id, objectId }))); } const dto = await toBookingDTO(row); // Best-effort Google Calendar sync — never blocks/fails booking creation. try { const googleEventId = await createCalendarEvent(dto); if (googleEventId) { await db.update(bookings).set({ googleEventId }).where(eq(bookings.id, row.id)); dto.googleEventId = googleEventId; } } catch (err) { console.error("Google Calendar sync failed for booking", row.bookingNumber, err); } return dto; } export interface UpdateBookingInput { contactName?: string; phone?: string; objectIds?: string[]; date?: Date; checkIn?: string; checkOut?: string; guestCount?: number; cost?: number; prepayment?: number; comment?: string | null; source?: string | null; status?: "confirmed" | "cancelled"; } export async function updateBooking(id: string, input: UpdateBookingInput): Promise { const { objectIds, ...rest } = input; if (Object.keys(rest).length > 0) { await db .update(bookings) .set({ ...rest, updatedAt: new Date() }) .where(eq(bookings.id, id)); } if (objectIds) { await db.delete(bookingObjects).where(eq(bookingObjects.bookingId, id)); if (objectIds.length > 0) { await db.insert(bookingObjects).values(objectIds.map((objectId) => ({ bookingId: id, objectId }))); } } const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) }); if (!row) return null; const dto = await toBookingDTO(row); // Best-effort Google Calendar sync — never blocks/fails the update. try { if (dto.status === "cancelled" && dto.googleEventId) { await deleteCalendarEvent(dto.googleEventId); await db.update(bookings).set({ googleEventId: null }).where(eq(bookings.id, id)); dto.googleEventId = null; } else if (dto.status === "confirmed" && dto.googleEventId) { await updateCalendarEvent(dto); } else if (dto.status === "confirmed" && !dto.googleEventId) { // Never synced yet (created before Google was connected, or just restored from cancelled). const googleEventId = await createCalendarEvent(dto); if (googleEventId) { await db.update(bookings).set({ googleEventId }).where(eq(bookings.id, id)); dto.googleEventId = googleEventId; } } } catch (err) { console.error("Google Calendar sync failed for booking", dto.bookingNumber, err); } return dto; } export async function getBooking(id: string): Promise { const row = await db.query.bookings.findFirst({ where: eq(bookings.id, id) }); if (!row) return null; return toBookingDTO(row); } export interface ListBookingsFilter { from?: Date; to?: Date; status?: "confirmed" | "cancelled"; search?: string; } export async function listBookings(filter: ListBookingsFilter = {}): Promise { const conditions = []; if (filter.from) conditions.push(gte(bookings.date, filter.from)); if (filter.to) conditions.push(lt(bookings.date, filter.to)); if (filter.status) conditions.push(eq(bookings.status, filter.status)); const rows = await db.query.bookings.findMany({ where: conditions.length ? and(...conditions) : undefined, orderBy: (t, { asc }) => [asc(t.date)], }); const filtered = filter.search ? rows.filter( (r) => r.contactName.toLowerCase().includes(filter.search!.toLowerCase()) || r.phone.includes(filter.search!), ) : rows; return Promise.all(filtered.map(toBookingDTO)); } /** Monthly stats grouped by bookable object — a booking with N objects contributes to all N counts. */ export async function getMonthlyObjectStats(monthKey: string): Promise<{ name: string; count: number }[]> { const [y, m] = monthKey.split("-").map(Number); const from = new Date(Date.UTC(y, m - 1, 1)); const to = new Date(Date.UTC(y, m, 1)); const rows = await db .select({ name: bookableObjects.name }) .from(bookingObjects) .innerJoin(bookings, eq(bookingObjects.bookingId, bookings.id)) .innerJoin(bookableObjects, eq(bookingObjects.objectId, bookableObjects.id)) .where(and(gte(bookings.date, from), lt(bookings.date, to), eq(bookings.status, "confirmed"))); const counts = new Map(); for (const r of rows) counts.set(r.name, (counts.get(r.name) ?? 0) + 1); return [...counts.entries()].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count); } export async function listBookableObjectsByIds(ids: string[]) { if (ids.length === 0) return []; return db.select().from(bookableObjects).where(inArray(bookableObjects.id, ids)); } export async function listUpcomingBookings(daysAhead: number, statusConfirmedOnly = true): Promise { const now = new Date(); const from = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const to = new Date(from.getTime() + daysAhead * 24 * 60 * 60 * 1000); return listBookings({ from, to, status: statusConfirmedOnly ? "confirmed" : undefined }); }