Added appointment reminder emails

This commit is contained in:
Karl Ludwig Weise
2026-08-28 17:24:01 +02:00
parent 7a70b437d6
commit e3307004ce
11 changed files with 658 additions and 3 deletions
+26
View File
@@ -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": {
+26
View File
@@ -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": {
+3
View File
@@ -0,0 +1,3 @@
export const EMAIL_TYPE = {
APPOINTMENT_REMINDER: "APPOINTMENT_REMINDER",
} as const;
+4 -2
View File
@@ -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>
+2 -1
View File
@@ -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,
+11
View File
@@ -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");
});
});
});