mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-29 04:04:51 +02:00
Added appointment reminder emails
This commit is contained in:
@@ -425,6 +425,32 @@
|
||||
"action": "Person hinzufügen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appointmentReminders": {
|
||||
"title": "Termin-Erinnerungen",
|
||||
"loading": "Lade Termine bis morgen Abend",
|
||||
"noneUpcoming": "Keine bevorstehenden Termine",
|
||||
"sending": [
|
||||
{
|
||||
"declarations": ["input length", "local lengthPlural = length: plural"],
|
||||
"selectors": ["lengthPlural"],
|
||||
"match": {
|
||||
"lengthPlural=one": "Sende {length} Erinnerung",
|
||||
"lengthPlural=other": "Sende {length} Erinnerungen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"success": [
|
||||
{
|
||||
"declarations": ["input length", "local lengthPlural = length: plural"],
|
||||
"selectors": ["lengthPlural"],
|
||||
"match": {
|
||||
"lengthPlural=one": "{length} Erinnerung gesendet",
|
||||
"lengthPlural=other": "{length} Erinnerungen gesendet"
|
||||
}
|
||||
}
|
||||
],
|
||||
"error": "Fehler beim Senden"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -433,6 +433,32 @@
|
||||
"action": "Add Staff Member"
|
||||
}
|
||||
}
|
||||
},
|
||||
"appointmentReminders": {
|
||||
"title": "Appointment Reminders",
|
||||
"loading": "Loading appointments until tomorrow evening",
|
||||
"noneUpcoming": "No upcoming appointments",
|
||||
"sending": [
|
||||
{
|
||||
"declarations": ["input length", "local lengthPlural = length: plural"],
|
||||
"selectors": ["lengthPlural"],
|
||||
"match": {
|
||||
"lengthPlural=one": "Sending {length} reminder",
|
||||
"lengthPlural=other": "Sending {length} reminders"
|
||||
}
|
||||
}
|
||||
],
|
||||
"success": [
|
||||
{
|
||||
"declarations": ["input length", "local lengthPlural = length: plural"],
|
||||
"selectors": ["lengthPlural"],
|
||||
"match": {
|
||||
"lengthPlural=one": "{length} reminder sent",
|
||||
"lengthPlural=other": "{length} reminders sent"
|
||||
}
|
||||
}
|
||||
],
|
||||
"error": "Failed to send reminders"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const EMAIL_TYPE = {
|
||||
APPOINTMENT_REMINDER: "APPOINTMENT_REMINDER",
|
||||
} as const;
|
||||
@@ -20,7 +20,7 @@
|
||||
cancelUrl,
|
||||
}: {
|
||||
locale: SupportedLocale;
|
||||
user: SelectClient;
|
||||
user: SelectClient & { name?: string };
|
||||
tenant: SelectTenant;
|
||||
channel: string;
|
||||
appointment: SelectAppointment & { agentName: string };
|
||||
@@ -36,7 +36,9 @@
|
||||
</script>
|
||||
|
||||
<EmailLayout {locale}>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.email }, { locale })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.greeting"]({ name: user.name || user.email }, { locale })}
|
||||
</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName }, { locale })}
|
||||
</EmailText>
|
||||
|
||||
@@ -24,6 +24,7 @@ import PinReset from "$lib/emails/PinReset.svelte";
|
||||
import { dev } from "$app/environment";
|
||||
import Notification from "$lib/emails/Notification.svelte";
|
||||
import AppointmentUpdated from "$lib/emails/AppointmentUpdated.svelte";
|
||||
import AppointmentReminder from "$lib/emails/AppointmentReminder.svelte";
|
||||
|
||||
export type SelectClient = {
|
||||
email: string;
|
||||
@@ -202,7 +203,7 @@ export async function sendAppointmentReminderEmail(
|
||||
},
|
||||
{ locale },
|
||||
);
|
||||
const emailRender = render(AppointmentBooked, {
|
||||
const emailRender = render(AppointmentReminder, {
|
||||
props: {
|
||||
locale,
|
||||
channel: channelTitle || appointment.channelId,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { EMAIL_TYPE } from "$lib/const/email";
|
||||
|
||||
export type TEmailAppointmentReminder = {
|
||||
type: typeof EMAIL_TYPE.APPOINTMENT_REMINDER;
|
||||
appointment: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
locale: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { getLocale } from "$i18n/runtime";
|
||||
import { getStaffKeyShares } from "$lib/client/crypto";
|
||||
import * as Card from "$lib/components/ui/card";
|
||||
import { EMAIL_TYPE } from "$lib/const/email";
|
||||
import { staffCrypto } from "$lib/stores/staff-crypto";
|
||||
import { tenants } from "$lib/stores/tenants";
|
||||
import type { TEmailAppointmentReminder } from "$lib/types/emails";
|
||||
import { Check, Cross, Loader2 } from "@lucide/svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getNextAppointments, sendAppointmentReminders } from "./utils";
|
||||
import { m } from "$i18n/messages";
|
||||
|
||||
let status: "init" | "loading" | "sending" | "success" | "error" = $state("init");
|
||||
let appointments: TEmailAppointmentReminder[] = $state([]);
|
||||
|
||||
onMount(() => {
|
||||
sendReminders();
|
||||
});
|
||||
|
||||
const sendReminders = async () => {
|
||||
const tenantId = $tenants.currentTenant?.id;
|
||||
if (tenantId) {
|
||||
status = "loading";
|
||||
|
||||
// Wait a moment for staff to recognize
|
||||
// Also gives staff crypto time to load, if not already loaded
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const allAppointments = await getNextAppointments(tenantId);
|
||||
const decryptPromises = allAppointments.map(async (appointment) => {
|
||||
if (
|
||||
!$staffCrypto.crypto ||
|
||||
!appointment.encryptedPayload ||
|
||||
!appointment.iv ||
|
||||
!appointment.authTag
|
||||
)
|
||||
return undefined;
|
||||
|
||||
// TODO: Don't even decrypt, if reminder was already sent
|
||||
// if (appointment.remindedAt) return undefined;
|
||||
|
||||
const staffKeyShares = await getStaffKeyShares(tenantId, appointment.tunnelId);
|
||||
const staffKeyShare = staffKeyShares[0];
|
||||
|
||||
const decryptedAppointment = await $staffCrypto.crypto.decryptStaffAppointment({
|
||||
encryptedAppointment: {
|
||||
encryptedPayload: appointment.encryptedPayload,
|
||||
iv: appointment.iv,
|
||||
authTag: appointment.authTag,
|
||||
},
|
||||
staffKeyShare: staffKeyShare.encryptedTunnelKey,
|
||||
});
|
||||
|
||||
// Remove clients that don't want notifications
|
||||
if (decryptedAppointment.shareEmail !== true) return undefined;
|
||||
|
||||
return {
|
||||
type: EMAIL_TYPE.APPOINTMENT_REMINDER,
|
||||
appointment: {
|
||||
id: appointment.id,
|
||||
appointmentDate: new Date(appointment.appointmentDate),
|
||||
timezone: appointment.timezone,
|
||||
name: decryptedAppointment.name,
|
||||
email: decryptedAppointment.email,
|
||||
locale: decryptedAppointment.locale || getLocale(),
|
||||
},
|
||||
};
|
||||
});
|
||||
const decryptedAppointments = await Promise.all(decryptPromises);
|
||||
appointments = decryptedAppointments.filter((it) => it !== undefined);
|
||||
|
||||
if (appointments.length > 0) {
|
||||
status = "sending";
|
||||
const resp = await sendAppointmentReminders(tenantId, appointments);
|
||||
status = resp ? "success" : "error";
|
||||
} else {
|
||||
status = "success";
|
||||
}
|
||||
} else {
|
||||
status = "error";
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Card.Root class="gap-2 p-3">
|
||||
<Card.Header class="p-0">
|
||||
<Card.Title>{m["dashboard.appointmentReminders.title"]()}</Card.Title>
|
||||
<Card.Description class="flex items-center gap-2">
|
||||
{#if ["init", "loading"].includes(status)}
|
||||
<Loader2 class="size-4 animate-spin" />
|
||||
{m["dashboard.appointmentReminders.loading"]()}
|
||||
{:else if appointments.length === 0}
|
||||
<Check class="size-4" />
|
||||
{m["dashboard.appointmentReminders.noneUpcoming"]()}
|
||||
{:else if status === "sending"}
|
||||
<Loader2 class="size-4 animate-spin" />
|
||||
{m["dashboard.appointmentReminders.sending"]({ length: appointments.length })}
|
||||
{:else if status === "success"}
|
||||
{m["dashboard.appointmentReminders.success"]({ length: appointments.length })}
|
||||
{:else}
|
||||
<Cross class="size-4" />
|
||||
{m["dashboard.appointmentReminders.error"]()}
|
||||
{/if}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
@@ -0,0 +1,65 @@
|
||||
import { browser } from "$app/env";
|
||||
import { goto } from "$app/navigation";
|
||||
import { resolve } from "$app/paths";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import type { TAppointment } from "$lib/types/appointments";
|
||||
import type { TEmailAppointmentReminder } from "$lib/types/emails";
|
||||
|
||||
export const getNextAppointments = async (tenant: string) => {
|
||||
if (!browser) return [];
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const tomorrowEvening = new Date(now);
|
||||
tomorrowEvening.setDate(now.getDate() + 1);
|
||||
tomorrowEvening.setHours(23, 59, 59, 999);
|
||||
const params = new URLSearchParams({
|
||||
startDate: now.toISOString(),
|
||||
endDate: tomorrowEvening.toISOString(),
|
||||
});
|
||||
const res = await fetch(`/api/tenants/${tenant}/appointments?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status < 400) {
|
||||
return data.appointments as TAppointment[];
|
||||
} else {
|
||||
if (res.status === 401) {
|
||||
goto(resolve(ROUTES.LOGIN));
|
||||
} else {
|
||||
console.error("Unable to fetch next appointments", res.status, res.statusText);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch next appointments", err);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const sendAppointmentReminders = async (
|
||||
tenant: string,
|
||||
appointments: TEmailAppointmentReminder[],
|
||||
) => {
|
||||
if (!browser) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/tenants/${tenant}/send-emails`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ emails: appointments }),
|
||||
});
|
||||
|
||||
if (res.status < 400) {
|
||||
return true;
|
||||
} else {
|
||||
if (res.status === 401) {
|
||||
goto(resolve(ROUTES.LOGIN));
|
||||
} else {
|
||||
console.error("Unable to send appointment reminders", res.status, res.statusText);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send appointment reminders", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@
|
||||
import { isSetupStateDone } from "$lib/utils/tenants";
|
||||
import MenuPositionIcon from "@lucide/svelte/icons/corner-left-up";
|
||||
import CloseIcon from "@lucide/svelte/icons/x";
|
||||
import EmailReminder from "./(components)/EmailReminder.svelte";
|
||||
|
||||
const tenant = $derived($tenants.currentTenant);
|
||||
</script>
|
||||
@@ -155,6 +156,9 @@
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
{#if $auth.user && ["TENANT_ADMIN", "STAFF"].includes($auth.user?.role) && tenant && tenant.setupState === "READY"}
|
||||
<EmailReminder />
|
||||
{/if}
|
||||
</div>
|
||||
</SidebarLayout>
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { EMAIL_TYPE } from "$lib/const/email";
|
||||
import { ERRORS } from "$lib/errors";
|
||||
import { logger } from "$lib/logger";
|
||||
import { sendAppointmentReminderEmail } from "$lib/server/email/email-service";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import { AppointmentService } from "$lib/server/services/appointment-service";
|
||||
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
|
||||
import {
|
||||
AuthenticationError,
|
||||
AuthorizationError,
|
||||
BackendError,
|
||||
InternalError,
|
||||
logError,
|
||||
ValidationError,
|
||||
} from "$lib/server/utils/errors";
|
||||
import { checkPermission } from "$lib/server/utils/permissions";
|
||||
import type { RequestHandler } from "@sveltejs/kit";
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
|
||||
// Register OpenAPI documentation for POST
|
||||
registerOpenAPIRoute("/tenants/{id}/send-emails", "POST", {
|
||||
summary: "Send email endpoint for staff members",
|
||||
description:
|
||||
"Sends a list of emails as a batch operation. Only staff and tenant admins can send emails. Used for appointment reminders.",
|
||||
tags: ["E-Mails", "Appointments"],
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string", format: "uuid" },
|
||||
description: "Tenant ID",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
description: "List of emails to be sent",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["APPOINTMENT_REMINDER"] },
|
||||
appointment: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", format: "uuid" },
|
||||
name: { type: "string" },
|
||||
email: { type: "string", format: "email" },
|
||||
locale: { type: "string" },
|
||||
},
|
||||
required: ["id", "name", "email", "locale"],
|
||||
},
|
||||
},
|
||||
required: ["type", "appointment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description: "E-Mails successfully sent",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Invalid input data",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"401": {
|
||||
description: "Authentication required",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "Proper role required",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"500": {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emailSendingSchema = z.object({
|
||||
emails: z.array(
|
||||
z.object({
|
||||
type: z.enum(EMAIL_TYPE),
|
||||
appointment: z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
email: z.email(),
|
||||
locale: z.string(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async ({ params, locals, request }) => {
|
||||
const log = logger.setContext("API");
|
||||
const tenantId = params.id;
|
||||
const user = locals.user;
|
||||
|
||||
if (!user) {
|
||||
throw new AuthenticationError();
|
||||
}
|
||||
|
||||
if (!user.tenantId || !tenantId) {
|
||||
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
|
||||
}
|
||||
|
||||
checkPermission(locals, tenantId, true);
|
||||
|
||||
if (!["STAFF", "TENANT_ADMIN"].includes(locals.user?.role || "")) {
|
||||
throw new AuthorizationError("Forbidden", 403);
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validation = emailSendingSchema.safeParse(body);
|
||||
if (!validation.success) {
|
||||
throw new ValidationError("Invalid email queue data");
|
||||
}
|
||||
|
||||
// Get tenant
|
||||
const tenantAdminService = await TenantAdminService.getTenantById(user.tenantId);
|
||||
const tenant = tenantAdminService.tenantData;
|
||||
if (!tenant) {
|
||||
throw new BackendError("Tenant not found", 500);
|
||||
}
|
||||
|
||||
// Get appointment service ready
|
||||
const appointmentService = await AppointmentService.forTenant(tenantId);
|
||||
|
||||
// Send emails
|
||||
const sentEmails = validation.data.emails.map(async (email) => {
|
||||
const appointment = await appointmentService.getAppointmentById(email.appointment.id);
|
||||
switch (email.type) {
|
||||
case EMAIL_TYPE.APPOINTMENT_REMINDER:
|
||||
return sendAppointmentReminderEmail(
|
||||
{
|
||||
name: email.appointment.name,
|
||||
email: email.appointment.email,
|
||||
language: email.appointment.locale,
|
||||
},
|
||||
tenant,
|
||||
appointment,
|
||||
);
|
||||
default:
|
||||
throw new ValidationError(`Unsupported email type: ${email.type}`);
|
||||
}
|
||||
});
|
||||
await Promise.all(sentEmails);
|
||||
|
||||
log.debug("E-Mails successfully sent", { tenantId });
|
||||
return json({});
|
||||
} catch (error) {
|
||||
logError(log)("Error sending e-mails", error, locals.user?.id, params.id);
|
||||
|
||||
if (error instanceof BackendError) {
|
||||
return error.toJson();
|
||||
}
|
||||
|
||||
return new InternalError().toJson();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RequestEvent } from "@sveltejs/kit";
|
||||
import { EMAIL_TYPE } from "$lib/const/email";
|
||||
import { AuthenticationError, AuthorizationError, ValidationError } from "$lib/server/utils/errors";
|
||||
import { POST } from "../+server";
|
||||
|
||||
vi.mock("$lib/server/services/appointment-service", () => ({
|
||||
AppointmentService: {
|
||||
forTenant: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/services/tenant-admin-service", () => ({
|
||||
TenantAdminService: {
|
||||
getTenantById: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/email/email-service", () => ({
|
||||
sendAppointmentReminderEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/utils/permissions", () => ({
|
||||
checkPermission: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/openapi", () => ({ registerOpenAPIRoute: vi.fn() }));
|
||||
|
||||
vi.mock("$lib/logger", () => ({
|
||||
logger: {
|
||||
setContext: vi.fn(() => ({ debug: vi.fn(), error: vi.fn() })),
|
||||
},
|
||||
}));
|
||||
|
||||
import { sendAppointmentReminderEmail } from "$lib/server/email/email-service";
|
||||
import { AppointmentService } from "$lib/server/services/appointment-service";
|
||||
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
|
||||
import { checkPermission } from "$lib/server/utils/permissions";
|
||||
|
||||
describe("Send Email API", () => {
|
||||
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
|
||||
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174001";
|
||||
const mockTenant = { id: mockTenantId, shortName: "test-practice" };
|
||||
const mockAppointment = { id: mockAppointmentId };
|
||||
const mockAppointmentService = { getAppointmentById: vi.fn() };
|
||||
const validBody = {
|
||||
emails: [
|
||||
{
|
||||
type: EMAIL_TYPE.APPOINTMENT_REMINDER,
|
||||
appointment: {
|
||||
id: mockAppointmentId,
|
||||
name: "Ada Lovelace",
|
||||
email: "ada@example.com",
|
||||
locale: "de",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const createEvent = (
|
||||
body: unknown = validBody,
|
||||
overrides: Partial<RequestEvent> = {},
|
||||
): RequestEvent =>
|
||||
({
|
||||
params: { id: mockTenantId },
|
||||
locals: {
|
||||
user: { id: "staff-123", tenantId: mockTenantId, role: "STAFF" },
|
||||
},
|
||||
request: new Request("http://localhost/api/tenants/send-emails", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
...overrides,
|
||||
}) as any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(checkPermission).mockImplementation(() => {});
|
||||
vi.mocked(TenantAdminService.getTenantById).mockResolvedValue({
|
||||
tenantData: mockTenant,
|
||||
} as any);
|
||||
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockAppointmentService as any);
|
||||
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
|
||||
vi.mocked(sendAppointmentReminderEmail).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("POST /api/tenants/[id]/send-emails", () => {
|
||||
it("sends every appointment reminder in the batch", async () => {
|
||||
const secondAppointmentId = "123e4567-e89b-12d3-a456-426614174002";
|
||||
const body = {
|
||||
emails: [
|
||||
...validBody.emails,
|
||||
{
|
||||
type: EMAIL_TYPE.APPOINTMENT_REMINDER,
|
||||
appointment: {
|
||||
id: secondAppointmentId,
|
||||
name: "Grace Hopper",
|
||||
email: "grace@example.com",
|
||||
locale: "en",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const response = await POST(createEvent(body));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({});
|
||||
expect(checkPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
mockTenantId,
|
||||
true,
|
||||
);
|
||||
expect(TenantAdminService.getTenantById).toHaveBeenCalledWith(mockTenantId);
|
||||
expect(AppointmentService.forTenant).toHaveBeenCalledWith(mockTenantId);
|
||||
expect(mockAppointmentService.getAppointmentById).toHaveBeenCalledWith(mockAppointmentId);
|
||||
expect(mockAppointmentService.getAppointmentById).toHaveBeenCalledWith(secondAppointmentId);
|
||||
expect(sendAppointmentReminderEmail).toHaveBeenCalledWith(
|
||||
{ name: "Ada Lovelace", email: "ada@example.com", language: "de" },
|
||||
mockTenant,
|
||||
mockAppointment,
|
||||
);
|
||||
expect(sendAppointmentReminderEmail).toHaveBeenCalledWith(
|
||||
{ name: "Grace Hopper", email: "grace@example.com", language: "en" },
|
||||
mockTenant,
|
||||
mockAppointment,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows tenant administrators to send reminders", async () => {
|
||||
const response = await POST(
|
||||
createEvent(validBody, {
|
||||
locals: {
|
||||
user: { id: "admin-123", tenantId: mockTenantId, role: "TENANT_ADMIN" },
|
||||
} as any,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(sendAppointmentReminderEmail).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
await expect(
|
||||
POST(createEvent(validBody, { locals: { user: null } as any })),
|
||||
).rejects.toBeInstanceOf(AuthenticationError);
|
||||
});
|
||||
|
||||
it("rejects requests without a tenant ID", async () => {
|
||||
await expect(
|
||||
POST(
|
||||
createEvent(validBody, { locals: { user: { id: "staff-123", role: "STAFF" } } as any }),
|
||||
),
|
||||
).rejects.toBeInstanceOf(ValidationError);
|
||||
});
|
||||
|
||||
it("propagates permission failures", async () => {
|
||||
vi.mocked(checkPermission).mockImplementation(() => {
|
||||
throw new AuthorizationError("Insufficient permissions");
|
||||
});
|
||||
|
||||
await expect(POST(createEvent())).rejects.toBeInstanceOf(AuthorizationError);
|
||||
});
|
||||
|
||||
it("rejects roles other than staff and tenant admin", async () => {
|
||||
await expect(
|
||||
POST(
|
||||
createEvent(validBody, {
|
||||
locals: { user: { id: "user-123", tenantId: mockTenantId, role: "USER" } } as any,
|
||||
}),
|
||||
),
|
||||
).rejects.toBeInstanceOf(AuthorizationError);
|
||||
});
|
||||
|
||||
it("returns a validation error for an invalid email queue", async () => {
|
||||
const response = await POST(createEvent({ emails: [{ type: "INVALID" }] }));
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(await response.json()).toHaveProperty("error");
|
||||
expect(TenantAdminService.getTenantById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a backend error when the tenant no longer exists", async () => {
|
||||
vi.mocked(TenantAdminService.getTenantById).mockResolvedValue({ tenantData: null } as any);
|
||||
|
||||
const response = await POST(createEvent());
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toMatchObject({ error: "Tenant not found" });
|
||||
expect(AppointmentService.forTenant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an internal error when appointment lookup fails", async () => {
|
||||
mockAppointmentService.getAppointmentById.mockRejectedValue(
|
||||
new Error("Database unavailable"),
|
||||
);
|
||||
|
||||
const response = await POST(createEvent());
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toHaveProperty("error");
|
||||
expect(sendAppointmentReminderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns an internal error when email delivery fails", async () => {
|
||||
vi.mocked(sendAppointmentReminderEmail).mockRejectedValue(
|
||||
new Error("Email provider unavailable"),
|
||||
);
|
||||
|
||||
const response = await POST(createEvent());
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user