Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/booking/services/AvailabilityService.server.ts
T
2026-07-08 16:15:37 +02:00

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,
})),
});
}
});
}