From 9aaef3f8aa476fd9147ac183cb18aae79e9bce4f Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Sun, 4 Jan 2026 17:29:07 +0100 Subject: [PATCH] Deletion endpoint for staff --- .../[appointmentId]/delete/+server.ts | 171 ++++++++++++ .../delete/__tests__/delete-api.test.ts | 246 ++++++++++++++++++ 2 files changed, 417 insertions(+) create mode 100644 src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/+server.ts create mode 100644 src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/__tests__/delete-api.test.ts diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/+server.ts new file mode 100644 index 0000000..d6f5036 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/+server.ts @@ -0,0 +1,171 @@ +import { json } from "@sveltejs/kit"; +import { z } from "zod"; +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import type { RequestHandler } from "@sveltejs/kit"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import logger from "$lib/logger"; +import { checkPermission } from "$lib/server/utils/permissions"; + +const requestSchema = z.object({ + clientEmail: z.string().email(), + clientLanguage: z.string().optional().default("de"), +}); + +// Register OpenAPI documentation for DELETE +registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", { + summary: "Delete appointment", + description: + "Deletes an appointment, removing it from the database, sending a cancellation email to the client, and creating notifications for channel staff. Accessible to staff and tenant admins.", + tags: ["Appointments"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "appointmentId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Appointment ID", + }, + ], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + clientEmail: { + type: "string", + format: "email", + description: "Email address of the client", + example: "client@example.com", + }, + clientLanguage: { + type: "string", + description: "Client's preferred language for the cancellation email", + default: "de", + example: "de", + }, + }, + required: ["clientEmail"], + }, + }, + }, + }, + responses: { + "200": { + description: "Appointment deleted successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "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: "Insufficient permissions", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Appointment not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const DELETE: RequestHandler = async ({ params, request, locals }) => { + const log = logger.setContext("API"); + + try { + const tenantId = params.id; + const appointmentId = params.appointmentId; + + // Check if user is authenticated + if (!tenantId || !appointmentId) { + throw new ValidationError("Tenant ID and appointment ID are required"); + } + + checkPermission(locals, tenantId); + + // Parse request body + const body = await request.json(); + const validationResult = requestSchema.safeParse(body); + + if (!validationResult.success) { + throw new ValidationError("Invalid request body"); + } + + const { clientEmail, clientLanguage } = validationResult.data; + + log.debug("Deleting appointment", { + tenantId, + appointmentId, + clientEmail, + requestedBy: locals.user?.id, + }); + + const appointmentService = await AppointmentService.forTenant(tenantId); + await appointmentService.deleteAppointmentByStaff(appointmentId, clientEmail, clientLanguage); + + log.debug("Appointment deleted successfully", { + tenantId, + appointmentId, + requestedBy: locals.user?.id, + }); + + return json({ + message: "Appointment deleted successfully", + }); + } catch (error) { + logError(log)("Error deleting appointment", 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]/appointments/[appointmentId]/delete/__tests__/delete-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/__tests__/delete-api.test.ts new file mode 100644 index 0000000..921b103 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/delete/__tests__/delete-api.test.ts @@ -0,0 +1,246 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { DELETE } from "../+server"; +import * as appointmentService from "$lib/server/services/appointment-service"; +import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; + +// Mock the appointment service +vi.mock("$lib/server/services/appointment-service", () => ({ + AppointmentService: { + forTenant: vi.fn(), + }, +})); + +// Mock permission check +vi.mock("$lib/server/utils/permissions", () => ({ + checkPermission: vi.fn(), +})); + +// Mock logger +vi.mock("$lib/logger", () => ({ + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + })), + }, +})); + +describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete", () => { + const mockTenantId = "tenant-123"; + const mockAppointmentId = "appointment-456"; + const mockClientEmail = "client@example.com"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("should delete appointment successfully with valid data", async () => { + const mockDeleteAppointmentByStaff = vi.fn().mockResolvedValue(undefined); + + vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({ + deleteAppointmentByStaff: mockDeleteAppointmentByStaff, + } as any); + + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: mockClientEmail, + clientLanguage: "de", + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(result).toEqual({ + message: "Appointment deleted successfully", + }); + expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith( + mockAppointmentId, + mockClientEmail, + "de", + ); + }); + + it("should use default language when not provided", async () => { + const mockDeleteAppointmentByStaff = vi.fn().mockResolvedValue(undefined); + + vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({ + deleteAppointmentByStaff: mockDeleteAppointmentByStaff, + } as any); + + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: mockClientEmail, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + const result = await response.json(); + + expect(response.status).toBe(200); + expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith( + mockAppointmentId, + mockClientEmail, + "de", + ); + }); + + it("should return 422 when clientEmail is missing", async () => { + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({}), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + expect(response.status).toBe(422); + }); + + it("should return 422 when clientEmail is invalid", async () => { + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: "invalid-email", + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + expect(response.status).toBe(422); + }); + + it("should return 422 when tenantId is missing", async () => { + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: mockClientEmail, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + expect(response.status).toBe(422); + }); + + it("should return 422 when appointmentId is missing", async () => { + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: mockClientEmail, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + expect(response.status).toBe(422); + }); + + it("should return 404 when appointment is not found", async () => { + const mockDeleteAppointmentByStaff = vi + .fn() + .mockRejectedValue(new NotFoundError("Appointment not found")); + + vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({ + deleteAppointmentByStaff: mockDeleteAppointmentByStaff, + } as any); + + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: mockClientEmail, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + expect(response.status).toBe(404); + }); + + it("should handle service errors gracefully", async () => { + const mockDeleteAppointmentByStaff = vi + .fn() + .mockRejectedValue(new Error("Database connection failed")); + + vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({ + deleteAppointmentByStaff: mockDeleteAppointmentByStaff, + } as any); + + const request = new Request("http://localhost", { + method: "DELETE", + body: JSON.stringify({ + clientEmail: mockClientEmail, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const response = await DELETE({ + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request, + locals: { user: { id: "user-123" } }, + } as any); + + expect(response.status).toBe(500); + }); +});