Scheduling api (#68)

* Additions for agent absencies and appointments. Schedule determination.

* Scheduling tests. Agent and appointment tests.

* Absence and Agent APIs

* Linting

* Appointment API (staffing side)

* LInting and fixed tests

* Centralised permission check

* Permission refactorings, session bugfixes

* Test fixes (first part)

* Test fixes

* Fixed type

* Linting fixes

* Fixed tests

* Formatting

* Data schema changes

* Adaptions to last merges and changes

* Incorporated changes as discussed in weekly

* Test fix
This commit is contained in:
Hendrik
2025-09-15 16:22:41 +02:00
committed by GitHub
parent fd8990e258
commit 629351d6fa
44 changed files with 8003 additions and 286 deletions
+2 -10
View File
@@ -1,22 +1,14 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
import type { SelectUser } from "$lib/server/db/central-schema";
import type { Locale } from "$i18n/runtime";
import type { UserRole } from "$lib/server/auth/authorization-service";
import type { JWTPayload } from "jose";
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
interface Locals {
user?: JWTPayload & {
userId: string;
sessionId: string;
name: string;
email: string;
role: UserRole;
tenantId?: string | null;
};
user?: SelectUser & { userId: string; sessionId: string; exp: number };
locale?: Locale;
}
// interface PageData {}
+3 -1
View File
@@ -88,13 +88,15 @@ export const tenantConfig = pgTable(
}),
);
// TODO: Filter in API für bestimmte Rollen
export const user = pgTable(
"user",
{
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name").notNull(),
role: userRoleEnum("role").notNull().default("GLOBAL_ADMIN"),
role: userRoleEnum("role").notNull().default("STAFF"),
tenantId: uuid("tenant_id").references(() => tenant.id),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
+32 -34
View File
@@ -9,6 +9,7 @@ import {
time,
integer,
json,
timestamp,
} from "drizzle-orm/pg-core";
import { bytea } from "./base";
@@ -16,9 +17,6 @@ import { bytea } from "./base";
* Database enums for tenant-specific entities
*/
/** Channel type enumeration - defines what kind of resource a channel represents */
export const channelTypeEnum = pgEnum("channel_type", ["ROOM", "MACHINE", "PERSONNEL"]);
/** Appointment status enumeration - tracks the lifecycle of appointments */
export const appointmentStatusEnum = pgEnum("appointment_status", [
"NEW",
@@ -42,7 +40,7 @@ export const agent = pgTable("agent", {
/** Optional description of the agent's role or specialties */
description: text("description"),
/** Optional logo/profile image for the agent (PNG, JPEG, GIF, or WEBP) */
logo: bytea("logo"),
image: bytea("image"),
});
/**
@@ -146,29 +144,6 @@ export const client = pgTable("client", {
language: text("language"),
});
/**
* Staff table - represents employees/staff members who manage appointments
* Staff members have administrative access and can view/manage appointments
* Stored in tenant-specific database
* @table staff
*/
export const staff = pgTable("staff", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Hash of staff login name for identification */
hashKey: text("hash_key").notNull().unique(),
/** Staff member's public key for end-to-end encryption */
publicKey: text("public_key").notNull(),
/** Staff member's display name */
name: text("name"),
/** Job title or position within the organization */
position: text("position"),
/** Staff email address (required for notifications) */
email: text("email").notNull(),
/** Preferred language for communications (de/en) */
language: text("language"),
});
/**
* Appointment table - represents scheduled appointments between clients and channels
* Contains encrypted appointment data for privacy protection
@@ -190,12 +165,35 @@ export const appointment = pgTable("appointment", {
appointmentDate: date("appointment_date").notNull(),
/** When appointment data expires and can be auto-deleted */
expiryDate: date("expiry_date").notNull(),
/** Appointment title/subject */
title: text("title").notNull(),
/** Optional detailed description of the appointment */
description: text("description"),
/** Current status of the appointment */
status: appointmentStatusEnum("status").notNull().default("NEW"),
/** Appointment title/subject */
name: text("name").notNull(),
/** Optional detailed description of the appointment */
phone: text("phone"), // TODO Sensible information will be removed in future (appointment) branch since it will be stored in an encrypted blob
});
/**
* Agent Absence table - represents periods when agents are unavailable
* Used to block agent availability during vacation, training, meetings, etc.
* Stored in tenant-specific database
* @table agentAbsence
*/
export const agentAbsence = pgTable("agent_absence", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to agent who is absent */
agentId: uuid("agent_id")
.notNull()
.references(() => agent.id),
/** Start date and time of absence */
startDate: timestamp("start_date").notNull(),
/** End date and time of absence */
endDate: timestamp("end_date").notNull(),
/** Type of absence (free text: Urlaub, Krankheit, Fortbildung, etc.) */
absenceType: text("absence_type").notNull().default(""),
/** Optional description/reason for absence */
description: text("description"),
});
/**
@@ -206,9 +204,6 @@ export const appointment = pgTable("appointment", {
/** Client record type for database queries */
export type SelectClient = InferSelectModel<typeof client>;
/** Staff record type for database queries */
export type SelectStaff = InferSelectModel<typeof staff>;
/** Channel record type for database queries */
export type SelectChannel = InferSelectModel<typeof channel>;
@@ -226,3 +221,6 @@ export type SelectChannelAgent = InferSelectModel<typeof channelAgent>;
/** Channel-SlotTemplate junction record type for database queries */
export type SelectChannelSlotTemplate = InferSelectModel<typeof channelSlotTemplate>;
/** Agent absence record type for database queries */
export type SelectAgentAbsence = InferSelectModel<typeof agentAbsence>;
-8
View File
@@ -32,14 +32,6 @@ export class TenantService {
return await db.select().from(tenantSchema.client);
}
/**
* Get all staff for this tenant
*/
async getStaff() {
const db = await this.getDb();
return await db.select().from(tenantSchema.staff);
}
/**
* Get all channels for this tenant
*/
@@ -230,12 +230,23 @@ describe("Email System", () => {
// Create a mock staff user with German language
const staffUser = {
id: "test-id",
tenantId: "",
hashKey: "test-hash",
publicKey: "test-key",
privateKeyShare: "test-share",
name: "Max Mustermann",
position: "Arzt",
email: "test@example.com",
language: "de",
role: "TENANT_ADMIN" as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF",
createdAt: null,
updatedAt: null,
lastLoginAt: null,
isActive: null,
confirmed: null,
token: null,
tokenValidUntil: null,
passphraseHash: null,
recoveryPassphrase: null,
};
const mockTenant = {
id: "tenant-1",
@@ -280,14 +291,26 @@ describe("Email System", () => {
// Create a mock staff user with English language
const staffUser = {
tenantId: "",
id: "test-id",
hashKey: "test-hash",
publicKey: "test-key",
privateKeyShare: "test-share",
name: "John Doe",
position: "Doctor",
email: "test@example.com",
language: "en",
role: "TENANT_ADMIN" as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF",
createdAt: null,
updatedAt: null,
lastLoginAt: null,
isActive: null,
confirmed: null,
token: null,
tokenValidUntil: null,
passphraseHash: null,
recoveryPassphrase: null,
};
const mockTenant = {
id: "tenant-1",
shortName: "test",
@@ -388,7 +411,6 @@ describe("Email System", () => {
const staffUser = {
id: "test-id",
hashKey: "test-hash",
publicKey: "test-key",
name: "Max Mustermann",
position: "Arzt",
email: "test@example.com",
@@ -441,7 +463,6 @@ describe("Email System", () => {
const staffUser = {
id: "test-id",
hashKey: "test-hash",
publicKey: "test-key",
name: "John Doe",
position: "Doctor",
email: "test@example.com",
+17 -20
View File
@@ -5,8 +5,8 @@ import {
type TemplateData,
type Language,
} from "./template-engine";
import type { SelectClient, SelectStaff, SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectTenant } from "$lib/server/db/central-schema";
import type { SelectClient, SelectAppointment } from "$lib/server/db/tenant-schema";
import type { SelectTenant, SelectUser } from "$lib/server/db/central-schema";
/**
* Send a templated email using the template engine
@@ -46,14 +46,14 @@ export async function sendTemplatedEmail(
/**
* Send welcome email to newly created user
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectClient | SelectUser} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @param {string} loginUrl - URL for user to login
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendUserCreatedEmail(
user: SelectClient | SelectStaff,
user: SelectClient | SelectUser,
tenant: SelectTenant,
loginUrl: string,
): Promise<void> {
@@ -68,13 +68,13 @@ export async function sendUserCreatedEmail(
/**
* Send informational email about PIN reset (no reset code included)
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectClient | SelectUser} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendPinResetEmail(
user: SelectClient | SelectStaff,
user: SelectClient | SelectUser,
tenant: SelectTenant,
): Promise<void> {
const recipient = createEmailRecipient(user);
@@ -86,13 +86,13 @@ export async function sendPinResetEmail(
/**
* Send informational email about key reset
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectClient | SelectUser} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @throws {Error} When email sending fails
* @returns {Promise<void>}
*/
export async function sendKeyResetEmail(
user: SelectClient | SelectStaff,
user: SelectClient | SelectUser,
tenant: SelectTenant,
): Promise<void> {
const recipient = createEmailRecipient(user);
@@ -104,7 +104,7 @@ export async function sendKeyResetEmail(
/**
* Send appointment reminder email
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectClient | SelectUser} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @param {SelectAppointment} appointment - Appointment details
* @param {string} [cancelUrl] - Optional URL to cancel appointment
@@ -112,7 +112,7 @@ export async function sendKeyResetEmail(
* @returns {Promise<void>}
*/
export async function sendAppointmentReminderEmail(
user: SelectClient | SelectStaff,
user: SelectClient | SelectUser,
tenant: SelectTenant,
appointment: SelectAppointment,
cancelUrl?: string,
@@ -125,15 +125,14 @@ export async function sendAppointmentReminderEmail(
appointment,
appointmentDate: appointment.appointmentDate,
appointmentTime: appointment.appointmentDate, // You might want to add a separate time field
title: appointment.title,
description: appointment.description,
title: appointment.channelId,
cancelUrl,
});
}
/**
* Send appointment confirmation email for newly created appointments
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectClient | SelectUser} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @param {SelectAppointment} appointment - Appointment details
* @param {string} [cancelUrl] - Optional URL to cancel appointment
@@ -141,7 +140,7 @@ export async function sendAppointmentReminderEmail(
* @returns {Promise<void>}
*/
export async function sendAppointmentCreatedEmail(
user: SelectClient | SelectStaff,
user: SelectClient | SelectUser,
tenant: SelectTenant,
appointment: SelectAppointment,
cancelUrl?: string,
@@ -153,15 +152,14 @@ export async function sendAppointmentCreatedEmail(
await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
title: appointment.title,
description: appointment.description,
title: appointment.channelId, // TODO: Get the channel to give back a proper title
cancelUrl,
});
}
/**
* Send appointment update notification email
* @param {SelectClient | SelectStaff} user - Database user object
* @param {SelectClient | SelectUser} user - Database user object
* @param {SelectTenant} tenant - Tenant information for branding
* @param {SelectAppointment} appointment - Updated appointment details
* @param {string} [cancelUrl] - Optional URL to cancel appointment
@@ -169,7 +167,7 @@ export async function sendAppointmentCreatedEmail(
* @returns {Promise<void>}
*/
export async function sendAppointmentUpdatedEmail(
user: SelectClient | SelectStaff,
user: SelectClient | SelectUser,
tenant: SelectTenant,
appointment: SelectAppointment,
cancelUrl?: string,
@@ -181,8 +179,7 @@ export async function sendAppointmentUpdatedEmail(
await sendTemplatedEmail("appointment-updated", recipient, subject, language, tenant, {
appointment,
appointmentDate: appointment.appointmentDate,
title: appointment.title,
description: appointment.description,
title: appointment.channelId, // TODO: Get the channel to give back a proper title
cancelUrl,
});
}
+3 -10
View File
@@ -1,7 +1,8 @@
import nodemailer from "nodemailer";
import { env } from "$env/dynamic/private";
import type { SelectClient, SelectStaff } from "$lib/server/db/tenant-schema";
import type { SelectClient } from "$lib/server/db/tenant-schema";
import type Mail from "nodemailer/lib/mailer";
import type { SelectUser } from "../db/central-schema";
/**
* Email recipient interface
@@ -25,7 +26,7 @@ export interface EmailRecipient {
export function createEmailRecipient(
user:
| SelectClient
| SelectStaff
| SelectUser
| { id: string; email: string | null; name: string | null; language?: string | null },
): EmailRecipient {
// Handle SelectUser type (from central schema)
@@ -36,14 +37,6 @@ export function createEmailRecipient(
language: user.language || "de", // Use user's language preference
};
}
// Handle SelectStaff type (has name property)
else if ("name" in user) {
return {
email: user.email,
name: user.name || undefined,
language: user.language || "de",
};
}
// Handle SelectClient type (no name property)
else {
return {
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ValidationError, NotFoundError } from "../../utils/errors";
import { ValidationError, NotFoundError, ConflictError } from "../../utils/errors";
// Mock dependencies before imports
vi.mock("../../db", () => ({
@@ -18,7 +18,7 @@ vi.mock("$lib/logger", () => ({
}));
// Import after mocking
import { AgentService } from "../agent-service";
import { AgentService, type AbsenceCreationRequest } from "../agent-service";
import { getTenantDb } from "../../db";
// Mock database operations
@@ -101,7 +101,7 @@ describe("AgentService", () => {
const request = {
name: "Test Agent",
description: "Test description",
logo: Buffer.from("test"),
image: Buffer.from("test"),
};
const result = await service.createAgent(request);
@@ -111,7 +111,7 @@ describe("AgentService", () => {
expect(insertChain.values).toHaveBeenCalledWith({
name: "Test Agent",
description: "Test description",
logo: Buffer.from("test"),
image: Buffer.from("test"),
});
});
@@ -488,4 +488,360 @@ describe("AgentService", () => {
);
});
});
describe("Absence Management", () => {
let service: AgentService;
const mockAbsence = {
id: "absence-123",
agentId: "agent-123",
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
absenceType: "Urlaub",
description: "Annual vacation",
};
beforeEach(async () => {
service = await AgentService.forTenant("tenant-123");
});
describe("createAbsence", () => {
it("should create absence successfully", async () => {
const request: AbsenceCreationRequest = {
agentId: "123e4567-e89b-12d3-a456-426614174001",
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
absenceType: "Urlaub",
description: "Annual vacation",
};
// Mock agent exists
const selectChainForAgent = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([mockAgent]),
})),
})),
};
// Mock no overlapping absences
const selectChainForOverlap = {
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([]),
})),
};
let selectCallCount = 0;
mockDb.select.mockImplementation(() => {
selectCallCount++;
return selectCallCount === 1 ? selectChainForAgent : selectChainForOverlap;
});
const insertChain = {
values: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([mockAbsence]),
})),
};
mockDb.insert.mockReturnValue(insertChain);
const result = await service.createAbsence(request);
expect(result).toEqual(mockAbsence);
expect(insertChain.values).toHaveBeenCalledWith({
agentId: request.agentId,
startDate: new Date(request.startDate),
endDate: new Date(request.endDate),
absenceType: request.absenceType,
description: request.description,
});
});
it("should validate absence creation request", async () => {
const invalidRequest = {
agentId: "invalid-uuid",
startDate: "invalid-date",
endDate: "2024-01-01T17:00:00.000Z",
absenceType: "",
description: "",
};
await expect(
service.createAbsence(invalidRequest as AbsenceCreationRequest),
).rejects.toThrow(ValidationError);
});
it("should validate date range", async () => {
const request: AbsenceCreationRequest = {
agentId: "agent-123",
startDate: "2024-01-01T17:00:00.000Z", // End before start
endDate: "2024-01-01T08:00:00.000Z",
absenceType: "Urlaub",
};
await expect(service.createAbsence(request)).rejects.toThrow(ValidationError);
});
it("should throw NotFoundError if agent does not exist", async () => {
const request: AbsenceCreationRequest = {
agentId: "123e4567-e89b-12d3-a456-426614174099", // Valid UUID format
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
absenceType: "Urlaub",
};
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain);
await expect(service.createAbsence(request)).rejects.toThrow(NotFoundError);
});
it("should throw ConflictError if absence period overlaps", async () => {
const request: AbsenceCreationRequest = {
agentId: "123e4567-e89b-12d3-a456-426614174001",
startDate: "2024-01-01T08:00:00.000Z",
endDate: "2024-01-01T17:00:00.000Z",
absenceType: "Urlaub",
};
// Mock agent exists
const selectChainForAgent = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([mockAgent]),
})),
})),
};
// Mock overlapping absence exists
const selectChainForOverlap = {
from: vi.fn(() => ({
where: vi.fn().mockResolvedValue([mockAbsence]),
})),
};
let selectCallCount = 0;
mockDb.select.mockImplementation(() => {
selectCallCount++;
return selectCallCount === 1 ? selectChainForAgent : selectChainForOverlap;
});
await expect(service.createAbsence(request)).rejects.toThrow(ConflictError);
});
});
describe("getAbsenceById", () => {
it("should return absence when found", async () => {
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([mockAbsence]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain);
const result = await service.getAbsenceById("absence-123");
expect(result).toEqual(mockAbsence);
});
it("should return null when absence not found", async () => {
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain);
const result = await service.getAbsenceById("non-existent");
expect(result).toBeNull();
});
});
describe("getAgentAbsences", () => {
it("should return agent absences", async () => {
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn().mockResolvedValue([mockAbsence]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain as any);
const result = await service.getAgentAbsences("agent-123");
expect(result).toEqual([mockAbsence]);
});
it("should filter by date range when provided", async () => {
// Mock the queryAbsences method behavior
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn().mockResolvedValue([mockAbsence]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain as any);
const result = await service.getAgentAbsences(
"123e4567-e89b-12d3-a456-426614174001",
"2024-01-01T00:00:00.000Z",
"2024-01-31T23:59:59.999Z",
);
expect(result).toEqual([mockAbsence]);
});
});
describe("updateAbsence", () => {
it("should update absence successfully", async () => {
const updateData = {
absenceType: "Krankheit",
description: "Sick leave",
};
// Mock current absence exists
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([mockAbsence]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain);
const updateChain = {
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([{ ...mockAbsence, ...updateData }]),
})),
})),
};
mockDb.update.mockReturnValue(updateChain);
const result = await service.updateAbsence("absence-123", updateData);
expect(result.absenceType).toBe(updateData.absenceType);
expect(result.description).toBe(updateData.description);
});
it("should validate update request", async () => {
const invalidUpdate = {
absenceType: "", // Invalid empty type
startDate: "invalid-date",
};
await expect(service.updateAbsence("absence-123", invalidUpdate)).rejects.toThrow(
ValidationError,
);
});
it("should throw NotFoundError if absence does not exist", async () => {
const updateData = { absenceType: "Krankheit" };
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain);
await expect(service.updateAbsence("non-existent", updateData)).rejects.toThrow(
NotFoundError,
);
});
it("should validate new date range", async () => {
const updateData = {
startDate: "2024-01-01T17:00:00.000Z", // End before start
endDate: "2024-01-01T08:00:00.000Z",
};
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn().mockResolvedValue([mockAbsence]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain);
await expect(service.updateAbsence("absence-123", updateData)).rejects.toThrow(
ValidationError,
);
});
});
describe("deleteAbsence", () => {
it("should delete absence successfully", async () => {
const deleteChain = {
where: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([mockAbsence]),
})),
};
mockDb.delete.mockReturnValue(deleteChain);
const result = await service.deleteAbsence("absence-123");
expect(result).toBe(true);
});
it("should return false if absence not found", async () => {
const deleteChain = {
where: vi.fn(() => ({
returning: vi.fn().mockResolvedValue([]),
})),
};
mockDb.delete.mockReturnValue(deleteChain);
const result = await service.deleteAbsence("non-existent");
expect(result).toBe(false);
});
});
describe("queryAbsences", () => {
it("should validate query request", async () => {
const invalidQuery = {
startDate: "invalid-date",
endDate: "2024-01-31T23:59:59.999Z",
};
await expect(service.queryAbsences(invalidQuery as any)).rejects.toThrow(ValidationError);
});
it("should query absences with date range", async () => {
const validQuery = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
agentId: "123e4567-e89b-12d3-a456-426614174001",
};
const selectChain = {
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn().mockResolvedValue([mockAbsence]),
})),
})),
};
mockDb.select.mockReturnValue(selectChain as any);
const result = await service.queryAbsences(validQuery);
expect(result).toEqual([mockAbsence]);
});
});
});
});
@@ -0,0 +1,604 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ValidationError, NotFoundError, ConflictError } from "../../utils/errors";
// Mock dependencies before imports
vi.mock("../../db", () => ({
getTenantDb: vi.fn(),
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
// Import after mocking
import { AppointmentService, type AppointmentCreationRequest } from "../appointment-service";
import { getTenantDb } from "../../db";
// Mock database operations
const mockDb = {
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(),
})),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(),
})),
leftJoin: vi.fn(() => ({
leftJoin: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(),
})),
})),
where: vi.fn(() => ({
orderBy: vi.fn(),
})),
})),
orderBy: vi.fn(),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(),
})),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(),
})),
})),
};
describe("AppointmentService", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
beforeEach(() => {
vi.clearAllMocks();
(getTenantDb as any).mockResolvedValue(mockDb);
});
describe("forTenant", () => {
it("should create an appointment service instance", async () => {
const service = await AppointmentService.forTenant(mockTenantId);
expect(service).toBeInstanceOf(AppointmentService);
expect(service.tenantId).toBe(mockTenantId);
expect(getTenantDb).toHaveBeenCalledWith(mockTenantId);
});
it("should throw error if database connection fails", async () => {
(getTenantDb as any).mockRejectedValue(new Error("Database connection failed"));
await expect(AppointmentService.forTenant(mockTenantId)).rejects.toThrow(
"Database connection failed",
);
});
});
describe("createAppointment", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should validate appointment creation request", async () => {
const invalidRequest = {
clientId: "invalid-uuid",
channelId: "123e4567-e89b-12d3-a456-426614174001",
appointmentDate: "invalid-date",
expiryDate: "2024-01-02",
phone: "",
name: "",
};
await expect(
service.createAppointment(invalidRequest as AppointmentCreationRequest),
).rejects.toThrow(ValidationError);
});
it("should create appointment successfully", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174001",
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
description: "Test Description",
status: "NEW",
};
// Mock database responses
const mockClient = [
{
id: validRequest.clientId,
hashKey: "test",
publicKey: "test",
privateKeyShare: "test",
email: "test@test.com",
language: "de",
},
];
const mockChannel = [
{
id: validRequest.channelId,
names: ["Test Channel"],
pause: false,
descriptions: ["Test"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: null,
},
];
const mockConflictingAppointments: any[] = [];
const mockCreatedAppointment = {
id: "appointment-id",
...validRequest,
status: "NEW",
};
let selectCallCount = 0;
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => {
selectCallCount++;
switch (selectCallCount) {
case 1:
return mockClient;
case 2:
return mockChannel;
case 3:
return mockConflictingAppointments;
default:
return [];
}
},
}),
}),
}));
(mockDb.insert as any).mockImplementation(() => ({
values: () => ({
returning: () => [mockCreatedAppointment],
}),
}));
const result = await service.createAppointment(validRequest);
expect(result).toEqual(mockCreatedAppointment);
});
it("should throw NotFoundError if client does not exist", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174099", // Valid UUID format
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
status: "NEW",
};
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => [], // No client found
}),
}),
}));
await expect(service.createAppointment(validRequest)).rejects.toThrow(NotFoundError);
});
it("should throw ConflictError if channel is paused", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174001",
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
status: "NEW",
};
const mockClient = [{ id: validRequest.clientId }];
const mockPausedChannel = [{ id: validRequest.channelId, pause: true }];
let selectCallCount = 0;
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => {
selectCallCount++;
return selectCallCount === 1 ? mockClient : mockPausedChannel;
},
}),
}),
}));
await expect(service.createAppointment(validRequest)).rejects.toThrow(ConflictError);
});
it("should throw ConflictError if time slot is already booked", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174001",
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
status: "NEW",
};
const mockClient = [{ id: validRequest.clientId }];
const mockChannel = [{ id: validRequest.channelId, pause: false }];
const mockConflictingAppointment = [
{ id: "existing-appointment", appointmentDate: validRequest.appointmentDate },
];
let selectCallCount = 0;
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => {
selectCallCount++;
switch (selectCallCount) {
case 1:
return { limit: () => mockClient }; // client check
case 2:
return { limit: () => mockChannel }; // channel check
case 3:
return mockConflictingAppointment; // conflict check (no limit)
default:
return { limit: () => [] };
}
},
}),
}));
await expect(service.createAppointment(validRequest)).rejects.toThrow(ConflictError);
});
});
describe("getAppointmentById", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should return appointment with details", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockResult = [
{
appointment: {
id: appointmentId,
clientId: "client-id",
channelId: "channel-id",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
title: "Test Appointment",
description: null,
status: "NEW",
},
client: {
id: "client-id",
hashKey: "test",
publicKey: "test",
privateKeyShare: "test",
email: "test@test.com",
language: "de",
},
channel: {
id: "channel-id",
names: ["Test Channel"],
pause: false,
descriptions: ["Test"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: null,
},
},
];
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
leftJoin: () => ({
leftJoin: () => ({
where: () => ({
limit: () => mockResult,
}),
}),
}),
}),
}));
const result = await service.getAppointmentById(appointmentId);
expect(result).toEqual({
...mockResult[0].appointment,
client: mockResult[0].client,
channel: mockResult[0].channel,
});
});
it("should return null if appointment not found", async () => {
const appointmentId = "non-existent";
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
leftJoin: () => ({
leftJoin: () => ({
where: () => ({
limit: () => [],
}),
}),
}),
}),
}));
const result = await service.getAppointmentById(appointmentId);
expect(result).toBeNull();
});
});
describe("updateAppointment", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should validate update request", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const invalidUpdate = {
title: "", // Invalid empty title
status: "INVALID_STATUS" as any,
};
await expect(service.updateAppointment(appointmentId, invalidUpdate)).rejects.toThrow(
ValidationError,
);
});
it("should update appointment successfully", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const updateData = {
title: "Updated Title",
status: "CONFIRMED" as const,
};
const mockUpdatedAppointment = {
id: appointmentId,
clientId: "client-id",
channelId: "channel-id",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
title: updateData.title,
description: null,
status: updateData.status,
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
}),
}),
}));
const result = await service.updateAppointment(appointmentId, updateData);
expect(result).toEqual(mockUpdatedAppointment);
});
it("should throw NotFoundError if appointment does not exist", async () => {
const appointmentId = "non-existent";
const updateData = { title: "Updated Title" };
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [], // No rows updated
}),
}),
}));
await expect(service.updateAppointment(appointmentId, updateData)).rejects.toThrow(
NotFoundError,
);
});
});
describe("status update methods", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should cancel appointment", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "REJECTED",
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
}),
}),
}));
const result = await service.cancelAppointment(appointmentId);
expect(result.status).toBe("REJECTED");
});
it("should confirm appointment", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "CONFIRMED",
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
}),
}),
}));
const result = await service.confirmAppointment(appointmentId);
expect(result.status).toBe("CONFIRMED");
});
it("should complete appointment", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "HELD",
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
}),
}),
}));
const result = await service.completeAppointment(appointmentId);
expect(result.status).toBe("HELD");
});
it("should mark as no-show", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "NO_SHOW",
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
}),
}),
}));
const result = await service.markNoShow(appointmentId);
expect(result.status).toBe("NO_SHOW");
});
});
describe("deleteAppointment", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should delete appointment successfully", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
(mockDb.delete as any).mockImplementation(() => ({
where: () => ({
returning: () => [{ id: appointmentId }], // Appointment was deleted
}),
}));
const result = await service.deleteAppointment(appointmentId);
expect(result).toBe(true);
});
it("should return false if appointment not found", async () => {
const appointmentId = "non-existent";
(mockDb.delete as any).mockImplementation(() => ({
where: () => ({
returning: () => [], // No rows deleted
}),
}));
const result = await service.deleteAppointment(appointmentId);
expect(result).toBe(false);
});
});
describe("queryAppointments", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should validate query request", async () => {
const invalidQuery = {
startDate: "invalid-date",
endDate: "2024-01-02T00:00:00.000Z",
};
await expect(service.queryAppointments(invalidQuery as any)).rejects.toThrow(ValidationError);
});
it("should query appointments with filters", async () => {
const validQuery = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
channelId: "123e4567-e89b-12d3-a456-426614174002",
status: "NEW" as const,
};
const mockResults = [
{
appointment: {
id: "appointment-id",
clientId: "client-id",
channelId: validQuery.channelId,
appointmentDate: "2024-01-15T10:00:00.000Z",
expiryDate: "2024-01-31",
title: "Test Appointment",
description: null,
status: validQuery.status,
},
client: { id: "client-id" },
channel: { id: validQuery.channelId },
},
];
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
leftJoin: () => ({
leftJoin: () => ({
where: () => ({
orderBy: () => mockResults,
}),
}),
}),
}),
}));
const result = await service.queryAppointments(validQuery);
expect(result).toHaveLength(1);
expect(result[0].channelId).toBe(validQuery.channelId);
expect(result[0].status).toBe(validQuery.status);
});
});
});
@@ -58,7 +58,7 @@ const mockAgent = {
id: "550e8400-e29b-41d4-a716-446655440001",
name: "Test Agent",
description: "Test description",
logo: null,
image: null,
};
const mockSlotTemplate = {
@@ -0,0 +1,644 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ValidationError } from "../../utils/errors";
// Set timezone to UTC for consistent test behavior
process.env.TZ = "UTC";
// Mock dependencies before imports
vi.mock("../../db", () => ({
getTenantDb: vi.fn(),
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
// Import after mocking
import { ScheduleService, type ScheduleRequest } from "../schedule-service";
import { getTenantDb } from "../../db";
// Mock database operations with proper query chain handling
const mockDb = {
select: vi.fn(),
};
// Helper to setup database query mocks for the exact ScheduleService query pattern
function setupDbMocks(responses: {
channels: any[];
slotTemplates: any[];
appointments: any[];
absences: any[];
channelAgents: any[];
}) {
let queryCallIndex = 0;
(mockDb.select as any).mockImplementation(() => {
queryCallIndex++;
// Query 1: Channels - simple select with where
if (queryCallIndex === 1) {
return {
from: vi.fn(() => ({
where: vi.fn(() => responses.channels),
})),
};
}
// Query 2: Slot Templates - select with innerJoin
if (queryCallIndex === 2) {
return {
from: vi.fn(() => ({
innerJoin: vi.fn(() => responses.slotTemplates),
})),
};
}
// Query 3: Appointments - select with where (complex conditions)
if (queryCallIndex === 3) {
return {
from: vi.fn(() => ({
where: vi.fn(() => responses.appointments),
})),
};
}
// Query 4: Absences - select with where (complex date conditions)
if (queryCallIndex === 4) {
return {
from: vi.fn(() => ({
where: vi.fn(() => responses.absences),
})),
};
}
// Query 5: Channel Agents - select with innerJoin
if (queryCallIndex === 5) {
return {
from: vi.fn(() => ({
innerJoin: vi.fn(() => responses.channelAgents),
})),
};
}
// Default fallback
return {
from: vi.fn(() => ({
where: vi.fn(() => []),
innerJoin: vi.fn(() => []),
})),
};
});
}
describe("ScheduleService", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
beforeEach(() => {
vi.clearAllMocks();
(getTenantDb as any).mockResolvedValue(mockDb);
// Reset the mock to ensure clean state
(mockDb.select as any).mockClear();
});
describe("forTenant", () => {
it("should create a schedule service instance", async () => {
const service = await ScheduleService.forTenant(mockTenantId);
expect(service).toBeInstanceOf(ScheduleService);
expect(service.tenantId).toBe(mockTenantId);
expect(getTenantDb).toHaveBeenCalledWith(mockTenantId);
});
it("should throw error if database connection fails", async () => {
(getTenantDb as any).mockRejectedValue(new Error("Database connection failed"));
await expect(ScheduleService.forTenant(mockTenantId)).rejects.toThrow(
"Database connection failed",
);
});
});
describe("getSchedule", () => {
let service: ScheduleService;
beforeEach(async () => {
service = await ScheduleService.forTenant(mockTenantId);
});
it("should validate schedule request", async () => {
const invalidRequest = {
startDate: "invalid-date",
endDate: "2024-01-02T00:00:00.000Z",
tenantId: "invalid-uuid",
};
await expect(service.getSchedule(invalidRequest as ScheduleRequest)).rejects.toThrow(
ValidationError,
);
});
it("should generate schedule for valid date range", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
// Mock database responses
const mockChannels = [
{
id: "channel1",
names: ["Test Channel"],
pause: false,
descriptions: ["Test Description"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: "#ff0000",
},
];
const mockSlotTemplates = [
{
slotTemplate: {
id: "template1",
weekdays: 1, // Monday (2^(1-1) = 1)
from: "09:00",
to: "17:00",
duration: 60,
},
channelId: "channel1",
},
];
const mockAppointments: any[] = [];
const mockAbsences: any[] = [];
const mockChannelAgents = [
{
channelId: "channel1",
agent: {
id: "agent1",
name: "Test Agent",
description: "Test Description",
logo: null,
},
},
];
// Setup mock database responses
setupDbMocks({
channels: mockChannels,
slotTemplates: mockSlotTemplates,
appointments: mockAppointments,
absences: mockAbsences,
channelAgents: mockChannelAgents,
});
const result = await service.getSchedule(validRequest);
// Validate basic structure
expect(result).toHaveProperty("period");
expect(result.period.startDate).toBe(validRequest.startDate);
expect(result.period.endDate).toBe(validRequest.endDate);
expect(result).toHaveProperty("schedule");
expect(Array.isArray(result.schedule)).toBe(true);
expect(result.schedule).toHaveLength(1); // One day
// Validate Monday schedule with 8 slots from 09:00-17:00
const mondaySchedule = result.schedule[0];
expect(mondaySchedule.date).toBe("2024-01-01");
expect(mondaySchedule.channels).toHaveProperty("channel1");
const channelSchedule = mondaySchedule.channels["channel1"];
expect(channelSchedule.channel.id).toBe("channel1");
expect(channelSchedule.appointments).toHaveLength(0);
expect(channelSchedule.availableSlots).toHaveLength(8); // 8 slots from 09:00-17:00
// Validate each slot has correct times, duration, and agents
const expectedSlots = [
{ from: "09:00", to: "10:00" },
{ from: "10:00", to: "11:00" },
{ from: "11:00", to: "12:00" },
{ from: "12:00", to: "13:00" },
{ from: "13:00", to: "14:00" },
{ from: "14:00", to: "15:00" },
{ from: "15:00", to: "16:00" },
{ from: "16:00", to: "17:00" },
];
expectedSlots.forEach((expectedSlot, index) => {
const actualSlot = channelSchedule.availableSlots[index];
expect(actualSlot.from).toBe(expectedSlot.from);
expect(actualSlot.to).toBe(expectedSlot.to);
expect(actualSlot.duration).toBe(60); // 60-minute slots
expect(actualSlot.availableAgents).toHaveLength(1);
expect(actualSlot.availableAgents[0].id).toBe("agent1");
expect(actualSlot.availableAgents[0].name).toBe("Test Agent");
});
});
it("should handle empty channel results", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
// Setup mock database responses with empty data
setupDbMocks({
channels: [],
slotTemplates: [],
appointments: [],
absences: [],
channelAgents: [],
});
const result = await service.getSchedule(validRequest);
expect(result.schedule).toHaveLength(1); // One day
expect(result.schedule[0].channels).toEqual({});
});
it("should handle database errors", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
(mockDb.select as any).mockImplementation(() => {
throw new Error("Database error");
});
await expect(service.getSchedule(validRequest)).rejects.toThrow("Database error");
});
it("should generate multiple days for date range", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-03T23:59:59.999Z", // 3 days
tenantId: mockTenantId,
};
// Setup mock database responses with empty data
setupDbMocks({
channels: [],
slotTemplates: [],
appointments: [],
absences: [],
channelAgents: [],
});
const result = await service.getSchedule(validRequest);
expect(result.schedule).toHaveLength(3); // Three days
expect(result.schedule[0].date).toBe("2024-01-01");
expect(result.schedule[1].date).toBe("2024-01-02");
expect(result.schedule[2].date).toBe("2024-01-03");
});
});
describe("slot generation logic", () => {
let service: ScheduleService;
beforeEach(async () => {
service = await ScheduleService.forTenant(mockTenantId);
});
it("should filter slots by weekday", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z", // Monday
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
const mockChannels = [
{
id: "channel1",
names: ["Test Channel"],
pause: false,
descriptions: ["Test Description"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: "#ff0000",
},
];
const mockSlotTemplates = [
{
slotTemplate: {
id: "template1",
weekdays: 1, // Only Monday (2^(1-1) = 1)
from: "09:00",
to: "10:00",
duration: 60,
},
channelId: "channel1",
},
{
slotTemplate: {
id: "template2",
weekdays: 2, // Only Tuesday (2^(2-1) = 2)
from: "14:00",
to: "15:00",
duration: 60,
},
channelId: "channel1",
},
];
const mockChannelAgents = [
{
channelId: "channel1",
agent: {
id: "agent1",
name: "Test Agent",
description: null,
logo: null,
},
},
];
// Setup mock database responses
setupDbMocks({
channels: mockChannels,
slotTemplates: mockSlotTemplates,
appointments: [],
absences: [],
channelAgents: mockChannelAgents,
});
const result = await service.getSchedule(validRequest);
// Should only have Monday slot (09:00-10:00), not Tuesday slot
const channelSchedule = result.schedule[0].channels["channel1"];
expect(channelSchedule.availableSlots).toHaveLength(1);
expect(channelSchedule.availableSlots[0].from).toBe("09:00");
});
it("should exclude slots with appointments", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
const mockChannels = [
{
id: "channel1",
names: ["Test"],
pause: false,
descriptions: ["Test"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: null,
},
];
const mockSlotTemplates = [
{
slotTemplate: {
id: "template1",
weekdays: 1, // Monday (2^(1-1) = 1)
from: "09:00",
to: "11:00",
duration: 60,
},
channelId: "channel1",
},
];
const mockAppointments = [
{
id: "appointment1",
clientId: "client1",
channelId: "channel1",
appointmentDate: "2024-01-01T09:00:00.000Z", // 09:00 UTC
expiryDate: "2024-01-01",
title: "Test Appointment",
description: null,
status: "NEW",
},
];
const mockChannelAgents = [
{
channelId: "channel1",
agent: {
id: "agent1",
name: "Agent",
description: null,
logo: null,
},
},
];
// Setup mock database responses
setupDbMocks({
channels: mockChannels,
slotTemplates: mockSlotTemplates,
appointments: mockAppointments,
absences: [],
channelAgents: mockChannelAgents,
});
const result = await service.getSchedule(validRequest);
const channelSchedule = result.schedule[0].channels["channel1"];
// Should only have 10:00-11:00 slot, not 09:00-10:00 (has appointment)
expect(channelSchedule.availableSlots).toHaveLength(1);
expect(channelSchedule.availableSlots[0].from).toBe("10:00");
});
it("should handle appointments correctly and reduce available slots", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z", // Monday
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
const mockChannels = [
{
id: "channel1",
names: ["Test Channel"],
pause: false,
descriptions: ["Test Description"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: "#ff0000",
},
];
const mockSlotTemplates = [
{
slotTemplate: {
id: "template1",
weekdays: 1, // Monday (2^(1-1) = 1)
from: "09:00",
to: "17:00",
duration: 60,
},
channelId: "channel1",
},
];
// Two existing appointments: 10:00-11:00 and 14:00-15:00
const mockAppointments = [
{
id: "appointment1",
clientId: "client1",
channelId: "channel1",
appointmentDate: "2024-01-01T10:00:00.000Z", // 10:00 UTC
expiryDate: "2024-01-01",
title: "Appointment 1",
description: null,
status: "CONFIRMED",
},
{
id: "appointment2",
clientId: "client2",
channelId: "channel1",
appointmentDate: "2024-01-01T14:00:00.000Z", // 14:00 UTC
expiryDate: "2024-01-01",
title: "Appointment 2",
description: null,
status: "NEW",
},
];
const mockChannelAgents = [
{
channelId: "channel1",
agent: {
id: "agent1",
name: "Test Agent",
description: "Test Description",
logo: null,
},
},
];
// Setup mock database responses
setupDbMocks({
channels: mockChannels,
slotTemplates: mockSlotTemplates,
appointments: mockAppointments,
absences: [],
channelAgents: mockChannelAgents,
});
const result = await service.getSchedule(validRequest);
// Validate appointments are returned
const channelSchedule = result.schedule[0].channels["channel1"];
expect(channelSchedule.appointments).toHaveLength(2);
expect(channelSchedule.appointments[0].id).toBe("appointment1");
expect(channelSchedule.appointments[1].id).toBe("appointment2");
// Should have 6 available slots (8 original - 2 booked)
expect(channelSchedule.availableSlots).toHaveLength(6);
// Validate available slots exclude booked times (10:00-11:00 and 14:00-15:00)
const expectedAvailableSlots = [
{ from: "09:00", to: "10:00" },
{ from: "11:00", to: "12:00" },
{ from: "12:00", to: "13:00" },
{ from: "13:00", to: "14:00" },
{ from: "15:00", to: "16:00" },
{ from: "16:00", to: "17:00" },
];
expectedAvailableSlots.forEach((expectedSlot, index) => {
const actualSlot = channelSchedule.availableSlots[index];
expect(actualSlot.from).toBe(expectedSlot.from);
expect(actualSlot.to).toBe(expectedSlot.to);
expect(actualSlot.duration).toBe(60);
expect(actualSlot.availableAgents).toHaveLength(1);
expect(actualSlot.availableAgents[0].id).toBe("agent1");
});
});
it("should exclude slots when all agents are absent", async () => {
const validRequest: ScheduleRequest = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
tenantId: mockTenantId,
};
const mockChannels = [
{
id: "channel1",
names: ["Test"],
pause: false,
descriptions: ["Test"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: null,
},
];
const mockSlotTemplates = [
{
slotTemplate: {
id: "template1",
weekdays: 1, // Monday (2^(1-1) = 1)
from: "09:00",
to: "10:00",
duration: 60,
},
channelId: "channel1",
},
];
const mockAbsences = [
{
id: "absence1",
agentId: "agent1",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-01T23:59:59.999Z",
absenceType: "Urlaub",
description: null,
isFullDay: true,
},
];
const mockChannelAgents = [
{
channelId: "channel1",
agent: {
id: "agent1",
name: "Agent",
description: null,
logo: null,
},
},
];
// Setup mock database responses
setupDbMocks({
channels: mockChannels,
slotTemplates: mockSlotTemplates,
appointments: [],
absences: mockAbsences,
channelAgents: mockChannelAgents,
});
const result = await service.getSchedule(validRequest);
const channelSchedule = result.schedule[0].channels["channel1"];
// Should have no available slots since only agent is absent
expect(channelSchedule.availableSlots).toHaveLength(0);
});
});
});
+456 -7
View File
@@ -1,26 +1,50 @@
import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectAgent } from "../db/tenant-schema";
import { type SelectAgent, type SelectAgentAbsence } from "../db/tenant-schema";
import { eq } from "drizzle-orm";
import { eq, and, between, or, lte, gte, ne } from "drizzle-orm";
import logger from "$lib/logger";
import z from "zod/v4";
import { ValidationError, NotFoundError } from "../utils/errors";
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
const agentCreationSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().optional(),
logo: z.instanceof(Buffer).optional(),
image: z.instanceof(Buffer).optional(),
});
const agentUpdateSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().optional(),
logo: z.instanceof(Buffer).optional(),
image: z.instanceof(Buffer).optional(),
});
const absenceCreationSchema = z.object({
agentId: z.string().uuid({ message: "Invalid UUID format" }),
startDate: z.string().datetime({ message: "Invalid datetime format" }),
endDate: z.string().datetime({ message: "Invalid datetime format" }),
absenceType: z.string().min(1).max(100),
description: z.string().optional(),
});
const absenceUpdateSchema = z.object({
startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(),
absenceType: z.string().min(1).max(100).optional(),
description: z.string().optional(),
});
const absenceQuerySchema = z.object({
agentId: z.string().uuid({ message: "Invalid UUID format" }).optional(),
startDate: z.string().datetime({ message: "Invalid datetime format" }),
endDate: z.string().datetime({ message: "Invalid datetime format" }),
});
export type AgentCreationRequest = z.infer<typeof agentCreationSchema>;
export type AgentUpdateRequest = z.infer<typeof agentUpdateSchema>;
export type AbsenceCreationRequest = z.infer<typeof absenceCreationSchema>;
export type AbsenceUpdateRequest = z.infer<typeof absenceUpdateSchema>;
export type AbsenceQueryRequest = z.infer<typeof absenceQuerySchema>;
export class AgentService {
#db: Awaited<ReturnType<typeof getTenantDb>> | null = null;
@@ -73,7 +97,7 @@ export class AgentService {
.values({
name: request.name,
description: request.description,
logo: request.logo,
image: request.image,
})
.returning();
@@ -272,7 +296,7 @@ export class AgentService {
id: tenantSchema.agent.id,
name: tenantSchema.agent.name,
description: tenantSchema.agent.description,
logo: tenantSchema.agent.logo,
image: tenantSchema.agent.image,
})
.from(tenantSchema.agent)
.innerJoin(
@@ -376,6 +400,431 @@ export class AgentService {
}
}
/**
* Create a new absence for an agent
* @param request Absence creation request data
* @returns Created absence
*/
async createAbsence(request: AbsenceCreationRequest): Promise<SelectAgentAbsence> {
const log = logger.setContext("AgentService");
const validation = absenceCreationSchema.safeParse(request);
if (!validation.success) {
throw new ValidationError("Invalid absence creation request");
}
// Validate date range
const startDate = new Date(request.startDate);
const endDate = new Date(request.endDate);
if (startDate >= endDate) {
throw new ValidationError("End date must be after start date");
}
log.debug("Creating new absence", {
tenantId: this.tenantId,
agentId: request.agentId,
absenceType: request.absenceType,
startDate: request.startDate,
endDate: request.endDate,
});
try {
const db = await this.getDb();
// Verify agent exists
const agent = await db
.select()
.from(tenantSchema.agent)
.where(eq(tenantSchema.agent.id, request.agentId))
.limit(1);
if (agent.length === 0) {
throw new NotFoundError(`Agent with ID ${request.agentId} not found`);
}
// Check for overlapping absences
const overlappingAbsences = await db
.select()
.from(tenantSchema.agentAbsence)
.where(
and(
eq(tenantSchema.agentAbsence.agentId, request.agentId),
or(
// New absence starts during existing absence
and(
between(
tenantSchema.agentAbsence.startDate,
new Date(request.startDate),
new Date(request.endDate),
),
),
// New absence ends during existing absence
and(
between(
tenantSchema.agentAbsence.endDate,
new Date(request.startDate),
new Date(request.endDate),
),
),
// New absence entirely contains existing absence
and(
gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)),
lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)),
),
),
),
);
if (overlappingAbsences.length > 0) {
throw new ConflictError("Absence period overlaps with existing absence");
}
const result = await db
.insert(tenantSchema.agentAbsence)
.values({
agentId: request.agentId,
startDate: new Date(request.startDate),
endDate: new Date(request.endDate),
absenceType: request.absenceType,
description: request.description,
})
.returning();
log.debug("Absence created successfully", {
tenantId: this.tenantId,
absenceId: result[0].id,
agentId: request.agentId,
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError || error instanceof ConflictError) throw error;
log.error("Failed to create absence", {
tenantId: this.tenantId,
agentId: request.agentId,
error: String(error),
});
throw error;
}
}
/**
* Get absence by ID
* @param absenceId Absence ID
* @returns Absence or null if not found
*/
async getAbsenceById(absenceId: string): Promise<SelectAgentAbsence | null> {
const log = logger.setContext("AgentService");
log.debug("Getting absence by ID", { tenantId: this.tenantId, absenceId });
try {
const db = await this.getDb();
const result = await db
.select()
.from(tenantSchema.agentAbsence)
.where(eq(tenantSchema.agentAbsence.id, absenceId))
.limit(1);
if (result.length === 0) {
log.debug("Absence not found", { tenantId: this.tenantId, absenceId });
return null;
}
log.debug("Absence found", { tenantId: this.tenantId, absenceId });
return result[0];
} catch (error) {
log.error("Failed to get absence by ID", {
tenantId: this.tenantId,
absenceId,
error: String(error),
});
throw error;
}
}
/**
* Query absences with filters
* @param query Query parameters
* @returns Array of absences matching criteria
*/
async queryAbsences(query: AbsenceQueryRequest): Promise<SelectAgentAbsence[]> {
const log = logger.setContext("AgentService");
const validation = absenceQuerySchema.safeParse(query);
if (!validation.success) {
throw new ValidationError("Invalid absence query request");
}
log.debug("Querying absences", {
tenantId: this.tenantId,
agentId: query.agentId,
startDate: query.startDate,
endDate: query.endDate,
});
try {
const db = await this.getDb();
// Build where conditions
const conditions = [
or(
// Absence starts within query period
between(
tenantSchema.agentAbsence.startDate,
new Date(query.startDate),
new Date(query.endDate),
),
// Absence ends within query period
between(
tenantSchema.agentAbsence.endDate,
new Date(query.startDate),
new Date(query.endDate),
),
// Absence spans entire query period (absence starts before query and ends after query)
and(
lte(tenantSchema.agentAbsence.startDate, new Date(query.startDate)),
gte(tenantSchema.agentAbsence.endDate, new Date(query.endDate)),
),
),
];
if (query.agentId) {
conditions.push(eq(tenantSchema.agentAbsence.agentId, query.agentId));
}
const result = await db
.select()
.from(tenantSchema.agentAbsence)
.where(and(...conditions))
.orderBy(tenantSchema.agentAbsence.startDate);
log.debug("Retrieved absences", {
tenantId: this.tenantId,
count: result.length,
});
return result;
} catch (error) {
log.error("Failed to query absences", {
tenantId: this.tenantId,
error: String(error),
});
throw error;
}
}
/**
* Get all absences for a specific agent
* @param agentId Agent ID
* @param startDate Optional start date filter
* @param endDate Optional end date filter
* @returns Array of agent absences
*/
async getAgentAbsences(
agentId: string,
startDate?: string,
endDate?: string,
): Promise<SelectAgentAbsence[]> {
const log = logger.setContext("AgentService");
log.debug("Getting agent absences", {
tenantId: this.tenantId,
agentId,
startDate,
endDate,
});
try {
const db = await this.getDb();
const query = db
.select()
.from(tenantSchema.agentAbsence)
.where(eq(tenantSchema.agentAbsence.agentId, agentId));
// Add date filters if provided
if (startDate && endDate) {
const result = await this.queryAbsences({
agentId,
startDate,
endDate,
});
return result;
}
const result = await query.orderBy(tenantSchema.agentAbsence.startDate);
log.debug("Retrieved agent absences", {
tenantId: this.tenantId,
agentId,
count: result.length,
});
return result;
} catch (error) {
log.error("Failed to get agent absences", {
tenantId: this.tenantId,
agentId,
error: String(error),
});
throw error;
}
}
/**
* Update an existing absence
* @param absenceId Absence ID
* @param updateData Absence update data
* @returns Updated absence
*/
async updateAbsence(
absenceId: string,
updateData: AbsenceUpdateRequest,
): Promise<SelectAgentAbsence> {
const log = logger.setContext("AgentService");
const validation = absenceUpdateSchema.safeParse(updateData);
if (!validation.success) {
throw new ValidationError("Invalid absence update request");
}
log.debug("Updating absence", {
tenantId: this.tenantId,
absenceId,
updateFields: Object.keys(updateData),
});
try {
const db = await this.getDb();
// Get current absence data
const currentAbsence = await db
.select()
.from(tenantSchema.agentAbsence)
.where(eq(tenantSchema.agentAbsence.id, absenceId))
.limit(1);
if (currentAbsence.length === 0) {
throw new NotFoundError(`Absence with ID ${absenceId} not found`);
}
// If updating dates, validate the new date range
if (updateData.startDate || updateData.endDate) {
const newStartDate = updateData.startDate || currentAbsence[0].startDate;
const newEndDate = updateData.endDate || currentAbsence[0].endDate;
if (new Date(newStartDate) >= new Date(newEndDate)) {
throw new ValidationError("End date must be after start date");
}
// Check for overlapping absences (excluding current absence)
const overlappingAbsences = await db
.select()
.from(tenantSchema.agentAbsence)
.where(
and(
eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId),
// Exclude current absence from check
ne(tenantSchema.agentAbsence.id, absenceId),
or(
between(
tenantSchema.agentAbsence.startDate,
new Date(newStartDate),
new Date(newEndDate),
),
between(
tenantSchema.agentAbsence.endDate,
new Date(newStartDate),
new Date(newEndDate),
),
// New period entirely contains existing absence
and(
gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)),
lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)),
),
),
),
);
if (overlappingAbsences.length > 0) {
throw new ConflictError("Updated absence period overlaps with existing absence");
}
}
// Convert string dates to Date objects for database
const dbUpdateData = {
...updateData,
startDate: updateData.startDate ? new Date(updateData.startDate) : undefined,
endDate: updateData.endDate ? new Date(updateData.endDate) : undefined,
};
const result = await db
.update(tenantSchema.agentAbsence)
.set(dbUpdateData)
.where(eq(tenantSchema.agentAbsence.id, absenceId))
.returning();
if (result.length === 0) {
throw new NotFoundError(`Absence with ID ${absenceId} not found`);
}
log.debug("Absence updated successfully", {
tenantId: this.tenantId,
absenceId,
updateFields: Object.keys(updateData),
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError || error instanceof ConflictError) throw error;
log.error("Failed to update absence", {
tenantId: this.tenantId,
absenceId,
error: String(error),
});
throw error;
}
}
/**
* Delete an absence
* @param absenceId Absence ID
* @returns true if deleted, false if not found
*/
async deleteAbsence(absenceId: string): Promise<boolean> {
const log = logger.setContext("AgentService");
log.debug("Deleting absence", { tenantId: this.tenantId, absenceId });
try {
const db = await this.getDb();
const result = await db
.delete(tenantSchema.agentAbsence)
.where(eq(tenantSchema.agentAbsence.id, absenceId))
.returning();
if (result.length === 0) {
log.debug("Absence deletion failed: Absence not found", {
tenantId: this.tenantId,
absenceId,
});
return false;
}
log.debug("Absence deleted successfully", {
tenantId: this.tenantId,
absenceId,
});
return true;
} catch (error) {
log.error("Failed to delete absence", {
tenantId: this.tenantId,
absenceId,
error: String(error),
});
throw error;
}
}
/**
* Get the tenant's database connection (cached)
*/
@@ -0,0 +1,496 @@
import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectAppointment, type SelectClient, type SelectChannel } from "../db/tenant-schema";
import { eq, and, between, or } from "drizzle-orm";
import logger from "$lib/logger";
import z from "zod/v4";
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
const appointmentCreationSchema = z.object({
clientId: z.string().uuid(),
channelId: z.string().uuid(),
appointmentDate: z.string().datetime(),
expiryDate: z.string().date(),
name: z.string().min(1).max(200),
phone: z.string().min(1).max(200).optional().default(""),
description: z.string().optional(),
status: z.enum(["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]).default("NEW"),
});
const appointmentUpdateSchema = z.object({
appointmentDate: z.string().datetime().optional(),
expiryDate: z.string().date().optional(),
title: z.string().min(1).max(200).optional(),
name: z.string().optional(),
phone: z.string().min(1).max(200).optional(),
status: z.enum(["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]).optional(),
});
const appointmentQuerySchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
channelId: z.string().uuid().optional(),
clientId: z.string().uuid().optional(),
status: z.enum(["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]).optional(),
});
export type AppointmentCreationRequest = z.infer<typeof appointmentCreationSchema>;
export type AppointmentUpdateRequest = z.infer<typeof appointmentUpdateSchema>;
export type AppointmentQueryRequest = z.infer<typeof appointmentQuerySchema>;
export interface AppointmentWithDetails extends SelectAppointment {
client?: SelectClient;
channel?: SelectChannel;
}
export class AppointmentService {
#db: Awaited<ReturnType<typeof getTenantDb>> | null = null;
private constructor(public readonly tenantId: string) {}
/**
* Create an appointment service for a specific tenant
* @param tenantId The ID of the tenant
* @returns new AppointmentService instance
*/
static async forTenant(tenantId: string) {
const log = logger.setContext("AppointmentService");
log.debug("Creating appointment service for tenant", { tenantId });
try {
const service = new AppointmentService(tenantId);
service.#db = await getTenantDb(tenantId);
log.debug("Appointment service created successfully", { tenantId });
return service;
} catch (error) {
log.error("Failed to create appointment service", { tenantId, error: String(error) });
throw error;
}
}
/**
* Create a new appointment
* @param request Appointment creation request data
* @returns Created appointment
*/
async createAppointment(request: AppointmentCreationRequest): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
const validation = appointmentCreationSchema.safeParse(request);
if (!validation.success) {
throw new ValidationError("Invalid appointment creation request");
}
log.debug("Creating new appointment", {
tenantId: this.tenantId,
clientId: request.clientId,
channelId: request.channelId,
appointmentDate: request.appointmentDate,
});
try {
const db = await this.getDb();
// Check if client exists
const client = await db
.select()
.from(tenantSchema.client)
.where(eq(tenantSchema.client.id, request.clientId))
.limit(1);
if (client.length === 0) {
throw new NotFoundError(`Client with ID ${request.clientId} not found`);
}
// Check if channel exists and is not paused
const channel = await db
.select()
.from(tenantSchema.channel)
.where(eq(tenantSchema.channel.id, request.channelId))
.limit(1);
if (channel.length === 0) {
throw new NotFoundError(`Channel with ID ${request.channelId} not found`);
}
if (channel[0].pause) {
throw new ConflictError("Channel is currently paused and not accepting appointments");
}
// Check for conflicting appointments at the same time slot
const conflictingAppointments = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.channelId, request.channelId),
eq(tenantSchema.appointment.appointmentDate, request.appointmentDate),
or(
eq(tenantSchema.appointment.status, "NEW"),
eq(tenantSchema.appointment.status, "CONFIRMED"),
eq(tenantSchema.appointment.status, "HELD"),
),
),
);
if (conflictingAppointments.length > 0) {
throw new ConflictError("Time slot is already booked");
}
// Create the appointment
const result = await db
.insert(tenantSchema.appointment)
.values({
clientId: request.clientId,
channelId: request.channelId,
appointmentDate: request.appointmentDate,
expiryDate: request.expiryDate,
name: request.name,
phone: request.phone ?? "",
status: request.status,
})
.returning();
log.debug("Appointment created successfully", {
tenantId: this.tenantId,
appointmentId: result[0].id,
clientId: request.clientId,
channelId: request.channelId,
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError || error instanceof ConflictError) throw error;
log.error("Failed to create appointment", {
tenantId: this.tenantId,
clientId: request.clientId,
channelId: request.channelId,
error: String(error),
});
throw error;
}
}
/**
* Get an appointment by ID
* @param appointmentId Appointment ID
* @returns Appointment with details or null if not found
*/
async getAppointmentById(appointmentId: string): Promise<AppointmentWithDetails | null> {
const log = logger.setContext("AppointmentService");
log.debug("Getting appointment by ID", { tenantId: this.tenantId, appointmentId });
try {
const db = await this.getDb();
const result = await db
.select({
appointment: tenantSchema.appointment,
client: tenantSchema.client,
channel: tenantSchema.channel,
})
.from(tenantSchema.appointment)
.leftJoin(
tenantSchema.client,
eq(tenantSchema.appointment.clientId, tenantSchema.client.id),
)
.leftJoin(
tenantSchema.channel,
eq(tenantSchema.appointment.channelId, tenantSchema.channel.id),
)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (result.length === 0) {
log.debug("Appointment not found", { tenantId: this.tenantId, appointmentId });
return null;
}
const row = result[0];
log.debug("Appointment found", { tenantId: this.tenantId, appointmentId });
return {
...row.appointment,
client: row.client || undefined,
channel: row.channel || undefined,
};
} catch (error) {
log.error("Failed to get appointment by ID", {
tenantId: this.tenantId,
appointmentId,
error: String(error),
});
throw error;
}
}
/**
* Query appointments with filters
* @param query Query parameters
* @returns Array of appointments matching criteria
*/
async queryAppointments(query: AppointmentQueryRequest): Promise<AppointmentWithDetails[]> {
const log = logger.setContext("AppointmentService");
const validation = appointmentQuerySchema.safeParse(query);
if (!validation.success) {
throw new ValidationError("Invalid appointment query request");
}
log.debug("Querying appointments", {
tenantId: this.tenantId,
startDate: query.startDate,
endDate: query.endDate,
channelId: query.channelId,
clientId: query.clientId,
status: query.status,
});
try {
const db = await this.getDb();
// Build where conditions dynamically
const conditions = [
between(tenantSchema.appointment.appointmentDate, query.startDate, query.endDate),
];
if (query.channelId) {
conditions.push(eq(tenantSchema.appointment.channelId, query.channelId));
}
if (query.clientId) {
conditions.push(eq(tenantSchema.appointment.clientId, query.clientId));
}
if (query.status) {
conditions.push(eq(tenantSchema.appointment.status, query.status));
}
const result = await db
.select({
appointment: tenantSchema.appointment,
client: tenantSchema.client,
channel: tenantSchema.channel,
})
.from(tenantSchema.appointment)
.leftJoin(
tenantSchema.client,
eq(tenantSchema.appointment.clientId, tenantSchema.client.id),
)
.leftJoin(
tenantSchema.channel,
eq(tenantSchema.appointment.channelId, tenantSchema.channel.id),
)
.where(and(...conditions))
.orderBy(tenantSchema.appointment.appointmentDate);
log.debug("Retrieved appointments", {
tenantId: this.tenantId,
count: result.length,
});
return result.map((row) => ({
...row.appointment,
client: row.client || undefined,
channel: row.channel || undefined,
}));
} catch (error) {
log.error("Failed to query appointments", {
tenantId: this.tenantId,
error: String(error),
});
throw error;
}
}
/**
* Update an existing appointment
* @param appointmentId Appointment ID
* @param updateData Appointment update data
* @returns Updated appointment
*/
async updateAppointment(
appointmentId: string,
updateData: AppointmentUpdateRequest,
): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
const validation = appointmentUpdateSchema.safeParse(updateData);
if (!validation.success) {
throw new ValidationError("Invalid appointment update request");
}
log.debug("Updating appointment", {
tenantId: this.tenantId,
appointmentId,
updateFields: Object.keys(updateData),
});
try {
const db = await this.getDb();
// If updating appointment date, check for conflicts
if (updateData.appointmentDate) {
const existingAppointment = await db
.select()
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (existingAppointment.length === 0) {
throw new NotFoundError(`Appointment with ID ${appointmentId} not found`);
}
// Check for conflicting appointments at the new time slot
const conflictingAppointments = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.channelId, existingAppointment[0].channelId),
eq(tenantSchema.appointment.appointmentDate, updateData.appointmentDate),
eq(tenantSchema.appointment.id, appointmentId), // Exclude current appointment
or(
eq(tenantSchema.appointment.status, "NEW"),
eq(tenantSchema.appointment.status, "CONFIRMED"),
eq(tenantSchema.appointment.status, "HELD"),
),
),
);
if (conflictingAppointments.length > 0) {
throw new ConflictError("New time slot is already booked");
}
}
const result = await db
.update(tenantSchema.appointment)
.set(updateData)
.where(eq(tenantSchema.appointment.id, appointmentId))
.returning();
if (result.length === 0) {
log.warn("Appointment update failed: Appointment not found", {
tenantId: this.tenantId,
appointmentId,
});
throw new NotFoundError(`Appointment with ID ${appointmentId} not found`);
}
log.debug("Appointment updated successfully", {
tenantId: this.tenantId,
appointmentId,
updateFields: Object.keys(updateData),
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError || error instanceof ConflictError) throw error;
log.error("Failed to update appointment", {
tenantId: this.tenantId,
appointmentId,
error: String(error),
});
throw error;
}
}
/**
* Cancel an appointment (set status to REJECTED)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async cancelAppointment(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Cancelling appointment", { tenantId: this.tenantId, appointmentId });
return this.updateAppointment(appointmentId, { status: "REJECTED" });
}
/**
* Confirm an appointment (set status to CONFIRMED)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async confirmAppointment(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment", { tenantId: this.tenantId, appointmentId });
return this.updateAppointment(appointmentId, { status: "CONFIRMED" });
}
/**
* Mark appointment as completed (set status to HELD)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async completeAppointment(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Completing appointment", { tenantId: this.tenantId, appointmentId });
return this.updateAppointment(appointmentId, { status: "HELD" });
}
/**
* Mark appointment as no-show (set status to NO_SHOW)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async markNoShow(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Marking appointment as no-show", { tenantId: this.tenantId, appointmentId });
return this.updateAppointment(appointmentId, { status: "NO_SHOW" });
}
/**
* Delete an appointment
* @param appointmentId Appointment ID
* @returns true if deleted, false if not found
*/
async deleteAppointment(appointmentId: string): Promise<boolean> {
const log = logger.setContext("AppointmentService");
log.debug("Deleting appointment", { tenantId: this.tenantId, appointmentId });
try {
const db = await this.getDb();
const result = await db
.delete(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.returning();
if (result.length === 0) {
log.debug("Appointment deletion failed: Appointment not found", {
tenantId: this.tenantId,
appointmentId,
});
return false;
}
log.debug("Appointment deleted successfully", {
tenantId: this.tenantId,
appointmentId,
});
return true;
} catch (error) {
log.error("Failed to delete appointment", {
tenantId: this.tenantId,
appointmentId,
error: String(error),
});
throw error;
}
}
/**
* Get the tenant's database connection (cached)
*/
private async getDb() {
if (!this.#db) {
this.#db = await getTenantDb(this.tenantId);
}
return this.#db;
}
}
+2 -2
View File
@@ -339,7 +339,7 @@ export class ChannelService {
id: tenantSchema.agent.id,
name: tenantSchema.agent.name,
description: tenantSchema.agent.description,
logo: tenantSchema.agent.logo,
image: tenantSchema.agent.image,
})
.from(tenantSchema.agent)
.innerJoin(
@@ -516,7 +516,7 @@ export class ChannelService {
id: tenantSchema.agent.id,
name: tenantSchema.agent.name,
description: tenantSchema.agent.description,
logo: tenantSchema.agent.logo,
image: tenantSchema.agent.image,
})
.from(tenantSchema.agent)
.innerJoin(
+388
View File
@@ -0,0 +1,388 @@
import { getTenantDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import {
type SelectAgent,
type SelectChannel,
type SelectSlotTemplate,
type SelectAppointment,
type SelectAgentAbsence,
} from "../db/tenant-schema";
import { eq, and, between, sql, or } from "drizzle-orm";
import logger from "$lib/logger";
import z from "zod/v4";
import { ValidationError } from "../utils/errors";
const scheduleRequestSchema = z.object({
startDate: z.string().datetime({ offset: true }), // ISO date string with timezone
endDate: z.string().datetime({ offset: true }), // ISO date string with timezone
tenantId: z.string().uuid({ message: "Invalid tenant ID format" }),
});
export type ScheduleRequest = z.infer<typeof scheduleRequestSchema>;
export interface TimeSlot {
from: string; // HH:MM format
to: string; // HH:MM format
duration: number; // minutes
availableAgents: SelectAgent[];
}
export interface DaySchedule {
date: string; // YYYY-MM-DD format
channels: {
[channelId: string]: {
channel: SelectChannel;
appointments: SelectAppointment[];
availableSlots: TimeSlot[];
};
};
}
export interface ScheduleResult {
period: {
startDate: string;
endDate: string;
};
schedule: DaySchedule[];
}
export class ScheduleService {
#db: Awaited<ReturnType<typeof getTenantDb>> | null = null;
private constructor(public readonly tenantId: string) {}
/**
* Create a schedule service for a specific tenant
* @param tenantId The ID of the tenant
* @returns new ScheduleService instance
*/
static async forTenant(tenantId: string) {
const log = logger.setContext("ScheduleService");
log.debug("Creating schedule service for tenant", { tenantId });
try {
const service = new ScheduleService(tenantId);
service.#db = await getTenantDb(tenantId);
log.debug("Schedule service created successfully", { tenantId });
return service;
} catch (error) {
log.error("Failed to create schedule service", { tenantId, error: String(error) });
throw error;
}
}
/**
* Generate calendar view for a specific time period
* @param request Schedule request with date range
* @returns Schedule result with available slots and appointments
*/
async getSchedule(request: ScheduleRequest): Promise<ScheduleResult> {
const log = logger.setContext("ScheduleService");
const validation = scheduleRequestSchema.safeParse(request);
if (!validation.success) {
throw new ValidationError("Invalid schedule request");
}
log.debug("Generating schedule", {
tenantId: this.tenantId,
startDate: request.startDate,
endDate: request.endDate,
});
try {
const db = await this.getDb();
// 1. Get all channels for the tenant
const channels = await db
.select()
.from(tenantSchema.channel)
.where(eq(tenantSchema.channel.pause, false)); // Only active channels
// 2. Get all slot templates associated with channels
const slotTemplates = await db
.select({
slotTemplate: tenantSchema.slotTemplate,
channelId: tenantSchema.channelSlotTemplate.channelId,
})
.from(tenantSchema.slotTemplate)
.innerJoin(
tenantSchema.channelSlotTemplate,
eq(tenantSchema.slotTemplate.id, tenantSchema.channelSlotTemplate.slotTemplateId),
);
// 3. Get appointments in the date range
const appointments = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
between(tenantSchema.appointment.appointmentDate, request.startDate, request.endDate),
or(
eq(tenantSchema.appointment.status, "NEW"),
eq(tenantSchema.appointment.status, "CONFIRMED"),
eq(tenantSchema.appointment.status, "HELD"),
),
),
);
// 4. Get agent absences in the date range
const absences = await db
.select()
.from(tenantSchema.agentAbsence)
.where(
or(
// Absence starts within period
and(
sql`${tenantSchema.agentAbsence.startDate} >= ${request.startDate}`,
sql`${tenantSchema.agentAbsence.startDate} <= ${request.endDate}`,
),
// Absence ends within period
and(
sql`${tenantSchema.agentAbsence.endDate} >= ${request.startDate}`,
sql`${tenantSchema.agentAbsence.endDate} <= ${request.endDate}`,
),
// Absence spans entire period
and(
sql`${tenantSchema.agentAbsence.startDate} <= ${request.startDate}`,
sql`${tenantSchema.agentAbsence.endDate} >= ${request.endDate}`,
),
),
);
// 5. Get channel-agent assignments
const channelAgents = await db
.select({
channelId: tenantSchema.channelAgent.channelId,
agent: tenantSchema.agent,
})
.from(tenantSchema.channelAgent)
.innerJoin(
tenantSchema.agent,
eq(tenantSchema.channelAgent.agentId, tenantSchema.agent.id),
);
// 6. Generate daily schedules
const schedule = await this.generateDailySchedules({
startDate: new Date(request.startDate),
endDate: new Date(request.endDate),
channels,
slotTemplates,
appointments,
absences,
channelAgents,
});
log.debug("Schedule generated successfully", {
tenantId: this.tenantId,
daysGenerated: schedule.length,
});
return {
period: {
startDate: request.startDate,
endDate: request.endDate,
},
schedule,
};
} catch (error) {
log.error("Failed to generate schedule", {
tenantId: this.tenantId,
error: String(error),
});
throw error;
}
}
/**
* Generate daily schedules for the given period and data
*/
private async generateDailySchedules({
startDate,
endDate,
channels,
slotTemplates,
appointments,
absences,
channelAgents,
}: {
startDate: Date;
endDate: Date;
channels: SelectChannel[];
slotTemplates: { slotTemplate: SelectSlotTemplate; channelId: string }[];
appointments: SelectAppointment[];
absences: SelectAgentAbsence[];
channelAgents: { channelId: string; agent: SelectAgent }[];
}): Promise<DaySchedule[]> {
const dailySchedules: DaySchedule[] = [];
// Iterate through each day in the period
const currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateString = currentDate.toISOString().split("T")[0]; // YYYY-MM-DD
const weekday = currentDate.getDay(); // 0 = Sunday, 1 = Monday, ...
const weekdayBit = weekday === 0 ? 64 : Math.pow(2, weekday - 1); // Convert to bitmask
const daySchedule: DaySchedule = {
date: dateString,
channels: {},
};
// Process each channel
for (const channel of channels) {
// Get appointments for this channel on this day
const dayAppointments = appointments.filter(
(appointment) =>
appointment.channelId === channel.id &&
appointment.appointmentDate.startsWith(dateString),
);
// Get slot templates for this channel that apply to this weekday
const channelSlotTemplates = slotTemplates
.filter(
(st) =>
st.channelId === channel.id &&
st.slotTemplate.weekdays !== null &&
(st.slotTemplate.weekdays & weekdayBit) !== 0,
)
.map((st) => st.slotTemplate);
// Get agents assigned to this channel
const channelAgentsList = channelAgents
.filter((ca) => ca.channelId === channel.id)
.map((ca) => ca.agent);
// Generate available slots for this channel
const availableSlots = this.generateAvailableSlots({
date: currentDate,
slotTemplates: channelSlotTemplates,
appointments: dayAppointments,
agents: channelAgentsList,
absences,
});
daySchedule.channels[channel.id] = {
channel,
appointments: dayAppointments,
availableSlots,
};
}
dailySchedules.push(daySchedule);
// Move to next day
currentDate.setDate(currentDate.getDate() + 1);
}
return dailySchedules;
}
/**
* Generate available time slots for a specific day and channel
*/
private generateAvailableSlots({
date,
slotTemplates,
appointments,
agents,
absences,
}: {
date: Date;
slotTemplates: SelectSlotTemplate[];
appointments: SelectAppointment[];
agents: SelectAgent[];
absences: SelectAgentAbsence[];
}): TimeSlot[] {
const availableSlots: TimeSlot[] = [];
for (const template of slotTemplates) {
// Parse template times
const [fromHour, fromMinute] = template.from.split(":").map(Number);
const [toHour, toMinute] = template.to.split(":").map(Number);
// Generate slots based on duration
const slotDuration = template.duration;
let currentTime = fromHour * 60 + fromMinute; // minutes from midnight
const endTime = toHour * 60 + toMinute;
while (currentTime + slotDuration <= endTime) {
const slotStartHour = Math.floor(currentTime / 60);
const slotStartMinute = currentTime % 60;
const slotEndTime = currentTime + slotDuration;
const slotEndHour = Math.floor(slotEndTime / 60);
const slotEndMinute = slotEndTime % 60;
const slotStart = `${slotStartHour.toString().padStart(2, "0")}:${slotStartMinute.toString().padStart(2, "0")}`;
const slotEnd = `${slotEndHour.toString().padStart(2, "0")}:${slotEndMinute.toString().padStart(2, "0")}`;
// Check if this slot conflicts with any appointments
const hasAppointmentConflict = appointments.some((appointment) => {
const appointmentTime = new Date(appointment.appointmentDate).toTimeString().slice(0, 5);
return appointmentTime === slotStart;
});
if (!hasAppointmentConflict) {
// Get available agents for this slot (not absent)
const availableAgents = agents.filter((agent) => {
return !this.isAgentAbsent(agent.id, date, slotStart, slotEnd, absences);
});
// Only include slot if there are available agents
if (availableAgents.length > 0) {
availableSlots.push({
from: slotStart,
to: slotEnd,
duration: slotDuration,
availableAgents,
});
}
}
currentTime += slotDuration;
}
}
return availableSlots;
}
/**
* Check if an agent is absent during a specific time slot
*/
private isAgentAbsent(
agentId: string,
date: Date,
slotStart: string,
slotEnd: string,
absences: SelectAgentAbsence[],
): boolean {
const dateString = date.toISOString().split("T")[0];
const slotStartDateTime = new Date(`${dateString}T${slotStart}:00.000Z`);
const slotEndDateTime = new Date(`${dateString}T${slotEnd}:00.000Z`);
return absences.some((absence) => {
if (absence.agentId !== agentId) return false;
const absenceStart = new Date(absence.startDate);
const absenceEnd = new Date(absence.endDate);
// For time-specific absences, check if the time slot overlaps
return (
(slotStartDateTime >= absenceStart && slotStartDateTime < absenceEnd) ||
(slotEndDateTime > absenceStart && slotEndDateTime <= absenceEnd) ||
(slotStartDateTime <= absenceStart && slotEndDateTime >= absenceEnd)
);
});
}
/**
* Get the tenant's database connection (cached)
*/
private async getDb() {
if (!this.#db) {
this.#db = await getTenantDb(this.tenantId);
}
return this.#db;
}
}
+1
View File
@@ -20,6 +20,7 @@ export class AuthenticationError extends BackendError {
this.name = "AuthenticationError";
}
}
export class ValidationError extends BackendError {
constructor(
message: string,
+38
View File
@@ -0,0 +1,38 @@
import { json } from "@sveltejs/kit";
/**
* Check whether the user can access the data within a route.
* @param locals locals object containing the user information
* @param tenantId tenant id
* @returns error Response if permission check failed
*/
export const checkPermission = (
locals: App.Locals,
tenantId: string | null,
administrative: boolean = false,
): Response | null => {
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can view absences for any tenant
return null;
} else if (
locals.user.role === "TENANT_ADMIN" &&
tenantId != null &&
locals.user.tenantId === tenantId
) {
// Tenant admin and staff can view absences for their own tenant
return null;
} else if (
!administrative &&
locals.user.role === "STAFF" &&
tenantId != null &&
locals.user.tenantId === tenantId
) {
// Tenant admin and staff can view absences for their own tenant
return null;
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
}
};
+11 -13
View File
@@ -8,6 +8,7 @@ import { UserService } from "$lib/server/services/user-service";
import { generateAccessToken } from "$lib/server/auth/jwt-utils";
import { UniversalLogger } from "$lib/logger";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { checkPermission } from "$lib/server/utils/permissions";
const logger = new UniversalLogger().setContext("AdminTenantSwitch");
@@ -22,12 +23,9 @@ const tenantSwitchSchema = z.object({
export const POST: RequestHandler = async ({ request, locals, cookies }) => {
try {
// Verify user is authenticated and is global admin
if (!locals.user) {
throw error(401, "Authentication required");
}
if (locals.user.role !== "GLOBAL_ADMIN") {
throw error(403, "Only global admins can switch tenants");
const permissionError = checkPermission(locals, null, true);
if (permissionError) {
return permissionError;
}
const body = await request.json();
@@ -35,7 +33,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
if (!validation.success) {
logger.warn("Invalid tenant switch request", {
userId: locals.user.id,
userId: locals.user?.id,
errors: validation.error.errors,
});
throw error(400, "Invalid request body");
@@ -52,7 +50,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
if (tenantExists.length === 0) {
logger.warn("Tenant not found for switching", {
userId: locals.user.id,
userId: locals.user?.id,
tenantId,
});
throw error(404, "Tenant not found");
@@ -61,13 +59,13 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
// Current user is already authenticated via authHandle
// Update user's active tenant in the database
const updatedUser = await UserService.updateUser(locals.user.userId as string, {
const updatedUser = await UserService.updateUser(locals.user?.userId as string, {
tenantId: tenantId || null,
});
if (!updatedUser) {
logger.error("Failed to update user tenant", {
userId: locals.user.userId,
userId: locals.user?.userId,
tenantId,
});
throw error(500, "Failed to update user data");
@@ -76,7 +74,7 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
// Generate new access token with updated tenant context
const newAccessToken = await generateAccessToken(
updatedUser,
(locals.user.sessionId as string) || "temp-session",
(locals.user?.sessionId as string) || "temp-session",
);
// Set new access token cookie
@@ -89,8 +87,8 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
});
logger.info("Tenant switched successfully", {
userId: locals.user.userId,
fromTenant: locals.user.tenantId,
userId: locals.user?.userId,
fromTenant: locals.user?.tenantId,
toTenant: tenantId,
});
+36 -3
View File
@@ -89,9 +89,15 @@ vi.mock("$lib/server/utils/errors", () => ({
NotFoundError: class NotFoundError extends Error {},
}));
// Mock permissions module
vi.mock("$lib/server/utils/permissions", () => ({
checkPermission: vi.fn(),
}));
import { centralDb } from "$lib/server/db";
import { UserService } from "$lib/server/services/user-service";
import { generateAccessToken } from "$lib/server/auth/jwt-utils";
import { checkPermission } from "$lib/server/utils/permissions";
describe("POST /api/admin/tenant", () => {
const mockUser = {
@@ -123,7 +129,7 @@ describe("POST /api/admin/tenant", () => {
request: {
json: () => Promise.resolve(body),
} as Request,
locals: { user: { ...user, sessionId: "session-123" } },
locals: { user: user ? { ...user, sessionId: "session-123" } : null },
cookies: mockCookies,
params: {},
url: new URL("http://localhost/api/admin/tenant"),
@@ -144,6 +150,9 @@ describe("POST /api/admin/tenant", () => {
const tenantId = "550e8400-e29b-41d4-a716-446655440000";
const requestEvent = createRequestEvent({ tenantId });
// Mock permission check to pass
vi.mocked(checkPermission).mockReturnValue(null);
// Mock tenant exists query
const mockSelectQuery = {
from: vi.fn().mockReturnThis(),
@@ -188,7 +197,16 @@ describe("POST /api/admin/tenant", () => {
null,
);
await expect(POST(requestEvent)).rejects.toThrow();
// Mock permission check to return 401 error
const mockErrorResponse = new Response(JSON.stringify({ error: "Authentication required" }), {
status: 401,
});
vi.mocked(checkPermission).mockReturnValue(mockErrorResponse);
const response = await POST(requestEvent);
expect(response.status).toBe(401);
const result = await response.json();
expect(result.error).toBe("Authentication required");
});
it("should return 403 when user is not a global admin", async () => {
@@ -198,18 +216,33 @@ describe("POST /api/admin/tenant", () => {
tenantAdminUser,
);
await expect(POST(requestEvent)).rejects.toThrow();
// Mock permission check to return 403 error
const mockErrorResponse = new Response(JSON.stringify({ error: "Insufficient permissions" }), {
status: 403,
});
vi.mocked(checkPermission).mockReturnValue(mockErrorResponse);
const response = await POST(requestEvent);
expect(response.status).toBe(403);
const result = await response.json();
expect(result.error).toBe("Insufficient permissions");
});
it("should return 400 when request body is invalid", async () => {
const requestEvent = createRequestEvent({ tenantId: "invalid-uuid" });
// Mock permission check to pass
vi.mocked(checkPermission).mockReturnValue(null);
await expect(POST(requestEvent)).rejects.toThrow();
});
it("should return 404 when tenant does not exist", async () => {
const requestEvent = createRequestEvent({ tenantId: "550e8400-e29b-41d4-a716-446655440000" });
// Mock permission check to pass
vi.mocked(checkPermission).mockReturnValue(null);
// Mock tenant doesn't exist
const mockSelectQuery = {
from: vi.fn().mockReturnThis(),
+9 -1
View File
@@ -6,6 +6,7 @@ import { registerOpenAPIRoute } from "$lib/server/openapi";
import { db } from "$lib/server/db";
import { tenant } from "$lib/server/db/central-schema";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
import { ERRORS } from "$lib/errors";
// Register OpenAPI documentation
@@ -103,6 +104,7 @@ registerOpenAPIRoute("/tenants", "GET", {
id: { type: "string", format: "uuid", description: "Tenant ID" },
shortName: { type: "string", description: "Tenant short name" },
longName: { type: "string", description: "Tenant long name" },
logo: { type: "string", format: "uri", description: "URL of the tenant logo" },
setupState: {
type: "string",
enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"],
@@ -147,7 +149,7 @@ registerOpenAPIRoute("/tenants", "GET", {
},
});
export const POST: RequestHandler = async ({ request }) => {
export const POST: RequestHandler = async ({ locals, request }) => {
const log = logger.setContext("API");
try {
@@ -158,6 +160,11 @@ export const POST: RequestHandler = async ({ request }) => {
hasInviteAdmin: !!body.inviteAdmin,
});
const error = checkPermission(locals, null, true);
if (error) {
return error;
}
const tenantService = await TenantAdminService.createTenant({
shortName: body.shortName,
inviteAdmin: body.inviteAdmin,
@@ -216,6 +223,7 @@ export const GET: RequestHandler = async ({ locals }) => {
shortName: tenant.shortName,
longName: tenant.longName,
setupState: tenant.setupState,
logo: tenant.logo,
})
.from(tenant)
.orderBy(tenant.shortName);
+12 -15
View File
@@ -5,6 +5,7 @@ import { AuthorizationService } from "$lib/server/auth/authorization-service";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
import { ERRORS } from "$lib/errors";
// Register OpenAPI documentation for PUT
@@ -381,7 +382,7 @@ registerOpenAPIRoute("/tenants/{id}", "DELETE", {
},
});
export const PUT: RequestHandler = async ({ params, request }) => {
export const PUT: RequestHandler = async ({ locals, params, request }) => {
const log = logger.setContext("API");
try {
@@ -397,6 +398,11 @@ export const PUT: RequestHandler = async ({ params, request }) => {
return json({ error: "No tenant id given" }, { status: 400 });
}
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const tenantService = await TenantAdminService.getTenantById(tenantId);
const updatedTenant = await tenantService.updateTenantData(body);
@@ -435,27 +441,18 @@ export const GET: RequestHandler = async ({ params, locals }) => {
try {
const tenantId = params.id;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
log.debug("Getting tenant details", {
tenantId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
// Authorization check: Global admins can access any tenant, tenant admins only their own
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can access any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can access their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const tenantService = await TenantAdminService.getTenantById(tenantId);
@@ -467,7 +464,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
log.debug("Tenant details retrieved successfully", {
tenantId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
+12 -27
View File
@@ -4,6 +4,7 @@ import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/agents", "POST", {
@@ -122,7 +123,7 @@ registerOpenAPIRoute("/tenants/{id}/agents", "POST", {
registerOpenAPIRoute("/tenants/{id}/agents", "GET", {
summary: "List all agents",
description:
"Retrieves all agents for a specific tenant. Only global admins and tenant admins can view agents.",
"Retrieves all agents for a specific tenant. Global admins, tenant admins, and staff can view agents.",
tags: ["Agents"],
parameters: [
{
@@ -202,28 +203,20 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
const tenantId = params.id;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can create agents
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can create agents for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can create agents for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const body = await request.json();
log.debug("Creating new agent", {
tenantId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
agentName: body.name,
});
@@ -233,7 +226,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
log.debug("Agent created successfully", {
tenantId,
agentId: newAgent.id,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json(
@@ -265,26 +258,18 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const tenantId = params.id;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can view agents
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can view agents for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can view agents for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Getting all agents", {
tenantId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
const agentService = await AgentService.forTenant(tenantId);
@@ -293,7 +278,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
log.debug("Agents retrieved successfully", {
tenantId,
count: agents.length,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -4,12 +4,13 @@ import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "GET", {
summary: "Get agent details",
description:
"Retrieves detailed information about a specific agent. Only global admins and tenant admins can view agent details.",
"Retrieves detailed information about a specific agent. Global admins, tenant admins, and staff can view agent details.",
tags: ["Agents"],
parameters: [
{
@@ -285,27 +286,19 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const agentId = params.agentId;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId || !agentId) {
return json({ error: "Missing tenant or agent ID" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can view agent details
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can view agents for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can view agents for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Getting agent details", {
tenantId,
agentId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
const agentService = await AgentService.forTenant(tenantId);
@@ -318,7 +311,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
log.debug("Agent details retrieved successfully", {
tenantId,
agentId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -343,21 +336,13 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
const agentId = params.agentId;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId || !agentId) {
return json({ error: "Missing tenant or agent ID" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can update agents
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can update agents for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can update agents for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const body = await request.json();
@@ -365,7 +350,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
log.debug("Updating agent", {
tenantId,
agentId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
updateFields: Object.keys(body),
});
@@ -375,7 +360,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
log.debug("Agent updated successfully", {
tenantId,
agentId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -404,28 +389,19 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
const tenantId = params.id;
const agentId = params.agentId;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId || !agentId) {
return json({ error: "Missing tenant or agent ID" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can delete agents
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can delete agents for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can delete agents for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
log.debug("Deleting agent", {
tenantId,
agentId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
const agentService = await AgentService.forTenant(tenantId);
@@ -438,7 +414,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
log.debug("Agent deleted successfully", {
tenantId,
agentId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -0,0 +1,354 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, PUT, DELETE } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/agent-service", () => ({
AgentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AgentService } from "$lib/server/services/agent-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
describe("Agent Detail API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAgentId = "agent-123";
const mockAgentService = {
getAgentById: vi.fn(),
updateAgent: vi.fn(),
deleteAgent: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AgentService.forTenant as any).mockResolvedValue(mockAgentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, agentId: mockAgentId },
locals: {
user: {
userId: "user123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
request: {
json: vi.fn().mockResolvedValue({
name: "Updated Agent",
description: "Updated Description",
}),
} as any,
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/[id]/agents/[agentId]", () => {
it("should return agent details for authenticated tenant admin", async () => {
const mockAgent = {
id: mockAgentId,
name: "Test Agent",
description: "Test Description",
logo: null,
};
mockAgentService.getAgentById.mockResolvedValue(mockAgent);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.agent).toEqual(mockAgent);
expect(mockAgentService.getAgentById).toHaveBeenCalledWith(mockAgentId);
});
it("should allow staff to view agent details", async () => {
const mockAgent = {
id: mockAgentId,
name: "Test Agent",
description: "Test Description",
logo: null,
};
mockAgentService.getAgentById.mockResolvedValue(mockAgent);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.agent).toEqual(mockAgent);
});
it("should allow global admin to view any tenant's agent", async () => {
const mockAgent = {
id: mockAgentId,
name: "Test Agent",
description: "Test Description",
logo: null,
};
mockAgentService.getAgentById.mockResolvedValue(mockAgent);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.agent).toEqual(mockAgent);
});
it("should return 404 when agent not found", async () => {
mockAgentService.getAgentById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Agent not found");
});
it("should reject unauthenticated requests", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("Authentication required");
});
it("should reject insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
});
});
describe("PUT /api/tenants/[id]/agents/[agentId]", () => {
it("should update agent for authenticated tenant admin", async () => {
const mockUpdatedAgent = {
id: mockAgentId,
name: "Updated Agent",
description: "Updated Description",
logo: null,
};
mockAgentService.updateAgent.mockResolvedValue(mockUpdatedAgent);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Agent updated successfully");
expect(data.agent).toEqual(mockUpdatedAgent);
expect(mockAgentService.updateAgent).toHaveBeenCalledWith(mockAgentId, {
name: "Updated Agent",
description: "Updated Description",
});
});
it("should allow global admin to update any tenant's agent", async () => {
const mockUpdatedAgent = {
id: mockAgentId,
name: "Updated Agent",
description: "Updated Description",
logo: null,
};
mockAgentService.updateAgent.mockResolvedValue(mockUpdatedAgent);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Agent updated successfully");
expect(data.agent).toEqual(mockUpdatedAgent);
});
it("should reject staff users from updating agents", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(mockAgentService.updateAgent).not.toHaveBeenCalled();
});
it("should handle validation errors", async () => {
mockAgentService.updateAgent.mockRejectedValue(new ValidationError("Invalid agent data"));
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid agent data");
});
it("should handle not found errors", async () => {
mockAgentService.updateAgent.mockRejectedValue(new NotFoundError("Agent not found"));
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Agent not found");
});
});
describe("DELETE /api/tenants/[id]/agents/[agentId]", () => {
it("should delete agent for authenticated tenant admin", async () => {
mockAgentService.deleteAgent.mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Agent deleted successfully");
expect(mockAgentService.deleteAgent).toHaveBeenCalledWith(mockAgentId);
});
it("should allow global admin to delete any tenant's agent", async () => {
mockAgentService.deleteAgent.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Agent deleted successfully");
});
it("should reject staff users from deleting agents", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(mockAgentService.deleteAgent).not.toHaveBeenCalled();
});
it("should return 404 when agent not found", async () => {
mockAgentService.deleteAgent.mockResolvedValue(false);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Agent not found");
});
it("should handle service errors", async () => {
mockAgentService.deleteAgent.mockRejectedValue(new NotFoundError("Agent not found"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Agent not found");
});
it("should handle internal server errors", async () => {
mockAgentService.deleteAgent.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,380 @@
import { json } from "@sveltejs/kit";
import { AgentService } from "$lib/server/services/agent-service";
import { ValidationError, NotFoundError, ConflictError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", {
summary: "Create agent absence",
description:
"Creates a new absence period for an agent. Global admins, tenant admins, and staff can create absences.",
tags: ["Agent Absences"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "agentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Agent ID",
},
],
requestBody: {
description: "Absence creation data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
startDate: {
type: "string",
format: "date-time",
description: "Start date and time of absence",
example: "2024-01-15T00:00:00.000Z",
},
endDate: {
type: "string",
format: "date-time",
description: "End date and time of absence",
example: "2024-01-17T23:59:59.999Z",
},
absenceType: {
type: "string",
maxLength: 100,
description: "Type of absence (free text)",
example: "Urlaub",
},
description: {
type: "string",
description: "Optional description of the absence",
example: "Jahresurlaub",
},
isFullDay: {
type: "boolean",
description: "Whether this is a full day absence",
example: true,
default: true,
},
},
required: ["startDate", "endDate", "absenceType"],
},
},
},
},
responses: {
"201": {
description: "Absence created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
absence: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
},
},
required: ["message", "absence"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Agent or tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"409": {
description: "Absence period overlaps with existing absence",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "GET", {
summary: "Get agent absences",
description:
"Retrieves all absences for a specific agent. Global admins, tenant admins, and staff can view absences.",
tags: ["Agent Absences"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "agentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Agent ID",
},
{
name: "startDate",
in: "query",
required: false,
schema: { type: "string", format: "date-time" },
description: "Filter absences starting from this date",
},
{
name: "endDate",
in: "query",
required: false,
schema: { type: "string", format: "date-time" },
description: "Filter absences ending before this date",
},
],
responses: {
"200": {
description: "Agent absences retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
absences: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
},
},
},
required: ["absences"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Agent or tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const POST: RequestHandler = async ({ params, request, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const agentId = params.agentId;
if (!tenantId || !agentId) {
return json({ error: "Missing tenant or agent ID" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
const body = await request.json();
// Add agentId to the request body
const absenceRequest = {
...body,
agentId,
};
log.debug("Creating agent absence", {
tenantId,
agentId,
requestedBy: locals.user?.userId,
absenceType: body.absenceType,
startDate: body.startDate,
endDate: body.endDate,
});
const agentService = await AgentService.forTenant(tenantId);
const newAbsence = await agentService.createAbsence(absenceRequest);
log.debug("Agent absence created successfully", {
tenantId,
agentId,
absenceId: newAbsence.id,
requestedBy: locals.user?.userId,
});
return json(
{
message: "Absence created successfully",
absence: newAbsence,
},
{ status: 201 },
);
} catch (error) {
log.error("Error creating agent absence:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return json({ error: "Agent not found" }, { status: 404 });
}
if (error instanceof ConflictError) {
return json({ error: error.message }, { status: 409 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const GET: RequestHandler = async ({ params, locals, url }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const agentId = params.agentId;
if (!tenantId || !agentId) {
return json({ error: "Missing tenant or agent ID" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
// Get optional query parameters for date filtering
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
log.debug("Getting agent absences", {
tenantId,
agentId,
requestedBy: locals.user?.userId,
startDate,
endDate,
});
const agentService = await AgentService.forTenant(tenantId);
const absences = await agentService.getAgentAbsences(
agentId,
startDate || undefined,
endDate || undefined,
);
log.debug("Agent absences retrieved successfully", {
tenantId,
agentId,
count: absences.length,
requestedBy: locals.user?.userId,
});
return json({
absences,
});
} catch (error) {
log.error("Error getting agent absences:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return json({ error: "Agent not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,518 @@
import { json } from "@sveltejs/kit";
import { AgentService } from "$lib/server/services/agent-service";
import { ValidationError, NotFoundError, ConflictError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "GET", {
summary: "Get specific absence",
description:
"Retrieves details of a specific agent absence. Global admins, tenant admins, and staff can view absences.",
tags: ["Agent Absences"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "agentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Agent ID",
},
{
name: "absenceId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Absence ID",
},
],
responses: {
"200": {
description: "Absence details retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
absence: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
},
},
required: ["absence"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Absence, agent, or tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for PUT
registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT", {
summary: "Update agent absence",
description:
"Updates an existing agent absence. Global admins, tenant admins, and staff can update absences.",
tags: ["Agent Absences"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "agentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Agent ID",
},
{
name: "absenceId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Absence ID",
},
],
requestBody: {
description: "Absence update data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
startDate: {
type: "string",
format: "date-time",
description: "Start date and time of absence",
example: "2024-01-15T00:00:00.000Z",
},
endDate: {
type: "string",
format: "date-time",
description: "End date and time of absence",
example: "2024-01-17T23:59:59.999Z",
},
absenceType: {
type: "string",
maxLength: 100,
description: "Type of absence (free text)",
example: "Krankheit",
},
description: {
type: "string",
description: "Optional description of the absence",
example: "Erkältung",
},
isFullDay: {
type: "boolean",
description: "Whether this is a full day absence",
example: false,
},
},
},
},
},
},
responses: {
"200": {
description: "Absence updated successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
absence: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Absence ID" },
agentId: { type: "string", format: "uuid", description: "Agent ID" },
startDate: { type: "string", format: "date-time", description: "Start date" },
endDate: { type: "string", format: "date-time", description: "End date" },
absenceType: { type: "string", description: "Type of absence" },
description: { type: "string", description: "Description" },
isFullDay: { type: "boolean", description: "Full day absence" },
},
required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"],
},
},
required: ["message", "absence"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Absence, agent, or tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"409": {
description: "Absence period overlaps with existing absence",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "DELETE", {
summary: "Delete agent absence",
description:
"Deletes an agent absence. Global admins, tenant admins, and staff can delete absences.",
tags: ["Agent Absences"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "agentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Agent ID",
},
{
name: "absenceId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Absence ID",
},
],
responses: {
"200": {
description: "Absence deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
},
required: ["message"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Absence, agent, or tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const agentId = params.agentId;
const absenceId = params.absenceId;
// Check if user is authenticated
if (!tenantId || !agentId || !absenceId) {
return json({ error: "Missing tenant, agent, or absence ID" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Getting absence details", {
tenantId,
agentId,
absenceId,
requestedBy: locals.user?.userId,
});
const agentService = await AgentService.forTenant(tenantId);
const absence = await agentService.getAbsenceById(absenceId);
if (!absence) {
return json({ error: "Absence not found" }, { status: 404 });
}
// Verify the absence belongs to the requested agent
if (absence.agentId !== agentId) {
return json({ error: "Absence not found for this agent" }, { status: 404 });
}
log.debug("Absence details retrieved successfully", {
tenantId,
agentId,
absenceId,
requestedBy: locals.user?.userId,
});
return json({
absence,
});
} catch (error) {
log.error("Error getting absence details:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "Absence not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const PUT: RequestHandler = async ({ params, request, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const agentId = params.agentId;
const absenceId = params.absenceId;
// Check if user is authenticated
if (!tenantId || !agentId || !absenceId) {
return json({ error: "Missing tenant, agent, or absence ID" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
const body = await request.json();
log.debug("Updating agent absence", {
tenantId,
agentId,
absenceId,
requestedBy: locals.user?.userId,
updateFields: Object.keys(body),
});
const agentService = await AgentService.forTenant(tenantId);
// First verify the absence exists and belongs to the agent
const existingAbsence = await agentService.getAbsenceById(absenceId);
if (!existingAbsence) {
return json({ error: "Absence not found" }, { status: 404 });
}
if (existingAbsence.agentId !== agentId) {
return json({ error: "Absence not found for this agent" }, { status: 404 });
}
const updatedAbsence = await agentService.updateAbsence(absenceId, body);
log.debug("Agent absence updated successfully", {
tenantId,
agentId,
absenceId,
requestedBy: locals.user?.userId,
});
return json({
message: "Absence updated successfully",
absence: updatedAbsence,
});
} catch (error) {
log.error("Error updating agent absence:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return json({ error: "Absence not found" }, { status: 404 });
}
if (error instanceof ConflictError) {
return json({ error: error.message }, { status: 409 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const agentId = params.agentId;
const absenceId = params.absenceId;
if (!tenantId || !agentId || !absenceId) {
return json({ error: "Missing tenant, agent, or absence ID" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Deleting agent absence", {
tenantId,
agentId,
absenceId,
requestedBy: locals.user?.userId,
});
const agentService = await AgentService.forTenant(tenantId);
// First verify the absence exists and belongs to the agent
const existingAbsence = await agentService.getAbsenceById(absenceId);
if (!existingAbsence) {
return json({ error: "Absence not found" }, { status: 404 });
}
if (existingAbsence.agentId !== agentId) {
return json({ error: "Absence not found for this agent" }, { status: 404 });
}
const deleted = await agentService.deleteAbsence(absenceId);
if (!deleted) {
return json({ error: "Absence not found" }, { status: 404 });
}
log.debug("Agent absence deleted successfully", {
tenantId,
agentId,
absenceId,
requestedBy: locals.user?.userId,
});
return json({
message: "Absence deleted successfully",
});
} catch (error) {
log.error("Error deleting agent absence:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "Absence not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,610 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, PUT, DELETE } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/agent-service", () => ({
AgentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AgentService } from "$lib/server/services/agent-service";
import { ValidationError, NotFoundError, ConflictError } from "$lib/server/utils/errors";
describe("Agent Absence Detail API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAgentId = "agent-123";
const mockAbsenceId = "absence-456";
const mockAgentService = {
getAbsenceById: vi.fn(),
updateAbsence: vi.fn(),
deleteAbsence: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AgentService.forTenant as any).mockResolvedValue(mockAgentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, agentId: mockAgentId, absenceId: mockAbsenceId },
locals: {
user: {
userId: "user123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
request: {
json: vi.fn().mockResolvedValue({
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
description: "Updated description",
isFullDay: false,
}),
} as any,
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/[id]/agents/[agentId]/absences/[absenceId]", () => {
it("should return absence details for authenticated tenant admin", async () => {
const mockAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absence).toEqual(mockAbsence);
expect(mockAgentService.getAbsenceById).toHaveBeenCalledWith(mockAbsenceId);
});
it("should allow staff to view absence details", async () => {
const mockAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absence).toEqual(mockAbsence);
});
it("should allow global admin to view absence details for any tenant", async () => {
const mockAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absence).toEqual(mockAbsence);
});
it("should return 404 when absence not found", async () => {
mockAgentService.getAbsenceById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found");
});
it("should return 404 when absence belongs to different agent", async () => {
const mockAbsence = {
id: mockAbsenceId,
agentId: "different-agent",
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found for this agent");
});
it("should reject unauthenticated requests", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("Authentication required");
});
it("should reject insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
});
it("should handle service errors", async () => {
mockAgentService.getAbsenceById.mockRejectedValue(new NotFoundError("Absence not found"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found");
});
});
describe("PUT /api/tenants/[id]/agents/[agentId]/absences/[absenceId]", () => {
it("should update absence for authenticated tenant admin", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Original description",
isFullDay: true,
};
const mockUpdatedAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
description: "Updated description",
isFullDay: false,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.updateAbsence.mockResolvedValue(mockUpdatedAbsence);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Absence updated successfully");
expect(data.absence).toEqual(mockUpdatedAbsence);
expect(mockAgentService.updateAbsence).toHaveBeenCalledWith(mockAbsenceId, {
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
description: "Updated description",
isFullDay: false,
});
});
it("should allow staff to update absences", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Original description",
isFullDay: true,
};
const mockUpdatedAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
description: "Updated description",
isFullDay: false,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.updateAbsence.mockResolvedValue(mockUpdatedAbsence);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Absence updated successfully");
});
it("should allow global admin to update absences for any tenant", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Original description",
isFullDay: true,
};
const mockUpdatedAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
description: "Updated description",
isFullDay: false,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.updateAbsence.mockResolvedValue(mockUpdatedAbsence);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Absence updated successfully");
});
it("should return 404 when absence not found before update", async () => {
mockAgentService.getAbsenceById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found");
expect(mockAgentService.updateAbsence).not.toHaveBeenCalled();
});
it("should return 404 when absence belongs to different agent", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: "different-agent",
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Original description",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found for this agent");
expect(mockAgentService.updateAbsence).not.toHaveBeenCalled();
});
it("should handle validation errors", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Original description",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.updateAbsence.mockRejectedValue(new ValidationError("Invalid date range"));
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid date range");
});
it("should handle conflict errors for overlapping absences", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Original description",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.updateAbsence.mockRejectedValue(
new ConflictError("Absence overlaps with existing absence"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(409);
expect(data.error).toBe("Absence overlaps with existing absence");
});
});
describe("DELETE /api/tenants/[id]/agents/[agentId]/absences/[absenceId]", () => {
it("should delete absence for authenticated tenant admin", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.deleteAbsence.mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Absence deleted successfully");
expect(mockAgentService.deleteAbsence).toHaveBeenCalledWith(mockAbsenceId);
});
it("should allow staff to delete absences", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.deleteAbsence.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Absence deleted successfully");
});
it("should allow global admin to delete absences for any tenant", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.deleteAbsence.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Absence deleted successfully");
});
it("should return 404 when absence not found before delete", async () => {
mockAgentService.getAbsenceById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found");
expect(mockAgentService.deleteAbsence).not.toHaveBeenCalled();
});
it("should return 404 when absence belongs to different agent", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: "different-agent",
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found for this agent");
expect(mockAgentService.deleteAbsence).not.toHaveBeenCalled();
});
it("should return 404 when delete operation fails", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.deleteAbsence.mockResolvedValue(false);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found");
});
it("should handle service errors", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.deleteAbsence.mockRejectedValue(new NotFoundError("Absence not found"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Absence not found");
});
it("should handle internal server errors", async () => {
const mockExistingAbsence = {
id: mockAbsenceId,
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.getAbsenceById.mockResolvedValue(mockExistingAbsence);
mockAgentService.deleteAbsence.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,451 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/agent-service", () => ({
AgentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AgentService } from "$lib/server/services/agent-service";
import { ValidationError, NotFoundError, ConflictError } from "$lib/server/utils/errors";
describe("Agent Absence API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAgentId = "agent-123";
const mockAgentService = {
createAbsence: vi.fn(),
getAgentAbsences: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AgentService.forTenant as any).mockResolvedValue(mockAgentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, agentId: mockAgentId },
locals: {
user: {
userId: "user123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
request: {
json: vi.fn().mockResolvedValue({
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
}),
} as any,
url: new URL("http://localhost:3000/api/tenants/123/agents/agent-123/absences"),
...overrides,
} as RequestEvent;
}
describe("POST /api/tenants/[id]/agents/[agentId]/absences", () => {
it("should create absence for authenticated tenant admin", async () => {
const mockAbsence = {
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.createAbsence.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.message).toBe("Absence created successfully");
expect(data.absence).toEqual(mockAbsence);
expect(mockAgentService.createAbsence).toHaveBeenCalledWith({
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
agentId: mockAgentId,
});
});
it("should allow staff to create absences", async () => {
const mockAbsence = {
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
description: null,
isFullDay: true,
};
mockAgentService.createAbsence.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
request: {
json: vi.fn().mockResolvedValue({
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Krankheit",
isFullDay: true,
}),
} as any,
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.message).toBe("Absence created successfully");
expect(data.absence).toEqual(mockAbsence);
});
it("should allow global admin to create absences for any tenant", async () => {
const mockAbsence = {
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
};
mockAgentService.createAbsence.mockResolvedValue(mockAbsence);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.message).toBe("Absence created successfully");
});
it("should reject unauthenticated requests", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("Authentication required");
});
it("should reject insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
});
it("should handle validation errors", async () => {
mockAgentService.createAbsence.mockRejectedValue(new ValidationError("Invalid date range"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid date range");
});
it("should handle not found errors", async () => {
mockAgentService.createAbsence.mockRejectedValue(new NotFoundError("Agent not found"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Agent not found");
});
it("should handle conflict errors for overlapping absences", async () => {
mockAgentService.createAbsence.mockRejectedValue(
new ConflictError("Absence overlaps with existing absence"),
);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(409);
expect(data.error).toBe("Absence overlaps with existing absence");
});
it("should handle internal server errors", async () => {
mockAgentService.createAbsence.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
it("should handle missing parameters", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, agentId: undefined },
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Missing tenant or agent ID");
});
});
describe("GET /api/tenants/[id]/agents/[agentId]/absences", () => {
it("should return agent absences for authenticated tenant admin", async () => {
const mockAbsences = [
{
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
},
{
id: "absence2",
agentId: mockAgentId,
startDate: "2024-02-01T00:00:00.000Z",
endDate: "2024-02-01T23:59:59.999Z",
absenceType: "Krankheit",
description: null,
isFullDay: true,
},
];
mockAgentService.getAgentAbsences.mockResolvedValue(mockAbsences);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absences).toEqual(mockAbsences);
expect(mockAgentService.getAgentAbsences).toHaveBeenCalledWith(
mockAgentId,
undefined,
undefined,
);
});
it("should support date filtering with query parameters", async () => {
const mockAbsences = [
{
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
},
];
mockAgentService.getAgentAbsences.mockResolvedValue(mockAbsences);
const event = createMockRequestEvent({
url: new URL(
"http://localhost:3000/api/tenants/123/agents/agent-123/absences?startDate=2024-01-01T00:00:00.000Z&endDate=2024-01-31T23:59:59.999Z",
),
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absences).toEqual(mockAbsences);
expect(mockAgentService.getAgentAbsences).toHaveBeenCalledWith(
mockAgentId,
"2024-01-01T00:00:00.000Z",
"2024-01-31T23:59:59.999Z",
);
});
it("should allow staff to view absences", async () => {
const mockAbsences = [
{
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
},
];
mockAgentService.getAgentAbsences.mockResolvedValue(mockAbsences);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absences).toEqual(mockAbsences);
});
it("should allow global admin to view absences for any tenant", async () => {
const mockAbsences = [
{
id: "absence1",
agentId: mockAgentId,
startDate: "2024-01-15T00:00:00.000Z",
endDate: "2024-01-17T23:59:59.999Z",
absenceType: "Urlaub",
description: "Jahresurlaub",
isFullDay: true,
},
];
mockAgentService.getAgentAbsences.mockResolvedValue(mockAbsences);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.absences).toEqual(mockAbsences);
});
it("should reject unauthenticated requests", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("Authentication required");
});
it("should reject insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
});
it("should handle validation errors", async () => {
mockAgentService.getAgentAbsences.mockRejectedValue(
new ValidationError("Invalid date format"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid date format");
});
it("should handle not found errors", async () => {
mockAgentService.getAgentAbsences.mockRejectedValue(new NotFoundError("Agent not found"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Agent not found");
});
it("should handle internal server errors", async () => {
mockAgentService.getAgentAbsences.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,330 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/agent-service", () => ({
AgentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AgentService } from "$lib/server/services/agent-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
describe("Agent API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAgentService = {
getAllAgents: vi.fn(),
createAgent: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AgentService.forTenant as any).mockResolvedValue(mockAgentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId },
locals: {
user: {
userId: "user123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
request: {
json: vi.fn().mockResolvedValue({
name: "Test Agent",
description: "Test Description",
}),
} as any,
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/[id]/agents", () => {
it("should return agents for authenticated tenant admin", async () => {
const mockAgents = [
{
id: "agent1",
name: "Agent 1",
description: "Description 1",
logo: null,
},
];
mockAgentService.getAllAgents.mockResolvedValue(mockAgents);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.agents).toEqual(mockAgents);
expect(mockAgentService.getAllAgents).toHaveBeenCalledOnce();
});
it("should allow staff to view agents", async () => {
const mockAgents = [
{
id: "agent1",
name: "Agent 1",
description: "Description 1",
logo: null,
},
];
mockAgentService.getAllAgents.mockResolvedValue(mockAgents);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.agents).toEqual(mockAgents);
});
it("should allow global admin to view any tenant's agents", async () => {
const mockAgents = [
{
id: "agent1",
name: "Agent 1",
description: "Description 1",
logo: null,
},
];
mockAgentService.getAllAgents.mockResolvedValue(mockAgents);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.agents).toEqual(mockAgents);
});
it("should reject unauthenticated requests", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("Authentication required");
});
it("should reject insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("No tenant id given");
});
it("should handle service errors", async () => {
mockAgentService.getAllAgents.mockRejectedValue(new NotFoundError("Tenant not found"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Tenant not found");
});
it("should handle internal server errors", async () => {
mockAgentService.getAllAgents.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
describe("POST /api/tenants/[id]/agents", () => {
it("should create agent for authenticated tenant admin", async () => {
const mockAgent = {
id: "agent1",
name: "Test Agent",
description: "Test Description",
logo: null,
};
mockAgentService.createAgent.mockResolvedValue(mockAgent);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.message).toBe("Agent created successfully");
expect(data.agent).toEqual(mockAgent);
expect(mockAgentService.createAgent).toHaveBeenCalledWith({
name: "Test Agent",
description: "Test Description",
});
});
it("should allow global admin to create agents for any tenant", async () => {
const mockAgent = {
id: "agent1",
name: "Test Agent",
description: "Test Description",
logo: null,
};
mockAgentService.createAgent.mockResolvedValue(mockAgent);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.message).toBe("Agent created successfully");
expect(data.agent).toEqual(mockAgent);
});
it("should reject staff users from creating agents", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(mockAgentService.createAgent).not.toHaveBeenCalled();
});
it("should reject unauthenticated requests", async () => {
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(401);
expect(data.error).toBe("Authentication required");
});
it("should handle validation errors", async () => {
mockAgentService.createAgent.mockRejectedValue(new ValidationError("Invalid agent name"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid agent name");
});
it("should handle not found errors", async () => {
mockAgentService.createAgent.mockRejectedValue(new NotFoundError("Tenant not found"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Tenant not found");
});
it("should handle internal server errors", async () => {
mockAgentService.createAgent.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined },
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("No tenant id given");
});
});
});
@@ -0,0 +1,450 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments", "POST", {
summary: "Create a new appointment",
description:
"Creates a new appointment for a specific tenant. Only global admins, tenant admins, and staff can create appointments for existing clients.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Appointment creation data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
clientId: {
type: "string",
format: "uuid",
description: "Client ID (client must already exist)",
},
channelId: {
type: "string",
format: "uuid",
description: "Channel ID",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "When appointment data expires",
},
title: {
type: "string",
minLength: 1,
maxLength: 200,
description: "Appointment title",
},
description: {
type: "string",
description: "Appointment description",
},
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
description: "Appointment status",
default: "NEW",
},
},
required: ["clientId", "channelId", "appointmentDate", "expiryDate", "title"],
},
},
},
},
responses: {
"201": {
description: "Appointment created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
appointment: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
},
},
required: ["message", "appointment"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant, client, or channel not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"409": {
description: "Appointment conflict or channel paused",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments", "GET", {
summary: "List appointments",
description:
"Retrieves appointments for a specific tenant with optional filters. Global admins, tenant admins, and staff can view appointments.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "startDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "Start date for appointment search",
},
{
name: "endDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "End date for appointment search",
},
{
name: "channelId",
in: "query",
schema: { type: "string", format: "uuid" },
description: "Filter by channel ID",
},
{
name: "clientId",
in: "query",
schema: { type: "string", format: "uuid" },
description: "Filter by client ID",
},
{
name: "status",
in: "query",
schema: { type: "string", enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] },
description: "Filter by appointment status",
},
],
responses: {
"200": {
description: "Appointments retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
appointments: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
},
client: {
type: "object",
description: "Client details",
properties: {
id: { type: "string", format: "uuid" },
hashKey: { type: "string" },
email: { type: "string" },
},
},
channel: {
type: "object",
description: "Channel details",
properties: {
id: { type: "string", format: "uuid" },
names: { type: "array", items: { type: "string" } },
color: { type: "string" },
},
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
},
},
},
required: ["appointments"],
},
},
},
},
"400": {
description: "Invalid query parameters",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const POST: RequestHandler = async ({ params, request, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
const body = await request.json();
log.debug("Creating new appointment", {
tenantId,
requestedBy: locals.user?.userId,
clientId: body.clientId,
channelId: body.channelId,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const newAppointment = await appointmentService.createAppointment(body);
log.debug("Appointment created successfully", {
tenantId,
appointmentId: newAppointment.id,
requestedBy: locals.user?.userId,
});
return json(
{
message: "Appointment created successfully",
appointment: newAppointment,
},
{ status: 201 },
);
} catch (error) {
log.error("Error creating appointment:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return json({ error: error.message }, { status: 404 });
}
// ConflictError from AppointmentService
if (
(error instanceof Error && error.message.includes("conflict")) ||
(error instanceof Error && error.message.includes("paused"))
) {
return json({ error: error.message }, { status: 409 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const GET: RequestHandler = async ({ params, url, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
// Extract query parameters
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
const channelId = url.searchParams.get("channelId");
const clientId = url.searchParams.get("clientId");
const status = url.searchParams.get("status");
if (!startDate || !endDate) {
return json({ error: "startDate and endDate are required" }, { status: 400 });
}
const query = {
startDate,
endDate,
...(channelId && { channelId }),
...(clientId && { clientId }),
...(status && { status: status as "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW" }),
};
log.debug("Getting appointments", {
tenantId,
requestedBy: locals.user?.userId,
query,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const appointments = await appointmentService.queryAppointments(query);
log.debug("Appointments retrieved successfully", {
tenantId,
count: appointments.length,
requestedBy: locals.user?.userId,
});
return json({
appointments,
});
} catch (error) {
log.error("Error getting appointments:", JSON.stringify(error || "?"));
if (error instanceof ValidationError) {
return json({ error: error.message }, { status: 400 });
}
if (error instanceof NotFoundError) {
return json({ error: "Tenant not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,295 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
summary: "Get appointment by ID",
description:
"Retrieves a specific appointment by ID. Global admins, tenant admins, and staff can view appointments.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
responses: {
"200": {
description: "Appointment retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
appointment: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
},
client: {
type: "object",
description: "Client details",
properties: {
id: { type: "string", format: "uuid" },
hashKey: { type: "string" },
email: { type: "string" },
},
},
channel: {
type: "object",
description: "Channel details",
properties: {
id: { type: "string", format: "uuid" },
names: { type: "array", items: { type: "string" } },
color: { type: "string" },
},
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
},
},
required: ["appointment"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant or appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
summary: "Delete appointment",
description:
"Permanently deletes an appointment. Only global admins and tenant admins can delete appointments.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
responses: {
"200": {
description: "Appointment deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
},
required: ["message"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant or appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
if (!tenantId || !appointmentId) {
return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Getting appointment by ID", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const appointment = await appointmentService.getAppointmentById(appointmentId);
if (!appointment) {
return json({ error: "Appointment not found" }, { status: 404 });
}
log.debug("Appointment retrieved successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
return json({
appointment,
});
} catch (error) {
log.error("Error getting appointment:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "Tenant not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
if (!tenantId || !appointmentId) {
return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 });
}
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
log.debug("Deleting appointment", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const deleted = await appointmentService.deleteAppointment(appointmentId);
if (!deleted) {
return json({ error: "Appointment not found" }, { status: 404 });
}
log.debug("Appointment deleted successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
return json({
message: "Appointment deleted successfully",
});
} catch (error) {
log.error("Error deleting appointment:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: "Tenant not found" }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,293 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, DELETE } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
describe("Appointment Detail API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockAppointmentService = {
getAppointmentById: vi.fn(),
deleteAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, appointmentId: mockAppointmentId },
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/{id}/appointments/{appointmentId}", () => {
it("should get appointment by ID successfully", async () => {
const mockAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
client: { id: "client123", email: "test@example.com" },
channel: { id: "channel123", names: ["Room 1"] },
};
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.appointment).toEqual(mockAppointment);
expect(mockAppointmentService.getAppointmentById).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 404 if appointment not found", async () => {
mockAppointmentService.getAppointmentById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session456",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to view appointments for any tenant", async () => {
const mockAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session789",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
expect(response.status).toBe(200);
});
it("should allow staff to view appointments for their tenant", async () => {
const mockAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session101",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await GET(event);
expect(response.status).toBe(200);
});
});
describe("DELETE /api/tenants/{id}/appointments/{appointmentId}", () => {
it("should delete appointment successfully", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Appointment deleted successfully");
expect(mockAppointmentService.deleteAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 404 if appointment not found for deletion", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(false);
const event = createMockRequestEvent();
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions (staff cannot delete)", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session102",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to delete appointments for any tenant", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session103",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await DELETE(event);
expect(response.status).toBe(200);
});
it("should allow tenant admin to delete appointments for their tenant", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session104",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
} as any,
},
});
const response = await DELETE(event);
expect(response.status).toBe(200);
});
it("should return 400 if tenant ID or appointment ID is missing", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId },
});
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("Tenant ID and appointment ID are required");
});
it("should return 404 for not found errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(
new NotFoundError("Tenant not found"),
);
const event = createMockRequestEvent();
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Tenant not found");
});
});
});
@@ -0,0 +1,155 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for PUT
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/cancel", "PUT", {
summary: "Cancel appointment",
description:
"Cancels an appointment by setting its status to REJECTED. Global admins, tenant admins, and staff can cancel appointments.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
responses: {
"200": {
description: "Appointment cancelled successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
appointment: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
},
},
required: ["message", "appointment"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant or appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const PUT: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
// Check if user is authenticated
if (!tenantId || !appointmentId) {
return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Cancelling appointment", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const cancelledAppointment = await appointmentService.cancelAppointment(appointmentId);
log.debug("Appointment cancelled successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
return json({
message: "Appointment cancelled successfully",
appointment: cancelledAppointment,
});
} catch (error) {
log.error("Error cancelling appointment:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: error.message }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PUT } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
describe("Appointment Cancel API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockAppointmentService = {
cancelAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, appointmentId: mockAppointmentId },
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
...overrides,
} as RequestEvent;
}
describe("PUT /api/tenants/{id}/appointments/{appointmentId}/cancel", () => {
it("should cancel appointment successfully", async () => {
const mockCancelledAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "REJECTED",
};
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Appointment cancelled successfully");
expect(result.appointment).toEqual(mockCancelledAppointment);
expect(mockAppointmentService.cancelAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session456",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to cancel appointments for any tenant", async () => {
const mockCancelledAppointment = {
id: mockAppointmentId,
status: "REJECTED",
};
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session789",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should allow staff to cancel appointments for their tenant", async () => {
const mockCancelledAppointment = {
id: mockAppointmentId,
status: "REJECTED",
};
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session101",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should return 400 if tenant ID or appointment ID is missing", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId },
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("Tenant ID and appointment ID are required");
});
it("should return 404 for not found errors", async () => {
mockAppointmentService.cancelAppointment.mockRejectedValue(
new NotFoundError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
});
it("should return 500 for unexpected errors", async () => {
mockAppointmentService.cancelAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(500);
expect(result.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,154 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for PUT
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/confirm", "PUT", {
summary: "Confirm appointment",
description:
"Confirms an appointment by setting its status to CONFIRMED. Global admins, tenant admins, and staff can confirm appointments.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "appointmentId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Appointment ID",
},
],
responses: {
"200": {
description: "Appointment confirmed successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
appointment: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
},
},
required: ["message", "appointment"],
},
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant or appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const PUT: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
const appointmentId = params.appointmentId;
if (!tenantId || !appointmentId) {
return json({ error: "Tenant ID and appointment ID are required" }, { status: 400 });
}
const error = checkPermission(locals, tenantId);
if (error) {
return error;
}
log.debug("Confirming appointment", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const confirmedAppointment = await appointmentService.confirmAppointment(appointmentId);
log.debug("Appointment confirmed successfully", {
tenantId,
appointmentId,
requestedBy: locals.user?.userId,
});
return json({
message: "Appointment confirmed successfully",
appointment: confirmedAppointment,
});
} catch (error) {
log.error("Error confirming appointment:", JSON.stringify(error || "?"));
if (error instanceof NotFoundError) {
return json({ error: error.message }, { status: 404 });
}
return json({ error: "Internal server error" }, { status: 500 });
}
};
@@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PUT } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
describe("Appointment Confirm API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockAppointmentService = {
confirmAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, appointmentId: mockAppointmentId },
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
...overrides,
} as RequestEvent;
}
describe("PUT /api/tenants/{id}/appointments/{appointmentId}/confirm", () => {
it("should confirm appointment successfully", async () => {
const mockConfirmedAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "CONFIRMED",
};
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Appointment confirmed successfully");
expect(result.appointment).toEqual(mockConfirmedAppointment);
expect(mockAppointmentService.confirmAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session456",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to confirm appointments for any tenant", async () => {
const mockConfirmedAppointment = {
id: mockAppointmentId,
status: "CONFIRMED",
};
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session789",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should allow staff to confirm appointments for their tenant", async () => {
const mockConfirmedAppointment = {
id: mockAppointmentId,
status: "CONFIRMED",
};
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session101",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should return 400 if tenant ID or appointment ID is missing", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId },
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("Tenant ID and appointment ID are required");
});
it("should return 404 for not found errors", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(
new NotFoundError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
});
it("should return 500 for unexpected errors", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(500);
expect(result.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,326 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
describe("Appointment API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockClientId = "123e4567-e89b-12d3-a456-426614174001";
const mockChannelId = "123e4567-e89b-12d3-a456-426614174002";
const mockAppointmentService = {
queryAppointments: vi.fn(),
createAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId },
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
request: {
json: vi.fn().mockResolvedValue({
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
}),
} as any,
url: new URL("http://localhost?startDate=2024-12-01T00:00:00Z&endDate=2024-12-31T23:59:59Z"),
...overrides,
} as RequestEvent;
}
describe("POST /api/tenants/{id}/appointments", () => {
it("should create appointment successfully", async () => {
const mockAppointment = {
id: "appt123",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.createAppointment.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(201);
expect(result.message).toBe("Appointment created successfully");
expect(result.appointment).toEqual(mockAppointment);
expect(mockAppointmentService.createAppointment).toHaveBeenCalledWith({
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
});
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to create appointments for any tenant", async () => {
const mockAppointment = {
id: "appt123",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.createAppointment.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session456",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
expect(response.status).toBe(201);
});
it("should allow staff to create appointments for their tenant", async () => {
const mockAppointment = {
id: "appt123",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.createAppointment.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session789",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await POST(event);
expect(response.status).toBe(201);
});
it("should return 400 for validation errors", async () => {
mockAppointmentService.createAppointment.mockRejectedValue(
new ValidationError("Invalid data"),
);
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("Invalid data");
});
it("should return 404 for not found errors", async () => {
mockAppointmentService.createAppointment.mockRejectedValue(
new NotFoundError("Client not found"),
);
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Client not found");
});
it("should return 409 for conflict errors", async () => {
mockAppointmentService.createAppointment.mockRejectedValue(new Error("Time slot conflict"));
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(409);
expect(result.error).toBe("Time slot conflict");
});
});
describe("GET /api/tenants/{id}/appointments", () => {
it("should get appointments successfully", async () => {
const mockAppointments = [
{
id: "appt1",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Appointment 1",
status: "NEW",
client: { id: mockClientId, email: "test@example.com" },
channel: { id: mockChannelId, names: ["Room 1"] },
},
];
mockAppointmentService.queryAppointments.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.appointments).toEqual(mockAppointments);
expect(mockAppointmentService.queryAppointments).toHaveBeenCalledWith({
startDate: "2024-12-01T00:00:00Z",
endDate: "2024-12-31T23:59:59Z",
});
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should return 400 if startDate or endDate is missing", async () => {
const event = createMockRequestEvent({
url: new URL("http://localhost?startDate=2024-12-01T00:00:00Z"),
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("startDate and endDate are required");
});
it("should include optional filters in query", async () => {
mockAppointmentService.queryAppointments.mockResolvedValue([]);
const event = createMockRequestEvent({
url: new URL(
"http://localhost?startDate=2024-12-01T00:00:00Z&endDate=2024-12-31T23:59:59Z&channelId=" +
mockChannelId +
"&status=CONFIRMED",
),
});
await GET(event);
expect(mockAppointmentService.queryAppointments).toHaveBeenCalledWith({
startDate: "2024-12-01T00:00:00Z",
endDate: "2024-12-31T23:59:59Z",
channelId: mockChannelId,
status: "CONFIRMED",
});
});
it("should return 400 for validation errors", async () => {
mockAppointmentService.queryAppointments.mockRejectedValue(
new ValidationError("Invalid query"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(400);
expect(result.error).toBe("Invalid query");
});
});
});
+11 -28
View File
@@ -4,6 +4,7 @@ import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/channels", "POST", {
@@ -319,29 +320,20 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
try {
const tenantId = params.id;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can create channels
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can create channels for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can create channels for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const body = await request.json();
log.debug("Creating new channel", {
tenantId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
channelNames: body.names,
languages: body.languages,
});
@@ -352,7 +344,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
log.debug("Channel created successfully", {
tenantId,
channelId: newChannel.id,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json(
@@ -383,27 +375,18 @@ export const GET: RequestHandler = async ({ params, locals }) => {
try {
const tenantId = params.id;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId) {
return json({ error: "No tenant id given" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can view channels
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can view channels for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can view channels for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
log.debug("Getting all channels", {
tenantId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
const channelService = await ChannelService.forTenant(tenantId);
@@ -412,7 +395,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
log.debug("Channels retrieved successfully", {
tenantId,
count: channels.length,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -4,6 +4,7 @@ import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "GET", {
@@ -403,28 +404,19 @@ export const GET: RequestHandler = async ({ params, locals }) => {
const tenantId = params.id;
const channelId = params.channelId;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId || !channelId) {
return json({ error: "Missing tenant or channel ID" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can view channel details
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can view channels for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can view channels for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
log.debug("Getting channel details", {
tenantId,
channelId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
const channelService = await ChannelService.forTenant(tenantId);
@@ -437,7 +429,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
log.debug("Channel details retrieved successfully", {
tenantId,
channelId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -461,22 +453,13 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
const tenantId = params.id;
const channelId = params.channelId;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId || !channelId) {
return json({ error: "Missing tenant or channel ID" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can update channels
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can update channels for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can update channels for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const body = await request.json();
@@ -484,7 +467,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
log.debug("Updating channel", {
tenantId,
channelId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
updateFields: Object.keys(body),
});
@@ -494,7 +477,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
log.debug("Channel updated successfully", {
tenantId,
channelId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
@@ -523,28 +506,19 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
const tenantId = params.id;
const channelId = params.channelId;
// Check if user is authenticated
if (!locals.user) {
return json({ error: "Authentication required" }, { status: 401 });
}
if (!tenantId || !channelId) {
return json({ error: "Missing tenant or channel ID" }, { status: 400 });
}
// Authorization check: Only global admins and tenant admins can delete channels
if (locals.user.role === "GLOBAL_ADMIN") {
// Global admin can delete channels for any tenant
} else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) {
// Tenant admin can delete channels for their own tenant
} else {
return json({ error: "Insufficient permissions" }, { status: 403 });
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
log.debug("Deleting channel", {
tenantId,
channelId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
const channelService = await ChannelService.forTenant(tenantId);
@@ -557,7 +531,7 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
log.debug("Channel deleted successfully", {
tenantId,
channelId,
requestedBy: locals.user.userId,
requestedBy: locals.user?.userId,
});
return json({
+13 -2
View File
@@ -4,6 +4,7 @@ import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/config", "GET", {
@@ -175,7 +176,7 @@ registerOpenAPIRoute("/tenants/{id}/config", "PUT", {
},
});
export const GET: RequestHandler = async ({ params }) => {
export const GET: RequestHandler = async ({ locals, params }) => {
const log = logger.setContext("API");
try {
@@ -187,6 +188,11 @@ export const GET: RequestHandler = async ({ params }) => {
return json({ error: "No tenant id given" }, { status: 400 });
}
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const tenantService = await TenantAdminService.getTenantById(tenantId);
const config = await tenantService.configuration;
@@ -207,7 +213,7 @@ export const GET: RequestHandler = async ({ params }) => {
}
};
export const PUT: RequestHandler = async ({ params, request }) => {
export const PUT: RequestHandler = async ({ locals, params, request }) => {
const log = logger.setContext("API");
try {
@@ -223,6 +229,11 @@ export const PUT: RequestHandler = async ({ params, request }) => {
return json({ error: "No tenant id given" }, { status: 400 });
}
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const tenantService = await TenantAdminService.getTenantById(tenantId);
await tenantService.updateTenantConfig(body);
@@ -5,6 +5,7 @@ import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import z from "zod/v4";
import { checkPermission } from "$lib/server/utils/permissions";
const setupStateSchema = z.object({
setupState: z.enum(["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]),
@@ -100,7 +101,7 @@ registerOpenAPIRoute("/tenants/{id}/setup-state", "PUT", {
},
});
export const PUT: RequestHandler = async ({ params, request }) => {
export const PUT: RequestHandler = async ({ locals, params, request }) => {
const log = logger.setContext("API");
try {
@@ -116,6 +117,11 @@ export const PUT: RequestHandler = async ({ params, request }) => {
return json({ error: "No tenant id given" }, { status: 400 });
}
const error = checkPermission(locals, tenantId, true);
if (error) {
return error;
}
const validation = setupStateSchema.safeParse(body);
if (!validation.success) {
return json({ error: "Invalid setup state" }, { status: 400 });
+55 -2
View File
@@ -111,6 +111,13 @@ describe("Tenant API Routes", () => {
const response = await PUT({
params: { id: "123" },
request: mockRequest as any,
locals: {
user: {
userId: "test-user-id",
tenantId: "123",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -137,6 +144,13 @@ describe("Tenant API Routes", () => {
const response = await PUT({
params: { id: "" },
request: mockRequest as any,
locals: {
user: {
userId: "test-user-id",
tenantId: "123",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -165,7 +179,16 @@ describe("Tenant API Routes", () => {
vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any);
const response = await GET({ params: { id: "123" } } as any);
const response = await GET({
params: { id: "123" },
locals: {
user: {
userId: "test-user-id",
tenantId: "123",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123");
@@ -175,7 +198,16 @@ describe("Tenant API Routes", () => {
it("should handle missing tenant ID in GET", async () => {
const { GET } = await import("./[id]/config/+server.js");
const response = await GET({ params: { id: "" } } as any);
const response = await GET({
params: { id: "" },
locals: {
user: {
userId: "test-user-id",
tenantId: "123",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
expect(data).toEqual({
@@ -210,6 +242,13 @@ describe("Tenant API Routes", () => {
const response = await PUT({
params: { id: "123" },
request: mockRequest as any,
locals: {
user: {
userId: "test-user-id",
tenantId: "123",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -246,6 +285,13 @@ describe("Tenant API Routes", () => {
const response = await PUT({
params: { id: "123" },
request: mockRequest as any,
locals: {
user: {
userId: "test-user-id",
tenantId: "123",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -274,6 +320,13 @@ describe("Tenant API Routes", () => {
const response = await PUT({
params: { id: "non-existent-id" },
request: mockRequest as any,
locals: {
user: {
userId: "test-user-id",
tenantId: "non-existent-id",
role: "TENANT_ADMIN",
},
},
} as any);
const data = await response.json();
+35 -5
View File
@@ -74,7 +74,13 @@ describe("/api/tenants", () => {
isSubRequest: false,
platform: undefined,
setHeaders: {} as any,
locals: {},
locals: {
user: {
userId: "global-admin-id",
tenantId: null,
role: "GLOBAL_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -119,7 +125,13 @@ describe("/api/tenants", () => {
isSubRequest: false,
platform: undefined,
setHeaders: {} as any,
locals: {},
locals: {
user: {
userId: "global-admin-id",
tenantId: null,
role: "GLOBAL_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -162,7 +174,13 @@ describe("/api/tenants", () => {
isSubRequest: false,
platform: undefined,
setHeaders: {} as any,
locals: {},
locals: {
user: {
userId: "global-admin-id",
tenantId: null,
role: "GLOBAL_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -198,7 +216,13 @@ describe("/api/tenants", () => {
isSubRequest: false,
platform: undefined,
setHeaders: {} as any,
locals: {},
locals: {
user: {
userId: "global-admin-id",
tenantId: null,
role: "GLOBAL_ADMIN",
},
},
} as any);
const data = await response.json();
@@ -234,7 +258,13 @@ describe("/api/tenants", () => {
isSubRequest: false,
platform: undefined,
setHeaders: {} as any,
locals: {},
locals: {
user: {
userId: "global-admin-id",
tenantId: null,
role: "GLOBAL_ADMIN",
},
},
} as any);
const data = await response.json();
+3 -5
View File
@@ -26,12 +26,10 @@ export const authGuard: Handle = async ({ event, resolve }) => {
// Set user in auth store for SSR
event.locals.user = {
userId: sessionData.user.id,
...sessionData.user,
sessionId: sessionData.sessionId,
name: sessionData.user.name,
email: sessionData.user.email,
role: sessionData.user.role,
tenantId: sessionData.user.tenantId,
userId: sessionData.user.id,
exp: sessionData.exp.valueOf(),
};
switch (true) {