149 allow creation of appointments through staff members (#174)

* Simplified locals.user
Checked routes for accessibilty

* Fixed tests

* Fixed import errors

* Fixed invite test

* PIN API implementation

* Tests for pin reset

* HOusekeeping

* Update src/routes/api/tenants/[id]/clients/pin-reset/request/+server.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fixes for id and housekeeping

* End routes for staff appointment bookings and check whether client exists.

* Merged main, fixed tests

* Linting errors

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Hendrik
2026-01-22 11:48:11 +01:00
committed by GitHub
co-authored by Copilot
parent a64afbd297
commit 75cdccf5d0
6 changed files with 1425 additions and 1 deletions
+108 -1
View File
@@ -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<AppointmentResponse> {
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", {
+1
View File
@@ -107,4 +107,5 @@ export interface AppointmentResponse {
id: string;
appointmentDate: string;
status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW";
requiresConfirmation?: boolean;
}
@@ -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 });
}
};
@@ -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();
});
});
});
@@ -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 });
}
};
@@ -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");
});
});