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

381 lines
14 KiB
TypeScript

// AK-P16b · appointment lifecycle (M06 Block D / M129 VOLL): held / no-show / cancel
// / reschedule / re-assign. Transitions are validated by the pure state machine; the
// local CalendarEvent mirror is kept in sync (archived on cancel, moved on re-assign,
// superseded on reschedule). Customer-facing changes (cancel/reschedule/re-assign)
// send an auto-mail (best-effort, M06 I.3) and record an Activity (M06 J.4); an in-app
// notification goes to the provider via notify() (M011). External calendar push lands
// with the AK-P16a write-path (here only the local mirror is updated).
import { formatInTimeZone } from "date-fns-tz";
import { logActivity, type ActivityType } from "@hivegrid/activity";
import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox";
import { notify, type NotificationClient } from "@hivegrid/foundation";
import { renderSystemMessageEmail, type SystemMessageDetail } from "@hivegrid/mail";
import { getMailTranslator, type MailTranslator } from "~/locale/mail-translator.server";
import { db } from "~/utils/db.server";
import { sendEmailMailjet } from "~/modules/emails/services/MailjetEmailService";
import { assertTransition, statusAfter, notifiesCustomer, type LifecycleAction } from "./bookingTransitions";
import { buildBookingCancelledWorkflowEvent, buildCalendarCreatedWorkflowEvent } from "./bookingAutomationEvents.server";
const MINUTE_MS = 60_000;
interface AppointmentForAction {
id: string;
status: "scheduled" | "held" | "cancelled" | "no_show" | "rescheduled";
providerUserId: string;
bookingPageId: string;
kontaktId: string | null;
startAt: Date;
endAt: Date;
timezone: string;
internalCalendarEventId: string | null;
intakeAnswers: unknown;
pageTitle: string;
bookingPageSlug: string;
durationMinutes: number;
tenantName: string;
}
async function loadAppointmentForAction(tenantId: string, appointmentId: string): Promise<AppointmentForAction | null> {
const row = await db.appointment.findFirst({
where: { id: appointmentId, tenantId },
select: {
id: true,
status: true,
providerUserId: true,
bookingPageId: true,
kontaktId: true,
startAt: true,
endAt: true,
timezone: true,
internalCalendarEventId: true,
intakeAnswers: true,
bookingPage: { select: { title: true, slug: true, durationMinutes: true } },
},
});
if (!row) {
return null;
}
const tenant = await db.tenant.findUnique({ where: { id: tenantId }, select: { name: true } });
return {
id: row.id,
status: row.status,
providerUserId: row.providerUserId,
bookingPageId: row.bookingPageId,
kontaktId: row.kontaktId,
startAt: row.startAt,
endAt: row.endAt,
timezone: row.timezone,
internalCalendarEventId: row.internalCalendarEventId,
intakeAnswers: row.intakeAnswers,
pageTitle: row.bookingPage.title,
bookingPageSlug: row.bookingPage.slug,
durationMinutes: row.bookingPage.durationMinutes,
tenantName: tenant?.name ?? "",
};
}
function bookerEmail(intakeAnswers: unknown): string | null {
if (intakeAnswers && typeof intakeAnswers === "object" && "email" in intakeAnswers) {
const email = (intakeAnswers as { email?: unknown }).email;
return typeof email === "string" && email.trim() ? email.trim() : null;
}
return null;
}
function bookerLocale(intakeAnswers: unknown): string | null {
if (intakeAnswers && typeof intakeAnswers === "object" && "locale" in intakeAnswers) {
const locale = (intakeAnswers as { locale?: unknown }).locale;
return typeof locale === "string" && locale.trim() ? locale.trim() : null;
}
return null;
}
async function recordActivity(
tenantId: string,
appt: AppointmentForAction,
activityType: ActivityType,
summaryText: string,
actorUserId: string | null
): Promise<void> {
if (!appt.kontaktId) {
return;
}
await logActivity({
tenantId,
actorUserId,
actorKind: actorUserId ? "user" : "system",
activityType,
subjectType: "contact",
subjectId: appt.kontaktId,
summaryText,
metadata: { appointmentId: appt.id },
}).catch(() => undefined);
}
async function notifyProvider(tenantId: string, providerUserId: string, messageKey: string, appointmentId: string): Promise<void> {
await notify(
{
tenantId,
userId: providerUserId,
channel: "IN_APP",
category: "bookings",
messageKey,
sourceKey: `${messageKey}:${appointmentId}`,
payloadMetadata: { appointmentId },
},
db as unknown as NotificationClient
).catch(() => undefined);
}
async function sendLifecycleMail(
appt: AppointmentForAction,
input: {
readonly kind: "cancelled" | "reassigned" | "rescheduled";
readonly newWhen?: string;
readonly reason?: string;
}
): Promise<boolean> {
const to = bookerEmail(appt.intakeAnswers);
if (!to) {
return false;
}
try {
const { t, locale } = await getMailTranslator(bookerLocale(appt.intakeAnswers));
const content = lifecycleMailContent(t, appt, input);
const rendered = renderSystemMessageEmail({
locale,
subject: content.subject,
previewText: content.intro,
eyebrow: content.eyebrow,
title: content.title,
intro: content.intro,
details: content.details,
notice: content.notice,
noticeTone: content.notice ? "info" : undefined,
});
await sendEmailMailjet({
data: {
to,
subject: content.subject,
body: rendered.html,
text: rendered.text,
},
});
return true;
} catch {
return false;
}
}
function lifecycleMailContent(
t: MailTranslator,
appt: AppointmentForAction,
input: {
readonly kind: "cancelled" | "reassigned" | "rescheduled";
readonly newWhen?: string;
readonly reason?: string;
}
): {
readonly subject: string;
readonly eyebrow: string;
readonly title: string;
readonly intro: string;
readonly details: readonly SystemMessageDetail[];
readonly notice?: string;
} {
const prefix = `booking.${input.kind}`;
const details: SystemMessageDetail[] = [{ label: t(`${prefix}.fields.appointment`), value: appt.pageTitle }];
if (input.kind === "rescheduled") {
details.push({ label: t(`${prefix}.fields.previous`), value: whenLabel(appt) }, { label: t(`${prefix}.fields.new`), value: input.newWhen ?? "" });
} else {
details.push({ label: t(`${prefix}.fields.when`), value: whenLabel(appt) });
}
return {
subject: t(`${prefix}.subject`, { title: appt.pageTitle }),
eyebrow: t(`${prefix}.eyebrow`),
title: t(`${prefix}.title`),
intro: t(`${prefix}.intro`),
details,
notice: input.reason ? t(`${prefix}.reason`, { reason: input.reason }) : undefined,
};
}
function whenLabel(appt: AppointmentForAction): string {
return formatInTimeZone(appt.startAt, appt.timezone, "dd.MM.yyyy HH:mm");
}
/** Mark a past appointment as held (M06 D). */
export async function markAppointmentHeld(tenantId: string, appointmentId: string, actorUserId: string): Promise<void> {
await applyStatusOnly(tenantId, appointmentId, "mark_held", actorUserId, "appointment_held", "activity.runtime.booking.held");
}
/** Mark an appointment as no-show (M06 D). */
export async function markAppointmentNoShow(tenantId: string, appointmentId: string, actorUserId: string): Promise<void> {
await applyStatusOnly(tenantId, appointmentId, "mark_no_show", actorUserId, "appointment_no_show", "activity.runtime.booking.noShow");
}
async function applyStatusOnly(
tenantId: string,
appointmentId: string,
action: LifecycleAction,
actorUserId: string,
activityType: ActivityType,
summaryKey: string
): Promise<void> {
const appt = await loadAppointmentForAction(tenantId, appointmentId);
if (!appt) {
return;
}
assertTransition(appt.status, action);
const next = statusAfter(action);
if (next) {
await db.appointment.update({ where: { id: appt.id }, data: { status: next } });
}
await recordActivity(tenantId, appt, activityType, summaryKey, actorUserId);
}
/** Cancel an appointment with a reason + auto-mail (M06 D / I). */
export async function cancelAppointment(input: {
tenantId: string;
appointmentId: string;
reason: string;
byKind: "customer" | "provider" | "system";
actorUserId: string | null;
}): Promise<void> {
const appt = await loadAppointmentForAction(input.tenantId, input.appointmentId);
if (!appt) {
return;
}
assertTransition(appt.status, "cancel");
const cancelledAt = new Date();
await db.$transaction(async (tx) => {
await tx.appointment.update({
where: { id: appt.id },
data: { status: "cancelled", cancelledAt, cancelledByKind: input.byKind, cancellationReason: input.reason },
});
if (appt.internalCalendarEventId) {
// Free the slot: archive the local mirror so it stops blocking availability.
await tx.calendarEvent.update({ where: { id: appt.internalCalendarEventId }, data: { archivedAt: new Date(), isBusyForAvailability: false } });
}
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildBookingCancelledWorkflowEvent({
tenantId: input.tenantId,
appointmentId: appt.id,
bookingPageId: appt.bookingPageId,
bookingPageSlug: appt.bookingPageSlug,
providerUserId: appt.providerUserId,
cancelledByKind: input.byKind,
cancellationReason: input.reason,
cancelledAt,
actorUserId: input.actorUserId,
})
);
});
await sendLifecycleMail(appt, {
kind: "cancelled",
reason: input.reason,
});
await recordActivity(input.tenantId, appt, "appointment_cancelled", "activity.runtime.booking.cancelled", input.actorUserId);
if (notifiesCustomer("cancel")) {
await notifyProvider(input.tenantId, appt.providerUserId, "bookings.status.cancelled", appt.id);
}
}
/** Reschedule to a new start time: supersede the old appointment with a successor (M06 D). */
export async function rescheduleAppointment(input: {
tenantId: string;
appointmentId: string;
newStartAt: Date;
actorUserId: string;
}): Promise<{ newAppointmentId: string }> {
const appt = await loadAppointmentForAction(input.tenantId, input.appointmentId);
if (!appt) {
throw new Error("appointment_not_found");
}
assertTransition(appt.status, "reschedule");
const newEndAt = new Date(input.newStartAt.getTime() + appt.durationMinutes * MINUTE_MS);
const newAppointmentId = await db.$transaction(async (tx) => {
await tx.appointment.update({ where: { id: appt.id }, data: { status: "rescheduled" } });
if (appt.internalCalendarEventId) {
await tx.calendarEvent.update({ where: { id: appt.internalCalendarEventId }, data: { archivedAt: new Date(), isBusyForAvailability: false } });
}
const successor = await tx.appointment.create({
data: {
tenantId: input.tenantId,
bookingPageId: appt.bookingPageId,
providerUserId: appt.providerUserId,
startAt: input.newStartAt,
endAt: newEndAt,
timezone: appt.timezone,
status: "scheduled",
kontaktId: appt.kontaktId,
intakeAnswers: appt.intakeAnswers as object,
rescheduledFromId: appt.id,
},
select: { id: true },
});
const calendarEvent = await tx.calendarEvent.create({
data: {
tenantId: input.tenantId,
userId: appt.providerUserId,
source: "hivegrid_internal",
title: appt.pageTitle,
startsAt: input.newStartAt,
endsAt: newEndAt,
isBusyForAvailability: true,
internalEntityLink: { type: "booking_appointment", id: successor.id },
},
select: { id: true },
});
await tx.appointment.update({ where: { id: successor.id }, data: { internalCalendarEventId: calendarEvent.id } });
await publishOutboxEvent(
tx as unknown as OutboxPublisherTransaction,
buildCalendarCreatedWorkflowEvent({
tenantId: input.tenantId,
calendarEventId: calendarEvent.id,
userId: input.actorUserId,
calendarKey: "hauptkalender",
eventKind: "termin",
source: "booking_reschedule",
sourceEntityId: successor.id,
startsAt: input.newStartAt,
endsAt: newEndAt,
})
);
return successor.id;
});
const newWhen = formatInTimeZone(input.newStartAt, appt.timezone, "dd.MM.yyyy HH:mm");
await sendLifecycleMail(appt, {
kind: "rescheduled",
newWhen,
});
await recordActivity(input.tenantId, appt, "appointment_rescheduled", "activity.runtime.booking.rescheduled", input.actorUserId);
await notifyProvider(input.tenantId, appt.providerUserId, "bookings.status.rescheduled", newAppointmentId);
return { newAppointmentId };
}
/** Re-assign the appointment to a different provider (M06 D, bookings.admin). */
export async function reassignAppointment(input: { tenantId: string; appointmentId: string; newProviderUserId: string; actorUserId: string }): Promise<void> {
const appt = await loadAppointmentForAction(input.tenantId, input.appointmentId);
if (!appt) {
return;
}
assertTransition(appt.status, "reassign");
await db.$transaction(async (tx) => {
await tx.appointment.update({ where: { id: appt.id }, data: { providerUserId: input.newProviderUserId } });
if (appt.internalCalendarEventId) {
// Move the local mirror to the new provider so availability follows.
await tx.calendarEvent.update({ where: { id: appt.internalCalendarEventId }, data: { userId: input.newProviderUserId } });
}
});
await sendLifecycleMail(appt, {
kind: "reassigned",
});
await recordActivity(input.tenantId, appt, "appointment_rescheduled", "activity.runtime.booking.reassigned", input.actorUserId);
await notifyProvider(input.tenantId, input.newProviderUserId, "bookings.status.scheduled", appt.id);
}