mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
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:
Vendored
+2
-10
@@ -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 {}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ export class AuthenticationError extends BackendError {
|
||||
this.name = "AuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends BackendError {
|
||||
constructor(
|
||||
message: string,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
+610
@@ -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 });
|
||||
}
|
||||
};
|
||||
+293
@@ -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 });
|
||||
}
|
||||
};
|
||||
+189
@@ -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 });
|
||||
}
|
||||
};
|
||||
+189
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user