mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-27 03:14:48 +02:00
Merge pull request #322 from open-reception/fix/calendar-load-times
Calendar: Added cache to load calendar faster
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
import type { InferSelectModel } from "drizzle-orm";
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
integer,
|
||||
json,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
time,
|
||||
timestamp,
|
||||
@@ -428,6 +431,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<typeof staffCrypto>;
|
||||
|
||||
@@ -442,3 +474,6 @@ export type SelectClientPinResetToken = InferSelectModel<typeof clientPinResetTo
|
||||
|
||||
/** BookingAccessToken record type for database queries */
|
||||
export type SelectBookingAccessToken = InferSelectModel<typeof bookingAccessToken>;
|
||||
|
||||
/** ScheduleCache record type for database queries */
|
||||
export type SelectScheduleCache = InferSelectModel<typeof scheduleCache>;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -35,6 +35,18 @@ vi.mock("../notification-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",
|
||||
@@ -86,6 +98,7 @@ const mockClientTunnelData = {
|
||||
describe("AppointmentService", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCleanAndRegenerateCache.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("forTenant", () => {
|
||||
@@ -289,6 +302,13 @@ describe("AppointmentService", () => {
|
||||
expect.objectContaining({ agentId: "agent-456" }),
|
||||
"Test Channel",
|
||||
);
|
||||
expect(mockCleanAndRegenerateCache).toHaveBeenCalledTimes(1);
|
||||
expect(mockCleanAndRegenerateCache).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelId: "channel-123",
|
||||
awaitRebuild: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,6 +349,7 @@ describe("AppointmentService", () => {
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "appointment-123",
|
||||
channelId: "channel-123",
|
||||
appointmentDate: new Date("2024-01-15T10:00:00Z"),
|
||||
status: "NEW",
|
||||
},
|
||||
@@ -363,6 +384,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 () => {
|
||||
@@ -512,6 +540,7 @@ describe("AppointmentService", () => {
|
||||
returning: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "apt-123",
|
||||
channelId: "channel-123",
|
||||
appointmentDate: new Date("2024-01-01T10:00:00Z"),
|
||||
status: "CONFIRMED",
|
||||
},
|
||||
@@ -545,6 +574,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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -611,6 +647,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 () => {
|
||||
@@ -688,6 +731,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 () => {
|
||||
@@ -764,6 +814,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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -948,6 +1005,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 () => {
|
||||
|
||||
@@ -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<typeof vi.spyOn>;
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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<typeof vi.spyOn>;
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -27,6 +27,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
|
||||
@@ -36,8 +44,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++;
|
||||
@@ -69,8 +80,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),
|
||||
@@ -79,7 +100,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),
|
||||
@@ -87,16 +109,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";
|
||||
|
||||
@@ -146,8 +200,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,
|
||||
};
|
||||
@@ -170,7 +224,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1, // Monday (2^(1-1) = 1)
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "17:00",
|
||||
duration: 60,
|
||||
@@ -211,7 +265,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"];
|
||||
@@ -221,14 +275,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) => {
|
||||
@@ -243,8 +321,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,
|
||||
};
|
||||
@@ -266,8 +344,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,
|
||||
};
|
||||
@@ -281,8 +359,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,
|
||||
};
|
||||
@@ -299,9 +377,9 @@ describe("ScheduleService", () => {
|
||||
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");
|
||||
expect(result.schedule[0].date).toBe(`${jan1stNextYearString}`);
|
||||
expect(result.schedule[1].date).toBe(`${jan2ndNextYearString}`);
|
||||
expect(result.schedule[2].date).toBe(`${jan3rdNextYearString}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -314,8 +392,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,
|
||||
};
|
||||
@@ -337,7 +415,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1, // Only Monday (2^(1-1) = 1)
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "10:00",
|
||||
duration: 60,
|
||||
@@ -382,13 +460,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,
|
||||
};
|
||||
@@ -410,7 +488,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1, // Monday (2^(1-1) = 1)
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "11:00",
|
||||
duration: 60,
|
||||
@@ -425,7 +503,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",
|
||||
},
|
||||
@@ -457,13 +535,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,
|
||||
};
|
||||
@@ -485,7 +563,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1, // Monday (2^(1-1) = 1)
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "17:00",
|
||||
duration: 60,
|
||||
@@ -501,7 +579,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",
|
||||
},
|
||||
@@ -510,7 +588,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",
|
||||
},
|
||||
@@ -550,12 +628,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) => {
|
||||
@@ -570,8 +666,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,
|
||||
};
|
||||
@@ -593,7 +689,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1, // Monday (2^(1-1) = 1)
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "10:00",
|
||||
duration: 60,
|
||||
@@ -606,8 +702,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,
|
||||
@@ -644,8 +740,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,
|
||||
};
|
||||
@@ -667,7 +763,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1,
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "10:00",
|
||||
duration: 60,
|
||||
@@ -682,7 +778,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",
|
||||
},
|
||||
@@ -721,15 +817,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,
|
||||
};
|
||||
@@ -761,7 +857,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1,
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "11:00",
|
||||
duration: 60,
|
||||
@@ -771,7 +867,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template2",
|
||||
weekdays: 1,
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "11:00",
|
||||
duration: 60,
|
||||
@@ -786,7 +882,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",
|
||||
},
|
||||
@@ -827,16 +923,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,
|
||||
};
|
||||
@@ -858,7 +954,7 @@ describe("ScheduleService", () => {
|
||||
{
|
||||
slotTemplate: {
|
||||
id: "template1",
|
||||
weekdays: 1, // Monday (2^(1-1) = 1)
|
||||
weekdays: bitmaskForJan1stNextYear,
|
||||
from: "09:00",
|
||||
to: "12:00",
|
||||
duration: 60,
|
||||
@@ -872,11 +968,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",
|
||||
},
|
||||
@@ -903,6 +999,8 @@ describe("ScheduleService", () => {
|
||||
channelAgents: mockChannelAgents,
|
||||
});
|
||||
|
||||
console.log("validRequest", validRequest);
|
||||
console.log("mockAbsences", mockAbsences);
|
||||
const result = await service.getSchedule(validRequest);
|
||||
|
||||
const channelSchedule = result.schedule[0].channels["channel1"];
|
||||
@@ -918,8 +1016,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: [
|
||||
@@ -931,10 +1029,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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -35,6 +35,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;
|
||||
@@ -429,6 +430,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<SelectAppointment> {
|
||||
@@ -451,6 +462,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;
|
||||
}
|
||||
@@ -473,6 +494,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;
|
||||
}
|
||||
@@ -554,6 +585,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,
|
||||
@@ -688,6 +729,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;
|
||||
}
|
||||
|
||||
@@ -869,6 +932,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;
|
||||
}
|
||||
|
||||
@@ -1066,6 +1138,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;
|
||||
}
|
||||
|
||||
@@ -1360,6 +1442,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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -14,6 +15,8 @@ import { z } from "zod";
|
||||
import { ValidationError } from "../utils/errors";
|
||||
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
|
||||
@@ -63,6 +66,13 @@ export interface ScheduleResult {
|
||||
|
||||
export class ScheduleService {
|
||||
#db: Awaited<ReturnType<typeof getTenantDb>> | null = null;
|
||||
#buffer: Array<{
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
channelId: string;
|
||||
timeZones: string[];
|
||||
}> = [];
|
||||
#isProcessingBuffer = false;
|
||||
|
||||
private constructor(public readonly tenantId: string) {}
|
||||
|
||||
@@ -185,16 +195,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()`,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -227,11 +240,6 @@ export class ScheduleService {
|
||||
timeZone: request.timeZone,
|
||||
});
|
||||
|
||||
log.debug("Schedule generated successfully", {
|
||||
tenantId: this.tenantId,
|
||||
daysGenerated: schedule.length,
|
||||
});
|
||||
|
||||
return {
|
||||
period: {
|
||||
startDate: request.startDate,
|
||||
@@ -320,7 +328,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,
|
||||
@@ -348,7 +357,8 @@ export class ScheduleService {
|
||||
/**
|
||||
* Generate available time slots for a specific day and channel
|
||||
*/
|
||||
private generateAvailableSlots({
|
||||
private async generateAvailableSlots({
|
||||
channelId,
|
||||
date,
|
||||
slotTemplates,
|
||||
appointments,
|
||||
@@ -356,15 +366,33 @@ export class ScheduleService {
|
||||
absences,
|
||||
timeZone,
|
||||
}: {
|
||||
channelId: string;
|
||||
date: Date;
|
||||
slotTemplates: SelectSlotTemplate[];
|
||||
appointments: SelectAppointment[];
|
||||
agents: CalendarAgent[];
|
||||
absences: SelectAgentAbsence[];
|
||||
timeZone: string;
|
||||
}): TimeSlot[] {
|
||||
}): Promise<TimeSlot[]> {
|
||||
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);
|
||||
@@ -405,6 +433,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)
|
||||
@@ -412,13 +446,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
|
||||
@@ -435,6 +471,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;
|
||||
}
|
||||
|
||||
@@ -488,6 +538,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;
|
||||
@@ -544,4 +599,226 @@ export class ScheduleService {
|
||||
}
|
||||
return this.#db;
|
||||
}
|
||||
|
||||
private async cleanCache({
|
||||
startDate,
|
||||
endDate,
|
||||
channelId,
|
||||
}: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
channelId: string;
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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<string[]> {
|
||||
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<void>
|
||||
*/
|
||||
async cleanAndRegenerateCache({
|
||||
startDate,
|
||||
endDate,
|
||||
channelId,
|
||||
awaitRebuild,
|
||||
}: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
channelId: string;
|
||||
awaitRebuild?: boolean;
|
||||
}): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
]}
|
||||
/>
|
||||
{#if item.appointment.status === "reserved"}
|
||||
<div class="mt-5 flex flex-col gap-2">
|
||||
<div class="mt-5 flex w-full flex-col gap-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
disabled={isConfirming || isDenying}
|
||||
|
||||
Reference in New Issue
Block a user