diff --git a/prisma/migrations/20261017180000_add_business_holidays/migration.sql b/prisma/migrations/20261017180000_add_business_holidays/migration.sql new file mode 100644 index 0000000..efa7723 --- /dev/null +++ b/prisma/migrations/20261017180000_add_business_holidays/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE `BusinessHoliday` ( + `id` VARCHAR(191) NOT NULL, + `businessId` VARCHAR(191) NOT NULL, + `name` VARCHAR(191) NOT NULL, + `startDate` DATE NOT NULL, + `endDate` DATE NOT NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `BusinessHoliday_businessId_startDate_endDate_idx`(`businessId`, `startDate`, `endDate`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; + +-- AddForeignKey +ALTER TABLE `BusinessHoliday` ADD CONSTRAINT `BusinessHoliday_businessId_fkey` FOREIGN KEY (`businessId`) REFERENCES `Business`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 87e411a..37b70e4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -344,6 +344,7 @@ model Business { locations BusinessLocation[] hours BusinessHours[] + holidays BusinessHoliday[] settings BusinessSettings? bookingRule BookingRule? serviceCategories ServiceCategory[] @@ -436,6 +437,26 @@ model BusinessSettings { /// Scheduling policy — see docs/implementation-plan.md §6/§16. Every field /// has a sane default so the row can be created once at business setup and /// tuned later without a migration. +/// A date range the whole business is closed — bank holidays, annual leave, +/// a refit — on top of the weekly BusinessHours. Both dates are inclusive +/// calendar dates in the business's own timezone (no time component), the +/// same convention as TimeOff. Availability treats every date in range as +/// closed for every staff member; see availability.service.ts. +model BusinessHoliday { + id String @id @default(cuid()) + + businessId String + business Business @relation(fields: [businessId], references: [id], onDelete: Cascade) + + name String + startDate DateTime @db.Date + endDate DateTime @db.Date + + createdAt DateTime @default(now()) + + @@index([businessId, startDate, endDate]) +} + model BookingRule { id String @id @default(cuid()) diff --git a/src/app/(owner)/owner/page.tsx b/src/app/(owner)/owner/page.tsx index 1a59f31..99929f2 100644 --- a/src/app/(owner)/owner/page.tsx +++ b/src/app/(owner)/owner/page.tsx @@ -6,6 +6,7 @@ import { Building2, Calendar, CalendarClock, + CalendarOff, CalendarX2, CheckCheck, ClipboardList, @@ -58,6 +59,7 @@ function getManageSections(isSuper: boolean) { links: [ { href: "/owner/settings/business", label: "Business Details", icon: Building2 }, { href: "/owner/settings/hours", label: "Opening Hours", icon: CalendarClock }, + { href: "/owner/settings/holidays", label: "Holidays", icon: CalendarOff }, { href: "/owner/settings/gallery", label: "Gallery", icon: Images }, ...(isSuper ? [{ href: "/owner/settings/app", label: "App Icon & Home Screen", icon: Smartphone }] diff --git a/src/app/(owner)/owner/settings/holidays/page.tsx b/src/app/(owner)/owner/settings/holidays/page.tsx new file mode 100644 index 0000000..573e07c --- /dev/null +++ b/src/app/(owner)/owner/settings/holidays/page.tsx @@ -0,0 +1,43 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/auth"; +import { SettingsPageShell } from "@/components/settings/settings-page-shell"; +import { prisma } from "@/lib/db/prisma"; +import { hasPermission } from "@/lib/auth/permissions"; +import { Permission } from "@/generated/prisma/enums"; +import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; +import { todayIn } from "@/lib/time/business-time"; +import { listBusinessHolidays } from "@/services/business/holiday.service"; +import { HolidaysManager } from "@/features/owner/components/holidays-manager"; + +export const metadata: Metadata = { title: "Holidays" }; +export const dynamic = "force-dynamic"; + +export default async function OwnerHolidaysPage() { + const session = await auth(); + if (!session?.user) redirect("/sign-in"); + + const canManage = await hasPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE); + if (!canManage) redirect("/staff"); + + const context = await getActingBusinessContext(); + if (!context) redirect("/staff"); + + const business = await prisma.business.findUnique({ + where: { id: context.businessId }, + select: { timezone: true, locale: true }, + }); + if (!business) redirect("/staff"); + + const holidays = await listBusinessHolidays(prisma, context.businessId); + + return ( + + + + ); +} diff --git a/src/features/booking/error-messages.ts b/src/features/booking/error-messages.ts index 3d32513..6f074a8 100644 --- a/src/features/booking/error-messages.ts +++ b/src/features/booking/error-messages.ts @@ -12,6 +12,7 @@ const FRIENDLY_MESSAGES: Record = { SERVICES_SPAN_MULTIPLE_BUSINESSES: "One or more selected services aren't available here.", STAFF_CANNOT_PERFORM_SERVICE: "This barber doesn't offer one of the selected services.", BUSINESS_CLOSED: "The business is closed at this time — please choose another slot.", + BUSINESS_HOLIDAY: "The business is closed on that date — please choose another day.", STAFF_NOT_WORKING: "This barber isn't working at that time — please choose another slot or barber.", STAFF_ON_BREAK: "This barber is on a break at that time — please choose another slot.", STAFF_ON_LEAVE: "This barber is away on that date — please choose another barber or date.", diff --git a/src/features/owner/actions/holidays.ts b/src/features/owner/actions/holidays.ts new file mode 100644 index 0000000..314f025 --- /dev/null +++ b/src/features/owner/actions/holidays.ts @@ -0,0 +1,119 @@ +"use server"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db/prisma"; +import { Permission } from "@/generated/prisma/enums"; +import { assertOwnerPermission, OwnerPermissionDeniedError } from "@/features/owner/authorization"; +import { writeAuditLog } from "@/lib/audit/write-audit-log"; +import { getActingBusinessContext } from "@/lib/business/get-acting-business-context"; +import { + createBusinessHoliday, + deleteBusinessHoliday, + findHolidayConflicts, + validateHolidayInput, +} from "@/services/business/holiday.service"; +import type { HolidayInput } from "@/services/business/holiday.service"; + +export interface HolidayActionResult { + success: boolean; + error?: string; + /** Appointments already booked inside the range — shown for review, never cancelled automatically. */ + conflicts?: { + id: string; + customerName: string; + serviceNames: string; + startAt: string; + staffName: string; + }[]; +} + +/** Holidays change when the whole business can be booked, so they share Business Details' permission. */ +async function requireBusinessManager() { + const session = await auth(); + if (!session?.user) return { staff: null, error: "You must be signed in." } as const; + + try { + await assertOwnerPermission(session.user.id, Permission.MANAGE_BUSINESS_PROFILE); + } catch (error) { + if (error instanceof OwnerPermissionDeniedError) + return { staff: null, error: error.message } as const; + throw error; + } + + const context = await getActingBusinessContext(); + if (!context) return { staff: null, error: "You must be signed in as staff." } as const; + + return { + staff: { businessId: context.businessId, userId: context.userId }, + error: null, + } as const; +} + +export async function addHolidayAction( + holiday: HolidayInput, + acknowledgeConflicts?: boolean, +): Promise { + const { staff, error } = await requireBusinessManager(); + if (!staff) return { success: false, error: error ?? "Not authorized." }; + + const validation = validateHolidayInput(holiday); + if (!validation.valid) return { success: false, error: validation.issues[0]?.message }; + + if (!acknowledgeConflicts) { + const business = await prisma.business.findUnique({ + where: { id: staff.businessId }, + select: { timezone: true }, + }); + if (!business) return { success: false, error: "Business not found." }; + + const conflicts = await findHolidayConflicts(prisma, { + businessId: staff.businessId, + businessTimezone: business.timezone, + startDate: holiday.startDate, + endDate: holiday.endDate, + }); + if (conflicts.length > 0) { + return { + success: false, + error: "Some appointments are already booked on these dates.", + conflicts: conflicts.map((c) => ({ + id: c.id, + customerName: `${c.customer.firstName} ${c.customer.lastName}`, + serviceNames: c.services.map((s) => s.service.name).join(" + "), + startAt: c.startAt.toISOString(), + staffName: c.staff.user.name ?? "Staff", + })), + }; + } + } + + const result = await createBusinessHoliday(prisma, { businessId: staff.businessId, holiday }); + if (!result.success) return { success: false, error: result.validation.issues[0]?.message }; + + await writeAuditLog(prisma, { + actorUserId: staff.userId, + action: "BUSINESS_HOLIDAY_CREATED", + entityType: "BusinessHoliday", + entityId: result.id, + metadata: { name: holiday.name.trim(), startDate: holiday.startDate, endDate: holiday.endDate }, + }); + + return { success: true }; +} + +export async function deleteHolidayAction(holidayId: string): Promise { + const { staff, error } = await requireBusinessManager(); + if (!staff) return { success: false, error: error ?? "Not authorized." }; + + const deleted = await deleteBusinessHoliday(prisma, { businessId: staff.businessId, holidayId }); + if (!deleted) return { success: false, error: "That holiday no longer exists." }; + + await writeAuditLog(prisma, { + actorUserId: staff.userId, + action: "BUSINESS_HOLIDAY_DELETED", + entityType: "BusinessHoliday", + entityId: holidayId, + }); + + return { success: true }; +} diff --git a/src/features/owner/components/holidays-manager.tsx b/src/features/owner/components/holidays-manager.tsx new file mode 100644 index 0000000..7d71a46 --- /dev/null +++ b/src/features/owner/components/holidays-manager.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { Trash2 } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { addHolidayAction, deleteHolidayAction } from "../actions/holidays"; +import type { HolidayActionResult } from "../actions/holidays"; + +export interface HolidayRow { + id: string; + name: string; + /** Inclusive calendar dates, "YYYY-MM-DD" in the business's own timezone. */ + startDate: string; + endDate: string; +} + +function formatDate(date: string, locale: string): string { + return new Intl.DateTimeFormat(locale, { + weekday: "short", + day: "numeric", + month: "short", + year: "numeric", + timeZone: "UTC", + }).format(new Date(`${date}T00:00:00Z`)); +} + +function formatRange(holiday: HolidayRow, locale: string): string { + return holiday.startDate === holiday.endDate + ? formatDate(holiday.startDate, locale) + : `${formatDate(holiday.startDate, locale)} – ${formatDate(holiday.endDate, locale)}`; +} + +export function HolidaysManager({ + holidays, + today, + locale, +}: { + holidays: HolidayRow[]; + today: string; + locale: string; +}) { + const router = useRouter(); + const [name, setName] = useState(""); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [error, setError] = useState(null); + const [conflicts, setConflicts] = useState>([]); + const [isPending, startTransition] = useTransition(); + + // ISO dates compare correctly as plain strings. + const upcoming = holidays.filter((h) => h.endDate >= today); + const past = holidays.filter((h) => h.endDate < today).reverse(); + + const submit = (acknowledgeConflicts: boolean) => { + setError(null); + startTransition(async () => { + const result = await addHolidayAction( + { name, startDate, endDate: endDate || startDate }, + acknowledgeConflicts, + ); + if (!result.success) { + setError(result.error ?? "Couldn't add the holiday."); + setConflicts(result.conflicts ?? []); + return; + } + setName(""); + setStartDate(""); + setEndDate(""); + setConflicts([]); + router.refresh(); + }); + }; + + const remove = (holidayId: string) => { + setError(null); + startTransition(async () => { + const result = await deleteHolidayAction(holidayId); + if (!result.success) { + setError(result.error ?? "Couldn't remove the holiday."); + return; + } + router.refresh(); + }); + }; + + if (conflicts.length > 0) { + return ( +
+

+ {conflicts.length} {conflicts.length > 1 ? "appointments are" : "appointment is"} already + booked on these dates. They won't be cancelled automatically — review them, then + confirm if you still want to close the business. +

+
    + {conflicts.map((c) => ( +
  • + + {c.customerName} + +

    + {new Intl.DateTimeFormat(locale, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(c.startAt))}{" "} + · {c.serviceNames} · with {c.staffName} +

    +
  • + ))} +
+
+ + +
+
+ ); + } + + return ( +
+
+

+ Add a holiday +

+
+

+ The whole business is closed on these dates: nobody can be booked, for any service or + staff member. Existing appointments are never cancelled automatically. +

+ setName(e.target.value)} + /> +
+ { + setStartDate(e.target.value); + if (endDate && e.target.value > endDate) setEndDate(""); + }} + /> + setEndDate(e.target.value)} + hint="Leave empty for a single day." + /> +
+ {error && ( +

+ {error} +

+ )} + +
+
+ +
+

