208 lines
7.2 KiB
TypeScript
208 lines
7.2 KiB
TypeScript
// 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<ProviderAvailabilitySettings> {
|
|
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<void> {
|
|
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,
|
|
})),
|
|
});
|
|
}
|
|
});
|
|
}
|