Files
top-reservation/src/lib/bookings/service.ts
T

268 lines
8.8 KiB
TypeScript

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<BookableObjectDTO[]> {
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<BookableObjectDTO> {
const maxOrder = await db
.select({ max: sql<number>`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<void> {
await db.update(bookableObjects).set({ active }).where(eq(bookableObjects.id, id));
}
export async function deleteBookableObject(id: string): Promise<void> {
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<BookingDTO> {
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<BookingDTO> {
const maxRow = await db.select({ max: sql<number>`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<BookingDTO | null> {
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<BookingDTO | null> {
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<BookingDTO[]> {
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<string, number>();
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<BookingDTO[]> {
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 });
}