From 8a167b3d845f39e2e38e50d154d01f3ba3ef32c5 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Mon, 20 Jul 2026 19:57:37 +0200 Subject: [PATCH] Complete regular absences management --- project.inlang/messages/de.json | 10 +- project.inlang/messages/en.json | 10 +- .../templates/list/list-item.svelte | 13 +- .../responsive-dialog.svelte | 6 +- src/lib/server/db/tenant-schema.ts | 10 + .../services/__tests__/agent-service.test.ts | 25 +- src/lib/server/services/agent-service.ts | 196 ++-- src/lib/utils/datetime.ts | 2 +- .../add-absence-form/add-absence-form.svelte | 23 +- .../(components)/add-absence-form/schema.ts | 34 + .../delete-absence-form.svelte | 5 +- .../edit-absence-form.svelte | 231 +++- .../(components)/edit-absence-form/schema.ts | 38 +- .../dashboard/absences/+page.server.ts | 12 +- .../(pages)/dashboard/absences/+page.svelte | 36 +- .../(pages)/dashboard/absences/utils.ts | 60 + .../[id]/agents/[agentId]/absences/+server.ts | 80 +- .../[agentId]/absences/[absenceId]/+server.ts | 51 +- .../tenants/[id]/agents/absences/+server.ts | 29 +- .../0017_optimal_professor_monster.sql | 8 + tenant-migrations/meta/0017_snapshot.json | 1020 +++++++++++++++++ tenant-migrations/meta/_journal.json | 7 + 22 files changed, 1705 insertions(+), 201 deletions(-) create mode 100644 src/routes/(pages)/dashboard/absences/utils.ts create mode 100644 tenant-migrations/0017_optimal_professor_monster.sql create mode 100644 tenant-migrations/meta/0017_snapshot.json diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index e4ab34c..3f6d6b9 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -60,6 +60,8 @@ "noEndDate": "Ende ist erforderlich", "endDateBeforeStartDate": "Das Ende muss nach dem Beginn sein", "endDateTooEarly": "Das Ende muss in der Zukunft liegen", + "noWeekdaysSelected": "Wähle mindestens einen Wochentag", + "fromAfterTo": "»Von« muss vor »Bis« liegen", "street": "Straße hinzufügen", "houseNo": "Pflichtfeld", "zip": "Postleitzahl hinzufügen", @@ -613,6 +615,7 @@ "action": "Abwesenheit hinzufügen", "success": "Abwesenheit hinzugefügt", "errors": { + "conflict": "Akteur ist bereits in diesem Zeitraum (teilweise) abwesend", "unknown": "Konnte Abwesenheit nicht hinzufügen" }, "unavailable": { @@ -630,12 +633,15 @@ "title": "Abwesenheit bearbeiten", "description": "Aktualisiere die Verfügbarkeit dieses Akteurs.", "success": "Abwesenheit aktualisiert", - "error": "Konnte Abwesenheit nicht aktualisieren.", + "errors": { + "conflict": "Akteur ist bereits in diesem Zeitraum (teilweise) abwesend", + "unknown": "Konnte Abwesenheit nicht aktualisieren" + }, "action": "Übernehmen" }, "delete": { "title": "Abwesenheit löschen", - "description": "{name} wird vom {startDate} bis {endDate} wieder für Termine verfügbar sein.", + "description": "{name} wird in diesem Zeitraum wieder für Termine verfügbar sein: {timespan}.", "description_fallback": "Diese/r Akteur:in", "action": "Abwesenheit löschen", "success": "Abwesenheit gelöscht", diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 1f6bc1d..827970d 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -75,6 +75,8 @@ "noEndDate": "End date is required", "endDateBeforeStartDate": "End date must be after start date", "endDateTooEarly": "End date must be in the future", + "noWeekdaysSelected": "Select at least one weekday", + "fromAfterTo": "»From« time must be before »To« time", "street": "Add a street", "houseNo": "Required", "zip": "Add a zip code", @@ -621,6 +623,7 @@ "action": "Add Absence", "success": "Absence added", "errors": { + "conflict": "Agent is already (partially) absent in this time period", "unknown": "Could not add absence" }, "unavailable": { @@ -638,12 +641,15 @@ "title": "Edit Absence", "description": "Update the agents availability.", "success": "Absence updated", - "error": "Could not update absence.", + "errors": { + "conflict": "Agent is already (partially) absent in this time period", + "unknown": "Could not update absence" + }, "action": "Edit Absence" }, "delete": { "title": "Delete Absence", - "description": "{name} will be available for appointments from {startDate} to {endDate}.", + "description": "{name} will be available for appointments again in this timespan: {timespan}.", "description_fallback": "This agent", "action": "Delete Absence", "success": "Absence deleted", diff --git a/src/lib/components/templates/list/list-item.svelte b/src/lib/components/templates/list/list-item.svelte index 5be5ccb..b131670 100644 --- a/src/lib/components/templates/list/list-item.svelte +++ b/src/lib/components/templates/list/list-item.svelte @@ -28,6 +28,7 @@ let { image, title, + icons, description, descriptionOnClick, actions, @@ -35,6 +36,7 @@ }: HTMLAttributes & { image?: string | Component; title: string; + icons?: Component[]; description?: string; descriptionOnClick?: () => void; actions?: ListItemAction[]; @@ -65,7 +67,16 @@
- {title} + + {title} + {#if icons && icons.length > 0} +
+ {#each icons as Icon, index (`icon-${index}`)} + + {/each} +
+ {/if} +
{#if badges && badges.length > 0}
{#each badges as badge, index (`${badge.label}-${index}`)} diff --git a/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte b/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte index c244039..ab5783b 100644 --- a/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte +++ b/src/lib/components/ui/responsive-dialog/responsive-dialog.svelte @@ -100,7 +100,10 @@ {/if} {/if} - + e.preventDefault()} + > {title} {#if description} @@ -129,6 +132,7 @@ {/if} e.preventDefault()} > {title} diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 45336cf..c1ca53c 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -26,6 +26,8 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [ "NO_SHOW", ]); +export const absenceTypeEnum = pgEnum("agent_absence_type", ["ONE_TIME", "REGULAR"]); + export const notificationTypes = [ "APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", @@ -200,6 +202,8 @@ export const appointment = pgTable("appointment", { export const agentAbsence = pgTable("agent_absence", { /** Primary key - unique identifier */ id: uuid("id").primaryKey().defaultRandom(), + /** Enum ONE_TIME or REGULAR, defaults to ONE_TIME */ + type: absenceTypeEnum("type").notNull().default("ONE_TIME"), /** Foreign key to agent who is absent */ agentId: uuid("agent_id") .notNull() @@ -212,6 +216,12 @@ export const agentAbsence = pgTable("agent_absence", { absenceType: text("absence_type").notNull().default(""), /** Optional description/reason for absence */ description: text("description"), + /** Set, when of type REGULAR: Bitmask for weekdays (1=Monday, 2=Tuesday, 4=Wednesday, etc.), is NULL by default */ + weekdays: integer("weekdays"), + /** Set, when of type REGULAR: Start time for the regular absence */ + from: time("from"), + /** Set, when of type REGULAR: End time for the regular absence */ + to: time("to"), }); /** diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 9aade62..7b007b8 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -30,7 +30,11 @@ vi.mock("$lib/logger", () => ({ })); // Import after mocking -import { AgentService, type AbsenceCreationRequest } from "../agent-service"; +import { + AgentService, + type AbsenceCreationRequest, + type AbsenceUpdateRequest, +} from "../agent-service"; import { getTenantDb } from "../../db"; import { ValidationError, NotFoundError, ConflictError } from "../../utils/errors"; @@ -676,6 +680,7 @@ describe("AgentService", () => { describe("createAbsence", () => { it("should create absence successfully", async () => { const request: AbsenceCreationRequest = { + type: "ONE_TIME", agentId: "123e4567-e89b-12d3-a456-426614174001", startDate: "2024-01-01T08:00:00.000Z", endDate: "2024-01-01T17:00:00.000Z", @@ -730,11 +735,15 @@ describe("AgentService", () => { expect(result).toEqual(mockAbsence); expect(insertChain.values).toHaveBeenCalledWith({ + type: "ONE_TIME", agentId: request.agentId, startDate: new Date(request.startDate), endDate: new Date(request.endDate), absenceType: request.absenceType, description: request.description, + weekdays: null, + from: null, + to: null, }); }); @@ -754,6 +763,7 @@ describe("AgentService", () => { it("should validate date range", async () => { const request: AbsenceCreationRequest = { + type: "ONE_TIME", agentId: "agent-123", startDate: "2024-01-01T17:00:00.000Z", // End before start endDate: "2024-01-01T08:00:00.000Z", @@ -765,6 +775,7 @@ describe("AgentService", () => { it("should throw NotFoundError if agent does not exist", async () => { const request: AbsenceCreationRequest = { + type: "ONE_TIME", agentId: "123e4567-e89b-12d3-a456-426614174099", // Valid UUID format startDate: "2024-01-01T08:00:00.000Z", endDate: "2024-01-01T17:00:00.000Z", @@ -792,6 +803,7 @@ describe("AgentService", () => { it("should throw ConflictError if absence period overlaps", async () => { const request: AbsenceCreationRequest = { + type: "ONE_TIME", agentId: "123e4567-e89b-12d3-a456-426614174001", startDate: "2024-01-01T08:00:00.000Z", endDate: "2024-01-01T17:00:00.000Z", @@ -921,7 +933,8 @@ describe("AgentService", () => { describe("updateAbsence", () => { it("should update absence successfully", async () => { - const updateData = { + const updateData: AbsenceUpdateRequest = { + type: "ONE_TIME", absenceType: "Krankheit", description: "Sick leave", }; @@ -959,7 +972,8 @@ describe("AgentService", () => { }); it("should validate update request", async () => { - const invalidUpdate = { + const invalidUpdate: AbsenceUpdateRequest = { + type: "ONE_TIME", absenceType: "", // Invalid empty type startDate: "invalid-date", }; @@ -970,7 +984,7 @@ describe("AgentService", () => { }); it("should throw NotFoundError if absence does not exist", async () => { - const updateData = { absenceType: "Krankheit" }; + const updateData: AbsenceUpdateRequest = { type: "ONE_TIME", absenceType: "Krankheit" }; const selectChain = { from: vi.fn(() => ({ @@ -994,7 +1008,8 @@ describe("AgentService", () => { }); it("should validate new date range", async () => { - const updateData = { + const updateData: AbsenceUpdateRequest = { + type: "ONE_TIME", startDate: "2024-01-01T17:00:00.000Z", // End before start endDate: "2024-01-01T08:00:00.000Z", }; diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index a9cb81a..d7c54ab 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -2,7 +2,7 @@ import { getTenantDb } from "../db"; import * as tenantSchema from "../db/tenant-schema"; import { type SelectAgent, type SelectAgentAbsence } from "../db/tenant-schema"; -import { eq, and, between, or, lte, gte, ne } from "drizzle-orm"; +import { eq, and, between, or, lte, gte, ne, sql } from "drizzle-orm"; import logger from "$lib/logger"; import { z } from "zod"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; @@ -23,20 +23,47 @@ const agentUpdateSchema = z.object({ languages: z.array(z.string()).optional(), }); -const absenceCreationSchema = z.object({ - agentId: z.string().uuid({ message: "Invalid UUID format" }), - startDate: z.string().datetime({ message: "Invalid datetime format" }), - endDate: z.string().datetime({ message: "Invalid datetime format" }), - absenceType: z.string().min(1).max(100), - description: z.string().optional(), -}); +const absenceCreationSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("ONE_TIME"), + agentId: z.uuid({ message: "Invalid UUID format" }), + startDate: z.string().datetime({ message: "Invalid datetime format" }), + endDate: z.string().datetime({ message: "Invalid datetime format" }), + absenceType: z.string().min(1).max(100), + description: z.string().optional(), + }), + z.object({ + type: z.literal("REGULAR"), + agentId: z.uuid({ message: "Invalid UUID format" }), + startDate: z.string().datetime({ message: "Invalid datetime format" }), + endDate: z.string().datetime({ message: "Invalid datetime format" }), + absenceType: z.string().min(1).max(100), + description: z.string().optional(), + weekdays: z.number().int().min(0).max(127).nullable(), + from: z.string().time({ message: "Invalid time format" }), + to: z.string().time({ message: "Invalid time format" }), + }), +]); -const absenceUpdateSchema = z.object({ - startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(), - endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(), - absenceType: z.string().min(1).max(100).optional(), - description: z.string().optional(), -}); +const absenceUpdateSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("ONE_TIME"), + startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(), + endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(), + absenceType: z.string().min(1).max(100).optional(), + description: z.string().optional(), + }), + z.object({ + type: z.literal("REGULAR"), + startDate: z.string().datetime({ message: "Invalid datetime format" }).optional(), + endDate: z.string().datetime({ message: "Invalid datetime format" }).optional(), + absenceType: z.string().min(1).max(100).optional(), + description: z.string().optional(), + weekdays: z.number().int().min(0).max(127).nullable(), + from: z.string().time({ message: "Invalid time format" }), + to: z.string().time({ message: "Invalid time format" }), + }), +]); const absenceQuerySchema = z.object({ agentId: z.string().uuid({ message: "Invalid UUID format" }).optional(), @@ -433,6 +460,11 @@ export class AgentService { const validation = absenceCreationSchema.safeParse(request); if (!validation.success) { + log.error("Error creating new absence", { + tenantId: this.tenantId, + request, + validation, + }); throw new ValidationError("Invalid absence creation request"); } @@ -446,7 +478,7 @@ export class AgentService { log.debug("Creating new absence", { tenantId: this.tenantId, agentId: request.agentId, - absenceType: request.absenceType, + type: request.type, startDate: request.startDate, endDate: request.endDate, }); @@ -468,37 +500,40 @@ export class AgentService { } // Check for overlapping absences - const overlappingAbsences = await db - .select() - .from(tenantSchema.agentAbsence) - .where( - and( - eq(tenantSchema.agentAbsence.agentId, request.agentId), - or( - // New absence starts during existing absence - and( - between( - tenantSchema.agentAbsence.startDate, - new Date(request.startDate), - new Date(request.endDate), + const overlappingAbsences = + request.type === "REGULAR" + ? [] + : await db + .select() + .from(tenantSchema.agentAbsence) + .where( + and( + eq(tenantSchema.agentAbsence.agentId, request.agentId), + or( + // New absence starts during existing absence + and( + between( + tenantSchema.agentAbsence.startDate, + new Date(request.startDate), + new Date(request.endDate), + ), + ), + // New absence ends during existing absence + and( + between( + tenantSchema.agentAbsence.endDate, + new Date(request.startDate), + new Date(request.endDate), + ), + ), + // New absence entirely contains existing absence + and( + gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)), + lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)), + ), + ), ), - ), - // New absence ends during existing absence - and( - between( - tenantSchema.agentAbsence.endDate, - new Date(request.startDate), - new Date(request.endDate), - ), - ), - // New absence entirely contains existing absence - and( - gte(tenantSchema.agentAbsence.startDate, new Date(request.startDate)), - lte(tenantSchema.agentAbsence.endDate, new Date(request.endDate)), - ), - ), - ), - ); + ); if (overlappingAbsences.length > 0) { throw new ConflictError("Absence period overlaps with existing absence"); @@ -507,11 +542,15 @@ export class AgentService { const result = await db .insert(tenantSchema.agentAbsence) .values({ + type: request.type, agentId: request.agentId, startDate: new Date(request.startDate), endDate: new Date(request.endDate), absenceType: request.absenceType, description: request.description, + weekdays: request.type === "REGULAR" ? request.weekdays : null, + from: request.type === "REGULAR" ? request.from : null, + to: request.type === "REGULAR" ? request.to : null, }) .returning(); @@ -621,7 +660,10 @@ export class AgentService { .select() .from(tenantSchema.agentAbsence) .where(and(...conditions)) - .orderBy(tenantSchema.agentAbsence.startDate); + .orderBy( + sql`CASE WHEN ${tenantSchema.agentAbsence.type} = 'ONE_TIME' THEN 0 ELSE 1 END`, + tenantSchema.agentAbsence.startDate, + ); log.debug("Retrieved absences", { tenantId: this.tenantId, @@ -666,7 +708,10 @@ export class AgentService { return result; } - const result = await query.orderBy(tenantSchema.agentAbsence.startDate); + const result = await query.orderBy( + sql`CASE WHEN ${tenantSchema.agentAbsence.type} = 'ONE_TIME' THEN 0 ELSE 1 END`, + tenantSchema.agentAbsence.startDate, + ); log.debug("Retrieved agent absences", { tenantId: this.tenantId, @@ -787,33 +832,36 @@ export class AgentService { } // Check for overlapping absences (excluding current absence) - const overlappingAbsences = await db - .select() - .from(tenantSchema.agentAbsence) - .where( - and( - eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId), - // Exclude current absence from check - ne(tenantSchema.agentAbsence.id, absenceId), - or( - between( - tenantSchema.agentAbsence.startDate, - new Date(newStartDate), - new Date(newEndDate), - ), - between( - tenantSchema.agentAbsence.endDate, - new Date(newStartDate), - new Date(newEndDate), - ), - // New period entirely contains existing absence - and( - gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)), - lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)), - ), - ), - ), - ); + const overlappingAbsences = + updateData.type === "REGULAR" + ? [] + : await db + .select() + .from(tenantSchema.agentAbsence) + .where( + and( + eq(tenantSchema.agentAbsence.agentId, currentAbsence[0].agentId), + // Exclude current absence from check + ne(tenantSchema.agentAbsence.id, absenceId), + or( + between( + tenantSchema.agentAbsence.startDate, + new Date(newStartDate), + new Date(newEndDate), + ), + between( + tenantSchema.agentAbsence.endDate, + new Date(newStartDate), + new Date(newEndDate), + ), + // New period entirely contains existing absence + and( + gte(tenantSchema.agentAbsence.startDate, new Date(newStartDate)), + lte(tenantSchema.agentAbsence.endDate, new Date(newEndDate)), + ), + ), + ), + ); if (overlappingAbsences.length > 0) { throw new ConflictError("Updated absence period overlaps with existing absence"); diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts index 127580c..835c137 100644 --- a/src/lib/utils/datetime.ts +++ b/src/lib/utils/datetime.ts @@ -250,7 +250,7 @@ export const getWeekDays = (day: CalendarDate, skipWeekend = false): CalendarDat return days; }; -export const toWeekdaysLabel = (bitmap: number | undefined) => { +export const toWeekdaysLabel = (bitmap: number | undefined | null) => { if (!bitmap) return m["components.slotTemplate.empty_weekdays"](); switch (bitmap) { diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte index 5bcd3ad..3b523de 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/add-absence-form.svelte @@ -38,9 +38,9 @@ absenceType: "VACATION", startDate: getDefaultStartTime(), endDate: getDefaultEndTime(), - weekdays: 0, - from: timeLocalWithoutOffsetToUTC("09:00:00"), - to: timeLocalWithoutOffsetToUTC("17:00:00"), + weekdays: 0 as number | undefined, + from: timeLocalWithoutOffsetToUTC("09:00:00") as string | undefined, + to: timeLocalWithoutOffsetToUTC("17:00:00") as string | undefined, }, { dataType: "json", @@ -50,7 +50,11 @@ toast.success(m["absences.add.success"]()); done(); } else if (event.result.type === "failure") { - toast.error(m["absences.add.errors.unknown"]()); + if (event.result.status === 409) { + toast.error(m["absences.add.errors.conflict"]()); + } else { + toast.error(m["absences.add.errors.unknown"]()); + } } isSubmitting = false; }, @@ -100,8 +104,15 @@ onValueChange={(v) => { if (v === "REGULAR") { isAllDay = true; - } else { + $formData.absenceType = "OTHER"; $formData.weekdays = 0; + $formData.from = timeLocalWithoutOffsetToUTC("09:00:00"); + $formData.to = timeLocalWithoutOffsetToUTC("17:00:00"); + } else { + $formData.weekdays = undefined; + $formData.from = undefined; + $formData.to = undefined; + $formData.absenceType = "VACATION"; } }} /> @@ -134,7 +145,7 @@ - + {#snippet children({ props })} {m["absences.add.fields.absenceType.title"]()} diff --git a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts index c52826f..b65076b 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts +++ b/src/routes/(pages)/dashboard/absences/(components)/add-absence-form/schema.ts @@ -3,11 +3,45 @@ import { z } from "zod"; export const formSchema = z .object({ + type: z.enum(["ONE_TIME", "REGULAR"]), agent: z.string().uuid({ message: m["form.errors.noAgentsSelected"]() }), absenceType: z.string().min(1).max(100), description: z.string().optional(), startDate: z.string(), endDate: z.string(), + weekdays: z.number().int().min(0).max(127).optional(), + from: z + .string() + .regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/) + .optional(), + to: z + .string() + .regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/) + .optional(), + }) + .superRefine((data, ctx) => { + if (data.type === "REGULAR") { + if (data.weekdays == null || data.weekdays === 0) { + ctx.addIssue({ + code: "custom", + message: m["form.errors.noWeekdaysSelected"](), + path: ["weekdays"], + }); + } + + if (data.from && data.to) { + const fromDate = new Date(`1970-01-01T${data.from}Z`); + const toDate = new Date(`1970-01-01T${data.to}Z`); + + if (fromDate >= toDate) { + ctx.addIssue({ + code: "custom", + message: m["form.errors.fromAfterTo"](), + path: ["from"], + }); + } + } + } }) .superRefine((data, ctx) => { const start = new Date(data.startDate); diff --git a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte index 7048746..2ee5784 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte +++ b/src/routes/(pages)/dashboard/absences/(components)/delete-absence-form/delete-absence-form.svelte @@ -5,11 +5,11 @@ import { Text } from "$lib/components/ui/typography"; import { agents as agentsStore } from "$lib/stores/agents"; import type { TAbsence } from "$lib/types/absence"; - import { toDisplayDateTime } from "$lib/utils/datetime"; import { toast } from "svelte-sonner"; import { superForm } from "sveltekit-superforms"; import { zod4Client as zodClient } from "sveltekit-superforms/adapters"; import { formSchema } from "."; + import { renderAbsenceTimespan } from "../../utils"; const agents = $derived($agentsStore.agents ?? []); let { entity, done }: { entity: TAbsence; done: () => void } = $props(); @@ -43,8 +43,7 @@ name: agents.find((a) => a.id === entity.agentId)?.name || m["absences.delete.description_fallback"](), - startDate: toDisplayDateTime(new Date(entity.startDate)), - endDate: toDisplayDateTime(new Date(entity.endDate)), + timespan: renderAbsenceTimespan(entity), })} + + + {#snippet children({ props })} + {m["absences.add.fields.type.title"]()} + { + if (v === "REGULAR") { + isAllDay = true; + $formData.absenceType = "OTHER"; + $formData.weekdays = 0; + $formData.from = timeLocalWithoutOffsetToUTC("09:00:00"); + $formData.to = timeLocalWithoutOffsetToUTC("17:00:00"); + } else { + $formData.weekdays = null; + $formData.from = null; + $formData.to = null; + $formData.absenceType = "VACATION"; + } + }} + /> + {/snippet} + + + {#snippet children({ props })} @@ -97,7 +153,7 @@ - + {#snippet children({ props })} {m["absences.add.fields.absenceType.title"]()} @@ -131,56 +187,125 @@ setTimeout(() => (endDateTouched = false), 100); } }} - class="mt-2 mb-1" + class={cn("mt-2 mb-1", $formData.type === "REGULAR" ? "hidden" : "")} /> - - - {#snippet children({ props })} - {m["absences.add.fields.startDate.title"]()} - { - if (!endDateTouched && $formData.endDate <= $formData.startDate) { - if (!isAllDay) { - const startDate = new SvelteDate($formData.startDate); - const endDate = new Date($formData.endDate); - startDate.setHours( - endDate.getHours(), - endDate.getMinutes(), - endDate.getSeconds(), - endDate.getMilliseconds(), - ); - $formData.endDate = startDate.toISOString(); - } else { - const startDate = new SvelteDate($formData.startDate); - startDate.setHours(23, 59, 59, 999); - $formData.endDate = startDate.toISOString(); +
+ + + {#snippet children({ props })} + {m["absences.add.fields.startDate.title"]()} + { + if (!endDateTouched && $formData.endDate <= $formData.startDate) { + if (!isAllDay) { + const startDate = new SvelteDate($formData.startDate); + const endDate = new Date($formData.endDate); + startDate.setHours( + endDate.getHours(), + endDate.getMinutes(), + endDate.getSeconds(), + endDate.getMilliseconds(), + ); + $formData.endDate = startDate.toISOString(); + } else { + const startDate = new SvelteDate($formData.startDate); + startDate.setHours(23, 59, 59, 999); + $formData.endDate = startDate.toISOString(); + } } - } - }} - /> - {/snippet} - - - - - - {#snippet children({ props })} - {m["absences.add.fields.endDate.title"]()} - (endDateTouched = true)} - /> - {/snippet} - - - + }} + /> + {/snippet} + + + + + + {#snippet children({ props })} + {m["absences.add.fields.endDate.title"]()} + (endDateTouched = true)} + /> + {/snippet} + + + +
+ {#if $formData.type === "REGULAR"} + + + {#snippet children({ props })} + {m["components.slotTemplate.weekdays"]()} + + + {toWeekdaysLabel($formData.weekdays)} + + + {weekdays[0].day} + {weekdays[1].day} + {weekdays[2].day} + {weekdays[3].day} + {weekdays[4].day} + {weekdays[5].day} + {weekdays[6].day} + + + {/snippet} + + + +
+ + + {#snippet children({ props })} + {m["components.slotTemplate.from"]()} + + + {getSelectedTime($formData.from as keyof typeof times)} + + + {#each Object.entries(times) as [time, value] (time)} + {value.label} + {/each} + + + {/snippet} + + + + + + {#snippet children({ props })} + {m["components.slotTemplate.to"]()} + + + {getSelectedTime($formData.to as keyof typeof times)} + + + {#each Object.entries(times) as [time, value] (time)} + {value.label} + {/each} + + + {/snippet} + + + +
+ {/if}
{m["absences.edit.action"]()} diff --git a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts index d61d4c5..ce6e694 100644 --- a/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts +++ b/src/routes/(pages)/dashboard/absences/(components)/edit-absence-form/schema.ts @@ -4,11 +4,45 @@ import { z } from "zod"; export const formSchema = z .object({ id: z.string(), + type: z.enum(["ONE_TIME", "REGULAR"]), agent: z.string().uuid({ message: m["form.errors.noAgentsSelected"]() }), absenceType: z.string().min(1).max(100), description: z.string().optional(), startDate: z.string(), endDate: z.string(), + weekdays: z.number().int().min(0).max(127).nullable(), + from: z + .string() + .regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/) + .nullable(), + to: z + .string() + .regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]$/) + .nullable(), + }) + .superRefine((data, ctx) => { + if (data.type === "REGULAR") { + if (data.weekdays == null || data.weekdays === 0) { + ctx.addIssue({ + code: "custom", + message: m["form.errors.noWeekdaysSelected"](), + path: ["weekdays"], + }); + } + + if (data.from && data.to) { + const fromDate = new Date(`1970-01-01T${data.from}Z`); + const toDate = new Date(`1970-01-01T${data.to}Z`); + + if (fromDate >= toDate) { + ctx.addIssue({ + code: "custom", + message: m["form.errors.fromAfterTo"](), + path: ["from"], + }); + } + } + } }) .superRefine((data, ctx) => { const start = new Date(data.startDate); @@ -17,7 +51,7 @@ export const formSchema = z if (end < start) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: m["form.errors.endDateBeforeStartDate"](), path: ["endDate"], }); @@ -25,7 +59,7 @@ export const formSchema = z if (end <= now) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: "custom", message: m["form.errors.endDateTooEarly"](), path: ["endDate"], }); diff --git a/src/routes/(pages)/dashboard/absences/+page.server.ts b/src/routes/(pages)/dashboard/absences/+page.server.ts index ea07339..9e30584 100644 --- a/src/routes/(pages)/dashboard/absences/+page.server.ts +++ b/src/routes/(pages)/dashboard/absences/+page.server.ts @@ -80,10 +80,14 @@ export const actions: Actions = { }, credentials: "same-origin", body: JSON.stringify({ + type: form.data.type, description: form.data.description, absenceType: form.data.absenceType, startDate: new Date(form.data.startDate).toISOString(), endDate: new Date(form.data.endDate).toISOString(), + weekdays: form.data.weekdays, + from: form.data.from, + to: form.data.to, }), }, ); @@ -98,7 +102,7 @@ export const actions: Actions = { } catch (e) { log.error("Failed to parse add absence error response", { error: e }); } - return fail(400, { + return fail(resp.status, { form, error, }); @@ -129,10 +133,14 @@ export const actions: Actions = { }, credentials: "same-origin", body: JSON.stringify({ + type: form.data.type, description: form.data.description, absenceType: form.data.absenceType, startDate: new Date(form.data.startDate).toISOString(), endDate: new Date(form.data.endDate).toISOString(), + weekdays: form.data.weekdays, + from: form.data.from, + to: form.data.to, }), }, ); @@ -147,7 +155,7 @@ export const actions: Actions = { } catch (e) { log.error("Failed to parse edit absences error response", { error: e }); } - return fail(400, { + return fail(resp.status, { form: { ...form, data: { ...form.data } }, error, }); diff --git a/src/routes/(pages)/dashboard/absences/+page.svelte b/src/routes/(pages)/dashboard/absences/+page.svelte index fc139d8..c6b65c8 100644 --- a/src/routes/(pages)/dashboard/absences/+page.svelte +++ b/src/routes/(pages)/dashboard/absences/+page.svelte @@ -11,7 +11,6 @@ import { ROUTES } from "$lib/const/routes"; import { agents as agentsStore } from "$lib/stores/agents"; import { type TAbsence } from "$lib/types/absence"; - import { toDisplayDateTime } from "$lib/utils/datetime"; import EditIcon from "@lucide/svelte/icons/pencil"; import PlusIcon from "@lucide/svelte/icons/plus"; import DeleteIcon from "@lucide/svelte/icons/trash-2"; @@ -21,7 +20,8 @@ import { DeleteAbsenceForm } from "./(components)/delete-absence-form"; import EditAbsenceForm from "./(components)/edit-absence-form/edit-absence-form.svelte"; import { reasons } from "./(components)/utils"; - import { getLocalTimeZone } from "@internationalized/date"; + import { renderAbsenceTimespan } from "./utils"; + import { RefreshCw } from "@lucide/svelte"; const { data } = $props(); const agents = $derived($agentsStore.agents ?? []); @@ -35,34 +35,11 @@ }); const renderDescription = (item: TAbsence) => { - const startDate = toDisplayDateTime(new Date(item.startDate)); - const fullStartDay = toDisplayDateTime(new Date(item.startDate), { - year: "numeric", - month: "long", - day: "numeric", - timeZone: getLocalTimeZone(), - }); - const endDate = toDisplayDateTime(new Date(item.endDate)); - const fullEndDay = toDisplayDateTime(new Date(item.endDate), { - year: "numeric", - month: "long", - day: "numeric", - timeZone: getLocalTimeZone(), - }); - const reason = reasons.find((r) => r.value === item.absenceType); - const isAllDay = - new Date(item.startDate).getHours() === 0 && - new Date(item.startDate).getMinutes() === 0 && - new Date(item.endDate).getHours() === 23 && - new Date(item.endDate).getMinutes() === 59; - const isSameDay = - new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString(); - if (isSameDay && isAllDay) { - return `${reason?.label}: ${fullStartDay}`; - } else if (isAllDay) { - return `${reason?.label}: ${fullStartDay} - ${fullEndDay}`; + if (item.type === "ONE_TIME") { + const reason = reasons.find((r) => r.value === item.absenceType); + return `${reason?.label}: ${renderAbsenceTimespan(item)}`; } else { - return `${reason?.label}: ${startDate} - ${endDate}`; + return renderAbsenceTimespan(item); } }; @@ -107,6 +84,7 @@ title={agent?.name || item.agentId} image={agent?.image || UnknownItemIcon} description={renderDescription(item)} + icons={item.type === "REGULAR" ? [RefreshCw] : []} actions={[ { type: "action", diff --git a/src/routes/(pages)/dashboard/absences/utils.ts b/src/routes/(pages)/dashboard/absences/utils.ts new file mode 100644 index 0000000..7e83a5c --- /dev/null +++ b/src/routes/(pages)/dashboard/absences/utils.ts @@ -0,0 +1,60 @@ +import { times } from "$lib/components/ui/slot-template/utils"; +import type { TAbsence } from "$lib/types/absence"; +import { + timeUTCToLocalWithoutOffset, + toDisplayDateTime, + toWeekdaysLabel, +} from "$lib/utils/datetime"; +import { getLocalTimeZone } from "@internationalized/date"; + +export const renderAbsenceTimespan = (item: TAbsence) => { + if (item.type === "ONE_TIME") { + return renderOneTimeAbsenceTimespan(item); + } else if (item.type === "REGULAR") { + return renderRegularAbsenceTimespan(item); + } else { + return ""; + } +}; + +export const renderOneTimeAbsenceTimespan = (item: TAbsence) => { + const startDate = toDisplayDateTime(new Date(item.startDate)); + const fullStartDay = toDisplayDateTime(new Date(item.startDate), { + year: "numeric", + month: "long", + day: "numeric", + timeZone: getLocalTimeZone(), + }); + const endDate = toDisplayDateTime(new Date(item.endDate)); + const fullEndDay = toDisplayDateTime(new Date(item.endDate), { + year: "numeric", + month: "long", + day: "numeric", + timeZone: getLocalTimeZone(), + }); + const isAllDay = + new Date(item.startDate).getHours() === 0 && + new Date(item.startDate).getMinutes() === 0 && + new Date(item.endDate).getHours() === 23 && + new Date(item.endDate).getMinutes() === 59; + const isSameDay = + new Date(item.startDate).toDateString() === new Date(item.endDate).toDateString(); + if (isSameDay && isAllDay) { + return `${fullStartDay}`; + } else if (isAllDay) { + return `${fullStartDay} - ${fullEndDay}`; + } else { + return `${startDate} - ${endDate}`; + } +}; + +export const renderRegularAbsenceTimespan = (item: TAbsence) => { + if (!item.from || !item.to) { + return ""; + } + + const weekdays = item.weekdays ?? 0; + const from = timeUTCToLocalWithoutOffset(item.from) as keyof typeof times; + const to = timeUTCToLocalWithoutOffset(item.to) as keyof typeof times; + return `${renderOneTimeAbsenceTimespan(item)}; ${toWeekdaysLabel(weekdays)} ${times[from]?.label ?? ""} - ${times[to]?.label ?? ""}`; +}; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts index 5aebfc1..ac93422 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/+server.ts @@ -35,6 +35,12 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", { schema: { type: "object", properties: { + type: { + type: "string", + enum: ["ONE_TIME", "REGULAR"], + description: "Type of absence", + example: "ONE_TIME", + }, startDate: { type: "string", format: "date-time", @@ -58,14 +64,26 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", { description: "Optional description of the absence", example: "Jahresurlaub", }, - isFullDay: { - type: "boolean", - description: "Whether this is a full day absence", - example: true, - default: true, + weekdays: { + type: "integer", + description: + "Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.", + example: 62, + }, + from: { + type: "string", + format: "time", + description: "Start time for REGULAR absences", + example: "09:00:00", + }, + to: { + type: "string", + format: "time", + description: "End time for REGULAR absences", + example: "17:00:00", }, }, - required: ["startDate", "endDate", "absenceType"], + required: ["type", "startDate", "endDate", "absenceType"], }, }, }, @@ -83,14 +101,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "POST", { type: "object", properties: { id: { type: "string", format: "uuid", description: "Absence ID" }, + type: { + type: "string", + enum: ["ONE_TIME", "REGULAR"], + description: "Type of absence", + }, agentId: { type: "string", format: "uuid", description: "Agent ID" }, startDate: { type: "string", format: "date-time", description: "Start date" }, endDate: { type: "string", format: "date-time", description: "End date" }, absenceType: { type: "string", description: "Type of absence" }, description: { type: "string", description: "Description" }, - isFullDay: { type: "boolean", description: "Full day absence" }, + weekdays: { + type: "integer", + description: + "Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.", + }, + from: { + type: "string", + format: "time", + description: "Start time for REGULAR absences", + }, + to: { + type: "string", + format: "time", + description: "End time for REGULAR absences", + }, }, - required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"], + required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"], }, }, required: ["message", "absence"], @@ -199,14 +236,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences", "GET", { type: "object", properties: { id: { type: "string", format: "uuid", description: "Absence ID" }, + type: { + type: "string", + enum: ["ONE_TIME", "REGULAR"], + description: "Type of absence", + }, agentId: { type: "string", format: "uuid", description: "Agent ID" }, startDate: { type: "string", format: "date-time", description: "Start date" }, endDate: { type: "string", format: "date-time", description: "End date" }, absenceType: { type: "string", description: "Type of absence" }, description: { type: "string", description: "Description" }, - isFullDay: { type: "boolean", description: "Full day absence" }, + weekdays: { + type: "integer", + description: + "Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.", + }, + from: { + type: "string", + format: "time", + description: "Start time for REGULAR absences", + }, + to: { + type: "string", + format: "time", + description: "End time for REGULAR absences", + }, }, - required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"], + required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"], }, }, }, @@ -278,6 +334,10 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { absenceType: body.absenceType, startDate: body.startDate, endDate: body.endDate, + type: body.type, + weekdays: body.weekdays, + from: body.from, + to: body.to, }); const agentService = await AgentService.forTenant(tenantId); diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts index a7ecdd4..b22181d 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/absences/[absenceId]/+server.ts @@ -53,14 +53,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "GET type: "object", properties: { id: { type: "string", format: "uuid", description: "Absence ID" }, + type: { + type: "string", + enum: ["ONE_TIME", "REGULAR"], + description: "Type of absence", + }, agentId: { type: "string", format: "uuid", description: "Agent ID" }, startDate: { type: "string", format: "date-time", description: "Start date" }, endDate: { type: "string", format: "date-time", description: "End date" }, absenceType: { type: "string", description: "Type of absence" }, description: { type: "string", description: "Description" }, - isFullDay: { type: "boolean", description: "Full day absence" }, + weekdays: { + type: "integer", + description: + "Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.", + }, + from: { + type: "string", + format: "time", + description: "Start time for REGULAR absences", + }, + to: { + type: "string", + format: "time", + description: "End time for REGULAR absences", + }, }, - required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"], + required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"], }, }, required: ["absence"], @@ -162,11 +181,6 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT description: "Optional description of the absence", example: "Erkältung", }, - isFullDay: { - type: "boolean", - description: "Whether this is a full day absence", - example: false, - }, }, }, }, @@ -185,14 +199,33 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}/absences/{absenceId}", "PUT type: "object", properties: { id: { type: "string", format: "uuid", description: "Absence ID" }, + type: { + type: "string", + enum: ["ONE_TIME", "REGULAR"], + description: "Type of absence", + }, agentId: { type: "string", format: "uuid", description: "Agent ID" }, startDate: { type: "string", format: "date-time", description: "Start date" }, endDate: { type: "string", format: "date-time", description: "End date" }, absenceType: { type: "string", description: "Type of absence" }, description: { type: "string", description: "Description" }, - isFullDay: { type: "boolean", description: "Full day absence" }, + weekdays: { + type: "integer", + description: + "Bitmask representing weekdays for regular absences (0 = Sunday, 1 = Monday, ..., 6 = Saturday). Only applicable for REGULAR absences.", + }, + from: { + type: "string", + format: "time", + description: "Start time for REGULAR absences", + }, + to: { + type: "string", + format: "time", + description: "End time for REGULAR absences", + }, }, - required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"], + required: ["id", "type", "agentId", "startDate", "endDate", "absenceType"], }, }, required: ["message", "absence"], diff --git a/src/routes/api/tenants/[id]/agents/absences/+server.ts b/src/routes/api/tenants/[id]/agents/absences/+server.ts index 5754449..3f459b9 100644 --- a/src/routes/api/tenants/[id]/agents/absences/+server.ts +++ b/src/routes/api/tenants/[id]/agents/absences/+server.ts @@ -49,13 +49,40 @@ registerOpenAPIRoute("/tenants/{id}/agents/absences", "GET", { type: "object", properties: { id: { type: "string", format: "uuid", description: "Absence ID" }, + type: { + type: "string", + enum: ["REGULAR", "ONE_TIME"], + description: "Type of absence", + }, agentId: { type: "string", format: "uuid", description: "Agent ID" }, startDate: { type: "string", format: "date-time", description: "Start date" }, endDate: { type: "string", format: "date-time", description: "End date" }, absenceType: { type: "string", description: "Type of absence" }, description: { type: "string", description: "Description" }, + weekdays: { + type: "integer", + description: "Weekdays bitmask for regular absences", + }, + from: { + type: "string", + format: "time", + description: "Start time for regular absences", + }, + to: { + type: "string", + format: "time", + description: "End time for regular absences", + }, }, - required: ["id", "agentId", "startDate", "endDate", "absenceType", "isFullDay"], + required: [ + "id", + "type", + "agentId", + "startDate", + "endDate", + "absenceType", + "isFullDay", + ], }, }, }, diff --git a/tenant-migrations/0017_optimal_professor_monster.sql b/tenant-migrations/0017_optimal_professor_monster.sql new file mode 100644 index 0000000..1ab9eb2 --- /dev/null +++ b/tenant-migrations/0017_optimal_professor_monster.sql @@ -0,0 +1,8 @@ +CREATE TYPE "public"."absence_type" AS ENUM('ONE_TIME', 'REGULAR');--> statement-breakpoint +ALTER TABLE "agent_absence" ADD COLUMN "type" "absence_type" DEFAULT 'ONE_TIME' NOT NULL;--> statement-breakpoint +ALTER TABLE "agent_absence" ADD COLUMN "weekdays" integer;--> statement-breakpoint +ALTER TABLE "agent_absence" ADD COLUMN "from" time;--> statement-breakpoint +ALTER TABLE "agent_absence" ADD COLUMN "to" time; + +-- Migrate existing absences +UPDATE "agent_absence" SET "type" = 'ONE_TIME'; \ No newline at end of file diff --git a/tenant-migrations/meta/0017_snapshot.json b/tenant-migrations/meta/0017_snapshot.json new file mode 100644 index 0000000..7a0c045 --- /dev/null +++ b/tenant-migrations/meta/0017_snapshot.json @@ -0,0 +1,1020 @@ +{ + "id": "69c01238-8c02-4f01-b10f-781e00b7e50f", + "prevId": "65117ae1-b30e-4c15-9db1-bea7b51df27c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(250000)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "agent_absence_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ONE_TIME'" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data_key": { + "name": "data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "appointment", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_agent_id_agent_id_fk": { + "name": "appointment_agent_id_agent_id_fk", + "tableFrom": "appointment", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_key_share": { + "name": "appointment_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_key_share_appointment_id_appointment_id_fk": { + "name": "appointment_key_share_appointment_id_appointment_id_fk", + "tableFrom": "appointment_key_share", + "tableTo": "appointment", + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.booking_access_token": { + "name": "booking_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_staff": { + "name": "channel_staff", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_staff_channel_id_channel_id_fk": { + "name": "channel_staff_channel_id_channel_id_fk", + "tableFrom": "channel_staff", + "tableTo": "channel", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_appointment_tunnel": { + "name": "client_appointment_tunnel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_key_share": { + "name": "client_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_appointment_tunnel_email_hash_unique": { + "name": "client_appointment_tunnel_email_hash_unique", + "nullsNotDistinct": false, + "columns": ["email_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pin_reset_token": { + "name": "client_pin_reset_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_pin_reset_token_token_unique": { + "name": "client_pin_reset_token_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tunnel_staff_key_share": { + "name": "client_tunnel_staff_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_tunnel_key": { + "name": "encrypted_tunnel_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "client_tunnel_staff_key_share", + "tableTo": "client_appointment_tunnel", + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'APPOINTMENT_CONFIRMED'" + }, + "meta_data": { + "name": "meta_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_crypto": { + "name": "staff_crypto", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": { + "staff_crypto_ua_idx": { + "name": "staff_crypto_ua_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.agent_absence_type": { + "name": "agent_absence_type", + "schema": "public", + "values": ["ONE_TIME", "REGULAR"] + }, + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 4cc8414..86caf84 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1781796561280, "tag": "0016_mighty_gamma_corps", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1784551934887, + "tag": "0017_optimal_professor_monster", + "breakpoints": true } ] }