diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 122a2a9..320c2f0 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -94,6 +94,7 @@ "usePasskey": "Passkey verwenden", "usePassphrase": "Passphrase verwenden", "title": "Anmelden", + "description_passkey": "Gib Deine E-Mail-Adresse ein und klicke/tippe um Passkey hinzuzufügen.", "description_passphrase": "Gib Deine E-Mail-Adresse und Deinen Passphrase ein, um Dich anzumelden.", "action": "Anmelden", "error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen" diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index d64adfd..581fea4 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -111,6 +111,7 @@ "usePassphrase": "use passphrase", "orUse": "Or {button}", "title": "Login", + "description_passkey": "Enter your email and click/tap to add passkey.", "description_passphrase": "Enter your email and passphrase below to access your account.", "action": "Login", "error": "Login failed. Please retry", diff --git a/src/lib/components/ui/form/form-root.svelte b/src/lib/components/ui/form/form-root.svelte index be1a6a7..d7dc1f0 100644 --- a/src/lib/components/ui/form/form-root.svelte +++ b/src/lib/components/ui/form/form-root.svelte @@ -3,7 +3,10 @@ import type { Action } from "svelte/action"; import type { HTMLAttributes } from "svelte/elements"; - export type EventReporter = (params: { isSubmitting?: boolean }) => void; + export type EventReporter = (params: { + isSubmitting?: boolean; + isHidingSubmit?: boolean; + }) => void; // eslint-disable-next-line @typescript-eslint/no-explicit-any type EnhanceFunction = Action; diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index d274c46..4297370 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -1,10 +1,13 @@ import type { InferSelectModel } from "drizzle-orm"; import { boolean, + date, integer, json, + jsonb, pgEnum, pgTable, + primaryKey, text, time, timestamp, @@ -431,6 +434,35 @@ export const clientPinResetToken = pgTable("client_pin_reset_token", { used: boolean("used").default(false).notNull(), }); +/** + * Schedule Cache table - stores cached schedule data for channels on specific dates + * Used to improve performance of schedule queries in calendar and appointment booking + * @table cacheSchedule + */ +export const scheduleCache = pgTable( + "cache_schedule", + { + /** date of the cached schedule */ + date: date("date").notNull(), + /** channel of the cached schedule */ + channel: uuid("channel") + .notNull() + .references(() => channel.id), + /** timezone for this calculation */ + timezone: text("timezone").notNull(), + /** cache data */ + data: jsonb("data").notNull(), + /** When this entry was created */ + createdAt: timestamp("created_at").notNull().defaultNow(), + /** When this entry was last updated */ + updatedAt: timestamp("updated_at") + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [primaryKey({ columns: [table.date, table.timezone, table.channel] })], +); + /** StaffCrypto record type for database queries */ export type SelectStaffCrypto = InferSelectModel; @@ -445,3 +477,6 @@ export type SelectClientPinResetToken = InferSelectModel; + +/** ScheduleCache record type for database queries */ +export type SelectScheduleCache = InferSelectModel; diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 447dae6..34115d2 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -29,6 +29,27 @@ vi.mock("$lib/logger", () => ({ }, })); +const { mockCleanAndRegenerateCache, mockGetAllChannels } = vi.hoisted(() => ({ + mockCleanAndRegenerateCache: vi.fn().mockResolvedValue(undefined), + mockGetAllChannels: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../schedule-service", () => ({ + ScheduleService: { + forTenant: vi.fn().mockResolvedValue({ + cleanAndRegenerateCache: mockCleanAndRegenerateCache, + }), + }, +})); + +vi.mock("../channel-service", () => ({ + ChannelService: { + forTenant: vi.fn().mockResolvedValue({ + getAllChannels: mockGetAllChannels, + }), + }, +})); + // Import after mocking import { AgentService, @@ -83,7 +104,9 @@ const mockDb = { })), })), delete: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([]), + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), })), }; return await callback(mockTx); @@ -104,6 +127,8 @@ describe("AgentService", () => { beforeEach(async () => { vi.clearAllMocks(); vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + mockCleanAndRegenerateCache.mockResolvedValue(undefined); + mockGetAllChannels.mockResolvedValue([]); // Import and get the mocked centralDb const dbModule = await import("../../db"); @@ -484,10 +509,39 @@ describe("AgentService", () => { }; mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockDb.transaction = vi.fn(async (callback) => { + const mockTx = { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([mockAgent]), + })), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([ + { + agentId: "agent-123", + channelId: "channel-123", + }, + ]), + })), + })), + }; + return await callback(mockTx); + }); + const result = await service.deleteAgent("agent-123"); expect(result).toBe(true); expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should return false when agent not found", async () => { @@ -502,7 +556,9 @@ describe("AgentService", () => { })), })), delete: vi.fn(() => ({ - where: vi.fn().mockResolvedValue([]), + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), })), }; return await callback(mockTx); @@ -615,6 +671,12 @@ describe("AgentService", () => { agentId: "agent-123", channelId: "channel-123", }); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should handle database error", async () => { @@ -648,6 +710,12 @@ describe("AgentService", () => { await service.removeAgentFromChannel("agent-123", "channel-123"); expect(mockDb.delete).toHaveBeenCalled(); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should handle database error", async () => { @@ -730,6 +798,12 @@ describe("AgentService", () => { })), }; mockDb.insert.mockReturnValue(insertChain); + mockGetAllChannels.mockResolvedValue([ + { + id: "channel-123", + agents: [{ id: request.agentId }], + }, + ] as any); const result = await service.createAbsence(request); @@ -745,6 +819,14 @@ describe("AgentService", () => { from: null, to: null, }); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + startDate: new Date(request.startDate), + endDate: new Date(request.endDate), + }), + ); }); it("should create recurring absence successfully", async () => { @@ -1054,11 +1136,23 @@ describe("AgentService", () => { })), }; mockDb.update.mockReturnValue(updateChain); + mockGetAllChannels.mockResolvedValue([ + { + id: "channel-123", + agents: [{ id: "agent-123" }], + }, + ] as any); const result = await service.updateAbsence("absence-123", updateData); expect(result.absenceType).toBe(updateData.absenceType); expect(result.description).toBe(updateData.description); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should update absence successfully", async () => { @@ -1196,10 +1290,22 @@ describe("AgentService", () => { })), }; mockDb.delete.mockReturnValue(deleteChain); + mockGetAllChannels.mockResolvedValue([ + { + id: "channel-123", + agents: [{ id: "agent-123" }], + }, + ] as any); const result = await service.deleteAbsence("absence-123"); expect(result).toBe(true); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + }), + ); }); it("should return false if absence not found", async () => { diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 595286d..1e49eb6 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -42,6 +42,18 @@ vi.mock("../../auth/webauthn-service", () => ({ }, })); +const { mockCleanAndRegenerateCache } = vi.hoisted(() => ({ + mockCleanAndRegenerateCache: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../schedule-service", () => ({ + ScheduleService: { + forTenant: vi.fn().mockResolvedValue({ + cleanAndRegenerateCache: mockCleanAndRegenerateCache, + }), + }, +})); + const mockAppointment = { id: "appointment-123", tunnelId: "tunnel-123", @@ -94,6 +106,7 @@ const mockClientTunnelData = { describe("AppointmentService", () => { beforeEach(() => { vi.clearAllMocks(); + mockCleanAndRegenerateCache.mockResolvedValue(undefined); }); describe("forTenant", () => { @@ -297,6 +310,13 @@ describe("AppointmentService", () => { expect.objectContaining({ agentId: "agent-456" }), "Test Channel", ); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); }); @@ -337,6 +357,7 @@ describe("AppointmentService", () => { returning: vi.fn().mockResolvedValue([ { id: "appointment-123", + channelId: "channel-123", appointmentDate: new Date("2024-01-15T10:00:00Z"), status: "NEW", }, @@ -371,6 +392,13 @@ describe("AppointmentService", () => { expect(result.id).toBe("appointment-123"); expect(result.status).toBe("NEW"); expect(result.appointmentDate).toBe("2024-01-15T10:00:00.000Z"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should block creation when no authorized users exist", async () => { @@ -520,6 +548,7 @@ describe("AppointmentService", () => { returning: vi.fn().mockResolvedValue([ { id: "apt-123", + channelId: "channel-123", appointmentDate: new Date("2024-01-01T10:00:00Z"), status: "CONFIRMED", }, @@ -553,6 +582,13 @@ describe("AppointmentService", () => { expect(result.status).toBe("CONFIRMED"); expect(mockTransaction).toHaveBeenCalled(); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); }); @@ -619,6 +655,13 @@ describe("AppointmentService", () => { const result = await service.deleteAppointment("appointment-123"); expect(result).toBe(true); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should return false when appointment not found", async () => { @@ -696,6 +739,13 @@ describe("AppointmentService", () => { expect(mockDb.delete).toHaveBeenCalled(); expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should throw NotFoundError when appointment does not exist", async () => { @@ -772,6 +822,13 @@ describe("AppointmentService", () => { await deletePromise; expect(mockGetChannelTitle).toHaveBeenCalledWith("tenant-123", "channel-123", "de"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); }); @@ -956,6 +1013,13 @@ describe("AppointmentService", () => { expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123"); expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin"); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1); + expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith( + expect.objectContaining({ + channelId: "channel-123", + awaitRebuild: true, + }), + ); }); it("should throw NotFoundError when challenge is not found", async () => { diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index 707eba1..833bf51 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -32,6 +32,18 @@ vi.mock("../../db", () => ({ })), insert: vi.fn(), update: vi.fn(), + }, + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + groupBy: vi.fn(() => ({ + limit: vi.fn(() => Promise.resolve([])), + })), + })), + })), delete: vi.fn(), }, })); @@ -67,6 +79,7 @@ vi.mock("../../db/tenant-config", () => ({ // Import after mocking import { ChannelService } from "../channel-service"; import { getTenantDb } from "../../db"; +import { ScheduleService } from "../schedule-service"; // Mock data with valid UUIDs const mockChannel = { @@ -102,7 +115,9 @@ const mockDb = { insert: vi.fn(), select: vi.fn(), update: vi.fn(), - delete: vi.fn(), + delete: vi.fn(() => ({ + where: vi.fn(() => ({})), + })), }; describe("ChannelService", () => { @@ -128,9 +143,13 @@ describe("ChannelService", () => { describe("createChannel", () => { let service: ChannelService; + let cleanAndRegenerateCacheSpy: ReturnType; beforeEach(async () => { service = await ChannelService.forTenant("tenant-123"); + cleanAndRegenerateCacheSpy = vi + .spyOn(ScheduleService.prototype, "cleanAndRegenerateCache") + .mockResolvedValue(undefined); }); it("should create channel successfully", async () => { @@ -145,12 +164,21 @@ describe("ChannelService", () => { // Use minimal valid request that matches schema exactly const request = { names: { en: "Test Channel" }, + slotTemplates: [ + { + weekdays: 1, + from: "09:00:00", + to: "17:00:00", + duration: 30, + }, + ], }; const result = await service.createChannel(request as any); expect(result).toEqual(expectedResult); expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).toHaveBeenCalled(); }); it("should create channel with slot templates and agents", async () => { @@ -245,16 +273,52 @@ describe("ChannelService", () => { describe("updateChannel", () => { let service: ChannelService; + let cleanAndRegenerateCacheSpy: ReturnType; beforeEach(async () => { service = await ChannelService.forTenant("tenant-123"); + cleanAndRegenerateCacheSpy = vi + .spyOn(ScheduleService.prototype, "cleanAndRegenerateCache") + .mockResolvedValue(undefined); }); it("should update channel successfully", async () => { const expectedResult = { ...mockChannel, names: { en: "Updated Channel" }, - agents: [], + agents: [mockAgent], + slotTemplates: [mockSlotTemplate], + }; + + mockDb.transaction.mockResolvedValue(expectedResult); + + const updateData = { + names: { en: "Updated Channel" }, + slotTemplates: [ + { + weekdays: 1, + from: "09:00:00", + to: "17:00:00", + duration: 30, + }, + ], + }; + + const result = await service.updateChannel( + "550e8400-e29b-41d4-a716-446655440000", + updateData, + ); + + expect(result).toEqual(expectedResult); + expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).toHaveBeenCalled(); + }); + + it("should not rebuild schedule cache if not needed", async () => { + const expectedResult = { + ...mockChannel, + names: { en: "Updated Channel" }, + agents: [mockAgent], slotTemplates: [], }; @@ -271,6 +335,7 @@ describe("ChannelService", () => { expect(result).toEqual(expectedResult); expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).not.toHaveBeenCalled(); }); it("should handle validation error for invalid name", async () => { @@ -391,18 +456,52 @@ describe("ChannelService", () => { describe("deleteChannel", () => { let service: ChannelService; + let cleanAndRegenerateCacheSpy: ReturnType; beforeEach(async () => { service = await ChannelService.forTenant("tenant-123"); + cleanAndRegenerateCacheSpy = vi + .spyOn(ScheduleService.prototype, "cleanAndRegenerateCache") + .mockResolvedValue(undefined); }); it("should delete channel successfully", async () => { - mockDb.transaction.mockResolvedValue(true); + mockDb.transaction.mockImplementation(async (callback: any) => { + let selectCall = 0; + const tx = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => { + selectCall++; + if (selectCall === 1) { + return [{ slotTemplateId: "550e8400-e29b-41d4-a716-446655440002" }]; + } + return { + limit: vi.fn().mockResolvedValue([]), + }; + }), + })), + })), + delete: vi.fn(() => ({ + where: vi.fn().mockResolvedValue(undefined), + })), + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([{ id: mockChannel.id }]), + })), + })), + })), + }; + + return callback(tx); + }); const result = await service.deleteChannel("550e8400-e29b-41d4-a716-446655440000"); expect(result).toBe(true); expect(mockDb.transaction).toHaveBeenCalled(); + expect(cleanAndRegenerateCacheSpy).toHaveBeenCalled(); }); it("should return false when channel not found", async () => { diff --git a/src/lib/server/services/__tests__/schedule-service.test.ts b/src/lib/server/services/__tests__/schedule-service.test.ts index cbe80b3..6350ab3 100644 --- a/src/lib/server/services/__tests__/schedule-service.test.ts +++ b/src/lib/server/services/__tests__/schedule-service.test.ts @@ -34,6 +34,14 @@ import { getTenantDb } from "../../db"; // Mock database operations with proper query chain handling const mockDb = { select: vi.fn(), + delete: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve()), + })), + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onConflictDoUpdate: vi.fn(() => Promise.resolve()), + })), + })), }; // Helper to setup database query mocks for the exact ScheduleService query pattern @@ -43,8 +51,11 @@ function setupDbMocks(responses: { appointments: any[]; absences: any[]; channelAgents: any[]; + scheduleCache?: any[]; + keyShares?: any[]; }) { let queryCallIndex = 0; + const hasKeyShareQuery = typeof responses.keyShares !== "undefined"; (mockDb.select as any).mockImplementation(() => { queryCallIndex++; @@ -76,8 +87,18 @@ function setupDbMocks(responses: { }; } + // Query 3a (optional): staff key shares - select with where + if (hasKeyShareQuery && queryCallIndex === 4) { + return { + from: vi.fn(() => ({ + where: vi.fn(() => responses.keyShares ?? []), + })), + }; + } + // Query 4: Absences - select with where (complex date conditions) - if (queryCallIndex === 4) { + const absencesQueryIndex = hasKeyShareQuery ? 5 : 4; + if (queryCallIndex === absencesQueryIndex) { return { from: vi.fn(() => ({ where: vi.fn(() => responses.absences), @@ -86,7 +107,8 @@ function setupDbMocks(responses: { } // Query 5: Channel Agents - select with innerJoin - if (queryCallIndex === 5) { + const channelAgentsQueryIndex = hasKeyShareQuery ? 6 : 5; + if (queryCallIndex === channelAgentsQueryIndex) { return { from: vi.fn(() => ({ innerJoin: vi.fn(() => responses.channelAgents), @@ -94,16 +116,48 @@ function setupDbMocks(responses: { }; } + // Query 6+: Schedule cache lookup - select with where().orderBy() + const firstCacheQueryIndex = hasKeyShareQuery ? 7 : 6; + if (queryCallIndex >= firstCacheQueryIndex) { + return { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn(() => responses.scheduleCache ?? []), + })), + })), + }; + } + // Default fallback return { from: vi.fn(() => ({ - where: vi.fn(() => []), + where: vi.fn(() => ({ + orderBy: vi.fn(() => []), + })), innerJoin: vi.fn(() => []), })), }; }); } +const january1stNextYear = new Date(); +january1stNextYear.setFullYear(january1stNextYear.getFullYear() + 1); +january1stNextYear.setMonth(0); +january1stNextYear.setDate(1); +const jan1stNextYearString = january1stNextYear.toISOString().split("T")[0]; + +const jan2ndNextYear = new Date(january1stNextYear); +jan2ndNextYear.setDate(january1stNextYear.getDate() + 1); +const jan2ndNextYearString = jan2ndNextYear.toISOString().split("T")[0]; + +const jan3rdNextYear = new Date(january1stNextYear); +jan3rdNextYear.setDate(january1stNextYear.getDate() + 2); +const jan3rdNextYearString = jan3rdNextYear.toISOString().split("T")[0]; + +const weekdayJan1stNextYear = january1stNextYear.getUTCDay(); +const bitmaskForJan1stNextYear = + weekdayJan1stNextYear === 0 ? 64 : Math.pow(2, weekdayJan1stNextYear - 1); + describe("ScheduleService", () => { const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; @@ -153,8 +207,8 @@ describe("ScheduleService", () => { 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", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -177,7 +231,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, // Monday (2^(1-1) = 1) + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "17:00", duration: 60, @@ -218,7 +272,7 @@ describe("ScheduleService", () => { // 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.date).toBe(`${jan1stNextYearString}`); expect(mondaySchedule.channels).toHaveProperty("channel1"); const channelSchedule = mondaySchedule.channels["channel1"]; @@ -228,14 +282,38 @@ describe("ScheduleService", () => { // Validate each slot has correct times, duration, and agents const expectedSlots = [ - { from: "2024-01-01T09:00:00.000Z", to: "2024-01-01T10:00:00.000Z" }, - { from: "2024-01-01T10:00:00.000Z", to: "2024-01-01T11:00:00.000Z" }, - { from: "2024-01-01T11:00:00.000Z", to: "2024-01-01T12:00:00.000Z" }, - { from: "2024-01-01T12:00:00.000Z", to: "2024-01-01T13:00:00.000Z" }, - { from: "2024-01-01T13:00:00.000Z", to: "2024-01-01T14:00:00.000Z" }, - { from: "2024-01-01T14:00:00.000Z", to: "2024-01-01T15:00:00.000Z" }, - { from: "2024-01-01T15:00:00.000Z", to: "2024-01-01T16:00:00.000Z" }, - { from: "2024-01-01T16:00:00.000Z", to: "2024-01-01T17:00:00.000Z" }, + { + from: `${jan1stNextYearString}T09:00:00.000Z`, + to: `${jan1stNextYearString}T10:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T10:00:00.000Z`, + to: `${jan1stNextYearString}T11:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T11:00:00.000Z`, + to: `${jan1stNextYearString}T12:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T12:00:00.000Z`, + to: `${jan1stNextYearString}T13:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T13:00:00.000Z`, + to: `${jan1stNextYearString}T14:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T14:00:00.000Z`, + to: `${jan1stNextYearString}T15:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T15:00:00.000Z`, + to: `${jan1stNextYearString}T16:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T16:00:00.000Z`, + to: `${jan1stNextYearString}T17:00:00.000Z`, + }, ]; expectedSlots.forEach((expectedSlot, index) => { @@ -250,8 +328,8 @@ describe("ScheduleService", () => { it("should handle empty channel results", async () => { const validRequest: ScheduleRequest = { - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -273,8 +351,8 @@ describe("ScheduleService", () => { it("should handle database errors", async () => { const validRequest: ScheduleRequest = { - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -290,8 +368,8 @@ describe("ScheduleService", () => { 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 + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan3rdNextYear.toISOString()}`, // 3 days timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -308,9 +386,9 @@ describe("ScheduleService", () => { const result = await service.getSchedule(validRequest, "passkeyId"); 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"); + expect(result.schedule[0].date).toBe(`${jan1stNextYearString}`); + expect(result.schedule[1].date).toBe(`${jan2ndNextYearString}`); + expect(result.schedule[2].date).toBe(`${jan3rdNextYearString}`); }); }); @@ -323,8 +401,8 @@ describe("ScheduleService", () => { 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", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, // Monday + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -346,7 +424,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, // Only Monday (2^(1-1) = 1) + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "10:00", duration: 60, @@ -391,13 +469,13 @@ describe("ScheduleService", () => { // 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("2024-01-01T09:00:00.000Z"); + expect(channelSchedule.availableSlots[0].from).toBe(`${jan1stNextYearString}T09:00:00.000Z`); }); it("should exclude slots with appointments", async () => { const validRequest: ScheduleRequest = { - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -419,7 +497,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, // Monday (2^(1-1) = 1) + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "11:00", duration: 60, @@ -434,7 +512,7 @@ describe("ScheduleService", () => { tunnelId: "tunnel1", channelId: "channel1", agentId: "agent1", - appointmentDate: "2024-01-01T09:00:00.000Z", // 09:00 UTC + appointmentDate: `${jan1stNextYearString}T09:00:00.000Z`, // 09:00 UTC duration: 60, status: "NEW", }, @@ -466,13 +544,13 @@ describe("ScheduleService", () => { 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("2024-01-01T10:00:00.000Z"); + expect(channelSchedule.availableSlots[0].from).toBe(`${jan1stNextYearString}T10:00:00.000Z`); }); 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", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, // Monday + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -494,7 +572,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, // Monday (2^(1-1) = 1) + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "17:00", duration: 60, @@ -510,7 +588,7 @@ describe("ScheduleService", () => { tunnelId: "tunnel1", channelId: "channel1", agentId: "agent1", - appointmentDate: "2024-01-01T10:00:00.000Z", // 10:00 UTC + appointmentDate: `${jan1stNextYearString}T10:00:00.000Z`, // 10:00 UTC duration: 60, status: "CONFIRMED", }, @@ -519,7 +597,7 @@ describe("ScheduleService", () => { tunnelId: "tunnel2", channelId: "channel1", agentId: "agent1", - appointmentDate: "2024-01-01T14:00:00.000Z", // 14:00 UTC + appointmentDate: `${jan1stNextYearString}T14:00:00.000Z`, // 14:00 UTC duration: 60, status: "NEW", }, @@ -559,12 +637,30 @@ describe("ScheduleService", () => { // Validate available slots exclude booked times (10:00-11:00 and 14:00-15:00) const expectedAvailableSlots = [ - { from: "2024-01-01T09:00:00.000Z", to: "2024-01-01T10:00:00.000Z" }, - { from: "2024-01-01T11:00:00.000Z", to: "2024-01-01T12:00:00.000Z" }, - { from: "2024-01-01T12:00:00.000Z", to: "2024-01-01T13:00:00.000Z" }, - { from: "2024-01-01T13:00:00.000Z", to: "2024-01-01T14:00:00.000Z" }, - { from: "2024-01-01T15:00:00.000Z", to: "2024-01-01T16:00:00.000Z" }, - { from: "2024-01-01T16:00:00.000Z", to: "2024-01-01T17:00:00.000Z" }, + { + from: `${jan1stNextYearString}T09:00:00.000Z`, + to: `${jan1stNextYearString}T10:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T11:00:00.000Z`, + to: `${jan1stNextYearString}T12:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T12:00:00.000Z`, + to: `${jan1stNextYearString}T13:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T13:00:00.000Z`, + to: `${jan1stNextYearString}T14:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T15:00:00.000Z`, + to: `${jan1stNextYearString}T16:00:00.000Z`, + }, + { + from: `${jan1stNextYearString}T16:00:00.000Z`, + to: `${jan1stNextYearString}T17:00:00.000Z`, + }, ]; expectedAvailableSlots.forEach((expectedSlot, index) => { @@ -579,8 +675,8 @@ describe("ScheduleService", () => { 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", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -602,7 +698,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, // Monday (2^(1-1) = 1) + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "10:00", duration: 60, @@ -615,8 +711,8 @@ describe("ScheduleService", () => { { id: "absence1", agentId: "agent1", - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, absenceType: "Urlaub", description: null, isFullDay: true, @@ -653,8 +749,8 @@ describe("ScheduleService", () => { it("should keep slot if one agent is booked but another is available", async () => { const validRequest: ScheduleRequest = { - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -676,7 +772,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "10:00", duration: 60, @@ -691,7 +787,7 @@ describe("ScheduleService", () => { tunnelId: "tunnel1", channelId: "channel1", agentId: "agent1", - appointmentDate: "2024-01-01T09:00:00.000Z", + appointmentDate: `${jan1stNextYearString}T09:00:00.000Z`, duration: 60, status: "CONFIRMED", }, @@ -730,15 +826,15 @@ describe("ScheduleService", () => { const channelSchedule = result.schedule[0].channels["channel1"]; expect(channelSchedule.availableSlots).toHaveLength(1); - expect(channelSchedule.availableSlots[0].from).toBe("2024-01-01T09:00:00.000Z"); + expect(channelSchedule.availableSlots[0].from).toBe(`${jan1stNextYearString}T09:00:00.000Z`); expect(channelSchedule.availableSlots[0].availableAgents).toHaveLength(1); expect(channelSchedule.availableSlots[0].availableAgents[0].id).toBe("agent2"); }); it("should block same agent across channels at the same time", async () => { const validRequest: ScheduleRequest = { - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -770,7 +866,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "11:00", duration: 60, @@ -780,7 +876,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template2", - weekdays: 1, + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "11:00", duration: 60, @@ -795,7 +891,7 @@ describe("ScheduleService", () => { tunnelId: "tunnel1", channelId: "channel1", agentId: "agent1", - appointmentDate: "2024-01-01T10:00:00.000Z", + appointmentDate: `${jan1stNextYearString}T10:00:00.000Z`, duration: 60, status: "CONFIRMED", }, @@ -836,16 +932,16 @@ describe("ScheduleService", () => { const channel2Schedule = result.schedule[0].channels["channel2"]; expect(channel1Schedule.availableSlots).toHaveLength(1); - expect(channel1Schedule.availableSlots[0].from).toBe("2024-01-01T09:00:00.000Z"); + expect(channel1Schedule.availableSlots[0].from).toBe(`${jan1stNextYearString}T09:00:00.000Z`); expect(channel2Schedule.availableSlots).toHaveLength(1); - expect(channel2Schedule.availableSlots[0].from).toBe("2024-01-01T09:00:00.000Z"); + expect(channel2Schedule.availableSlots[0].from).toBe(`${jan1stNextYearString}T09:00:00.000Z`); }); it("should exclude slots where an agent has a recurring absence", async () => { const validRequest: ScheduleRequest = { - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -867,7 +963,7 @@ describe("ScheduleService", () => { { slotTemplate: { id: "template1", - weekdays: 1, // Monday (2^(1-1) = 1) + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "12:00", duration: 60, @@ -881,11 +977,11 @@ describe("ScheduleService", () => { id: "absence1", type: "RECURRING", agentId: "agent1", - startDate: "2024-01-01T00:00:00.000Z", - endDate: "2024-01-01T23:59:59.999Z", + startDate: `${jan1stNextYearString}T00:00:00.000Z`, + endDate: `${jan1stNextYearString}T23:59:59.999Z`, absenceType: "Urlaub", description: null, - weekdays: 1, + weekdays: bitmaskForJan1stNextYear, from: "09:00", to: "10:00", }, @@ -927,8 +1023,8 @@ describe("ScheduleService", () => { }, ], duration: 60, - from: "2024-01-01T10:00:00.000Z", - to: "2024-01-01T11:00:00.000Z", + from: `${jan1stNextYearString}T10:00:00.000Z`, + to: `${jan1stNextYearString}T11:00:00.000Z`, }, { availableAgents: [ @@ -940,10 +1036,237 @@ describe("ScheduleService", () => { }, ], duration: 60, - from: "2024-01-01T11:00:00.000Z", - to: "2024-01-01T12:00:00.000Z", + from: `${jan1stNextYearString}T11:00:00.000Z`, + to: `${jan1stNextYearString}T12:00:00.000Z`, }, ]); }); }); + + describe("cache management", () => { + let service: ScheduleService; + + beforeEach(async () => { + service = await ScheduleService.forTenant(mockTenantId); + }); + + it("should clean cache and rebuild synchronously when awaitRebuild is true", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const endDate = new Date(`${jan2ndNextYearString}T23:59:59.999Z`); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }, { timezone: "Europe/Berlin" }]), + })), + }); + + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate, + channelId: "channel-123", + awaitRebuild: true, + }); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(getScheduleSpy).toHaveBeenCalledTimes(2); + expect(getScheduleSpy).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + tenantId: mockTenantId, + channelId: "channel-123", + timeZone: "UTC", + }), + ); + expect(getScheduleSpy).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + tenantId: mockTenantId, + channelId: "channel-123", + timeZone: "Europe/Berlin", + }), + ); + }); + + it("should cap rebuild end date to max allowed horizon", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const veryFarEndDate = new Date("3000-01-01T00:00:00.000Z"); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }]), + })), + }); + + (mockDb.delete as any).mockReturnValue({ + where: vi.fn(() => Promise.resolve()), + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: veryFarEndDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate: veryFarEndDate, + channelId: "channel-123", + awaitRebuild: true, + }); + + const now = new Date(); + const maxEndDate = new Date( + Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 14, 0, 23, 59, 59, 999), + ); + + expect(getScheduleSpy).toHaveBeenCalledTimes(1); + expect(new Date(getScheduleSpy.mock.calls[0][0].endDate).toISOString()).toBe( + maxEndDate.toISOString(), + ); + }); + + it("should clean cache and trigger background rebuild when awaitRebuild is false", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const endDate = new Date(`${jan2ndNextYearString}T23:59:59.999Z`); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }]), + })), + }); + + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate, + channelId: "channel-123", + }); + + // Background rebuild starts immediately even though it is not awaited. + await Promise.resolve(); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(getScheduleSpy).toHaveBeenCalledTimes(1); + }); + + it("should handle usedTimeZones errors and still clear cache", async () => { + const startDate = new Date(`${jan1stNextYearString}T00:00:00.000Z`); + const endDate = new Date(`${jan2ndNextYearString}T23:59:59.999Z`); + + (mockDb.select as any).mockReturnValue({ + from: vi.fn(() => ({ + groupBy: vi.fn(() => { + throw new Error("groupBy failed"); + }), + })), + }); + + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + }, + schedule: [], + }); + + await service.cleanAndRegenerateCache({ + startDate, + endDate, + channelId: "channel-123", + awaitRebuild: true, + }); + + expect(deleteWhere).toHaveBeenCalledTimes(1); + expect(getScheduleSpy).not.toHaveBeenCalled(); + }); + + it("should delete past cache entries", async () => { + const deleteWhere = vi.fn(() => Promise.resolve()); + (mockDb.delete as any).mockReturnValue({ + where: deleteWhere, + }); + + await service.cleanPastCache(); + + expect(mockDb.delete).toHaveBeenCalledTimes(1); + expect(deleteWhere).toHaveBeenCalledTimes(1); + }); + + it("should generate cache ahead for all channels and used timezones", async () => { + const getScheduleSpy = vi.spyOn(service, "getSchedule").mockResolvedValue({ + period: { + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + }, + schedule: [], + }); + + let selectCall = 0; + (mockDb.select as any).mockImplementation(() => { + selectCall++; + + // usedTimeZones() + if (selectCall === 1) { + return { + from: vi.fn(() => ({ + groupBy: vi.fn(() => [{ timezone: "UTC" }, { timezone: "Europe/Berlin" }]), + })), + }; + } + + // generateCacheAhead() channels query + return { + from: vi.fn(() => [{ id: "channel-1" }, { id: "channel-2" }]), + }; + }); + + await service.generateCacheAhead(); + await vi.waitFor(() => expect(getScheduleSpy).toHaveBeenCalledTimes(4)); + const observedCombinations = getScheduleSpy.mock.calls.map((call) => { + const req = call[0]; + return `${req.channelId}:${req.timeZone}`; + }); + + expect(observedCombinations).toEqual( + expect.arrayContaining([ + "channel-1:UTC", + "channel-1:Europe/Berlin", + "channel-2:UTC", + "channel-2:Europe/Berlin", + ]), + ); + }); + }); }); diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index 5cfdb93..7dee35c 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -9,6 +9,8 @@ import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; import { supportedLocales } from "$lib/const/locales"; import { TenantAdminService } from "./tenant-admin-service"; import { isToAfterFrom } from "$lib/utils/datetime"; +import { ScheduleService } from "./schedule-service"; +import { ChannelService } from "./channel-service"; const agentCreationSchema = z.object({ name: z.string().min(1).max(100), @@ -284,11 +286,16 @@ export class AgentService { try { const db = await this.getDb(); + let channels: { + agentId: string; + channelId: string; + }[] = []; const result = await db.transaction(async (tx) => { // First, remove all channel-agent associations - await tx + channels = await tx .delete(tenantSchema.channelAgent) - .where(eq(tenantSchema.channelAgent.agentId, agentId)); + .where(eq(tenantSchema.channelAgent.agentId, agentId)) + .returning(); // Then delete the agent (soft delete by setting archived flag) return await tx @@ -314,6 +321,16 @@ export class AgentService { const adminService = await TenantAdminService.getTenantById(this.tenantId); adminService.validateSetupState(); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + for (const channel of channels) { + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(), + endDate: new Date("2999-12-31"), + channelId: channel.channelId, + }); + } + return true; } catch (error) { log.error("Failed to delete agent", { @@ -402,6 +419,14 @@ export class AgentService { agentId, channelId, }); + + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(), + endDate: new Date("2999-12-31"), + channelId, + }); } catch (error) { log.error("Failed to assign agent to channel", { tenantId: this.tenantId, @@ -440,6 +465,14 @@ export class AgentService { agentId, channelId, }); + + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(), + endDate: new Date("2999-12-31"), + channelId, + }); } catch (error) { log.error("Failed to remove agent from channel", { tenantId: this.tenantId, @@ -566,6 +599,21 @@ export class AgentService { agentId: request.agentId, }); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const channelService = await ChannelService.forTenant(this.tenantId); + const channels = await channelService.getAllChannels(); + const relevantChannels = channels.filter((channel) => + channel.agents.map((it) => it.id).includes(request.agentId), + ); + for (const channel of relevantChannels) { + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(request.startDate), + endDate: new Date(request.endDate), + channelId: channel.id, + }); + } + return result[0]; } catch (error) { if (error instanceof NotFoundError || error instanceof ConflictError) throw error; @@ -902,6 +950,27 @@ export class AgentService { updateFields: Object.keys(updateData), }); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const channelService = await ChannelService.forTenant(this.tenantId); + const channels = await channelService.getAllChannels(); + const relevantChannels = channels.filter((channel) => + channel.agents.map((it) => it.id).includes(currentAbsence[0].agentId), + ); + const earliestStartDate = updateData.startDate + ? new Date(updateData.startDate) + : currentAbsence[0].startDate; + const latestEndDate = updateData.endDate + ? new Date(updateData.endDate) + : currentAbsence[0].endDate; + for (const channel of relevantChannels) { + scheduleService.cleanAndRegenerateCache({ + startDate: earliestStartDate, + endDate: latestEndDate, + channelId: channel.id, + }); + } + return result[0]; } catch (error) { if (error instanceof NotFoundError || error instanceof ConflictError) throw error; @@ -943,6 +1012,21 @@ export class AgentService { absenceId, }); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const channelService = await ChannelService.forTenant(this.tenantId); + const channels = await channelService.getAllChannels(); + const relevantChannels = channels.filter((channel) => + channel.agents.map((it) => it.id).includes(result[0].agentId), + ); + for (const channel of relevantChannels) { + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(result[0].startDate), + endDate: new Date(result[0].endDate), + channelId: channel.id, + }); + } + return true; } catch (error) { log.error("Failed to delete absence", { diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 6a46e14..7e85003 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -36,6 +36,7 @@ import { NotificationService } from "./notification-service"; import { TenantAdminService } from "./tenant-admin-service"; import type { PgTransaction } from "drizzle-orm/pg-core"; import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js"; +import { ScheduleService } from "./schedule-service"; export interface ClientTunnelData { tunnelId: string; @@ -431,6 +432,16 @@ export class AppointmentService { tenantId: this.tenantId, }); }); + + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(result[0].appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: result[0].channelId, + awaitRebuild: true, + }); } public async cancelAppointment(id: string): Promise { @@ -453,6 +464,16 @@ export class AppointmentService { throw new ValidationError("Appointment not found or in wrong state"); } + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(result[0].appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: result[0].channelId, + awaitRebuild: true, + }); + const row = result[0]; return row; } @@ -475,6 +496,16 @@ export class AppointmentService { return false; } + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(result[0].appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: result[0].channelId, + awaitRebuild: true, + }); + log.debug("Appointment deleted successfully", { appointmentId: id, tenantId: this.tenantId }); return true; } @@ -556,6 +587,16 @@ export class AppointmentService { ); } + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(appointmentResult[0].appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: appointmentResult[0].channelId, + awaitRebuild: true, + }); + log.info("Appointment deleted by staff successfully", { appointmentId, channelId, @@ -690,6 +731,28 @@ export class AppointmentService { tenantId: this.tenantId, }); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(appointmentResult[0].appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: appointmentResult[0].channelId, + awaitRebuild: true, + }); + // Also update the cache if the appointment date has changed to a different day + if ( + date.toISOString().split("T")[0] !== + newAppointment.appointmentDate.toISOString().split("T")[0] + ) { + await scheduleService.cleanAndRegenerateCache({ + startDate: newAppointment.appointmentDate, + endDate: newAppointment.appointmentDate, + channelId: appointmentResult[0].channelId, + awaitRebuild: true, + }); + } + return newAppointment; } @@ -885,6 +948,15 @@ export class AppointmentService { appointmentId: result.id, }); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + await scheduleService.cleanAndRegenerateCache({ + startDate: result.appointmentDate, + endDate: result.appointmentDate, + channelId: appointmentData.channelId, + awaitRebuild: true, + }); + return response; } @@ -1083,6 +1155,16 @@ export class AppointmentService { }); } + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(result.appointment.appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: result.appointment.channelId, + awaitRebuild: true, + }); + return response; } @@ -1377,6 +1459,16 @@ export class AppointmentService { appointmentId, channelId: appointment.channelId, }); + + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + const date = new Date(appointmentResult[0].appointmentDate); + await scheduleService.cleanAndRegenerateCache({ + startDate: date, + endDate: date, + channelId: appointmentResult[0].channelId, + awaitRebuild: true, + }); } /** diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index a9e335d..5e32f74 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -9,6 +9,7 @@ import * as tenantSchema from "../db/tenant-schema"; import { type SelectAgent, type SelectChannel, type SelectSlotTemplate } from "../db/tenant-schema"; import { NotFoundError, ValidationError } from "../utils/errors"; import { TenantAdminService } from "./tenant-admin-service"; +import { ScheduleService } from "./schedule-service"; const CHANNEL_COLORS = [ "#F3835C", @@ -240,6 +241,14 @@ export class ChannelService { const adminService = await TenantAdminService.getTenantById(this.tenantId); adminService.validateSetupState(); + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(), + endDate: new Date("2999-12-31"), + channelId: result.id, + }); + return result; } catch (error) { log.error("Failed to create channel", { @@ -538,6 +547,16 @@ export class ChannelService { slotTemplateCount: result.slotTemplates.length, }); + if (updateData.slotTemplates !== undefined && result.agents.length > 0) { + // Regenerate schedule cache + const scheduleService = await ScheduleService.forTenant(this.tenantId); + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(), + endDate: new Date("2999-12-31"), + channelId: result.id, + }); + } + return result; } catch (error) { if (error instanceof NotFoundError || error instanceof ValidationError) throw error; @@ -747,6 +766,16 @@ export class ChannelService { .where(eq(tenantSchema.channel.id, channelId)) .returning(); + // Regenerate schedule cache + if (slotTemplateIds.length > 0) { + const scheduleService = await ScheduleService.forTenant(this.tenantId); + scheduleService.cleanAndRegenerateCache({ + startDate: new Date(), + endDate: new Date("2999-12-31"), + channelId: channelId, + }); + } + return deleteResult.length > 0; }); diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index 5e52af4..b0034d8 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -6,6 +6,7 @@ import { type SelectSlotTemplate, type SelectAppointment, type SelectAgentAbsence, + scheduleCache, } from "../db/tenant-schema"; import { eq, and, between, sql, or, inArray } from "drizzle-orm"; @@ -15,6 +16,8 @@ import { ValidationError } from "../utils/errors"; import { WebAuthnService } from "../auth/webauthn-service"; import { isValidTimeZone, toLocalTime, toLocalTimeIgnoringDst } from "../utils/timezone"; +const CACHE_MAX_AHEAD_MONTHS = 14; + 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 @@ -64,6 +67,13 @@ export interface ScheduleResult { export class ScheduleService { #db: Awaited> | null = null; + #buffer: Array<{ + startDate: Date; + endDate: Date; + channelId: string; + timeZones: string[]; + }> = []; + #isProcessingBuffer = false; private constructor(public readonly tenantId: string) {} @@ -195,16 +205,19 @@ export class ScheduleService { and( sql`${tenantSchema.agentAbsence.startDate} >= ${request.startDate}`, sql`${tenantSchema.agentAbsence.startDate} <= ${request.endDate}`, + sql`${tenantSchema.agentAbsence.endDate} > now()`, ), // Absence ends within period and( sql`${tenantSchema.agentAbsence.endDate} >= ${request.startDate}`, sql`${tenantSchema.agentAbsence.endDate} <= ${request.endDate}`, + sql`${tenantSchema.agentAbsence.endDate} > now()`, ), // Absence spans entire period and( sql`${tenantSchema.agentAbsence.startDate} <= ${request.startDate}`, sql`${tenantSchema.agentAbsence.endDate} >= ${request.endDate}`, + sql`${tenantSchema.agentAbsence.endDate} > now()`, ), ), ); @@ -237,11 +250,6 @@ export class ScheduleService { timeZone: request.timeZone, }); - log.debug("Schedule generated successfully", { - tenantId: this.tenantId, - daysGenerated: schedule.length, - }); - return { period: { startDate: request.startDate, @@ -330,7 +338,8 @@ export class ScheduleService { .map((ca) => ca.agent); // Generate available slots for this channel - const availableSlots = this.generateAvailableSlots({ + const availableSlots = await this.generateAvailableSlots({ + channelId: channel.id, date: currentDate, slotTemplates: channelSlotTemplates, appointments: dayAppointments, @@ -358,7 +367,8 @@ export class ScheduleService { /** * Generate available time slots for a specific day and channel */ - private generateAvailableSlots({ + private async generateAvailableSlots({ + channelId, date, slotTemplates, appointments, @@ -366,15 +376,33 @@ export class ScheduleService { absences, timeZone, }: { + channelId: string; date: Date; slotTemplates: SelectSlotTemplate[]; appointments: SelectAppointment[]; agents: CalendarAgent[]; absences: SelectAgentAbsence[]; timeZone: string; - }): TimeSlot[] { + }): Promise { const availableSlots: TimeSlot[] = []; + // Can I read from cache? + const db = await this.getDb(); + const cachedSchedule = await db + .select() + .from(scheduleCache) + .where( + and( + eq(scheduleCache.date, date.toISOString().split("T")[0]), + eq(scheduleCache.channel, channelId), + eq(scheduleCache.timezone, timeZone), + ), + ) + .orderBy(scheduleCache.date); + if (cachedSchedule.length > 0) { + return cachedSchedule[0].data as TimeSlot[]; + } + for (const template of slotTemplates) { // Parse template times const [fromHour, fromMinute] = template.from.split(":").map(Number); @@ -415,6 +443,12 @@ export class ScheduleService { ), ); + // Do not include slots that are in the past + if (slotEndDateTime.getTime() < new Date().getTime()) { + currentTime += slotDuration; + continue; + } + const availableAgents = agents.filter((agent) => { if ( this.isAgentAbsent(agent.id, slotStartDateTime, slotEndDateTime, absences, timeZone) @@ -422,13 +456,15 @@ export class ScheduleService { return false; } - return !this.hasAgentAppointmentConflict( + const conflictResult = !this.hasAgentAppointmentConflict( agent.id, slotStartDateTime, slotEndDateTime, appointments, timeZone, ); + + return conflictResult; }); // Only include slot if there are available agents @@ -445,6 +481,20 @@ export class ScheduleService { } } + // Save schedule to cache + await db + .insert(scheduleCache) + .values({ + date: date.toISOString().split("T")[0], + timezone: timeZone, + channel: channelId, + data: availableSlots, + }) + .onConflictDoUpdate({ + target: [scheduleCache.date, scheduleCache.timezone, scheduleCache.channel], + set: { data: availableSlots }, + }); + return availableSlots; } @@ -498,6 +548,11 @@ export class ScheduleService { const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone); const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone); + // Quick way out + if (slotStart > absenceEnd || slotEnd < absenceStart) { + return false; // No overlap + } + // For time-specific absences, check if the time slot overlaps const slotStartsDuringAbsence = slotStart >= absenceStart && slotStart < absenceEnd; const slotEndsDuringAbsence = slotEnd > absenceStart && slotEnd <= absenceEnd; @@ -554,4 +609,226 @@ export class ScheduleService { } return this.#db; } + + private async cleanCache({ + startDate, + endDate, + channelId, + }: { + startDate: Date; + endDate: Date; + channelId: string; + }): Promise { + const db = await this.getDb(); + + // If endDate is far ahead, set it to 2999-12-31 to avoid not deleting future cache entries, that have become invalid because we only generate a certain number of months ahead, but users could generate a schedule cache for dates far in the future. + const usedEndDate = + endDate > new Date(new Date().setMonth(new Date().getMonth() + CACHE_MAX_AHEAD_MONTHS - 2)) + ? new Date(2999, 11, 31) + : endDate; + + await db + .delete(scheduleCache) + .where( + and( + eq(scheduleCache.channel, channelId), + between( + scheduleCache.date, + startDate.toISOString().split("T")[0], + usedEndDate.toISOString().split("T")[0], + ), + ), + ); + } + + private async generateCache({ + startDate, + endDate, + channelId, + timeZones, + }: { + startDate: Date; + endDate: Date; + channelId: string; + timeZones: string[]; + }): Promise { + // Maximum end date is the last day of the month, 13 months from now + const now = new Date(); + const maxEndDate = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth() + CACHE_MAX_AHEAD_MONTHS, + 0, + 23, + 59, + 59, + 999, + ), + ); + const usedEndDate = endDate > maxEndDate ? maxEndDate : endDate; + + // Add to buffer + this.#buffer.push({ + startDate, + endDate: usedEndDate, + channelId, + timeZones, + }); + + // Start buffer queue + this.processBufferQueue(); + } + + private async processBufferQueue(): Promise { + if (this.#isProcessingBuffer || this.#buffer.length === 0) return; + + this.#isProcessingBuffer = true; + try { + const log = logger.setContext("ScheduleService.Buffer"); + while (this.#buffer.length > 0) { + const { startDate, endDate, channelId, timeZones } = this.#buffer[0]; + try { + log.debug("Generating schedule cache", { + tenantId: this.tenantId, + channelId, + startDate: startDate.toISOString().split("T")[0], + endDate: endDate.toISOString().split("T")[0], + }); + + // Generate cache for each timezone + for (const timeZone of timeZones) { + await this.getSchedule({ + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + tenantId: this.tenantId, + channelId, + timeZone, + }); + } + } catch (error) { + log.error("Failed to generate schedule cache", { + tenantId: this.tenantId, + channelId, + startDate: startDate.toISOString().split("T")[0], + endDate: endDate.toISOString().split("T")[0], + error: String(error), + }); + } finally { + // Remove the processed item from the buffer + this.#buffer.shift(); + } + } + } finally { + this.#isProcessingBuffer = false; + } + } + + private async usedTimeZones(): Promise { + try { + // TODO: Once we have a tenant timezone setting, we can get that timezone instead of all timezones in the cache + const db = await this.getDb(); + const timeZones = await db + .select({ timezone: scheduleCache.timezone }) + .from(scheduleCache) + .groupBy(scheduleCache.timezone); + return timeZones.map((tz) => tz.timezone); + } catch (error) { + const log = logger.setContext("ScheduleService"); + log.error("Failed to get used time zones", { + tenantId: this.tenantId, + error: String(error), + }); + return []; + } + } + + /** + * Clean and regenerate cache; used after actions that will invalidate the schedule cache, such as creating or deleting appointments, or changing agent availability. + * This method will clear the cache for the affected date range and channels, forcing a regeneration of available slots on the next request. + * @returns Promise + */ + async cleanAndRegenerateCache({ + startDate, + endDate, + channelId, + awaitRebuild, + }: { + startDate: Date; + endDate: Date; + channelId: string; + awaitRebuild?: boolean; + }): Promise { + const log = logger.setContext("ScheduleService"); + log.debug("Cleaning and regenerating cache", { + tenantId: this.tenantId, + channelId, + startDate: startDate.toISOString().split("T")[0], + endDate: endDate.toISOString().split("T")[0], + }); + // Get all time zones for the channel in the cache + const timeZones = await this.usedTimeZones(); + + // Clear cache + await this.cleanCache({ startDate, endDate, channelId }); + + // Optionally wait for rebuild + if (awaitRebuild) { + await this.generateCache({ + startDate, + endDate, + channelId, + timeZones, + }); + return; + } + + // Rebuild cache in the background without awaiting + this.generateCache({ + startDate, + endDate, + channelId, + timeZones, + }); + return; + } + + /** + * Removes cached schedule for dates in the past + */ + async cleanPastCache(): Promise { + const db = await this.getDb(); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + await db + .delete(scheduleCache) + .where(sql`${scheduleCache.date} < ${today.toISOString().split("T")[0]}`); + } + + /** + * Creates the cache for dates in the future + */ + async generateCacheAhead(): Promise { + const timeZones = await this.usedTimeZones(); + const log = logger.setContext("ScheduleService"); + + // Generate cache for next months + const db = await this.getDb(); + const channels = await db.select().from(tenantSchema.channel); + for (const channel of channels) { + log.debug("Generating schedule cache ahead", { + tenantId: this.tenantId, + channelId: channel.id, + startDate: new Date().toISOString().split("T")[0], + endDate: new Date(new Date().setMonth(new Date().getMonth() + CACHE_MAX_AHEAD_MONTHS)) + .toISOString() + .split("T")[0], + }); + this.generateCache({ + startDate: new Date(), + endDate: new Date(new Date().setMonth(new Date().getMonth() + CACHE_MAX_AHEAD_MONTHS)), + channelId: channel.id, + timeZones, + }); + } + } } diff --git a/src/lib/server/services/startup-service.ts b/src/lib/server/services/startup-service.ts index 7903c21..5fb7756 100644 --- a/src/lib/server/services/startup-service.ts +++ b/src/lib/server/services/startup-service.ts @@ -7,6 +7,7 @@ import { SessionService } from "../auth/session-service"; import { ClientPinResetService } from "./client-pin-reset-service"; import { UniversalLogger } from "$lib/logger"; import { AppointmentService } from "./appointment-service"; +import { ScheduleService } from "./schedule-service"; const logger = new UniversalLogger().setContext("StartupService"); const TWELVE_HOURS_IN_MS = 12 * 60 * 60 * 1000; @@ -174,12 +175,25 @@ export class StartupService { try { const pinResetService = await ClientPinResetService.forTenant(tenantData.id); const appointmentService = await AppointmentService.forTenant(tenantData.id); + const scheduleService = await ScheduleService.forTenant(tenantData.id); await appointmentService.cleanupExpiredAppointments(tenantData.id); logger.info("Cleaned up expired appointments for tenant", { tenantId: tenantData.id, shortName: tenantData.shortName, }); + await scheduleService.cleanPastCache(); + logger.info("Cleaned up past schedule cache for tenant", { + tenantId: tenantData.id, + shortName: tenantData.shortName, + }); + + await scheduleService.generateCacheAhead(); + logger.info("Generated future schedule cache for tenant", { + tenantId: tenantData.id, + shortName: tenantData.shortName, + }); + const deletedTokens = await pinResetService.cleanupExpiredTokens(); logger.info(`Cleaned up ${deletedTokens} expired PIN reset tokens for tenant`, { tenantId: tenantData.id, diff --git a/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte b/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte index c1df9ad..1d43e3e 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/Appointment.svelte @@ -195,7 +195,7 @@ ]} /> {#if item.appointment.status === "reserved"} -
+
. +
{/if}