Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/booking/db/booking.db.server.ts
T

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