407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
// AK-P16b · booking DB access (thin Prisma wrappers). Public reads resolve the
|
|
// tenant from the URL slug (no logged-in user); admin reads are tenant-scoped by the
|
|
// guard. Slot config + provider list feed the pure slot engine.
|
|
|
|
import { db } from "~/utils/db.server";
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
import { withTenantRLS, type TenantRlsClient } from "~/utils/db/.server/tenantRls.server";
|
|
import type { SlotEngineConfig } from "../services/slotEngine";
|
|
|
|
export interface PublicBookingPage {
|
|
tenantId: string;
|
|
tenantName: string;
|
|
tenantSlug: string;
|
|
pageId: string;
|
|
slug: string;
|
|
title: string;
|
|
description: string | null;
|
|
durationMinutes: number;
|
|
intakeFormId: string | null;
|
|
assignmentStrategy: string;
|
|
confirmationEmailSubject: string;
|
|
confirmationEmailBody: string;
|
|
internalEventTitleTemplate: string | null;
|
|
redirectUrlAfterBooking: string | null;
|
|
providerUserIds: string[];
|
|
config: SlotEngineConfig;
|
|
}
|
|
|
|
const PAGE_SELECT = {
|
|
id: true,
|
|
slug: true,
|
|
title: true,
|
|
description: true,
|
|
durationMinutes: true,
|
|
intakeFormId: true,
|
|
bufferBeforeMinutes: true,
|
|
bufferAfterMinutes: true,
|
|
slotIncrementMinutes: true,
|
|
minNoticeHours: true,
|
|
maxAdvanceDays: true,
|
|
assignmentStrategy: true,
|
|
confirmationEmailSubject: true,
|
|
confirmationEmailBody: true,
|
|
internalEventTitleTemplate: true,
|
|
redirectUrlAfterBooking: true,
|
|
} as const;
|
|
|
|
function toConfig(page: { durationMinutes: number; bufferBeforeMinutes: number; bufferAfterMinutes: number; slotIncrementMinutes: number; minNoticeHours: number; maxAdvanceDays: number }): SlotEngineConfig {
|
|
return {
|
|
durationMinutes: page.durationMinutes,
|
|
bufferBeforeMinutes: page.bufferBeforeMinutes,
|
|
bufferAfterMinutes: page.bufferAfterMinutes,
|
|
slotIncrementMinutes: page.slotIncrementMinutes,
|
|
minNoticeHours: page.minNoticeHours,
|
|
maxAdvanceDays: page.maxAdvanceDays,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve a public booking page by tenant slug + page slug. Returns null when the
|
|
* tenant is inactive/unknown or the page is unpublished/archived (M06 G.4/K.6 — a
|
|
* 404 with the same Klartext, never leaking tenant existence). Active providers only.
|
|
*/
|
|
export async function resolvePublicBookingPage(tenantSlug: string, pageSlug: string): Promise<PublicBookingPage | null> {
|
|
const tenant = await db.tenant.findUnique({ where: { slug: tenantSlug }, select: { id: true, name: true, active: true } });
|
|
if (!tenant || !tenant.active) {
|
|
return null;
|
|
}
|
|
const page = await db.bookingPage.findFirst({
|
|
where: { tenantId: tenant.id, slug: pageSlug, isPublished: true, archivedAt: null },
|
|
select: PAGE_SELECT,
|
|
});
|
|
if (!page) {
|
|
return null;
|
|
}
|
|
const providers = await db.bookingPageProvider.findMany({
|
|
where: { bookingPageId: page.id, tenantId: tenant.id, isActive: true },
|
|
select: { providerUserId: true },
|
|
orderBy: { displayOrder: "asc" },
|
|
});
|
|
return {
|
|
tenantId: tenant.id,
|
|
tenantName: tenant.name,
|
|
tenantSlug,
|
|
pageId: page.id,
|
|
slug: page.slug,
|
|
title: page.title,
|
|
description: page.description,
|
|
durationMinutes: page.durationMinutes,
|
|
intakeFormId: page.intakeFormId,
|
|
assignmentStrategy: page.assignmentStrategy,
|
|
confirmationEmailSubject: page.confirmationEmailSubject,
|
|
confirmationEmailBody: page.confirmationEmailBody,
|
|
internalEventTitleTemplate: page.internalEventTitleTemplate,
|
|
redirectUrlAfterBooking: page.redirectUrlAfterBooking,
|
|
providerUserIds: providers.map((p) => p.providerUserId),
|
|
config: toConfig(page),
|
|
};
|
|
}
|
|
|
|
export interface AppointmentListRow {
|
|
id: string;
|
|
startAt: Date;
|
|
endAt: Date;
|
|
status: "scheduled" | "held" | "cancelled" | "no_show" | "rescheduled";
|
|
providerUserId: string;
|
|
pageTitle: string;
|
|
customerName: string;
|
|
customerEmail: string;
|
|
}
|
|
|
|
function intakeName(intakeAnswers: unknown): { name: string; email: string } {
|
|
if (intakeAnswers && typeof intakeAnswers === "object") {
|
|
const a = intakeAnswers as { vorname?: unknown; nachname?: unknown; email?: unknown };
|
|
const name = [a.vorname, a.nachname].filter((v) => typeof v === "string" && v).join(" ");
|
|
return { name, email: typeof a.email === "string" ? a.email : "" };
|
|
}
|
|
return { name: "", email: "" };
|
|
}
|
|
|
|
/**
|
|
* List a tenant's appointments (M06 Block C). When `ownerUserId` is set (read_own),
|
|
* only that provider's appointments are returned — the server-side owner filter
|
|
* (M06 K.5), never a client filter.
|
|
*/
|
|
export async function listAppointmentsForTenant(input: { tenantId: string; ownerUserId?: string | null }): Promise<AppointmentListRow[]> {
|
|
const rows = await db.appointment.findMany({
|
|
where: { tenantId: input.tenantId, ...(input.ownerUserId ? { providerUserId: input.ownerUserId } : {}) },
|
|
select: { id: true, startAt: true, endAt: true, status: true, providerUserId: true, intakeAnswers: true, bookingPage: { select: { title: true } } },
|
|
orderBy: { startAt: "desc" },
|
|
take: 200,
|
|
});
|
|
return rows.map((r) => {
|
|
const { name, email } = intakeName(r.intakeAnswers);
|
|
return { id: r.id, startAt: r.startAt, endAt: r.endAt, status: r.status, providerUserId: r.providerUserId, pageTitle: r.bookingPage.title, customerName: name, customerEmail: email };
|
|
});
|
|
}
|
|
|
|
export interface AppointmentDetail {
|
|
id: string;
|
|
status: "scheduled" | "held" | "cancelled" | "no_show" | "rescheduled";
|
|
providerUserId: string;
|
|
startAt: Date;
|
|
endAt: Date;
|
|
timezone: string;
|
|
pageTitle: string;
|
|
customerName: string;
|
|
customerEmail: string;
|
|
intakeAnswers: Record<string, unknown>;
|
|
cancellationReason: string | null;
|
|
notesInternal: string | null;
|
|
/** The booking page's slot config, so the reschedule picker reuses the engine. */
|
|
pageConfig: SlotEngineConfig;
|
|
}
|
|
|
|
/** One appointment's detail (M06 Block D). Returns providerUserId so the route owner-checks. */
|
|
export async function getAppointmentDetail(tenantId: string, appointmentId: string): Promise<AppointmentDetail | null> {
|
|
const row = await db.appointment.findFirst({
|
|
where: { id: appointmentId, tenantId },
|
|
select: {
|
|
id: true,
|
|
status: true,
|
|
providerUserId: true,
|
|
startAt: true,
|
|
endAt: true,
|
|
timezone: true,
|
|
intakeAnswers: true,
|
|
cancellationReason: true,
|
|
notesInternal: true,
|
|
bookingPage: {
|
|
select: {
|
|
title: true,
|
|
durationMinutes: true,
|
|
bufferBeforeMinutes: true,
|
|
bufferAfterMinutes: true,
|
|
slotIncrementMinutes: true,
|
|
minNoticeHours: true,
|
|
maxAdvanceDays: true,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
const { name, email } = intakeName(row.intakeAnswers);
|
|
return {
|
|
id: row.id,
|
|
status: row.status,
|
|
providerUserId: row.providerUserId,
|
|
startAt: row.startAt,
|
|
endAt: row.endAt,
|
|
timezone: row.timezone,
|
|
pageTitle: row.bookingPage.title,
|
|
customerName: name,
|
|
customerEmail: email,
|
|
intakeAnswers: (row.intakeAnswers as Record<string, unknown>) ?? {},
|
|
cancellationReason: row.cancellationReason,
|
|
notesInternal: row.notesInternal,
|
|
pageConfig: toConfig(row.bookingPage),
|
|
};
|
|
}
|
|
|
|
export interface BookingPageListRow {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
durationMinutes: number;
|
|
isPublished: boolean;
|
|
providerCount: number;
|
|
}
|
|
|
|
/** List a tenant's booking pages (M06 Block E.1). */
|
|
export async function listBookingPages(tenantId: string): Promise<BookingPageListRow[]> {
|
|
const rows = await db.bookingPage.findMany({
|
|
where: { tenantId, archivedAt: null },
|
|
select: { id: true, slug: true, title: true, durationMinutes: true, isPublished: true, _count: { select: { providers: true } } },
|
|
orderBy: { createdAt: "desc" },
|
|
});
|
|
return rows.map((r) => ({ id: r.id, slug: r.slug, title: r.title, durationMinutes: r.durationMinutes, isPublished: r.isPublished, providerCount: r._count.providers }));
|
|
}
|
|
|
|
export interface BookingPageEdit {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
description: string | null;
|
|
durationMinutes: number;
|
|
confirmationEmailSubject: string;
|
|
confirmationEmailBody: string;
|
|
isPublished: boolean;
|
|
providerUserIds: string[];
|
|
intakeFormId: string | null;
|
|
}
|
|
|
|
/** Load a booking page for the editor (M06 Block E.2). */
|
|
export async function getBookingPageForEdit(tenantId: string, pageId: string): Promise<BookingPageEdit | null> {
|
|
const row = await db.bookingPage.findFirst({
|
|
where: { id: pageId, tenantId, archivedAt: null },
|
|
select: { id: true, slug: true, title: true, description: true, durationMinutes: true, confirmationEmailSubject: true, confirmationEmailBody: true, isPublished: true, intakeFormId: true, providers: { select: { providerUserId: true } } },
|
|
});
|
|
if (!row) {
|
|
return null;
|
|
}
|
|
return {
|
|
id: row.id,
|
|
slug: row.slug,
|
|
title: row.title,
|
|
description: row.description,
|
|
durationMinutes: row.durationMinutes,
|
|
confirmationEmailSubject: row.confirmationEmailSubject,
|
|
confirmationEmailBody: row.confirmationEmailBody,
|
|
isPublished: row.isPublished,
|
|
providerUserIds: row.providers.map((p) => p.providerUserId),
|
|
intakeFormId: row.intakeFormId,
|
|
};
|
|
}
|
|
|
|
export interface IntakeFormOption {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
/** Non-archived tenant forms selectable as a booking page's intake form (AK-P16c link). */
|
|
export async function listIntakeFormOptions(tenantId: string): Promise<IntakeFormOption[]> {
|
|
const rows = await db.form.findMany({
|
|
where: { tenantId, status: { not: "archived" } },
|
|
select: { id: true, name: true },
|
|
orderBy: { updatedAt: "desc" },
|
|
});
|
|
return rows.map((r) => ({ id: r.id, name: r.name }));
|
|
}
|
|
|
|
export interface BookingPageInput {
|
|
tenantId: string;
|
|
createdByUserId: string;
|
|
slug: string;
|
|
title: string;
|
|
description?: string | null;
|
|
durationMinutes: number;
|
|
confirmationEmailSubject: string;
|
|
confirmationEmailBody: string;
|
|
isPublished: boolean;
|
|
providerUserIds: string[];
|
|
intakeFormId?: string | null;
|
|
}
|
|
|
|
async function validateBookingPageReferences(
|
|
tx: TenantRlsClient,
|
|
input: Pick<BookingPageInput, "tenantId" | "providerUserIds" | "intakeFormId">,
|
|
): Promise<string[] | null> {
|
|
const providerUserIds = [...new Set(input.providerUserIds)];
|
|
if (providerUserIds.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const activeProviders = await tx.tenantUser.findMany({
|
|
where: {
|
|
tenantId: input.tenantId,
|
|
userId: { in: providerUserIds },
|
|
status: TenantUserStatus.ACTIVE,
|
|
},
|
|
select: { userId: true },
|
|
});
|
|
const activeProviderIds = new Set(activeProviders.map((provider) => provider.userId));
|
|
if (providerUserIds.some((providerUserId) => !activeProviderIds.has(providerUserId))) {
|
|
return null;
|
|
}
|
|
|
|
if (input.intakeFormId) {
|
|
const intakeForm = await tx.form.findFirst({
|
|
where: { id: input.intakeFormId, tenantId: input.tenantId, status: { not: "archived" } },
|
|
select: { id: true },
|
|
});
|
|
if (!intakeForm) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return providerUserIds;
|
|
}
|
|
|
|
/** Create a booking page + its provider links (M06 Block E.2). */
|
|
export async function createBookingPage(input: BookingPageInput): Promise<string | null> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
const providerUserIds = await validateBookingPageReferences(tx, input);
|
|
if (!providerUserIds) {
|
|
return null;
|
|
}
|
|
const page = await tx.bookingPage.create({
|
|
data: {
|
|
tenantId: input.tenantId,
|
|
createdByUserId: input.createdByUserId,
|
|
slug: input.slug,
|
|
title: input.title,
|
|
description: input.description ?? null,
|
|
durationMinutes: input.durationMinutes,
|
|
confirmationEmailSubject: input.confirmationEmailSubject,
|
|
confirmationEmailBody: input.confirmationEmailBody,
|
|
isPublished: input.isPublished,
|
|
intakeFormId: input.intakeFormId ?? null,
|
|
},
|
|
select: { id: true },
|
|
});
|
|
await tx.bookingPageProvider.createMany({
|
|
data: providerUserIds.map((providerUserId, index) => ({ tenantId: input.tenantId, bookingPageId: page.id, providerUserId, displayOrder: index })),
|
|
});
|
|
return page.id;
|
|
});
|
|
}
|
|
|
|
/** Update a booking page + replace its provider links (M06 Block E.2). */
|
|
export async function updateBookingPage(pageId: string, input: BookingPageInput): Promise<boolean> {
|
|
return withTenantRLS(input.tenantId, async (tx) => {
|
|
const page = await tx.bookingPage.findFirst({
|
|
where: { id: pageId, tenantId: input.tenantId, archivedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (!page) {
|
|
return false;
|
|
}
|
|
|
|
const providerUserIds = await validateBookingPageReferences(tx, input);
|
|
if (!providerUserIds) {
|
|
return false;
|
|
}
|
|
|
|
await tx.bookingPage.update({
|
|
where: { id: page.id, tenantId: input.tenantId },
|
|
data: {
|
|
slug: input.slug,
|
|
title: input.title,
|
|
description: input.description ?? null,
|
|
durationMinutes: input.durationMinutes,
|
|
confirmationEmailSubject: input.confirmationEmailSubject,
|
|
confirmationEmailBody: input.confirmationEmailBody,
|
|
isPublished: input.isPublished,
|
|
intakeFormId: input.intakeFormId ?? null,
|
|
},
|
|
});
|
|
await tx.bookingPageProvider.deleteMany({ where: { bookingPageId: page.id, tenantId: input.tenantId } });
|
|
await tx.bookingPageProvider.createMany({
|
|
data: providerUserIds.map((providerUserId, index) => ({ tenantId: input.tenantId, bookingPageId: page.id, providerUserId, displayOrder: index })),
|
|
});
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/** True when `slug` is already used by another page in the tenant (M06 E.2 validation). */
|
|
export async function isSlugTaken(tenantId: string, slug: string, exceptPageId?: string): Promise<boolean> {
|
|
const row = await db.bookingPage.findFirst({ where: { tenantId, slug, ...(exceptPageId ? { NOT: { id: exceptPageId } } : {}) }, select: { id: true } });
|
|
return row !== null;
|
|
}
|
|
|
|
/** Match an incoming booker email to an existing tenant contact (M06 J.1). */
|
|
export async function findKontaktIdByEmail(tenantId: string, email: string): Promise<string | null> {
|
|
const normalized = email.trim().toLowerCase();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
const row = await db.kontaktEmail.findFirst({
|
|
where: { tenantId, email: { equals: normalized, mode: "insensitive" } },
|
|
select: { kontaktId: true },
|
|
});
|
|
return row?.kontaktId ?? null;
|
|
}
|