268 lines
8.8 KiB
TypeScript
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 });
|
|
}
|