mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-28 11:44:51 +02:00
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:
@@ -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", {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user