From 9f981173f429a4746d54f34d5d37baf7e33a8d39 Mon Sep 17 00:00:00 2001 From: Stefan Lazic Date: Sat, 17 Oct 2026 12:23:53 +0100 Subject: [PATCH] holidays feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner page Where: Manage → Business → Holidays, at /owner/settings/holidays. It needs the same permission as Business Details. Adding: you enter a name, a start date and an optional end date. Leaving the end empty gives a single day. Upcoming and past: upcoming holidays are listed by date, with a trash icon to remove each one. Past ones sit in a collapsed “Past” list. Existing bookings: if appointments are already booked on those dates, you see them and confirm with “Add Anyway”, the same pattern as Opening Hours. Nothing is cancelled automatically. What it does to booking Customer calendar: holiday dates are greyed out, for every staff member and every service. Time slots: no slots are offered on those dates. Direct bookings: staff and owner bookings and reschedules onto a holiday are refused, with a message naming the holiday. This is deliberate, but if you want owners to be able to override it, that’s a small change. --- .../migration.sql | 16 ++ prisma/schema.prisma | 21 ++ src/app/(owner)/owner/page.tsx | 2 + .../(owner)/owner/settings/holidays/page.tsx | 43 +++ src/features/booking/error-messages.ts | 1 + src/features/owner/actions/holidays.ts | 119 ++++++++ .../owner/components/holidays-manager.tsx | 257 ++++++++++++++++++ .../holiday.service.integration.test.ts | 186 +++++++++++++ .../__tests__/holiday.service.test.ts | 52 ++++ src/services/business/holiday.service.ts | 178 ++++++++++++ .../scheduling/availability.service.ts | 25 ++ .../scheduling/staff-availability.service.ts | 16 +- src/services/scheduling/types.ts | 1 + 13 files changed, 914 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20261017180000_add_business_holidays/migration.sql create mode 100644 src/app/(owner)/owner/settings/holidays/page.tsx create mode 100644 src/features/owner/actions/holidays.ts create mode 100644 src/features/owner/components/holidays-manager.tsx create mode 100644 src/services/business/__tests__/holiday.service.integration.test.ts create mode 100644 src/services/business/__tests__/holiday.service.test.ts create mode 100644 src/services/business/holiday.service.ts 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",