diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 777ced7..2c2f3e4 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -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": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index c692033..3d1226e 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -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": { diff --git a/src/lib/const/email.ts b/src/lib/const/email.ts new file mode 100644 index 0000000..8436839 --- /dev/null +++ b/src/lib/const/email.ts @@ -0,0 +1,3 @@ +export const EMAIL_TYPE = { + APPOINTMENT_REMINDER: "APPOINTMENT_REMINDER", +} as const; diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte index 3be3d7e..fcd9c12 100644 --- a/src/lib/emails/AppointmentReminder.svelte +++ b/src/lib/emails/AppointmentReminder.svelte @@ -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 @@ - {m["emails.greeting"]({ name: user.email }, { locale })} + + {m["emails.greeting"]({ name: user.name || user.email }, { locale })} + {m["emails.appointmentReminder.introduction"]({ tenant: tenant.longName }, { locale })} diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index c4fd211..71de07f 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -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, diff --git a/src/lib/types/emails.ts b/src/lib/types/emails.ts new file mode 100644 index 0000000..5c5b0ca --- /dev/null +++ b/src/lib/types/emails.ts @@ -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; + }; +}; diff --git a/src/routes/(pages)/dashboard/(components)/EmailReminder.svelte b/src/routes/(pages)/dashboard/(components)/EmailReminder.svelte new file mode 100644 index 0000000..6501de1 --- /dev/null +++ b/src/routes/(pages)/dashboard/(components)/EmailReminder.svelte @@ -0,0 +1,107 @@ + + + + + {m["dashboard.appointmentReminders.title"]()} + + {#if ["init", "loading"].includes(status)} + + {m["dashboard.appointmentReminders.loading"]()} + {:else if appointments.length === 0} + + {m["dashboard.appointmentReminders.noneUpcoming"]()} + {:else if status === "sending"} + + {m["dashboard.appointmentReminders.sending"]({ length: appointments.length })} + {:else if status === "success"} + {m["dashboard.appointmentReminders.success"]({ length: appointments.length })} + {:else} + + {m["dashboard.appointmentReminders.error"]()} + {/if} + + + diff --git a/src/routes/(pages)/dashboard/(components)/utils.ts b/src/routes/(pages)/dashboard/(components)/utils.ts new file mode 100644 index 0000000..360afe9 --- /dev/null +++ b/src/routes/(pages)/dashboard/(components)/utils.ts @@ -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; + } +}; diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte index 6d4a28c..70975f2 100644 --- a/src/routes/(pages)/dashboard/+page.svelte +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -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); @@ -155,6 +156,9 @@ ]} /> {/if} + {#if $auth.user && ["TENANT_ADMIN", "STAFF"].includes($auth.user?.role) && tenant && tenant.setupState === "READY"} + + {/if} diff --git a/src/routes/api/tenants/[id]/send-emails/+server.ts b/src/routes/api/tenants/[id]/send-emails/+server.ts new file mode 100644 index 0000000..020a83a --- /dev/null +++ b/src/routes/api/tenants/[id]/send-emails/+server.ts @@ -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(); + } +}; diff --git a/src/routes/api/tenants/[id]/send-emails/__tests__/send-emails-api.test.ts b/src/routes/api/tenants/[id]/send-emails/__tests__/send-emails-api.test.ts new file mode 100644 index 0000000..3586f04 --- /dev/null +++ b/src/routes/api/tenants/[id]/send-emails/__tests__/send-emails-api.test.ts @@ -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 => + ({ + 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"); + }); + }); +});