+ Upcoming +

+ {upcoming.length === 0 ? ( +

+ No holidays set. The business follows its regular opening hours every day. +

+ ) : ( +
    + {upcoming.map((holiday) => ( + + ))} +
+ )} +
+ + {past.length > 0 && ( +
+ + Past ({past.length}) + +
    + {past.map((holiday) => ( + + ))} +
+
+ )} +
+ ); +} + +function HolidayItem({ + holiday, + locale, + disabled, + onRemove, +}: { + holiday: HolidayRow; + locale: string; + disabled: boolean; + onRemove: (id: string) => void; +}) { + return ( +
  • +
    +

    {holiday.name}

    +

    {formatRange(holiday, locale)}

    +
    + +
  • + ); +} diff --git a/src/services/business/__tests__/holiday.service.integration.test.ts b/src/services/business/__tests__/holiday.service.integration.test.ts new file mode 100644 index 0000000..de896aa --- /dev/null +++ b/src/services/business/__tests__/holiday.service.integration.test.ts @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { Weekday } from "@/generated/prisma/enums"; +import { addDays, todayIn, wallClockToInstant, weekdayOf } from "@/lib/time/business-time"; +import type { CalendarDate } from "@/lib/time/business-time"; +import { + getAvailableDates, + getAvailableTimeSlots, + validateBookingSlot, +} from "@/services/scheduling/availability.service"; +import { + cleanupTestBusiness, + createTestAppointment, + createTestBusiness, + createTestCustomer, + createTestStaff, + prisma, +} from "@/services/scheduling/__tests__/fixtures"; +import type { TestBusiness } from "@/services/scheduling/__tests__/fixtures"; +import { + createBusinessHoliday, + deleteBusinessHoliday, + findHolidayConflicts, + getClosedDates, + listBusinessHolidays, +} from "../holiday.service"; + +function nextWeekday(timezone: string, target: Weekday, minDaysAhead = 7): CalendarDate { + let date = addDays(todayIn(timezone), minDaysAhead); + for (let i = 0; i < 8; i++) { + if (weekdayOf(date) === target) return date; + date = addDays(date, 1); + } + throw new Error("unreachable"); +} + +describe("business holidays (integration)", () => { + let business: TestBusiness; + let monday: CalendarDate; + let tuesday: CalendarDate; + let wednesday: CalendarDate; + + beforeEach(async () => { + business = await createTestBusiness(); + monday = nextWeekday(business.timezone, Weekday.MONDAY); + tuesday = addDays(monday, 1); + wednesday = addDays(monday, 2); + }); + + afterEach(async () => { + await cleanupTestBusiness(business); + }); + + const addHoliday = (name: string, startDate: CalendarDate, endDate: CalendarDate) => + createBusinessHoliday(prisma, { + businessId: business.businessId, + holiday: { name, startDate, endDate }, + }); + + it("stores and lists holidays as inclusive calendar dates", async () => { + await addHoliday("Refit", monday, tuesday); + + expect(await listBusinessHolidays(prisma, business.businessId)).toEqual([ + expect.objectContaining({ name: "Refit", startDate: monday, endDate: tuesday }), + ]); + }); + + it("closes every date in a range, but not the days around it", async () => { + await createTestStaff(business); + await addHoliday("Refit", monday, tuesday); + + const { dates } = await getAvailableDates(prisma, { + businessId: business.businessId, + serviceIds: [business.haircutServiceId], + fromDate: monday, + toDate: wednesday, + }); + + expect(dates).not.toContain(monday); + expect(dates).not.toContain(tuesday); + expect(dates).toContain(wednesday); + }); + + it("offers no time slots on a holiday", async () => { + await createTestStaff(business); + await addHoliday("Bank holiday", monday, monday); + + const { slots, validation } = await getAvailableTimeSlots(prisma, { + businessId: business.businessId, + serviceIds: [business.haircutServiceId], + date: monday, + }); + + expect(validation.valid).toBe(true); + expect(slots).toEqual([]); + }); + + it("refuses to book a holiday directly, naming it", async () => { + const { staffId } = await createTestStaff(business); + await addHoliday("Bank holiday", monday, monday); + + const result = await validateBookingSlot(prisma, { + businessId: business.businessId, + serviceIds: [business.haircutServiceId], + staffId, + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + + expect(result.validation.valid).toBe(false); + expect(result.validation.issues.map((i) => i.code)).toContain("BUSINESS_HOLIDAY"); + expect(result.validation.issues.find((i) => i.code === "BUSINESS_HOLIDAY")?.message).toContain( + "Bank holiday", + ); + }); + + it("reopens the dates once the holiday is removed", async () => { + await createTestStaff(business); + const created = await addHoliday("Bank holiday", monday, monday); + if (!created.success) throw new Error("setup failed"); + + expect( + await deleteBusinessHoliday(prisma, { + businessId: business.businessId, + holidayId: created.id, + }), + ).toBe(true); + + const { dates } = await getAvailableDates(prisma, { + businessId: business.businessId, + serviceIds: [business.haircutServiceId], + fromDate: monday, + toDate: monday, + }); + expect(dates).toContain(monday); + }); + + it("never deletes another business's holiday", async () => { + const created = await addHoliday("Bank holiday", monday, monday); + if (!created.success) throw new Error("setup failed"); + + expect( + await deleteBusinessHoliday(prisma, { businessId: "someone-else", holidayId: created.id }), + ).toBe(false); + expect(await listBusinessHolidays(prisma, business.businessId)).toHaveLength(1); + }); + + it("only reports closed dates inside the window asked for", async () => { + await addHoliday("Long break", monday, addDays(monday, 10)); + + const closed = await getClosedDates(prisma, { + businessId: business.businessId, + fromDate: tuesday, + toDate: wednesday, + }); + + expect([...closed.keys()]).toEqual([tuesday, wednesday]); + expect(closed.get(tuesday)).toBe("Long break"); + }); + + it("reports appointments already booked in the range, without cancelling them", async () => { + const { staffId } = await createTestStaff(business); + const { customerId } = await createTestCustomer(business); + const inRange = await createTestAppointment(business, { + customerId, + staffId, + startAt: wallClockToInstant(monday, 10 * 60, business.timezone), + }); + await createTestAppointment(business, { + customerId, + staffId, + startAt: wallClockToInstant(addDays(monday, 5), 10 * 60, business.timezone), + }); + + const conflicts = await findHolidayConflicts(prisma, { + businessId: business.businessId, + businessTimezone: business.timezone, + startDate: monday, + endDate: tuesday, + }); + + expect(conflicts.map((c) => c.id)).toEqual([inRange.appointmentId]); + const stored = await prisma.appointment.findUniqueOrThrow({ + where: { id: inRange.appointmentId }, + }); + expect(stored.status).not.toBe("CANCELLED"); + }); +}); diff --git a/src/services/business/__tests__/holiday.service.test.ts b/src/services/business/__tests__/holiday.service.test.ts new file mode 100644 index 0000000..6b7c260 --- /dev/null +++ b/src/services/business/__tests__/holiday.service.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { + HOLIDAY_MAX_SPAN_DAYS, + HOLIDAY_NAME_MAX_LENGTH, + validateHolidayInput, +} from "../holiday.service"; + +const base = { name: "Christmas", startDate: "2026-12-25", endDate: "2026-12-26" }; + +describe("validateHolidayInput", () => { + it("accepts a single day and a multi-day range", () => { + expect(validateHolidayInput({ ...base, endDate: "2026-12-25" }).valid).toBe(true); + expect(validateHolidayInput(base).valid).toBe(true); + }); + + it("requires a name", () => { + const result = validateHolidayInput({ ...base, name: " " }); + expect(result.valid).toBe(false); + expect(result.issues[0]?.code).toBe("INVALID_INPUT"); + }); + + it("rejects an over-long name", () => { + expect( + validateHolidayInput({ ...base, name: "x".repeat(HOLIDAY_NAME_MAX_LENGTH + 1) }).valid, + ).toBe(false); + }); + + it("rejects dates that aren't real calendar dates", () => { + expect(validateHolidayInput({ ...base, startDate: "2026-02-30" }).valid).toBe(false); + expect(validateHolidayInput({ ...base, endDate: "25/12/2026" }).valid).toBe(false); + expect(validateHolidayInput({ ...base, startDate: "" }).valid).toBe(false); + }); + + it("rejects an end date before the start date", () => { + expect( + validateHolidayInput({ ...base, startDate: "2026-12-26", endDate: "2026-12-25" }).valid, + ).toBe(false); + }); + + it("caps how long a holiday can last, returning an error rather than throwing", () => { + expect( + validateHolidayInput({ ...base, startDate: "2026-01-01", endDate: "2026-12-31" }).valid, + ).toBe(true); + const tooLong = validateHolidayInput({ + ...base, + startDate: "2026-01-01", + endDate: "2028-01-01", + }); + expect(tooLong.valid).toBe(false); + expect(HOLIDAY_MAX_SPAN_DAYS).toBe(366); + }); +}); diff --git a/src/services/business/holiday.service.ts b/src/services/business/holiday.service.ts new file mode 100644 index 0000000..6e62447 --- /dev/null +++ b/src/services/business/holiday.service.ts @@ -0,0 +1,178 @@ +import type { PrismaClient } from "@/generated/prisma/client"; +import { + calendarDateToUtcMidnight, + compareCalendarDates, + eachCalendarDate, + endOfBusinessDay, + startOfBusinessDay, +} from "@/lib/time/business-time"; +import type { CalendarDate } from "@/lib/time/business-time"; +import { fail, ok } from "@/lib/validation-result"; +import type { ValidationResult } from "@/lib/validation-result"; + +/** + * Business-wide closures: bank holidays, annual leave, a refit. Each is an + * inclusive range of calendar dates in the business's own timezone during + * which nobody can be booked, on top of the weekly opening hours (see + * BusinessHoliday in schema.prisma, and availability.service.ts for where + * the booking engine honours them). Creating one never cancels anything — + * it reports the appointments already in range so the owner can deal with + * them, same as the opening-hours conflict pattern. + */ + +export type HolidayServiceErrorCode = "INVALID_INPUT"; + +export const HOLIDAY_NAME_MAX_LENGTH = 80; +/** A guard against a typo'd end date closing the business for years. */ +export const HOLIDAY_MAX_SPAN_DAYS = 366; + +export interface HolidayInput { + name: string; + startDate: CalendarDate; + endDate: CalendarDate; +} + +const CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/; + +function isRealCalendarDate(value: string): boolean { + if (!CALENDAR_DATE.test(value)) return false; + const parsed = new Date(`${value}T00:00:00Z`); + return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; +} + +export function validateHolidayInput( + input: HolidayInput, +): ValidationResult { + if (!input.name.trim()) + return fail({ code: "INVALID_INPUT", message: "Give the holiday a name." }); + if (input.name.trim().length > HOLIDAY_NAME_MAX_LENGTH) { + return fail({ + code: "INVALID_INPUT", + message: `Keep the name under ${HOLIDAY_NAME_MAX_LENGTH} characters.`, + }); + } + if (!isRealCalendarDate(input.startDate) || !isRealCalendarDate(input.endDate)) { + return fail({ code: "INVALID_INPUT", message: "Choose a valid start and end date." }); + } + if (compareCalendarDates(input.endDate, input.startDate) < 0) { + return fail({ code: "INVALID_INPUT", message: "The end date can't be before the start date." }); + } + const days = + (calendarDateToUtcMidnight(input.endDate).getTime() - + calendarDateToUtcMidnight(input.startDate).getTime()) / + 86_400_000 + + 1; + if (days > HOLIDAY_MAX_SPAN_DAYS) { + return fail({ code: "INVALID_INPUT", message: "A holiday can last at most a year." }); + } + return ok(); +} + +/** A stored DATE column comes back as UTC midnight — its calendar date is just the ISO date. */ +function toCalendarDate(stored: Date): CalendarDate { + return stored.toISOString().slice(0, 10); +} + +export async function listBusinessHolidays(prisma: PrismaClient, businessId: string) { + const rows = await prisma.businessHoliday.findMany({ + where: { businessId }, + orderBy: [{ startDate: "asc" }, { name: "asc" }], + }); + return rows.map((row) => ({ + id: row.id, + name: row.name, + startDate: toCalendarDate(row.startDate), + endDate: toCalendarDate(row.endDate), + })); +} + +export type BusinessHolidayRow = Awaited>[number]; + +/** Future, still-active appointments inside the range — reported, never cancelled. */ +export async function findHolidayConflicts( + prisma: PrismaClient, + params: { + businessId: string; + businessTimezone: string; + startDate: CalendarDate; + endDate: CalendarDate; + }, +) { + return prisma.appointment.findMany({ + where: { + businessId: params.businessId, + status: { notIn: ["CANCELLED", "NO_SHOW", "COMPLETED"] }, + startAt: { + gte: new Date(), + lt: endOfBusinessDay(params.endDate, params.businessTimezone), + }, + endAt: { gt: startOfBusinessDay(params.startDate, params.businessTimezone) }, + }, + include: { + services: { include: { service: true } }, + customer: true, + staff: { include: { user: { select: { name: true } } } }, + }, + orderBy: { startAt: "asc" }, + }); +} + +export async function createBusinessHoliday( + prisma: PrismaClient, + params: { businessId: string; holiday: HolidayInput }, +): Promise< + | { success: true; id: string } + | { success: false; validation: ValidationResult } +> { + const validation = validateHolidayInput(params.holiday); + if (!validation.valid) return { success: false, validation }; + + const created = await prisma.businessHoliday.create({ + data: { + businessId: params.businessId, + name: params.holiday.name.trim(), + startDate: calendarDateToUtcMidnight(params.holiday.startDate), + endDate: calendarDateToUtcMidnight(params.holiday.endDate), + }, + }); + return { success: true, id: created.id }; +} + +/** Scoped to the business so one business can never delete another's holiday by id. */ +export async function deleteBusinessHoliday( + prisma: PrismaClient, + params: { businessId: string; holidayId: string }, +): Promise { + const { count } = await prisma.businessHoliday.deleteMany({ + where: { id: params.holidayId, businessId: params.businessId }, + }); + return count > 0; +} + +/** Every date in [fromDate, toDate] covered by a holiday — what the booking engine treats as closed. */ +export async function getClosedDates( + prisma: PrismaClient, + params: { businessId: string; fromDate: CalendarDate; toDate: CalendarDate }, +): Promise> { + const rows = await prisma.businessHoliday.findMany({ + where: { + businessId: params.businessId, + startDate: { lte: calendarDateToUtcMidnight(params.toDate) }, + endDate: { gte: calendarDateToUtcMidnight(params.fromDate) }, + }, + orderBy: { startDate: "asc" }, + }); + + const closed = new Map(); + for (const row of rows) { + const rowStart = toCalendarDate(row.startDate); + const rowEnd = toCalendarDate(row.endDate); + // Clamp to the window asked for, so a long holiday never walks more days than the caller needs. + const start = compareCalendarDates(rowStart, params.fromDate) < 0 ? params.fromDate : rowStart; + const end = compareCalendarDates(rowEnd, params.toDate) > 0 ? params.toDate : rowEnd; + for (const date of eachCalendarDate(start, end)) { + if (!closed.has(date)) closed.set(date, row.name); + } + } + return closed; +} diff --git a/src/services/scheduling/availability.service.ts b/src/services/scheduling/availability.service.ts index 038132a..adfdee7 100644 --- a/src/services/scheduling/availability.service.ts +++ b/src/services/scheduling/availability.service.ts @@ -21,6 +21,7 @@ import { computeEffectiveBuffer, } from "./business-rules.service"; import type { BookingRuleConfig } from "./business-rules.service"; +import { getClosedDates } from "@/services/business/holiday.service"; import { computeFreeWindows, generateSlotStarts } from "./slot-calculator"; import { businessOpenByWeekdayFrom, @@ -233,6 +234,11 @@ export async function getAvailableDates( const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } }); const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours); + const closedDates = await getClosedDates(prisma, { + businessId: context.business.id, + fromDate: params.fromDate, + toDate: params.toDate, + }); const today = todayIn(context.business.timezone); const now = new Date(); @@ -245,6 +251,7 @@ export async function getAvailableDates( toDate: params.toDate, businessTimezone: context.business.timezone, businessOpenByWeekday, + closedDates, effectiveBuffer: context.effectiveBuffer, }), ), @@ -330,6 +337,11 @@ export async function getAvailableTimeSlots( const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } }); const businessOpenByWeekday = businessOpenByWeekdayFrom(businessHours); + const closedDates = await getClosedDates(prisma, { + businessId: context.business.id, + fromDate: params.date, + toDate: params.date, + }); const businessCount = ( await countAppointmentsPerDate(prisma, { @@ -364,6 +376,7 @@ export async function getAvailableTimeSlots( toDate: params.date, businessTimezone: context.business.timezone, businessOpenByWeekday, + closedDates, effectiveBuffer: context.effectiveBuffer, }); const freeWindows = windowsByDate.get(params.date) ?? []; @@ -448,6 +461,18 @@ export async function validateBookingSlot( checkMaxAdvance(date, today, context.bookingRule.maxAdvanceDays), ); + const holidayName = (await getClosedDates(prisma, { businessId: context.business.id, fromDate: date, toDate: date })).get(date); + if (holidayName !== undefined) { + return { + context, + endAt, + validation: mergeValidation( + ruleChecks, + fail({ code: "BUSINESS_HOLIDAY", message: `The business is closed on this date (${holidayName}).` }), + ), + }; + } + const businessHours = await prisma.businessHours.findMany({ where: { businessId: context.business.id } }); const businessOpen = businessOpenByWeekdayFrom(businessHours).get(weekdayOf(date)); diff --git a/src/services/scheduling/staff-availability.service.ts b/src/services/scheduling/staff-availability.service.ts index 2f4695c..42d45fa 100644 --- a/src/services/scheduling/staff-availability.service.ts +++ b/src/services/scheduling/staff-availability.service.ts @@ -53,6 +53,8 @@ export interface GetStaffFreeWindowsForRangeParams { toDate: CalendarDate; businessTimezone: string; businessOpenByWeekday: BusinessOpenByWeekday; + /** Dates the whole business is closed (holidays) — free windows are empty on these regardless of opening hours. */ + closedDates?: ReadonlyMap; effectiveBuffer: { beforeMinutes: number; afterMinutes: number }; excludeAppointmentId?: string; } @@ -61,8 +63,16 @@ export async function getStaffFreeWindowsForRange( prisma: PrismaClient, params: GetStaffFreeWindowsForRangeParams, ): Promise> { - const { staffId, fromDate, toDate, businessTimezone, businessOpenByWeekday, effectiveBuffer, excludeAppointmentId } = - params; + const { + staffId, + fromDate, + toDate, + businessTimezone, + businessOpenByWeekday, + closedDates, + effectiveBuffer, + excludeAppointmentId, + } = params; const rangeStart = startOfBusinessDay(fromDate, businessTimezone); const rangeEnd = endOfBusinessDay(toDate, businessTimezone); @@ -104,7 +114,7 @@ export async function getStaffFreeWindowsForRange( const weekday = weekdayOf(date); const businessOpen = businessOpenByWeekday.get(weekday); - if (!businessOpen) { + if (!businessOpen || closedDates?.has(date)) { result.set(date, []); continue; } diff --git a/src/services/scheduling/types.ts b/src/services/scheduling/types.ts index 9cced26..2660f84 100644 --- a/src/services/scheduling/types.ts +++ b/src/services/scheduling/types.ts @@ -34,6 +34,7 @@ export const BOOKING_VALIDATION_ERROR_CODES = [ "SERVICES_SPAN_MULTIPLE_BUSINESSES", "STAFF_CANNOT_PERFORM_SERVICE", "BUSINESS_CLOSED", + "BUSINESS_HOLIDAY", "STAFF_NOT_WORKING", "STAFF_ON_BREAK", "STAFF_ON_LEAVE",