diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index be4b704..f9c27e7 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -84,7 +84,7 @@ export class AppointmentService { * @param clientLanguage - Client's preferred language * @param requiresConfirmation - Whether the appointment requires staff confirmation */ - private async sendAppointmentNotification( + async sendAppointmentNotification( appointmentId: string, channelId: string, clientEmail: string, @@ -460,6 +460,112 @@ export class AppointmentService { })); } + /** + * Add appointment to existing client tunnel + */ + public async addAppointmentToTunnel(appointmentData: { + emailHash: string; + tunnelId: string; + channelId: string; + agentId: string; + appointmentDate: string; + duration: number; + clientEmail: string; + clientLanguage?: string; + encryptedAppointment: { + encryptedPayload: string; + iv: string; + authTag: string; + }; + }): Promise { + const log = logger.setContext("AppointmentService"); + + log.info("Adding appointment to existing tunnel", { + tenantId: this.tenantId, + tunnelId: appointmentData.tunnelId, + appointmentDate: appointmentData.appointmentDate, + emailHashPrefix: appointmentData.emailHash.slice(0, 8), + }); + + const db = await this.getDb(); + + // Check if tunnel exists and belongs to client + const tunnelResult = await db + .select({ id: tenantSchema.clientAppointmentTunnel.id }) + .from(tenantSchema.clientAppointmentTunnel) + .where(eq(tenantSchema.clientAppointmentTunnel.emailHash, appointmentData.emailHash)) + .limit(1); + + if (tunnelResult.length === 0) { + log.warn("Client tunnel not found", { + tenantId: this.tenantId, + tunnelId: appointmentData.tunnelId, + emailHashPrefix: appointmentData.emailHash.slice(0, 8), + }); + throw new NotFoundError("Tunnel not found or access denied"); + } + + // Get channel configuration to determine initial status + const channelResult = await db + .select({ requiresConfirmation: tenantSchema.channel.requiresConfirmation }) + .from(tenantSchema.channel) + .where( + and( + eq(tenantSchema.channel.id, appointmentData.channelId), + eq(tenantSchema.channel.isPublic, true), + ), + ) + .limit(1); + + if (channelResult.length === 0) { + throw new NotFoundError("Active channel not found"); + } + + const initialStatus = channelResult[0].requiresConfirmation ? "NEW" : "CONFIRMED"; + const requiresConfirmation = channelResult[0].requiresConfirmation || false; + + // Create encrypted appointment + const appointmentResult = await db + .insert(tenantSchema.appointment) + .values({ + tunnelId: appointmentData.tunnelId, + channelId: appointmentData.channelId, + agentId: appointmentData.agentId, + appointmentDate: new Date(appointmentData.appointmentDate), + duration: appointmentData.duration, + encryptedPayload: appointmentData.encryptedAppointment.encryptedPayload, + iv: appointmentData.encryptedAppointment.iv, + authTag: appointmentData.encryptedAppointment.authTag, + status: initialStatus, + }) + .returning({ + id: tenantSchema.appointment.id, + appointmentDate: tenantSchema.appointment.appointmentDate, + status: tenantSchema.appointment.status, + }); + + if (appointmentResult.length === 0) { + throw new InternalError("Failed to create appointment"); + } + + const result = appointmentResult[0]; + + const response: AppointmentResponse = { + id: result.id, + appointmentDate: result.appointmentDate.toISOString(), + status: result.status, + requiresConfirmation, + }; + + log.info("Successfully added appointment to tunnel", { + tenantId: this.tenantId, + tunnelId: appointmentData.tunnelId, + appointmentId: result.id, + }); + + return response; + } + /** * Create a new client tunnel with their first appointment */ @@ -615,6 +721,7 @@ export class AppointmentService { id: result.appointment.id, appointmentDate: result.appointment.appointmentDate.toISOString(), status: result.appointment.status, + requiresConfirmation: result.requiresConfirmation, }; log.info("Successfully created new client appointment tunnel", { diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index fda335e..b47cdf8 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -107,4 +107,5 @@ export interface AppointmentResponse { id: string; appointmentDate: string; status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW"; + requiresConfirmation?: boolean; } diff --git a/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts b/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts new file mode 100644 index 0000000..425b280 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts @@ -0,0 +1,450 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { logger } from "$lib/logger"; +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { ClientPinResetService } from "$lib/server/services/client-pin-reset-service"; +import { checkPermission } from "$lib/server/utils/permissions"; +import { ValidationError, BackendError, logError, ConflictError } from "$lib/server/utils/errors"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; + +const requestSchema = z + .object({ + // Client identification + clientEmail: z.email().optional(), + hasNoEmail: z.boolean().optional(), + emailHash: z.string(), + + // Appointment details + appointmentDate: z.string(), + duration: z.number().int().positive(), + channelId: z.string(), + agentId: z.string(), + + // Crypto data for new client + tunnelId: z.string().optional(), + clientPublicKey: z.string().optional(), + privateKeyShare: z.string().optional(), + clientEncryptedTunnelKey: z.string().optional(), + staffKeyShares: z + .array( + z.object({ + userId: z.string(), + encryptedTunnelKey: z.string(), + }), + ) + .optional(), + + // Encrypted appointment data + encryptedAppointment: z.object({ + encryptedPayload: z.string(), + iv: z.string(), + authTag: z.string(), + }), + + // Client preferences + clientLanguage: z.string().optional().default("de"), + sendEmail: z.boolean().optional().default(false), + }) + .refine( + (data) => { + // If sendEmail is true, clientEmail is required (unless hasNoEmail is true) + if (data.sendEmail && !data.hasNoEmail && !data.clientEmail) { + return false; + } + return true; + }, + { + message: "clientEmail is required when sendEmail is true (unless hasNoEmail is set)", + }, + ); + +// Register OpenAPI documentation for POST +registerOpenAPIRoute("/tenants/{id}/appointments/staff-create", "POST", { + summary: "Staff creates appointment for client", + description: + "Allows staff members to create appointments on behalf of clients. Supports both existing clients (by email) and new clients (with or without email).\n\n" + + "**Client-Side Encryption Required**: All appointment data must be encrypted client-side before sending to this endpoint. " + + "The staff member's frontend application must:\n" + + "1. For new clients: Generate a new tunnel with keys and encrypt the appointment data\n" + + "2. For existing clients: Decrypt the staff key share to access the tunnel key, then encrypt the appointment data\n\n" + + "**Workflow for new clients with email**:\n" + + "1. Staff creates appointment with encrypted data\n" + + "2. Backend stores the appointment and automatically initiates PIN reset flow\n" + + "3. Client receives email with PIN reset link\n" + + "4. Client sets their PIN and gains access to the appointment\n\n" + + "**Workflow for new clients without email**:\n" + + "1. Staff creates appointment with encrypted data and `hasNoEmail: true`\n" + + "2. Backend stores the appointment without PIN reset\n" + + "3. Client must visit practice in person to access their appointment\n\n" + + "**Workflow for existing clients**:\n" + + "1. Staff checks if client exists (email hash)\n" + + "2. Staff decrypts their staff key share to get tunnel key\n" + + "3. Staff encrypts new appointment with tunnel key\n" + + "4. Backend adds appointment to existing tunnel\n" + + "5. Optionally sends email notification to client\n\n" + + "Requires staff permissions.", + tags: ["Appointments", "Staff"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Staff appointment creation data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + clientEmail: { + type: "string", + format: "email", + description: + "Client's email address. Only required if sendEmail is true and hasNoEmail is false. Used for sending appointment confirmation and PIN reset emails.", + }, + hasNoEmail: { + type: "boolean", + description: "Set to true if client has no email address", + }, + emailHash: { + type: "string", + description: "SHA-256 hash of client email or unique identifier", + }, + appointmentDate: { + type: "string", + format: "date-time", + description: "Appointment date and time (ISO 8601)", + }, + duration: { + type: "number", + description: "Appointment duration in minutes", + }, + channelId: { + type: "string", + format: "uuid", + description: "Channel ID", + }, + agentId: { + type: "string", + format: "uuid", + description: "Agent ID", + }, + tunnelId: { + type: "string", + format: "uuid", + description: "Tunnel ID (for new clients)", + }, + clientPublicKey: { + type: "string", + description: "Client's public key (for new clients)", + }, + privateKeyShare: { + type: "string", + description: "Server share of private key (for new clients)", + }, + clientEncryptedTunnelKey: { + type: "string", + description: "Tunnel key encrypted for client (for new clients)", + }, + staffKeyShares: { + type: "array", + description: "Tunnel key shares for staff members (for new clients)", + items: { + type: "object", + properties: { + userId: { type: "string", format: "uuid" }, + encryptedTunnelKey: { type: "string" }, + }, + }, + }, + encryptedAppointment: { + type: "object", + description: "Encrypted appointment data", + properties: { + encryptedPayload: { type: "string" }, + iv: { type: "string" }, + authTag: { type: "string" }, + }, + }, + clientLanguage: { + type: "string", + description: "Client's preferred language", + default: "de", + }, + sendEmail: { + type: "boolean", + description: "Whether to send appointment confirmation email", + default: false, + }, + }, + required: [ + "emailHash", + "appointmentDate", + "duration", + "channelId", + "agentId", + "encryptedAppointment", + ], + }, + }, + }, + }, + responses: { + "200": { + description: "Appointment created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + id: { + type: "string", + format: "uuid", + description: "Created appointment ID", + }, + appointmentDate: { + type: "string", + format: "date-time", + }, + status: { + type: "string", + enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"], + }, + isNewClient: { + type: "boolean", + description: "Whether this was a new client", + }, + pinResetToken: { + type: "string", + description: "PIN reset token (only for new clients with email)", + }, + }, + }, + }, + }, + }, + "400": { + description: "Invalid request data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Staff permissions required", + 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/staff-create + * + * Staff creates appointment for client + * - Checks if client exists (by emailHash) + * - For existing clients: adds appointment to tunnel + * - For new clients: creates tunnel and first appointment + * - For new clients with email: initiates PIN reset flow + * - Optionally sends appointment confirmation email + */ +export const POST: RequestHandler = async ({ params, request, locals }) => { + const tenantId = params.id!; + + try { + logger.debug("Staff appointment creation request", { tenantId, userId: locals.user?.id }); + + // Check permissions - authenticated staff can create appointments for clients + await checkPermission(locals, tenantId); + + // Parse and validate request + const body = await request.json(); + const validatedData = requestSchema.parse(body); + + logger.debug("Request validated", { + tenantId, + shouldSendEmail: !!validatedData.clientEmail, + hasNoEmail: validatedData.hasNoEmail, + }); + + const appointmentService = await AppointmentService.forTenant(tenantId); + + // Check if client already exists + const tunnels = await appointmentService.getClientTunnels(); + const existingTunnel = tunnels.find((t) => t.emailHash === validatedData.emailHash); + + let result; + let isNewClient = false; + let pinResetToken: string | undefined; + + if (existingTunnel) { + // Existing client - add appointment to existing tunnel + logger.debug("Adding appointment to existing client tunnel", { + tenantId, + tunnelId: existingTunnel.id, + }); + + // Prepare data for existing client + const appointmentData = { + emailHash: validatedData.emailHash, + tunnelId: existingTunnel.id, + channelId: validatedData.channelId, + agentId: validatedData.agentId, + appointmentDate: validatedData.appointmentDate, + duration: validatedData.duration, + clientEmail: validatedData.clientEmail || "", + clientLanguage: validatedData.clientLanguage, + encryptedAppointment: validatedData.encryptedAppointment, + }; + + result = await appointmentService.addAppointmentToTunnel(appointmentData); + } else { + // New client - create tunnel and appointment + isNewClient = true; + logger.debug("Creating new client tunnel with appointment", { + tenantId, + hasEmail: !!validatedData.clientEmail, + }); + + // Validate required fields for new client + if ( + !validatedData.tunnelId || + !validatedData.clientPublicKey || + !validatedData.privateKeyShare || + !validatedData.clientEncryptedTunnelKey || + !validatedData.staffKeyShares + ) { + throw new ValidationError( + "Missing required crypto data for new client: tunnelId, clientPublicKey, privateKeyShare, clientEncryptedTunnelKey, staffKeyShares", + ); + } + + // Prepare data for new client + const clientData = { + tunnelId: validatedData.tunnelId, + channelId: validatedData.channelId, + agentId: validatedData.agentId, + appointmentDate: validatedData.appointmentDate, + duration: validatedData.duration, + emailHash: validatedData.emailHash, + clientEmail: validatedData.clientEmail || "", + clientLanguage: validatedData.clientLanguage, + clientPublicKey: validatedData.clientPublicKey, + privateKeyShare: validatedData.privateKeyShare, + encryptedAppointment: validatedData.encryptedAppointment, + staffKeyShares: validatedData.staffKeyShares, + clientEncryptedTunnelKey: validatedData.clientEncryptedTunnelKey, + }; + + result = await appointmentService.createNewClientWithAppointment(clientData); + + // For new clients with email: initiate PIN reset flow + if (validatedData.clientEmail && !validatedData.hasNoEmail) { + try { + logger.debug("Initiating PIN reset for new client", { + tenantId, + emailHash: validatedData.emailHash.slice(0, 8), + }); + + const pinResetService = await ClientPinResetService.forTenant(tenantId); + // Use longer expiration for email-based reset (60 minutes) + pinResetToken = await pinResetService.createResetToken(validatedData.emailHash, 60); + + logger.info("PIN reset token created for new client", { + tenantId, + tokenId: pinResetToken.slice(0, 8), + }); + } catch (error) { + logger.error("Failed to create PIN reset token for new client", { + tenantId, + error: String(error), + }); + // Don't fail the appointment creation if PIN reset fails + // Staff can manually initiate it later + } + } + } + + // Send email notification if requested and client has email + if (validatedData.sendEmail && validatedData.clientEmail && !validatedData.hasNoEmail) { + try { + await appointmentService.sendAppointmentNotification( + result.id, + validatedData.channelId, + validatedData.clientEmail, + validatedData.clientLanguage, + !!result.requiresConfirmation, + ); + } catch (error) { + logger.error("Failed to send appointment notification", { + tenantId, + appointmentId: result.id, + error: String(error), + }); + // Don't fail the request if email sending fails + } + } + + logger.info("Staff appointment created successfully", { + tenantId, + appointmentId: result.id, + isNewClient, + hasPinReset: !!pinResetToken, + }); + + return json({ + id: result.id, + appointmentDate: result.appointmentDate, + status: result.status, + isNewClient, + pinResetToken, + }); + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn("Validation error in staff appointment creation", { + tenantId, + error, + }); + const firstIssue = error.issues[0]; + const errorMessage = firstIssue?.message || "Invalid request data"; + return json({ error: errorMessage, details: error }, { status: 400 }); + } + + if (error instanceof ValidationError) { + logger.warn("Validation error", { tenantId, error: error.message }); + return json({ error: error.message }, { status: 400 }); + } + + if (error instanceof ConflictError) { + logger.warn("Conflict error", { tenantId, error: error.message }); + return json({ error: error.message }, { status: 409 }); + } + + logError(logger)("General error:", error as BackendError, "staff-create-appointment", tenantId); + return json({ error: "Failed to create appointment" }, { status: 500 }); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts b/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts new file mode 100644 index 0000000..082e18f --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts @@ -0,0 +1,522 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "../+server"; + +// Mock dependencies +vi.mock("$lib/server/services/appointment-service", () => ({ + AppointmentService: { + forTenant: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/client-pin-reset-service", () => ({ + ClientPinResetService: { + forTenant: vi.fn(), + }, +})); + +vi.mock("$lib/server/utils/permissions", () => ({ + checkPermission: vi.fn(), +})); + +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { ClientPinResetService } from "$lib/server/services/client-pin-reset-service"; +import { checkPermission } from "$lib/server/utils/permissions"; + +describe("POST /api/tenants/[id]/appointments/staff-create", () => { + const tenantId = "12345678-1234-4234-8234-123456789012"; + const emailHash = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"; + + const mockAppointmentService = { + getClientTunnels: vi.fn(), + createNewClientWithAppointment: vi.fn(), + addAppointmentToTunnel: vi.fn(), + sendAppointmentNotification: vi.fn(), + }; + + const mockPinResetService = { + createResetToken: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkPermission).mockResolvedValue(undefined); + vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockAppointmentService as any); + vi.mocked(ClientPinResetService.forTenant).mockResolvedValue(mockPinResetService as any); + }); + + describe("New Client with Email", () => { + it("should create appointment for new client with email", async () => { + // Mock no existing tunnels + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + + // Mock appointment creation + mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({ + id: "appointment-123", + appointmentDate: "2026-01-15T14:00:00.000Z", + status: "NEW", + requiresConfirmation: true, + }); + + // Mock PIN reset token creation + mockPinResetService.createResetToken.mockResolvedValue("reset-token-123"); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientEmail: "test@example.com", + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + tunnelId: "tunnel-123", + clientPublicKey: "public-key", + privateKeyShare: "private-key-share", + clientEncryptedTunnelKey: "encrypted-tunnel-key", + staffKeyShares: [ + { + userId: "staff-123", + encryptedTunnelKey: "encrypted-for-staff", + }, + ], + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + sendEmail: true, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(200); + const data = await result.json(); + + expect(data.id).toBe("appointment-123"); + expect(data.isNewClient).toBe(true); + expect(data.pinResetToken).toBe("reset-token-123"); + expect(mockAppointmentService.createNewClientWithAppointment).toHaveBeenCalledWith({ + tunnelId: "tunnel-123", + channelId: "channel-123", + agentId: "agent-123", + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + emailHash, + clientEmail: "test@example.com", + clientLanguage: "de", + clientPublicKey: "public-key", + privateKeyShare: "private-key-share", + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + staffKeyShares: [ + { + userId: "staff-123", + encryptedTunnelKey: "encrypted-for-staff", + }, + ], + clientEncryptedTunnelKey: "encrypted-tunnel-key", + }); + expect(mockPinResetService.createResetToken).toHaveBeenCalledWith(emailHash, 60); + }); + }); + + describe("New Client without Email", () => { + it("should create appointment for new client without email", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + + mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({ + id: "appointment-456", + appointmentDate: "2026-01-15T14:00:00.000Z", + status: "CONFIRMED", + requiresConfirmation: false, + }); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + hasNoEmail: true, + emailHash: "unique-hash-for-no-email", + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + tunnelId: "tunnel-456", + clientPublicKey: "public-key", + privateKeyShare: "private-key-share", + clientEncryptedTunnelKey: "encrypted-tunnel-key", + staffKeyShares: [ + { + userId: "staff-123", + encryptedTunnelKey: "encrypted-for-staff", + }, + ], + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + sendEmail: false, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(200); + const data = await result.json(); + + expect(data.id).toBe("appointment-456"); + expect(data.isNewClient).toBe(true); + expect(data.pinResetToken).toBeUndefined(); + expect(mockPinResetService.createResetToken).not.toHaveBeenCalled(); + }); + }); + + describe("Existing Client", () => { + it("should add appointment to existing client tunnel", async () => { + // Mock existing tunnel + mockAppointmentService.getClientTunnels.mockResolvedValue([ + { + id: "tunnel-789", + emailHash, + clientPublicKey: "existing-public-key", + }, + ]); + + mockAppointmentService.addAppointmentToTunnel.mockResolvedValue({ + id: "appointment-789", + appointmentDate: "2026-01-15T14:00:00.000Z", + status: "CONFIRMED", + requiresConfirmation: false, + }); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientEmail: "existing@example.com", + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + sendEmail: true, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(200); + const data = await result.json(); + + expect(data.id).toBe("appointment-789"); + expect(data.isNewClient).toBe(false); + expect(data.pinResetToken).toBeUndefined(); + expect(mockAppointmentService.addAppointmentToTunnel).toHaveBeenCalledWith({ + emailHash, + tunnelId: "tunnel-789", + channelId: "channel-123", + agentId: "agent-123", + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + clientEmail: "existing@example.com", + clientLanguage: "de", + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + }); + }); + }); + + describe("Error Cases", () => { + it("should return 400 for invalid request data", async () => { + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + // Missing required fields + emailHash, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBeDefined(); + }); + + it("should return 400 for new client with missing crypto data", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientEmail: "test@example.com", + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + // Missing tunnelId, clientPublicKey, etc. + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toContain("Missing required crypto data"); + }); + + it("should check permissions", async () => { + vi.mocked(checkPermission).mockRejectedValue(new Error("Permission denied")); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientEmail: "test@example.com", + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "user-123", role: "USER" } } as any, + } as any); + + expect(result.status).toBe(500); + expect(checkPermission).toHaveBeenCalledWith( + { user: { id: "user-123", role: "USER" } }, + tenantId, + ); + }); + + it("should return 400 for neither clientEmail nor hasNoEmail provided", async () => { + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + // Neither clientEmail nor hasNoEmail + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBeDefined(); + }); + + it("should return 400 when sendEmail is true but clientEmail is missing", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + tunnelId: "tunnel-123", + clientPublicKey: "public-key", + privateKeyShare: "private-key-share", + clientEncryptedTunnelKey: "encrypted-tunnel-key", + staffKeyShares: [ + { + userId: "staff-123", + encryptedTunnelKey: "encrypted-for-staff", + }, + ], + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + sendEmail: true, // Email required but not provided + }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toContain("clientEmail is required when sendEmail is true"); + }); + }); + + describe("Email Sending", () => { + it("should send email for new client when sendEmail is true", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({ + id: "appointment-123", + appointmentDate: "2026-01-15T14:00:00.000Z", + status: "NEW", + requiresConfirmation: true, + }); + mockPinResetService.createResetToken.mockResolvedValue("reset-token-123"); + mockAppointmentService.sendAppointmentNotification.mockResolvedValue(undefined); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientEmail: "test@example.com", + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + tunnelId: "tunnel-123", + clientPublicKey: "public-key", + privateKeyShare: "private-key-share", + clientEncryptedTunnelKey: "encrypted-tunnel-key", + staffKeyShares: [ + { + userId: "staff-123", + encryptedTunnelKey: "encrypted-for-staff", + }, + ], + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + sendEmail: true, + }), + }); + + await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + // Email sending is async, so we just verify it was called + expect(mockAppointmentService.sendAppointmentNotification).toHaveBeenCalledWith( + "appointment-123", + "channel-123", + "test@example.com", + "de", + true, + ); + }); + + it("should not send email when sendEmail is false", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({ + id: "appointment-123", + appointmentDate: "2026-01-15T14:00:00.000Z", + status: "NEW", + requiresConfirmation: true, + }); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + clientEmail: "test@example.com", + emailHash, + appointmentDate: "2026-01-15T14:00:00.000Z", + duration: 30, + channelId: "channel-123", + agentId: "agent-123", + tunnelId: "tunnel-123", + clientPublicKey: "public-key", + privateKeyShare: "private-key-share", + clientEncryptedTunnelKey: "encrypted-tunnel-key", + staffKeyShares: [ + { + userId: "staff-123", + encryptedTunnelKey: "encrypted-for-staff", + }, + ], + encryptedAppointment: { + encryptedPayload: "encrypted-payload", + iv: "iv", + authTag: "auth-tag", + }, + sendEmail: false, + }), + }); + + await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(mockAppointmentService.sendAppointmentNotification).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/clients/exists/+server.ts b/src/routes/api/tenants/[id]/clients/exists/+server.ts new file mode 100644 index 0000000..04bf7fc --- /dev/null +++ b/src/routes/api/tenants/[id]/clients/exists/+server.ts @@ -0,0 +1,149 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { logger } from "$lib/logger"; +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { checkPermission } from "$lib/server/utils/permissions"; +import { ValidationError, logError, BackendError } from "$lib/server/utils/errors"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; + +const requestSchema = z.object({ + emailHash: z.string().min(64).max(64), +}); + +registerOpenAPIRoute("/tenants/{id}/clients/exists", "POST", { + summary: "Check if client exists", + description: + "Check if a client with the given email hash already exists in the tenant's database. Requires staff permissions.", + tags: ["Clients"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Email hash to check", + content: { + "application/json": { + schema: { + type: "object", + properties: { + emailHash: { + type: "string", + description: "SHA-256 hash of client email (64 hex characters)", + minLength: 64, + maxLength: 64, + }, + }, + required: ["emailHash"], + }, + }, + }, + }, + responses: { + "200": { + description: "Client existence check result", + content: { + "application/json": { + schema: { + type: "object", + properties: { + exists: { + type: "boolean", + description: "Whether a client with this email hash exists", + }, + emailHash: { + type: "string", + description: "The email hash that was checked (first 8 characters)", + }, + }, + required: ["exists", "emailHash"], + }, + }, + }, + }, + "400": { + description: "Invalid request data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "401": { + description: "Authentication required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Staff permissions required", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const tenantId = params.id!; + + try { + logger.debug("Client exists check request", { tenantId, userId: locals.user?.id }); + + // Check permissions + await checkPermission(locals, tenantId); + + // Parse and validate request + const body = await request.json(); + const validatedData = requestSchema.parse(body); + + logger.debug("Request validated", { emailHashPrefix: validatedData.emailHash.slice(0, 8) }); + + const appointmentService = await AppointmentService.forTenant(tenantId); + const tunnels = await appointmentService.getClientTunnels(); + const exists = tunnels.some((t) => t.emailHash === validatedData.emailHash); + + logger.debug("Client exists check completed", { + tenantId, + exists, + emailHashPrefix: validatedData.emailHash.slice(0, 8), + }); + + return json({ + exists, + emailHash: validatedData.emailHash.slice(0, 8), + }); + } catch (error) { + if (error instanceof z.ZodError) { + logger.warn("Validation error in client exists check", { + tenantId, + error, + }); + return json({ error: "Invalid request data", details: error }, { status: 400 }); + } + + if (error instanceof ValidationError) { + logger.warn("Validation error", { tenantId, error: error.message }); + return json({ error: error.message }, { status: 400 }); + } + + logError(logger)("General Error", error as BackendError, "client-exists-check", tenantId); + return json({ error: "Failed to check client existence" }, { status: 500 }); + } +}; diff --git a/src/routes/api/tenants/[id]/clients/exists/__tests__/client-exists.test.ts b/src/routes/api/tenants/[id]/clients/exists/__tests__/client-exists.test.ts new file mode 100644 index 0000000..4a5d20d --- /dev/null +++ b/src/routes/api/tenants/[id]/clients/exists/__tests__/client-exists.test.ts @@ -0,0 +1,195 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { POST } from "../+server"; + +// Mock dependencies +vi.mock("$lib/server/services/appointment-service", () => ({ + AppointmentService: { + forTenant: vi.fn(), + }, +})); + +vi.mock("$lib/server/utils/permissions", () => ({ + checkPermission: vi.fn(), +})); + +import { AppointmentService } from "$lib/server/services/appointment-service"; +import { checkPermission } from "$lib/server/utils/permissions"; + +describe("POST /api/tenants/[id]/clients/exists", () => { + const tenantId = "12345678-1234-4234-8234-123456789012"; + const emailHash = "2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"; + + const mockAppointmentService = { + getClientTunnels: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(checkPermission).mockResolvedValue(undefined); + vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockAppointmentService as any); + }); + + it("should return true when client exists", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([ + { + id: "tunnel-123", + emailHash, + clientPublicKey: "public-key", + }, + { + id: "tunnel-456", + emailHash: "different-hash", + clientPublicKey: "public-key-2", + }, + ]); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(200); + const data = await result.json(); + + expect(data.exists).toBe(true); + expect(data.emailHash).toBe(emailHash.slice(0, 8)); + expect(mockAppointmentService.getClientTunnels).toHaveBeenCalled(); + }); + + it("should return false when client does not exist", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([ + { + id: "tunnel-456", + emailHash: "different-hash", + clientPublicKey: "public-key-2", + }, + ]); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(200); + const data = await result.json(); + + expect(data.exists).toBe(false); + expect(data.emailHash).toBe(emailHash.slice(0, 8)); + }); + + it("should return false when no client tunnels exist", async () => { + mockAppointmentService.getClientTunnels.mockResolvedValue([]); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(200); + const data = await result.json(); + + expect(data.exists).toBe(false); + }); + + it("should return 400 for invalid email hash length", async () => { + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash: "short-hash" }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBeDefined(); + }); + + it("should return 400 for missing email hash", async () => { + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(400); + const data = await result.json(); + expect(data.error).toBeDefined(); + }); + + it("should check permissions", async () => { + vi.mocked(checkPermission).mockRejectedValue(new Error("Permission denied")); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "user-123", role: "USER" } } as any, + } as any); + + expect(result.status).toBe(500); + expect(checkPermission).toHaveBeenCalledWith( + { user: { id: "user-123", role: "USER" } }, + tenantId, + ); + }); + + it("should handle service errors gracefully", async () => { + mockAppointmentService.getClientTunnels.mockRejectedValue( + new Error("Database connection failed"), + ); + + const request = new Request("http://localhost/api", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emailHash }), + }); + + const result = await POST({ + params: { id: tenantId }, + request, + locals: { user: { id: "staff-123", role: "STAFF" } } as any, + } as any); + + expect(result.status).toBe(500); + const data = await result.json(); + expect(data.error).toBe("Failed to check client existence"); + }); +});