// AK-P16b ยท provider availability write/read service (M06 Block F). The screen is // still D-P16-blocked, but the server-side contract is complete: rules and date // exceptions are validated, tenant/provider-scoped, and replaced atomically. import { db } from "~/utils/db.server"; import type { BookingWeekday } from "./slotEngine"; export type AvailabilityExceptionKind = "unavailable" | "custom_window"; export interface AvailabilityRuleInput { readonly dayOfWeek: BookingWeekday; readonly startTime: string; readonly endTime: string; readonly timezone: string; } export interface AvailabilityExceptionInput { /** ISO calendar date `YYYY-MM-DD`. */ readonly date: string; readonly kind: AvailabilityExceptionKind; readonly startTime?: string | null; readonly endTime?: string | null; readonly reason?: string | null; readonly timezone: string; } export interface AvailabilityRuleRow extends AvailabilityRuleInput { readonly id: string; } export interface AvailabilityExceptionRow { readonly id: string; readonly date: string; readonly kind: AvailabilityExceptionKind; readonly startTime: string | null; readonly endTime: string | null; readonly reason: string | null; readonly timezone: string; } export interface ProviderAvailabilitySettings { readonly providerUserId: string; readonly rules: AvailabilityRuleRow[]; readonly exceptions: AvailabilityExceptionRow[]; } export class AvailabilityValidationError extends Error { readonly code: string; constructor(code: string) { super(code); this.name = "AvailabilityValidationError"; this.code = code; } } const WEEKDAYS: readonly BookingWeekday[] = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; const WEEKDAY_ORDER = new Map(WEEKDAYS.map((day, index) => [day, index])); const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/u; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/u; function assertTimezone(timezone: string): void { try { new Intl.DateTimeFormat("en", { timeZone: timezone }).format(new Date("2026-01-01T00:00:00Z")); } catch { throw new AvailabilityValidationError("bookings.error.invalidTimezone"); } } function minutesOf(time: string): number { if (!TIME_RE.test(time)) { throw new AvailabilityValidationError("bookings.error.invalidTime"); } const [hours, minutes] = time.split(":").map(Number); return hours * 60 + minutes; } function isoDateToDbDate(date: string): Date { if (!DATE_RE.test(date)) { throw new AvailabilityValidationError("bookings.error.invalidDate"); } return new Date(`${date}T00:00:00Z`); } function dbDateToIso(date: Date): string { return date.toISOString().slice(0, 10); } function validateWindow(startTime: string, endTime: string): void { if (minutesOf(endTime) <= minutesOf(startTime)) { throw new AvailabilityValidationError("bookings.error.invalidTimeWindow"); } } function normalizeRule(rule: AvailabilityRuleInput): AvailabilityRuleInput { if (!WEEKDAY_ORDER.has(rule.dayOfWeek)) { throw new AvailabilityValidationError("bookings.error.invalidWeekday"); } const timezone = rule.timezone.trim(); assertTimezone(timezone); validateWindow(rule.startTime, rule.endTime); return { dayOfWeek: rule.dayOfWeek, startTime: rule.startTime, endTime: rule.endTime, timezone }; } function normalizeException(exception: AvailabilityExceptionInput): AvailabilityExceptionInput & { dateObject: Date } { if (exception.kind !== "unavailable" && exception.kind !== "custom_window") { throw new AvailabilityValidationError("bookings.error.invalidAvailabilityException"); } const timezone = exception.timezone.trim(); assertTimezone(timezone); const dateObject = isoDateToDbDate(exception.date); if (exception.kind === "custom_window") { if (!exception.startTime || !exception.endTime) { throw new AvailabilityValidationError("bookings.error.invalidTimeWindow"); } validateWindow(exception.startTime, exception.endTime); return { ...exception, dateObject, timezone, startTime: exception.startTime, endTime: exception.endTime, reason: exception.reason?.trim() || null, }; } return { ...exception, dateObject, timezone, startTime: null, endTime: null, reason: exception.reason?.trim() || null, }; } /** Load one provider's persisted availability settings. */ export async function listProviderAvailability(tenantId: string, providerUserId: string): Promise { const [rules, exceptions] = await Promise.all([ db.availabilityRule.findMany({ where: { tenantId, providerUserId }, select: { id: true, dayOfWeek: true, startTime: true, endTime: true, timezone: true }, }), db.availabilityException.findMany({ where: { tenantId, providerUserId }, select: { id: true, date: true, kind: true, startTime: true, endTime: true, reason: true, timezone: true }, }), ]); return { providerUserId, rules: rules .map((rule) => ({ id: rule.id, dayOfWeek: rule.dayOfWeek as BookingWeekday, startTime: rule.startTime, endTime: rule.endTime, timezone: rule.timezone })) .sort((a, b) => (WEEKDAY_ORDER.get(a.dayOfWeek) ?? 99) - (WEEKDAY_ORDER.get(b.dayOfWeek) ?? 99) || a.startTime.localeCompare(b.startTime)), exceptions: exceptions .map((exception) => ({ id: exception.id, date: dbDateToIso(exception.date), kind: exception.kind as AvailabilityExceptionKind, startTime: exception.startTime, endTime: exception.endTime, reason: exception.reason, timezone: exception.timezone, })) .sort((a, b) => a.date.localeCompare(b.date) || (a.startTime ?? "").localeCompare(b.startTime ?? "")), }; } /** Replace a provider's availability settings atomically. */ export async function replaceProviderAvailability(input: { readonly tenantId: string; readonly providerUserId: string; readonly rules: readonly AvailabilityRuleInput[]; readonly exceptions: readonly AvailabilityExceptionInput[]; }): Promise { const rules = input.rules.map(normalizeRule); const exceptions = input.exceptions.map(normalizeException); await db.$transaction(async (tx) => { await tx.availabilityRule.deleteMany({ where: { tenantId: input.tenantId, providerUserId: input.providerUserId } }); await tx.availabilityException.deleteMany({ where: { tenantId: input.tenantId, providerUserId: input.providerUserId } }); if (rules.length > 0) { await tx.availabilityRule.createMany({ data: rules.map((rule) => ({ tenantId: input.tenantId, providerUserId: input.providerUserId, dayOfWeek: rule.dayOfWeek, startTime: rule.startTime, endTime: rule.endTime, timezone: rule.timezone, })), }); } if (exceptions.length > 0) { await tx.availabilityException.createMany({ data: exceptions.map((exception) => ({ tenantId: input.tenantId, providerUserId: input.providerUserId, date: exception.dateObject, kind: exception.kind, startTime: exception.startTime, endTime: exception.endTime, reason: exception.reason ?? null, timezone: exception.timezone, })), }); } }); }