Deletion endpoint for staff

This commit is contained in:
Hendrik Belitz
2026-01-04 17:29:07 +01:00
parent e89642db82
commit 9aaef3f8aa
2 changed files with 417 additions and 0 deletions
@@ -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();
}
};
@@ -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);
});
});