// 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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); }