381 lines
14 KiB
TypeScript
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);
|
|
}
|