Files
NextChair/src/features/owner/actions/holidays.ts
T
Stefan Lazic 9f981173f4
CI / ci (push) Canceled after 0s
holidays feature
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.
2026-10-17 12:23:53 +01:00

120 lines
4.1 KiB
TypeScript

"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<HolidayActionResult> {
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<HolidayActionResult> {
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 };
}