Merge remote-tracking branch 'origin/main' into feat/manage-passkeys

This commit is contained in:
Karl Ludwig Weise
2026-08-31 09:50:55 +02:00
23 changed files with 1368 additions and 144 deletions
+4 -1
View File
@@ -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<HTMLFormElement, any>;
+35
View File
@@ -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<typeof staffCrypto>;
@@ -445,3 +477,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 () => {
@@ -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 () => {
@@ -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 () => {
@@ -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",
]),
);
});
});
});
+86 -2
View File
@@ -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", {
@@ -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<SelectAppointment> {
@@ -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,
});
}
/**
@@ -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;
});
+286 -9
View File
@@ -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<ReturnType<typeof getTenantDb>> | 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<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);
@@ -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<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}
@@ -127,6 +127,7 @@
<div class="relative flex w-full flex-col">
{#if isWeekView && items}
<!-- Date -->
{@const isSelected = selectedDate.toString() === day.toString()}
{@const isToday = toCalendarDate(day).toString() === today(getLocalTimeZone()).toString()}
<Text
@@ -142,10 +143,25 @@
weekday: "short",
})}
</Text>
<!-- Vertical line separating days in week view -->
<Separator
orientation="vertical"
class="bg-muted-foreground absolute top-0 bottom-0 left-0 z-10 transition-all duration-200"
style={{ height: `${(latestEndHour * 30 + 30) * scale + 20}px`, marginTop: "-10px" }}
style={{
height: `${
// shown hours in minutes
((latestEndHour - earliestStartHour) * 60 +
// plus 30 minutes before the day starts
30) *
scale +
// plus 3 pixels for the bottom overflow of the line
4 +
// plus 10 pixels for the margin
10
}px`,
marginTop: "-10px",
}}
/>
{/if}
<div class="absolute top-0 right-0 bottom-0 left-0 z-10">
@@ -195,9 +195,9 @@
class="mt-2 mb-1"
>
{#snippet label()}
<div class="flex items-center gap-1">
<div class="flex items-start gap-1">
<div
class="bg-primary h-4 w-2 rounded-sm"
class="bg-primary mt-0.5 h-4 w-2 rounded-sm"
style:background-color={channel.color}
></div>
{name}
@@ -244,6 +244,7 @@
changeView("week");
}
}}
class="min-h-82"
/>
</HorizontalPagePadding>
</Sidebar.Content>
@@ -12,6 +12,7 @@
today,
} from "@internationalized/date";
import Loader from "@lucide/svelte/icons/loader-2";
import { isAfterOperatingHours, isBeforeOperatingHours } from "./utils";
let {
day = $bindable(),
@@ -32,6 +33,8 @@
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
const isBeforeHours = $derived(isBeforeOperatingHours(curTimeIndicator, earliestStartHour));
const isAfterHours = $derived(isAfterOperatingHours(curTimeIndicator, latestEndHour));
</script>
<div class="relative flex w-16 shrink-0 flex-col">
@@ -50,14 +53,10 @@
</div>
{/if}
<!-- Current Time Indicator -->
<!-- Current Time Indicator Time Bubble -->
{#if !isLoading && curTimeIndicator}
{@const isToday = toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString()}
{@const isNotAfterHours =
latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
{@const isNotBeforeHours =
earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
{#if isToday && isNotAfterHours && isNotBeforeHours}
{#if isToday && !isAfterHours && !isBeforeHours}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
@@ -10,6 +10,7 @@
toCalendarDateTime,
today,
} from "@internationalized/date";
import { isAfterOperatingHours, isBeforeOperatingHours } from "./utils";
let {
day = $bindable(),
@@ -32,10 +33,13 @@
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
const isBeforeHours = $derived(isBeforeOperatingHours(curTimeIndicator, earliestStartHour));
const isAfterHours = $derived(isAfterOperatingHours(curTimeIndicator, latestEndHour));
</script>
{#if !isLoading}
<div class="absolute flex w-[calc(100%-2rem)] flex-col">
<!-- First half hour line before day starts -->
<div
class="relative flex w-full items-start justify-between transition-all duration-200"
style:height={`${focusAdjustment}px`}
@@ -43,14 +47,10 @@
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Current Time Indicator -->
<!-- Current Time Indicator Line -->
{#if !isLoading && curTimeIndicator}
{@const isToday = toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString()}
{@const isNotAfterHours =
latestEndHour * hourSize + hourSize / 2 > curTimeIndicator.hour * hourSize}
{@const isNotBeforeHours =
earliestStartHour * hourSize - hourSize * 2 < curTimeIndicator.hour * hourSize}
{#if isToday && isNotAfterHours && isNotBeforeHours}
{#if isToday && !isAfterHours && !isBeforeHours}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
@@ -19,12 +19,14 @@
import { type ComponentProps } from "svelte";
import type { OnChangeFn } from "vaul-svelte";
import { calendarMonthQuery } from "./queries";
import { cn } from "$lib/utils";
let {
selectedDate = $bindable(),
shownAppointments,
shownChannels,
shownAgents,
class: className,
onSelectDay,
ref = $bindable(null),
}: ComponentProps<typeof Sidebar.Root> & {
@@ -32,6 +34,7 @@
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
class?: string;
onSelectDay?: OnChangeFn<DateValue | undefined>;
} = $props();
@@ -176,7 +179,7 @@
type="single"
locale={getLocale()}
calendarLabel={m["calendar.selectDate"]()}
class="bg-transparent p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow"
class={cn("bg-transparent p-0 [&_td]:grow [&_td_*]:mx-auto [&_th]:grow", className)}
preventDeselect={true}
bind:value={selectedDate}
bind:placeholder
@@ -9,10 +9,15 @@ import { staffCrypto } from "$lib/stores/staff-crypto";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import type { TChannel } from "$lib/types/channel";
import { serverAppointmentStatusToUiFilterStatus } from "$lib/utils/appointments";
import { getWeekStartsOn, localToUTCWithoutDST } from "$lib/utils/datetime";
import {
getWeekStartsOn,
localToUTCWithoutDST,
timeUTCToLocalWithoutOffset,
} from "$lib/utils/datetime";
import {
getLocalTimeZone,
parseAbsoluteToLocal,
parseAbsolute,
toCalendarDate,
type CalendarDate,
} from "@internationalized/date";
@@ -174,6 +179,81 @@ export function positionItems(items: TCalendarItem[] | undefined) {
return processedItems;
}
export const getOperatingHours = (channels: TChannel[], calendar: TCalendar | undefined) => {
const earliestSlotStartHour = channels
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
const lastSlotEndHour = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr, minuteStr] = timeUTCToLocalWithoutOffset(time).split(":");
let hour = parseInt(hourStr, 10);
if (minuteStr !== "00") {
hour += 1;
}
return hour;
});
const earliestAppointmentStartHour = (calendar?.calendar || [])
.map((day) =>
Array.from(
Object.values(day.channels)
.map((it) =>
it.appointments.map(
(a) =>
// @ts-expect-error appointmentDate is not typed correctly
parseAbsolute(a.appointmentDate, getLocalTimeZone()).hour,
),
)
.flat(),
),
)
.flat();
const lastAppointmentEndHour = (calendar?.calendar || [])
.map((day) =>
Array.from(
Object.values(day.channels)
.map((it) =>
it.appointments.map(
(a) =>
// @ts-expect-error appointmentDate is not typed correctly
parseAbsolute(a.appointmentDate, getLocalTimeZone()).add({
minutes: a.duration,
}).hour,
),
)
.flat(),
),
)
.flat();
return {
from: Math.min(...earliestSlotStartHour, ...earliestAppointmentStartHour),
to: Math.max(...lastSlotEndHour, ...lastAppointmentEndHour),
};
};
export const isBeforeOperatingHours = (
curTimeIndicator: { hour: number; minute: number } | undefined,
earliestStartHour: number,
) => {
if (!curTimeIndicator) return true;
const result = earliestStartHour * 60 - 0.5 > curTimeIndicator.hour * 60;
return result;
};
export const isAfterOperatingHours = (
curTimeIndicator: { hour: number; minute: number } | undefined,
latestEndHour: number,
) => {
if (!curTimeIndicator) return true;
const result = latestEndHour * 60 + 30 < curTimeIndicator.hour * 60 + curTimeIndicator.minute;
return result;
};
export const moveAppointment = async (opts: {
tenant: string;
appointment: string;
@@ -11,7 +11,6 @@
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { getLocalTimeZone, today, type CalendarDate } from "@internationalized/date";
import { SlidersHorizontal } from "@lucide/svelte";
@@ -25,10 +24,10 @@
import CalendarLegend from "./(components)/CalendarLegend.svelte";
import CalendarLines from "./(components)/CalendarLines.svelte";
import CalendarWeek from "./(components)/CalendarWeek.svelte";
import { calendarMonthQuery } from "./(components)/queries";
import { convertDate, openAppointmentById } from "./(components)/utils";
import type { CalendarView } from "./types";
import MoveAppointment from "./(components)/MoveAppointment.svelte";
import { calendarMonthQuery } from "./(components)/queries";
import { convertDate, getOperatingHours, openAppointmentById } from "./(components)/utils";
import type { CalendarView } from "./types";
const queryClient = useQueryClient();
const tenantId = $derived($auth.user?.tenantId);
@@ -49,23 +48,7 @@
mode: "VIEW",
});
let view: CalendarView = $state(page.data.calendarView);
let hours = $derived.by(() => {
const from = channels
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
const to = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
return { from: Math.min(...from), to: Math.max(...to) };
});
let hours = $derived.by(() => getOperatingHours(channels, calendar));
let scale = $state(page.data.calendarZoom);
$effect(() => {
+28 -16
View File
@@ -9,14 +9,18 @@
import { ROUTES } from "$lib/const/routes";
const formId = "create-account-form";
let isHidingSubmit = $state(true);
let isSubmitting = $state(false);
const onEvent: EventReporter = (params) => {
if (params.isSubmitting) {
isSubmitting = true;
}
if (params.isSubmitting === false) {
} else if (params.isSubmitting === false) {
isSubmitting = false;
} else if (params.isHidingSubmit === true) {
isHidingSubmit = true;
} else if (params.isHidingSubmit === false) {
isHidingSubmit = false;
}
};
</script>
@@ -34,23 +38,31 @@
<CenteredCard.Title>
{m["login.title"]()}
</CenteredCard.Title>
<CenteredCard.Description>
{m["login.description_passphrase"]()}
</CenteredCard.Description>
{#if isHidingSubmit}
<CenteredCard.Description>
{m["login.description_passkey"]()}
</CenteredCard.Description>
{:else}
<CenteredCard.Description>
{m["login.description_passphrase"]()}
</CenteredCard.Description>
{/if}
</CenteredCard.Header>
<CenteredCard.Main>
<LoginForm {formId} {onEvent} />
</CenteredCard.Main>
<CenteredCard.Action>
<Form.Button
size="lg"
class="w-full"
form={formId}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["login.action"]()}
</Form.Button>
</CenteredCard.Action>
{#if !isHidingSubmit}
<CenteredCard.Action>
<Form.Button
size="lg"
class="w-full"
form={formId}
isLoading={isSubmitting}
disabled={isSubmitting}
>
{m["login.action"]()}
</Form.Button>
</CenteredCard.Action>
{/if}
</CenteredCard.Root>
</PageWithClaim>
+3 -1
View File
@@ -68,6 +68,7 @@
type: "passphrase",
passphrase: "",
};
onEvent({ isHidingSubmit: false });
} else {
$formData = {
...$formData,
@@ -78,6 +79,7 @@
signatureBase64: "",
};
setProperPasskeyState();
onEvent({ isHidingSubmit: true });
}
};
@@ -246,7 +248,7 @@
{m["login.or"]()}
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
{m["login.usePassphrase"]()}
</Button>.
</Button>
</Text>
</div>
{/if}