diff --git a/src/app.d.ts b/src/app.d.ts index d7e42ae..ca4d92b 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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 {} diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 8c531c7..55304da 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -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(), diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 748b751..95b9193 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -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; -/** Staff record type for database queries */ -export type SelectStaff = InferSelectModel; - /** Channel record type for database queries */ export type SelectChannel = InferSelectModel; @@ -226,3 +221,6 @@ export type SelectChannelAgent = InferSelectModel; /** Channel-SlotTemplate junction record type for database queries */ export type SelectChannelSlotTemplate = InferSelectModel; + +/** Agent absence record type for database queries */ +export type SelectAgentAbsence = InferSelectModel; diff --git a/src/lib/server/db/tenant-service.ts b/src/lib/server/db/tenant-service.ts index df7ad20..a9a2517 100644 --- a/src/lib/server/db/tenant-service.ts +++ b/src/lib/server/db/tenant-service.ts @@ -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 */ diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index e9f1a14..51eacc1 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -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", diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 593772b..b0c30d4 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -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} */ export async function sendUserCreatedEmail( - user: SelectClient | SelectStaff, + user: SelectClient | SelectUser, tenant: SelectTenant, loginUrl: string, ): Promise { @@ -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} */ export async function sendPinResetEmail( - user: SelectClient | SelectStaff, + user: SelectClient | SelectUser, tenant: SelectTenant, ): Promise { 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} */ export async function sendKeyResetEmail( - user: SelectClient | SelectStaff, + user: SelectClient | SelectUser, tenant: SelectTenant, ): Promise { 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} */ 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} */ 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} */ 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, }); } diff --git a/src/lib/server/email/mailer.ts b/src/lib/server/email/mailer.ts index db9fc8e..6870212 100644 --- a/src/lib/server/email/mailer.ts +++ b/src/lib/server/email/mailer.ts @@ -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 { diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 9809f69..e9ba7dd 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -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]); + }); + }); + }); }); diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts new file mode 100644 index 0000000..2e9c4d0 --- /dev/null +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index 6557db2..bd37f06 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -58,7 +58,7 @@ const mockAgent = { id: "550e8400-e29b-41d4-a716-446655440001", name: "Test Agent", description: "Test description", - logo: null, + image: null, }; const mockSlotTemplate = { diff --git a/src/lib/server/services/__tests__/schedule-service.test.ts b/src/lib/server/services/__tests__/schedule-service.test.ts new file mode 100644 index 0000000..e311c28 --- /dev/null +++ b/src/lib/server/services/__tests__/schedule-service.test.ts @@ -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); + }); + }); +}); diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index b84a332..9329fd4 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -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; export type AgentUpdateRequest = z.infer; +export type AbsenceCreationRequest = z.infer; +export type AbsenceUpdateRequest = z.infer; +export type AbsenceQueryRequest = z.infer; export class AgentService { #db: Awaited> | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) */ diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts new file mode 100644 index 0000000..6345f7d --- /dev/null +++ b/src/lib/server/services/appointment-service.ts @@ -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; +export type AppointmentUpdateRequest = z.infer; +export type AppointmentQueryRequest = z.infer; + +export interface AppointmentWithDetails extends SelectAppointment { + client?: SelectClient; + channel?: SelectChannel; +} + +export class AppointmentService { + #db: Awaited> | 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index 1141cff..996e6f9 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -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( diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts new file mode 100644 index 0000000..400c309 --- /dev/null +++ b/src/lib/server/services/schedule-service.ts @@ -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; + +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> | 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 { + 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 { + 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; + } +} diff --git a/src/lib/server/utils/errors.ts b/src/lib/server/utils/errors.ts index fc5d064..95bec9c 100644 --- a/src/lib/server/utils/errors.ts +++ b/src/lib/server/utils/errors.ts @@ -20,6 +20,7 @@ export class AuthenticationError extends BackendError { this.name = "AuthenticationError"; } } + export class ValidationError extends BackendError { constructor( message: string, diff --git a/src/lib/server/utils/permissions.ts b/src/lib/server/utils/permissions.ts new file mode 100644 index 0000000..639aafc --- /dev/null +++ b/src/lib/server/utils/permissions.ts @@ -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 }); + } +}; diff --git a/src/routes/api/admin/tenant/+server.ts b/src/routes/api/admin/tenant/+server.ts index 89cc3d3..22e02d1 100644 --- a/src/routes/api/admin/tenant/+server.ts +++ b/src/routes/api/admin/tenant/+server.ts @@ -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, }); diff --git a/src/routes/api/admin/tenant/server.test.ts b/src/routes/api/admin/tenant/server.test.ts index b6cde5c..d26f74e 100644 --- a/src/routes/api/admin/tenant/server.test.ts +++ b/src/routes/api/admin/tenant/server.test.ts @@ -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(), diff --git a/src/routes/api/tenants/+server.ts b/src/routes/api/tenants/+server.ts index 41bd14a..29e1594 100644 --- a/src/routes/api/tenants/+server.ts +++ b/src/routes/api/tenants/+server.ts @@ -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); diff --git a/src/routes/api/tenants/[id]/+server.ts b/src/routes/api/tenants/[id]/+server.ts index f37fc01..b706dd9 100644 --- a/src/routes/api/tenants/[id]/+server.ts +++ b/src/routes/api/tenants/[id]/+server.ts @@ -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({ diff --git a/src/routes/api/tenants/[id]/agents/+server.ts b/src/routes/api/tenants/[id]/agents/+server.ts index 5413921..17edd56 100644 --- a/src/routes/api/tenants/[id]/agents/+server.ts +++ b/src/routes/api/tenants/[id]/agents/+server.ts @@ -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({ diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts index 0e788ff..36aa61b 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts @@ -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({ diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts b/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts new file mode 100644 index 0000000..8d9a9ca --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/[agentId]/__tests__/agent-detail-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts new file mode 100644 index 0000000..1376870 --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts new file mode 100644 index 0000000..c3f8f06 --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts new file mode 100644 index 0000000..3724a00 --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/__tests__/absence-detail-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts new file mode 100644 index 0000000..ffc1124 --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/__tests__/absence-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts b/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts new file mode 100644 index 0000000..eabe044 --- /dev/null +++ b/src/routes/api/tenants/[id]/agents/__tests__/agent-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/+server.ts b/src/routes/api/tenants/[id]/appointments/+server.ts new file mode 100644 index 0000000..f9da3c1 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts new file mode 100644 index 0000000..d93cb99 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts new file mode 100644 index 0000000..50c64cd --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/__tests__/appointment-detail-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts new file mode 100644 index 0000000..025e96e --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts new file mode 100644 index 0000000..204105d --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/cancel/__tests__/appointment-cancel-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts new file mode 100644 index 0000000..ad75fbf --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts new file mode 100644 index 0000000..9b1b7c2 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/confirm/__tests__/appointment-confirm-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts b/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts new file mode 100644 index 0000000..534459e --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/__tests__/appointment-api.test.ts @@ -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 { + 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"); + }); + }); +}); diff --git a/src/routes/api/tenants/[id]/channels/+server.ts b/src/routes/api/tenants/[id]/channels/+server.ts index 138f81c..5223383 100644 --- a/src/routes/api/tenants/[id]/channels/+server.ts +++ b/src/routes/api/tenants/[id]/channels/+server.ts @@ -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({ diff --git a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts index d0a6c07..5261554 100644 --- a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts +++ b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts @@ -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({ diff --git a/src/routes/api/tenants/[id]/config/+server.ts b/src/routes/api/tenants/[id]/config/+server.ts index 3a4520e..5f5ee1b 100644 --- a/src/routes/api/tenants/[id]/config/+server.ts +++ b/src/routes/api/tenants/[id]/config/+server.ts @@ -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); diff --git a/src/routes/api/tenants/[id]/setup-state/+server.ts b/src/routes/api/tenants/[id]/setup-state/+server.ts index 1fd9f3b..becb7d0 100644 --- a/src/routes/api/tenants/[id]/setup-state/+server.ts +++ b/src/routes/api/tenants/[id]/setup-state/+server.ts @@ -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 }); diff --git a/src/routes/api/tenants/tenant-routes.test.ts b/src/routes/api/tenants/tenant-routes.test.ts index 7fd912a..4c214f3 100644 --- a/src/routes/api/tenants/tenant-routes.test.ts +++ b/src/routes/api/tenants/tenant-routes.test.ts @@ -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(); diff --git a/src/routes/api/tenants/tenants.test.ts b/src/routes/api/tenants/tenants.test.ts index beb1f49..08ae348 100644 --- a/src/routes/api/tenants/tenants.test.ts +++ b/src/routes/api/tenants/tenants.test.ts @@ -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(); diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts index 9618506..7d69e40 100644 --- a/src/server-hooks/authGuard.ts +++ b/src/server-hooks/authGuard.ts @@ -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) {