diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/deny/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/deny/+server.ts new file mode 100644 index 0000000..d13b755 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/deny/+server.ts @@ -0,0 +1,151 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import { z } from "zod"; +import logger from "$lib/logger"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { checkPermission } from "$lib/server/utils/permissions"; + +const requestSchema = z.object({ + clientEmail: z.string().email().optional(), + clientLanguage: z.string().optional(), +}); + +// Register OpenAPI documentation for POST +registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/deny", "POST", { + summary: "Deny appointment", + description: + "Denies a pending appointment request. Updates the appointment status to REJECTED and sends a rejection notification email to the client.", + tags: ["Appointments", "Staff"], + 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 to deny", + }, + ], + requestBody: { + description: "Client information for rejection email", + content: { + "application/json": { + schema: { + type: "object", + properties: { + clientEmail: { + type: "string", + format: "email", + description: "Client email address for rejection notification", + }, + clientLanguage: { + type: "string", + description: "Client preferred language (e.g., 'de', 'en')", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Appointment denied successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { + type: "boolean", + example: true, + }, + }, + }, + }, + }, + }, + "400": { + description: "Missing required parameters", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Appointment not found or in wrong state", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +/** + * POST /api/tenants/[id]/appointments/[appointmentId]/deny + * + * Denies a pending appointment request. The appointment must be in NEW status. + * Sends a rejection email to the client and creates staff notifications. + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const log = logger.setContext("API.DenyAppointment"); + const tenantId = params.id; + const { appointmentId } = params; + + try { + if (!tenantId) { + throw new ValidationError("Tenant ID is required"); + } + if (!appointmentId) { + throw new ValidationError("appointmentId is required"); + } + + checkPermission(locals, tenantId); + + const appointmentService = await AppointmentService.forTenant(tenantId); + const body = await request.json(); + const { clientEmail, clientLanguage } = requestSchema.parse(body); + + log.info("Denying appointment", { + tenantId, + appointmentId, + clientEmailPrefix: clientEmail ? clientEmail.slice(0, 3) : undefined, + }); + + await appointmentService.denyAppointment(appointmentId, clientEmail, clientLanguage); + log.info("Appointment denied successfully", { + tenantId, + appointmentId, + }); + return json({ success: true }); + } catch (error) { + logError(log)("Error denying appointment", error); + + if (error instanceof BackendError) { + return error.toJson(); + } + + if (error instanceof z.ZodError) { + return new ValidationError("Invalid request data").toJson(); + } + + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/deny/__tests__/deny-appointment-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/deny/__tests__/deny-appointment-api.test.ts new file mode 100644 index 0000000..1c98f11 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/deny/__tests__/deny-appointment-api.test.ts @@ -0,0 +1,84 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "../+server"; +import type { RequestEvent } from "@sveltejs/kit"; + +vi.mock("$lib/server/services/appointment-service", () => ({ + AppointmentService: { + forTenant: vi.fn(), + }, +})); + +vi.mock("$lib/server/utils/permissions", () => ({ + checkPermission: vi.fn(), +})); + +vi.mock("$lib/logger", () => ({ + default: { + setContext: vi.fn(() => ({ + info: vi.fn(), + error: vi.fn(), + })), + }, +})); + +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { checkPermission } from "$lib/server/utils/permissions"; + +describe("POST /api/tenants/[id]/appointments/[appointmentId]/deny", () => { + const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; + const mockAppointmentId = "456e7890-e12b-34d5-a678-901234567890"; + const mockAppointmentService = { + denyAppointment: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService); + vi.mocked(checkPermission).mockImplementation(() => {}); + }); + + function createMockRequestEvent(overrides: Partial = {}): RequestEvent { + return { + params: { id: mockTenantId, appointmentId: mockAppointmentId }, + request: { + json: vi.fn().mockResolvedValue({ clientEmail: "test@example.com", clientLanguage: "de" }), + } as any, + locals: { + user: { + userId: "user123", + role: "TENANT_ADMIN", + tenantId: mockTenantId, + }, + } as any, + ...overrides, + } as RequestEvent; + } + + it("should deny appointment", async () => { + mockAppointmentService.denyAppointment.mockResolvedValue(true); + const event = createMockRequestEvent(); + const response = await POST(event); + const data = await response.json(); + expect(response.status).toBe(200); + expect(data.success).toBe(true); + expect(mockAppointmentService.denyAppointment).toHaveBeenCalledWith( + mockAppointmentId, + "test@example.com", + "de", + ); + }); + + it("should return 400 if appointmentId is missing", async () => { + const event = createMockRequestEvent({ params: { id: mockTenantId } }); + const response = await POST(event); + expect(response.status).toBe(422); + }); + + it("should handle service errors", async () => { + mockAppointmentService.denyAppointment.mockRejectedValue(new Error("fail")); + const event = createMockRequestEvent(); + const response = await POST(event); + expect(response.status).toBe(500); + }); +});