diff --git a/docs/datetime.md b/docs/datetime.md new file mode 100644 index 0000000..f479b2d --- /dev/null +++ b/docs/datetime.md @@ -0,0 +1,27 @@ +# Date and Time + +We are adjusting for date and time differences between clients and tenants. + +We are also adjusting for daylight saving, meaning we are not shifting times, when they usually shift due to daylight saving. We are doing this because organizations adjust their appointment schedules according to daylight saving as well (a 9am appointment will always be at 9am). + +We are following these priciples: + +- The Back-End only ever handles UTC times. +- When working with schedules we are displaying local time without daylight saving adjustments. +- Once we select a slot for an appointment, we save it using the respective date and time in UTC incl. daylight saving adjustments. +- When we display appointments, we convert from UTC to local time incl. daylight saving adjustments. + +## How it works + +> This currently only works for the northern himsphere because we use January as a reference date (see `const jan = new Date`). We will improve this, when needed. + +1. Our Back-End will only every take and return dates using UTC (no daylight saving there) +1. When managing slots for channels + - we will show times without adjusting for daylight saving + - we will save times without adjusting for daylight saving +1. When booking appointments + - we will show time in local time (with or without daylight saving for the respective date) + - we will save appointments using UTC (converting it to local time and then to utc) +1. When looking a appointments in the calendar we will show local time (with or without daylight saving). +1. Client Dashboard converts to localtime (with or without daylight saving). +1. E-Mails going out about appointments include the timezone of the user, so the proper time can be shown. diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 0e35894..d8d92a2 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -634,6 +634,7 @@ } }, "settings": { + "title": "Einstellungen", "loading": "Lade Einstellungen...", "form": { "sections": { @@ -928,7 +929,7 @@ "today": "Heute", "loading": "Kalender wird geladen", "decrypting": "wird entschlüsselt...", - "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.", + "decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu laden/anmelden.", "decryptingErrorNoKeyShare": "Entschlüsselung fehlgeschlagen. Bitten Sie ein Teammitglied, Ihnen Zugriff zu gewähren.", "notificationHint": "Klienten werden per E-Mail benachrichtigt, wenn diese Benachrichtigungen aktiviert haben.", "shownAppointments": { diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index f2c0ee5..5a2755a 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -643,6 +643,7 @@ } }, "settings": { + "title": "Settings", "loading": "Loading settings...", "form": { "sections": { @@ -937,7 +938,7 @@ "today": "Today", "loading": "Loading calendar", "decrypting": "decrypting...", - "decryptingError": "Unable to decrypt data. Please log-in again", + "decryptingError": "Unable to decrypt data. Please reload/log-in again", "decryptingErrorNoKeyShare": "Unable to decrypt data. Get a team member to grant you access.", "notificationHint": "Clients will be notified via e-mail, if they have enabled e-mail notifications.", "shownAppointments": { diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index 7b3c71e..2b7530c 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -233,7 +233,7 @@ describe("hooks.server", () => { expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block"); expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); expect(response.headers.get("Content-Security-Policy")).toContain( - "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; font-src 'self' data: https://unpkg.com; connect-src 'self'; media-src 'self'; object-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; upgrade-insecure-requests", ); }); diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index c46ffec..75d8101 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -444,6 +444,8 @@ export class UnifiedAppointmentCrypto { ? `/api/tenants/${tenantId}/appointments/create-new-client` : `/api/tenants/${tenantId}/appointments/add-to-tunnel`; + const appointmentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + const requestData = isFirstAppointment ? { // New client @@ -451,6 +453,7 @@ export class UnifiedAppointmentCrypto { agentId, channelId, appointmentDate, + appointmentTimeZone, duration, emailHash: this.emailHash, clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined, @@ -470,6 +473,7 @@ export class UnifiedAppointmentCrypto { agentId, channelId, appointmentDate, + appointmentTimeZone, duration, clientEmail: appointmentData.shareEmail ? appointmentData.email : undefined, clientLanguage, @@ -592,12 +596,14 @@ export class UnifiedAppointmentCrypto { }; // Call endpoint + const appointmentTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const sendEmail = params.appointmentData.shareEmail && Boolean(params.email); const requestData = { clientEmail: params.appointmentData.shareEmail ? usedEmail : undefined, hasNoEmail: params.hasNoEmail, emailHash: await hashEmail(usedEmail), appointmentDate: params.appointmentDate.toISOString(), + appointmentTimeZone, duration: params.duration, agentId: params.agentId, channelId: params.channelId, diff --git a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte index 7e67ecd..f629082 100644 --- a/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/notification-item.svelte @@ -3,7 +3,6 @@ import { resolve } from "$app/paths"; import { page } from "$app/state"; import { m } from "$i18n/messages"; - import { getLocale } from "$i18n/runtime"; import { Button } from "$lib/components/ui/button"; import { Text } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; @@ -11,8 +10,9 @@ import { channels as channelsStore } from "$lib/stores/channels"; import type { TAppointment } from "$lib/types/appointments"; import type { TNotification } from "$lib/types/notification"; + import { toDisplayDateTime } from "$lib/utils/datetime"; import { getCurrentTranlslation } from "$lib/utils/localizations"; - import { getLocalTimeZone, parseAbsolute } from "@internationalized/date"; + import { getLocalTimeZone } from "@internationalized/date"; import { onMount } from "svelte"; let { @@ -81,20 +81,15 @@ style="xs" class="text-muted-foreground -mb-1 flex pr-8 text-start font-light whitespace-break-spaces" > - {Intl.DateTimeFormat(getLocale(), { + {toDisplayDateTime(new Date(appointment.appointmentDate), { year: "numeric", - month: "short", - day: "numeric", - weekday: "short", + month: "2-digit", + day: "2-digit", hour: "2-digit", minute: "2-digit", - timeZone: getLocalTimeZone().toString(), - }).format( - parseAbsolute( - appointment.appointmentDate as unknown as string, - getLocalTimeZone(), - ).toDate(), - )} + timeZone: getLocalTimeZone(), + timeZoneName: "short", + })} {/if} diff --git a/src/lib/components/ui/public/appointment-card.svelte b/src/lib/components/ui/public/appointment-card.svelte index c9ccaeb..bdf1f15 100644 --- a/src/lib/components/ui/public/appointment-card.svelte +++ b/src/lib/components/ui/public/appointment-card.svelte @@ -5,12 +5,12 @@ import { publicStore } from "$lib/stores/public"; import type { TPublicAppointment } from "$lib/types/public"; import { cn } from "$lib/utils"; - import { getLocalTimeZone } from "@internationalized/date"; - import { Eye, User, Calendar, FileText } from "@lucide/svelte"; + import { Calendar, Eye, FileText, User } from "@lucide/svelte"; import type { HTMLAttributes } from "svelte/elements"; import { resest } from "../../../../routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils"; import { Button } from "../button"; import LocalizedText from "./localized-text.svelte"; + import { toDisplayDateTime } from "$lib/utils/datetime"; let { class: className, @@ -76,14 +76,7 @@
- {Intl.DateTimeFormat($publicStore.locale, { - year: "numeric", - month: "long", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - timeZone: getLocalTimeZone().toString(), - }).format(appointment.slot.datetime.toDate(getLocalTimeZone()))} + {toDisplayDateTime(appointment.slot.datetime.toDate("UTC"))}
{/if} diff --git a/src/lib/components/ui/slot-template/slot-template.svelte b/src/lib/components/ui/slot-template/slot-template.svelte index a58fad3..0dcac55 100644 --- a/src/lib/components/ui/slot-template/slot-template.svelte +++ b/src/lib/components/ui/slot-template/slot-template.svelte @@ -5,6 +5,7 @@ import * as Select from "$lib/components/ui/select"; import type { TNewSlotTemplate, TSlotTemplate } from "$lib/types/channel"; import { cn } from "$lib/utils"; + import { timeLocalWithoutOffsetToUTC, timeUTCToLocalWithoutOffset } from "$lib/utils/datetime"; import { Trash } from "@lucide/svelte/icons"; import type { FsSuperForm } from "formsnap"; import type { HTMLAttributes } from "svelte/elements"; @@ -12,7 +13,6 @@ import { Input } from "../input"; import { Text } from "../typography"; import { durations, times, weekdays } from "./utils"; - import { localTimeToUTC, utcTimeToLocal } from "$lib/utils/datetime"; let { form, @@ -72,7 +72,7 @@ }; const getSelectedTime = (utcTime: string) => { - const localTime = utcTimeToLocal(utcTime) as keyof typeof times; + const localTime = timeUTCToLocalWithoutOffset(utcTime) as keyof typeof times; return times[localTime]?.label ?? ""; }; @@ -139,7 +139,7 @@ {#each Object.entries(times) as [time, value] (time)} - {value.label} + {value.label} {/each} @@ -162,7 +162,7 @@ {#each Object.entries(times) as [time, value] (time)} - {value.label} + {value.label} {/each} diff --git a/src/lib/emails/AppointmentBooked.svelte b/src/lib/emails/AppointmentBooked.svelte index 76ef184..93e20fc 100644 --- a/src/lib/emails/AppointmentBooked.svelte +++ b/src/lib/emails/AppointmentBooked.svelte @@ -48,9 +48,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} - {m["emails.oclock"]()} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{tenant.longName} diff --git a/src/lib/emails/AppointmentCancelled.svelte b/src/lib/emails/AppointmentCancelled.svelte index 3052fd1..f5e884f 100644 --- a/src/lib/emails/AppointmentCancelled.svelte +++ b/src/lib/emails/AppointmentCancelled.svelte @@ -45,9 +45,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} - {m["emails.oclock"]()} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{tenant.longName} diff --git a/src/lib/emails/AppointmentRejected.svelte b/src/lib/emails/AppointmentRejected.svelte index 9df32f8..106060e 100644 --- a/src/lib/emails/AppointmentRejected.svelte +++ b/src/lib/emails/AppointmentRejected.svelte @@ -45,9 +45,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} - {m["emails.oclock"]()} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{tenant.longName} diff --git a/src/lib/emails/AppointmentReminder.svelte b/src/lib/emails/AppointmentReminder.svelte index 0d51729..1db9bb8 100644 --- a/src/lib/emails/AppointmentReminder.svelte +++ b/src/lib/emails/AppointmentReminder.svelte @@ -48,9 +48,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} - {m["emails.oclock"]()} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{tenant.longName} diff --git a/src/lib/emails/AppointmentRequest.svelte b/src/lib/emails/AppointmentRequest.svelte index dcc96a0..da948ac 100644 --- a/src/lib/emails/AppointmentRequest.svelte +++ b/src/lib/emails/AppointmentRequest.svelte @@ -45,9 +45,8 @@ {channel} {appointment.agentName}
- {renderAppointmentDate(appointment.appointmentDate, locale)}
- {renderAppointmentTime(appointment.appointmentDate, locale)} - {m["emails.oclock"]()} + {renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}
+ {renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{tenant.longName} diff --git a/src/lib/emails/utils.ts b/src/lib/emails/utils.ts index 73f4154..768f285 100644 --- a/src/lib/emails/utils.ts +++ b/src/lib/emails/utils.ts @@ -1,6 +1,4 @@ import type { SupportedLocale } from "$lib/const/locales"; -import { format, type Locale } from "date-fns"; -import { de, enUS } from "date-fns/locale"; // is not exported from svelte export type RenderOutput = { @@ -68,15 +66,29 @@ export const htmlToText = (html: string) => { return text; }; -const localeMap: { [key: string]: Locale } = { - en: enUS, - de: de, +export const renderAppointmentDate = (date: Date, locale: SupportedLocale, timezone: string) => { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "long", + day: "numeric", + timeZone: timezone, + }).format(date); }; -export const renderAppointmentDate = (date: Date, locale: SupportedLocale) => { - return format(date, "PPP", { locale: localeMap[locale] as unknown as Locale }); -}; +export const renderAppointmentTime = (date: Date, locale: SupportedLocale, timezone: string) => { + const time = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + timeZone: timezone, + }).format(date); -export const renderAppointmentTime = (date: Date, locale: SupportedLocale) => { - return format(date, "p", { locale: localeMap[locale] as unknown as Locale }); + const timeZoneName = + new Intl.DateTimeFormat(locale, { + timeZone: timezone, + timeZoneName: "long", + }) + .formatToParts(date) + .find((part) => part.type === "timeZoneName")?.value ?? timezone; + + return `${time}, ${timeZoneName}`; }; diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 64eaf7a..2f345bb 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -171,6 +171,8 @@ export const appointment = pgTable("appointment", { appointmentDate: timestamp("appointment_date").notNull(), /** Duration of the appointment in minutes */ duration: integer("duration").notNull(), + /** Timezone of client booking appointment */ + timezone: text("timezone").notNull(), /** When appointment data expires and can be auto-deleted */ expiryDate: date("expiry_date"), /** Current status of the appointment - defaults depend on channel's requiresConfirmation setting */ diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 33d8b44..afc1027 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -63,6 +63,7 @@ const mockClientTunnelData = { channelId: "channel-123", agentId: "agent-123", appointmentDate: "2024-01-15T10:00:00Z", + appointmentTimeZone: "Europe/Berlin", duration: 10, emailHash: "email-hash-123", clientEmail: "test@example.com", diff --git a/src/lib/server/services/__tests__/schedule-service.test.ts b/src/lib/server/services/__tests__/schedule-service.test.ts index 54b392e..c6cec60 100644 --- a/src/lib/server/services/__tests__/schedule-service.test.ts +++ b/src/lib/server/services/__tests__/schedule-service.test.ts @@ -148,6 +148,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -248,6 +249,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -270,6 +272,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -284,6 +287,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-03T23:59:59.999Z", // 3 days + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -316,6 +320,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", // Monday endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -388,6 +393,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -463,6 +469,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", // Monday endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -572,6 +579,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -645,6 +653,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; @@ -729,6 +738,7 @@ describe("ScheduleService", () => { const validRequest: ScheduleRequest = { startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-01T23:59:59.999Z", + timeZone: "Europe/Berlin", tenantId: mockTenantId, }; diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 5a03571..e0be8a6 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -24,6 +24,7 @@ export interface ClientTunnelData { channelId: string; agentId: string; appointmentDate: string; + appointmentTimeZone: string; duration: number; emailHash: string; clientEmail?: string; @@ -500,6 +501,7 @@ export class AppointmentService { channelId: string; agentId: string; appointmentDate: string; + appointmentTimeZone: string; duration: number; clientEmail: string; clientLanguage?: string; @@ -567,6 +569,7 @@ export class AppointmentService { channelId: appointmentData.channelId, agentId: appointmentData.agentId, appointmentDate: new Date(appointmentData.appointmentDate), + timezone: appointmentData.appointmentTimeZone, duration: appointmentData.duration, encryptedPayload: appointmentData.encryptedAppointment.encryptedPayload, iv: appointmentData.encryptedAppointment.iv, @@ -588,6 +591,7 @@ export class AppointmentService { const response: AppointmentResponse = { id: result.id, appointmentDate: result.appointmentDate.toISOString(), + appointmentTimeZone: appointmentData.appointmentTimeZone, status: result.status, requiresConfirmation, }; @@ -716,6 +720,7 @@ export class AppointmentService { channelId: clientData.channelId, agentId: clientData.agentId, appointmentDate: new Date(clientData.appointmentDate), + timezone: clientData.appointmentTimeZone, duration: clientData.duration, encryptedPayload: clientData.encryptedAppointment.encryptedPayload, iv: clientData.encryptedAppointment.iv, @@ -725,6 +730,7 @@ export class AppointmentService { .returning({ id: tenantSchema.appointment.id, appointmentDate: tenantSchema.appointment.appointmentDate, + timezone: tenantSchema.appointment.timezone, status: tenantSchema.appointment.status, tunnelId: tenantSchema.appointment.tunnelId, channelId: tenantSchema.appointment.channelId, @@ -758,6 +764,7 @@ export class AppointmentService { const response: AppointmentResponse = { id: result.appointment.id, appointmentDate: result.appointment.appointmentDate.toISOString(), + appointmentTimeZone: result.appointment.timezone, status: result.appointment.status, requiresConfirmation: result.requiresConfirmation, }; diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index 0fe065f..d544516 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -12,11 +12,16 @@ import { eq, and, between, sql, or, inArray } from "drizzle-orm"; import logger from "$lib/logger"; import { z } from "zod"; import { ValidationError } from "../utils/errors"; +import { isValidTimeZone, toLocalTime, toLocalTimeIgnoringDst } from "../utils/timezone"; const scheduleRequestSchema = z.object({ startDate: z.string().datetime({ offset: true }), // ISO date string with timezone endDate: z.string().datetime({ offset: true }), // ISO date string with timezone tenantId: z.string().uuid({ message: "Invalid tenant ID format" }), + timeZone: z + .string() + .refine((tz) => isValidTimeZone(tz), { message: "Invalid IANA timezone format" }) + .default("UTC"), channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(), agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(), staffUserId: z.string().uuid({ message: "Invalid staff user ID format" }).optional(), @@ -217,6 +222,7 @@ export class ScheduleService { absences, channelAgents, staffKeyShares, + timeZone: request.timeZone, }); log.debug("Schedule generated successfully", { @@ -252,6 +258,7 @@ export class ScheduleService { absences, channelAgents, staffKeyShares, + timeZone, }: { startDate: Date; endDate: Date; @@ -261,6 +268,7 @@ export class ScheduleService { absences: SelectAgentAbsence[]; channelAgents: { channelId: string; agent: SelectAgent }[]; staffKeyShares: Record; + timeZone: string; }): Promise { const dailySchedules: DaySchedule[] = []; @@ -316,6 +324,7 @@ export class ScheduleService { appointments: dayAppointments, agents: channelAgentsList, absences, + timeZone, }); daySchedule.channels[channel.id] = { @@ -343,12 +352,14 @@ export class ScheduleService { appointments, agents, absences, + timeZone, }: { date: Date; slotTemplates: SelectSlotTemplate[]; appointments: SelectAppointment[]; agents: SelectAgent[]; absences: SelectAgentAbsence[]; + timeZone: string; }): TimeSlot[] { const availableSlots: TimeSlot[] = []; @@ -393,7 +404,9 @@ export class ScheduleService { ); const availableAgents = agents.filter((agent) => { - if (this.isAgentAbsent(agent.id, slotStartDateTime, slotEndDateTime, absences)) { + if ( + this.isAgentAbsent(agent.id, slotStartDateTime, slotEndDateTime, absences, timeZone) + ) { return false; } @@ -402,6 +415,7 @@ export class ScheduleService { slotStartDateTime, slotEndDateTime, appointments, + timeZone, ); }); @@ -430,19 +444,21 @@ export class ScheduleService { slotStartDateTime: Date, slotEndDateTime: Date, absences: SelectAgentAbsence[], + timeZone: string, ): boolean { return absences.some((absence) => { if (absence.agentId !== agentId) return false; - const absenceStart = new Date(absence.startDate); - const absenceEnd = new Date(absence.endDate); + const absenceStart = toLocalTime(new Date(absence.startDate), timeZone); + const absenceEnd = toLocalTime(new Date(absence.endDate), timeZone); + const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone); + const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone); // For time-specific absences, check if the time slot overlaps - return ( - (slotStartDateTime >= absenceStart && slotStartDateTime < absenceEnd) || - (slotEndDateTime > absenceStart && slotEndDateTime <= absenceEnd) || - (slotStartDateTime <= absenceStart && slotEndDateTime >= absenceEnd) - ); + const slotStartsDuringAbsence = slotStart >= absenceStart && slotStart < absenceEnd; + const slotEndsDuringAbsence = slotEnd > absenceStart && slotEnd <= absenceEnd; + const slotCoversEntireAbsence = slotStart <= absenceStart && slotEnd >= absenceEnd; + return slotStartsDuringAbsence || slotEndsDuringAbsence || slotCoversEntireAbsence; }); } @@ -454,6 +470,7 @@ export class ScheduleService { slotStartDateTime: Date, slotEndDateTime: Date, appointments: SelectAppointment[], + timeZone: string, ): boolean { return appointments.some((appointment) => { if (appointment.agentId !== agentId) return false; @@ -461,8 +478,13 @@ export class ScheduleService { const appointmentStart = new Date(appointment.appointmentDate); const appointmentDuration = Number.isFinite(appointment.duration) ? appointment.duration : 0; const appointmentEnd = new Date(appointmentStart.getTime() + appointmentDuration * 60 * 1000); + const slotStart = toLocalTimeIgnoringDst(slotStartDateTime, timeZone); + const slotEnd = toLocalTimeIgnoringDst(slotEndDateTime, timeZone); - return slotStartDateTime < appointmentEnd && slotEndDateTime > appointmentStart; + return ( + slotStart < toLocalTime(appointmentEnd, timeZone) && + slotEnd > toLocalTime(appointmentStart, timeZone) + ); }); } diff --git a/src/lib/server/utils/timezone.ts b/src/lib/server/utils/timezone.ts new file mode 100644 index 0000000..25ab9e0 --- /dev/null +++ b/src/lib/server/utils/timezone.ts @@ -0,0 +1,30 @@ +export function isValidTimeZone(timeZone: string): boolean { + if (timeZone === "UTC" || timeZone === "Etc/UTC") { + return true; + } + + const supportedValuesOf = (Intl as unknown as { supportedValuesOf?: (key: string) => string[] }) + .supportedValuesOf; + if (supportedValuesOf) { + return supportedValuesOf("timeZone").includes(timeZone); + } + + // Conservative fallback when Intl.supportedValuesOf is unavailable. + return timeZone === "UTC" || /^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(timeZone); +} + +export function toLocalTime(utcDate: Date, timeZone: string): Date { + const utcStr = utcDate.toLocaleString("en-US", { timeZone: "UTC" }); + const tzStr = utcDate.toLocaleString("en-US", { timeZone }); + const offsetMs = new Date(tzStr).getTime() - new Date(utcStr).getTime(); + return new Date(utcDate.getTime() + offsetMs); +} + +export function toLocalTimeIgnoringDst(utcDate: Date, timeZone: string): Date { + // Use January 1st to get the standard (non-DST) offset for this timezone + const jan = new Date(utcDate.getFullYear(), 0, 1); + const utcStr = jan.toLocaleString("en-US", { timeZone: "UTC" }); + const tzStr = jan.toLocaleString("en-US", { timeZone }); + const standardOffsetMs = new Date(tzStr).getTime() - new Date(utcStr).getTime(); + return new Date(utcDate.getTime() + standardOffsetMs); +} diff --git a/src/lib/stores/agents.ts b/src/lib/stores/agents.ts index a19bcf5..69b77e6 100644 --- a/src/lib/stores/agents.ts +++ b/src/lib/stores/agents.ts @@ -25,6 +25,7 @@ const createAgentsStore = () => { try { const tenantId = auth.getTenant(); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/agents`, { method: "GET", headers: { diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 648c23f..128ffb1 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -11,7 +11,7 @@ export interface PasskeyAuthData { export interface AuthState { isAuthenticated: boolean; - isRefreshing: boolean; + refreshPromise: Promise | null; user?: { id: string; email: string; @@ -26,17 +26,17 @@ export interface AuthState { function createAuthStore() { const store = writable({ isAuthenticated: false, - isRefreshing: false, + refreshPromise: null, }); return { ...store, - setRefreshing: (isRefreshing: boolean) => { - store.update((state) => ({ ...state, isRefreshing })); - }, setAuthenticated: (isAuthenticated: boolean) => { store.update((state) => ({ ...state, isAuthenticated })); }, + setRefreshPromise: (promise: Promise | null) => { + store.update((state) => ({ ...state, refreshPromise: promise })); + }, setUser: (user: AuthState["user"]) => { store.update((state) => ({ ...state, isAuthenticated: true, user })); @@ -57,7 +57,7 @@ function createAuthStore() { reset: () => { store.set({ isAuthenticated: false, - isRefreshing: false, + refreshPromise: null, user: undefined, passkeyAuthData: undefined, }); @@ -94,6 +94,17 @@ function createAuthStore() { unsubscribe(); return authState!.user?.tenantId || null; }, + waitForRefresh: async () => { + let authState: AuthState; + const unsubscribe = store.subscribe((state) => { + authState = state; + }); + unsubscribe(); + + if (authState!.refreshPromise) { + await authState!.refreshPromise; + } + }, }; } diff --git a/src/lib/stores/channels.ts b/src/lib/stores/channels.ts index 9dfbb20..1f63985 100644 --- a/src/lib/stores/channels.ts +++ b/src/lib/stores/channels.ts @@ -25,6 +25,7 @@ const createChannelsStore = () => { try { const tenantId = auth.getTenant(); + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/channels`, { method: "GET", headers: { diff --git a/src/lib/stores/notifications.ts b/src/lib/stores/notifications.ts index 9f1e6b6..99a0d08 100644 --- a/src/lib/stores/notifications.ts +++ b/src/lib/stores/notifications.ts @@ -27,6 +27,7 @@ const createNotificationsStore = () => { }); try { + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/notifications`, { method: "GET", headers: { diff --git a/src/lib/stores/staff.ts b/src/lib/stores/staff.ts index 5dc1ade..5fdbad0 100644 --- a/src/lib/stores/staff.ts +++ b/src/lib/stores/staff.ts @@ -27,6 +27,7 @@ const createStaffStore = () => { }); try { + await auth.waitForRefresh(); const res = await fetch(`/api/tenants/${tenantId}/staff`, { method: "GET", headers: { diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index 0539dc0..44ed87d 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -91,6 +91,7 @@ export interface AddAppointmentToTunnelRequest { emailHash: string; tunnelId: string; appointmentDate: string; + appointmentTimeZone: string; encryptedAppointment: EncryptedAppointmentData; } @@ -132,6 +133,7 @@ export interface ClientAppointmentsTunnel { export interface AppointmentResponse { id: string; appointmentDate: string; + appointmentTimeZone: string; status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW"; requiresConfirmation?: boolean; } diff --git a/src/lib/utils/datetime.ts b/src/lib/utils/datetime.ts index d678e70..2e04464 100644 --- a/src/lib/utils/datetime.ts +++ b/src/lib/utils/datetime.ts @@ -1,15 +1,35 @@ import { getLocale } from "$i18n/runtime"; import type { TCalendarSlot } from "$lib/types/calendar"; -import { parseAbsoluteToLocal, toCalendarDateTime } from "@internationalized/date"; +import { + getLocalTimeZone, + parseAbsoluteToLocal, + toCalendarDateTime, +} from "@internationalized/date"; -export const toDisplayDateTime = (date: Date, opts?: Intl.DateTimeFormatOptions) => { +export const toDisplayDateTime = ( + date: Date, + opts: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "long", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone(), + timeZoneName: "short", + }, +) => { const hours = date.getHours(); const minutes = date.getMinutes(); const formatter = new Intl.DateTimeFormat( getLocale(), - opts ?? { + opts || { + // TODO: Should be moved outside, in cases where needed dateStyle: "short", - ...(hours !== 0 || minutes !== 0 ? { timeStyle: "short" } : {}), + ...(hours !== 0 || minutes !== 0 + ? { timeStyle: "short" } + : { hour: "2-digit", minute: "2-digit" }), + timeZone: getLocalTimeZone(), + timeZoneName: "short", }, ); return formatter.format(date); @@ -20,47 +40,32 @@ export const calendarItemToDate = (item: TCalendarSlot) => { const parsedDate = new Date(item.start); if (!Number.isNaN(parsedDate.getTime())) { const [year, month, day] = item.date.split("-").map(Number); - return new Date(year, month - 1, day, parsedDate.getHours(), parsedDate.getMinutes(), 0, 0); + + // Get standard (non-DST) offset from a winter date + const jan = new Date(year, 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + + // Build a UTC date and apply the standard offset + const utc = Date.UTC( + year, + month - 1, + day, + parsedDate.getUTCHours(), + parsedDate.getUTCMinutes(), + 0, + 0, + ); + return new Date(utc - standardOffsetMs); } } - const [year, month, day] = item.date.split("-").map(Number); - const [hours, minutes] = item.start.split(":").map((it) => Number.parseInt(it)); - const date = new Date(year, month - 1, day); - date.setHours(hours, minutes, 0, 0); - return date; + throw new Error(`Unable to parse date from calendar item: ${item.start}`); }; export const toInputDateTime = (dateStr: string) => { return toCalendarDateTime(parseAbsoluteToLocal(dateStr)); }; -export const localTimeToUTC = (localTime: string) => { - const [hours, minutes, seconds] = localTime.split(":").map(Number); - - const now = new Date(); - now.setHours(hours, minutes, seconds, 0); - - const utcHours = String(now.getUTCHours()).padStart(2, "0"); - const utcMinutes = String(now.getUTCMinutes()).padStart(2, "0"); - const utcSeconds = String(now.getUTCSeconds()).padStart(2, "0"); - - return `${utcHours}:${utcMinutes}:${utcSeconds}`; -}; - -export function utcTimeToLocal(utcTime: string): string { - const [hours, minutes, seconds] = utcTime.split(":").map(Number); - - const now = new Date(); - now.setUTCHours(hours, minutes, seconds, 0); - - const localHours = String(now.getHours()).padStart(2, "0"); - const localMinutes = String(now.getMinutes()).padStart(2, "0"); - const localSeconds = String(now.getSeconds()).padStart(2, "0"); - - return `${localHours}:${localMinutes}:${localSeconds}`; -} - export const getDefaultStartTime = () => { const date = new Date(); date.setHours(7); @@ -78,3 +83,104 @@ export const getDefaultEndTime = () => { date.setMilliseconds(0); return date.toISOString(); }; + +/* + + Date and Time processing + See datetime.md for reference + +*/ + +export const timeLocalWithoutOffsetToUTC = (localTime: string) => { + const [hours, minutes, seconds] = localTime.split(":").map(Number); + + // Get the standard (non-DST) offset by checking a date in winter + const jan = new Date(new Date().getFullYear(), 0, 1); // January 1st + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + + const now = new Date(); + now.setHours(hours, minutes, seconds, 0); + + // Remove the current offset and apply the standard one instead + const utc = new Date(now.getTime() - now.getTimezoneOffset() * 60 * 1000 + standardOffsetMs); + + const utcHours = String(utc.getUTCHours()).padStart(2, "0"); + const utcMinutes = String(utc.getUTCMinutes()).padStart(2, "0"); + const utcSeconds = String(utc.getUTCSeconds()).padStart(2, "0"); + + return `${utcHours}:${utcMinutes}:${utcSeconds}`; +}; + +export function timeUTCToLocalWithoutOffset(utcTime: string): string { + const [hours, minutes, seconds] = utcTime.split(":").map(Number); + + // Standard (non-DST) offset + const jan = new Date(new Date().getFullYear(), 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + + const now = new Date(); + now.setUTCHours(hours, minutes, seconds, 0); + + // Remove the standard offset, then re-apply the current one + const local = new Date(now.getTime() + now.getTimezoneOffset() * 60 * 1000 - standardOffsetMs); + + const localHours = String(local.getHours()).padStart(2, "0"); + const localMinutes = String(local.getMinutes()).padStart(2, "0"); + const localSeconds = String(local.getSeconds()).padStart(2, "0"); + + return `${localHours}:${localMinutes}:${localSeconds}`; +} + +export const timeLocalToUTC = (localTime: string, date?: Date) => { + const [hours, minutes, seconds] = localTime.split(":").map(Number); + + // Use the provided date (to get the correct DST offset for that day), + // or fall back to today + const target = date ? new Date(date) : new Date(); + target.setHours(hours, minutes, seconds, 0); + + const utcHours = String(target.getUTCHours()).padStart(2, "0"); + const utcMinutes = String(target.getUTCMinutes()).padStart(2, "0"); + const utcSeconds = String(target.getUTCSeconds()).padStart(2, "0"); + + return `${utcHours}:${utcMinutes}:${utcSeconds}`; +}; + +export const timeUTCToLocal = (utcTime: string, date?: Date) => { + const [hours, minutes, seconds] = utcTime.split(":").map(Number); + + const target = date ? new Date(date) : new Date(); + target.setUTCHours(hours, minutes, seconds, 0); + + const localHours = String(target.getHours()).padStart(2, "0"); + const localMinutes = String(target.getMinutes()).padStart(2, "0"); + const localSeconds = String(target.getSeconds()).padStart(2, "0"); + + return `${localHours}:${localMinutes}:${localSeconds}`; +}; + +export const utcToLocalWithoutDST = (utcDate: Date): Date => { + const jan = new Date(utcDate.getFullYear(), 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + const currentOffsetMs = utcDate.getTimezoneOffset() * 60 * 1000; + + return new Date(utcDate.getTime() - standardOffsetMs + currentOffsetMs); +}; + +export const localToUTCWithoutDST = (localDate: Date): Date => { + const jan = new Date(localDate.getFullYear(), 0, 1); + const standardOffsetMs = jan.getTimezoneOffset() * 60 * 1000; + const currentOffsetMs = localDate.getTimezoneOffset() * 60 * 1000; + + return new Date(localDate.getTime() + standardOffsetMs - currentOffsetMs); +}; + +export const localToUTC = (localDate: Date): Date => { + const currentOffsetMs = localDate.getTimezoneOffset() * 60 * 1000; + return new Date(localDate.getTime() + currentOffsetMs); +}; + +export const utcToLocal = (utcDate: Date): Date => { + const currentOffsetMs = utcDate.getTimezoneOffset() * 60 * 1000; + return new Date(utcDate.getTime() - currentOffsetMs); +}; diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index c2a9c31..425e485 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -6,10 +6,8 @@ import { auth } from "$lib/stores/auth"; export const refreshSession = async () => { if (!auth.isAuthenticated()) return; - auth.setRefreshing(true); - try { - const response = await fetch("/api/auth/refresh", { + const refreshPromise = fetch("/api/auth/refresh", { method: "POST", headers: { "Content-Type": "application/json", @@ -17,9 +15,12 @@ export const refreshSession = async () => { credentials: "same-origin", }); + auth.setRefreshPromise(refreshPromise); + const response = await refreshPromise; + if (!response.ok) { if (response.status === 401) { - auth.setRefreshing(false); + auth.setRefreshPromise(null); goto(resolve(ROUTES.LOGOUT)); return; } @@ -27,7 +28,7 @@ export const refreshSession = async () => { refreshUserData(); } finally { - auth.setRefreshing(false); + auth.setRefreshPromise(null); } }; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte index fbde4d7..70931c4 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -9,8 +9,14 @@ import { Text } from "$lib/components/ui/typography"; import { publicStore } from "$lib/stores/public.js"; import type { TPublicAppointment, TPublicSchedule, TPublicSlot } from "$lib/types/public.js"; + import { + localToUTC, + timeUTCToLocalWithoutOffset, + utcToLocalWithoutDST, + } from "$lib/utils/datetime"; import { CalendarDate, + fromDate, getLocalTimeZone, parseDate, toCalendarDate, @@ -107,18 +113,8 @@ curDateStr === dateStr ? slots.filter((slot) => { if (dateStr === curDateStr) { - const now = new Date(); - const slotDate = new Date(slot.from); - const slotTime = new Date( - date.year, - date.month - 1, - date.day, - slotDate.getHours(), - slotDate.getMinutes(), - 0, - 0, - ); - return slotTime > now; + // double check, when be changed + return utcToLocalWithoutDST(new Date(slot.from)) > new Date(); } return true; }) @@ -136,6 +132,8 @@ const selectSlot = (slot: TPublicSlot) => { if (selectedDate && slot.availableAgents.length > 0) { + const localTime = utcToLocalWithoutDST(new Date(slot.from)); + const utc = localToUTC(localTime); proceed({ ...appointment, agent: { @@ -144,10 +142,7 @@ image: slot.availableAgents[0].image || null, }, slot: { - datetime: toCalendarDateTime(selectedDate).set({ - hour: new Date(slot.from).getUTCHours(), - minute: new Date(slot.from).getUTCMinutes(), - }), + datetime: toCalendarDateTime(fromDate(utc, getLocalTimeZone())), duration: slot.duration, }, }); @@ -156,21 +151,26 @@ const formatSlotTime = (slot: TPublicSlot) => { const slotDate = new Date(slot.from); - const displayDate = new Date( - slotDate.getFullYear(), - slotDate.getMonth(), - slotDate.getDate(), - slotDate.getHours(), - slotDate.getMinutes(), - 0, - 0, - ); + const utcTime = slot.from.slice(11, 19); // "HH:mm:ss" from ISO string + const localTime = timeUTCToLocalWithoutOffset(utcTime); + + const [hours, minutes] = localTime.split(":"); return new Intl.DateTimeFormat(getLocale(), { hour: "2-digit", minute: "2-digit", hour12: false, - }).format(displayDate); + }).format( + new Date( + slotDate.getFullYear(), + slotDate.getMonth(), + slotDate.getDate(), + Number(hours), + Number(minutes), + 0, + 0, + ), + ); }; @@ -230,9 +230,10 @@ {m["public.steps.slot.selectTime"]()}
{#each slots as slot (slot.from)} - + {/each} diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index 4cd2985..24b2ba4 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -7,7 +7,6 @@ import { Text } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; import { publicStore } from "$lib/stores/public.js"; - import { toZoned } from "@internationalized/date"; import { toast } from "svelte-sonner"; const tenant = $derived($publicStore.tenant); @@ -30,7 +29,7 @@ $publicStore.crypto .createAppointment( appointment.data, - toZoned(appointment.slot.datetime, "UTC").toAbsoluteString(), + appointment.slot.datetime.toDate("UTC").toISOString(), appointment.agent.id, channel.id, appointment.slot.duration, diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts index b20f3e3..acc7efc 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/utils.ts @@ -112,6 +112,7 @@ export const fetchSchedule = async (opts: { const params = new URLSearchParams({ startDate: startDate.toISOString(), endDate: endDate.toISOString(), + timeZone: getLocalTimeZone().toString(), channel: opts.channel, agent: opts.agent || "", }); diff --git a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte index 2ae45d0..4564a28 100644 --- a/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte +++ b/src/routes/(pages)/(clients)/clients/(components)/appointment-list.svelte @@ -11,7 +11,7 @@ import { Headline, Text } from "$lib/components/ui/typography"; import { ROUTES } from "$lib/const/routes"; import { publicStore } from "$lib/stores/public"; - import { toDisplayDateTime } from "$lib/utils/datetime"; + import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime"; import { getLocalTimeZone } from "@internationalized/date"; import { Calendar, CircleAlert } from "@lucide/svelte"; import { onMount } from "svelte"; @@ -82,15 +82,7 @@ {/if} - {toDisplayDateTime(new Date(item.appointmentDate), { - year: "numeric", - month: "long", - day: "numeric", - weekday: "short", - hour: "2-digit", - minute: "2-digit", - timeZone: getLocalTimeZone().toString(), - })} + {toDisplayDateTime(new Date(item.appointmentDate))} @@ -135,7 +127,7 @@
- {toDisplayDateTime(new Date(cancelling.appointmentDate), { + {toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), { year: "numeric", month: "long", day: "numeric", diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 5b16fef..acde2c7 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -23,12 +23,11 @@ updateStores(); if (!intervalData) { - refreshSession(); intervalData = setInterval(updateStores, 2 * 60 * 1000); // 2 minutes } if (!intervalSession) { refreshSession(); - intervalSession = setInterval(refreshSession, 5 * 60 * 1000); // 5 minutes + intervalSession = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes } const unsubscribe = () => { diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte index c0636cc..71df577 100644 --- a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -1,13 +1,12 @@ + + {m["settings.title"]()} - OpenReception + + { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: new Date(validatedData.appointmentDate), + timezone: validatedData.appointmentTimeZone, duration: validatedData.duration, encryptedPayload: validatedData.encryptedAppointment.encryptedPayload, iv: validatedData.encryptedAppointment.iv, @@ -266,6 +268,7 @@ export const POST: RequestHandler = async ({ request, params }) => { channelId: appointment.channelId, agentId: appointment.agentId, appointmentDate: appointment.appointmentDate, + timezone: appointment.timezone, expiryDate: appointment.expiryDate, status: appointment.status, encryptedPayload: appointment.encryptedPayload, @@ -287,6 +290,7 @@ export const POST: RequestHandler = async ({ request, params }) => { const response: AppointmentResponse = { id: result.id, appointmentDate: result.appointmentDate.toISOString(), + appointmentTimeZone: result.timezone, status: result.status, }; diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index 9061d68..5d89eff 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -22,6 +22,7 @@ const requestSchema = z.object({ channelId: z.string(), agentId: z.string(), appointmentDate: z.string(), + appointmentTimeZone: z.string(), duration: z.number().int().positive(), emailHash: z.string(), clientEmail: z.email().optional(), @@ -136,6 +137,11 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { format: "date-time", description: "Appointment date and time (ISO 8601)", }, + appointmentTimezone: { + type: "string", + format: "IANA Timezone", + description: "Time zone of the appointment (e.g. Europe/Berlin)", + }, emailHash: { type: "string", description: "SHA-256 hash of client email", diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts index c2be121..2376c4f 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts @@ -36,6 +36,7 @@ describe("Create New Client API Route", () => { channelId: mockChannelId, agentId: "agent-123", // Missing field added appointmentDate: "2024-12-25T14:30:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 10, emailHash: "test-email-hash", clientEmail: "test@example.com", diff --git a/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts b/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts index e31dbe7..fe85812 100644 --- a/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/staff-create/+server.ts @@ -16,6 +16,7 @@ const requestSchema = z // Appointment details appointmentDate: z.string(), + appointmentTimeZone: z.string(), duration: z.number().int().positive(), channelId: z.string(), agentId: z.string(), @@ -315,6 +316,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: validatedData.appointmentDate, + appointmentTimeZone: validatedData.appointmentTimeZone, duration: validatedData.duration, clientEmail: validatedData.clientEmail || "", clientLanguage: validatedData.clientLanguage, @@ -349,6 +351,7 @@ export const POST: RequestHandler = async ({ params, request, locals }) => { channelId: validatedData.channelId, agentId: validatedData.agentId, appointmentDate: validatedData.appointmentDate, + appointmentTimeZone: validatedData.appointmentTimeZone, duration: validatedData.duration, emailHash: validatedData.emailHash, clientEmail: validatedData.clientEmail || "", diff --git a/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts b/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts index 4dd43b6..12b8633 100644 --- a/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts +++ b/src/routes/api/tenants/[id]/appointments/staff-create/__tests__/staff-create.test.ts @@ -68,6 +68,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { clientEmail: "test@example.com", emailHash, appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, channelId: "channel-123", agentId: "agent-123", @@ -108,6 +109,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { channelId: "channel-123", agentId: "agent-123", appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, emailHash, clientEmail: "test@example.com", @@ -151,6 +153,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { hasNoEmail: true, emailHash: "unique-hash-for-no-email", appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, channelId: "channel-123", agentId: "agent-123", @@ -214,6 +217,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { clientEmail: "existing@example.com", emailHash, appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, channelId: "channel-123", agentId: "agent-123", @@ -245,6 +249,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { channelId: "channel-123", agentId: "agent-123", appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, clientEmail: "existing@example.com", clientLanguage: "de", @@ -291,6 +296,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { clientEmail: "test@example.com", emailHash, appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, channelId: "channel-123", agentId: "agent-123", @@ -387,6 +393,7 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => { body: JSON.stringify({ emailHash, appointmentDate: "2026-01-15T14:00:00.000Z", + appointmentTimeZone: "Europe/Berlin", duration: 30, channelId: "channel-123", agentId: "agent-123", diff --git a/src/routes/api/tenants/[id]/calendar/+server.ts b/src/routes/api/tenants/[id]/calendar/+server.ts index ff0cac9..b0c8402 100644 --- a/src/routes/api/tenants/[id]/calendar/+server.ts +++ b/src/routes/api/tenants/[id]/calendar/+server.ts @@ -33,6 +33,14 @@ registerOpenAPIRoute("/tenants/{id}/calendar", "GET", { schema: { type: "string", format: "date-time" }, description: "End date for the calendar range (ISO 8601 format with timezone)", }, + { + name: "timeZone", + in: "query", + required: false, + schema: { type: "string" }, + description: + "IANA timezone name for schedule calculations (e.g., 'Europe/Berlin', 'America/New_York'). Defaults to 'UTC' if not provided. This ensures slots are calculated with the correct daylight saving time offset.", + }, ], responses: { "200": { @@ -124,6 +132,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { // Parse query parameters for date range const startDateParam = url.searchParams.get("startDate"); const endDateParam = url.searchParams.get("endDate"); + const timeZoneParam = url.searchParams.get("timeZone") || "UTC"; if (!startDateParam || !endDateParam) { throw new ValidationError("Both startDate and endDate query parameters are required"); @@ -153,6 +162,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { tenantId, startDate: startDate.toISOString(), endDate: endDate.toISOString(), + timeZone: timeZoneParam, }); const scheduleService = await ScheduleService.forTenant(tenantId); @@ -164,6 +174,7 @@ export const GET: RequestHandler = async ({ params, url, locals }) => { tenantId, startDate: startDateParam, endDate: endDateParam, + timeZone: timeZoneParam, staffUserId, }); diff --git a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts index 7fb7e61..c39f778 100644 --- a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts +++ b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts @@ -142,6 +142,7 @@ describe("Calendar API", () => { tenantId: "tenant-123", startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-02T00:00:00.000Z", + timeZone: "UTC", staffUserId: undefined, }); }); diff --git a/src/routes/api/tenants/[id]/schedule/+server.ts b/src/routes/api/tenants/[id]/schedule/+server.ts index ae6bec3..1cb1564 100644 --- a/src/routes/api/tenants/[id]/schedule/+server.ts +++ b/src/routes/api/tenants/[id]/schedule/+server.ts @@ -138,6 +138,7 @@ export const GET: RequestHandler = async ({ params, url }) => { // Parse query parameters for date range const startDateParam = url.searchParams.get("startDate"); const endDateParam = url.searchParams.get("endDate"); + const timeZoneParam = url.searchParams.get("timeZone") || "UTC"; const channelIdParam = url.searchParams.get("channel"); const agentIdParam = url.searchParams.get("agent"); @@ -169,6 +170,7 @@ export const GET: RequestHandler = async ({ params, url }) => { tenantId, startDate: startDate.toISOString(), endDate: endDate.toISOString(), + timeZone: timeZoneParam, }); const scheduleService = await ScheduleService.forTenant(tenantId); @@ -178,6 +180,7 @@ export const GET: RequestHandler = async ({ params, url }) => { endDate: endDateParam, channelId: channelIdParam || undefined, agentId: agentIdParam || undefined, + timeZone: timeZoneParam, }); // Transform schedule to client-friendly format (remove appointments, simplify available slots) diff --git a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts index 2f5cf8a..2fd3686 100644 --- a/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts +++ b/src/routes/api/tenants/[id]/schedule/__tests__/schedule-api.test.ts @@ -126,6 +126,7 @@ describe("Schedule API Route", () => { tenantId: mockTenantId, startDate: "2024-01-01T00:00:00.000Z", endDate: "2024-01-07T23:59:59.999Z", + timeZone: "UTC", }); }); @@ -305,6 +306,7 @@ describe("Schedule API Route", () => { tenantId: mockTenantId, startDate, endDate, + timeZone: "UTC", }); }); }); diff --git a/src/server-hooks/secHeaderHandle.ts b/src/server-hooks/secHeaderHandle.ts index c1fb1e4..7033c6d 100644 --- a/src/server-hooks/secHeaderHandle.ts +++ b/src/server-hooks/secHeaderHandle.ts @@ -32,7 +32,7 @@ export const secHeaderHandle: Handle = async ({ event, resolve }) => { "font-src 'self' data: https://unpkg.com", "connect-src 'self'", "media-src 'self'", - "object-src 'none'", + "object-src 'self'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", @@ -40,7 +40,7 @@ export const secHeaderHandle: Handle = async ({ event, resolve }) => { ]; if (!dev) { cspDirectives.unshift("default-src 'self' 'wasm-unsafe-eval'"); - cspDirectives.push("img-src 'self' data: https:"); + cspDirectives.push("img-src 'self' data: blob: https:"); } response.headers.set("Content-Security-Policy", cspDirectives.join("; ")); diff --git a/svelte.config.js b/svelte.config.js index b5d398f..258012b 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -7,6 +7,7 @@ const config = { // Consult https://svelte.dev/docs/kit/integrations // for more information about preprocessors preprocess: vitePreprocess(), + checkOrigin: true, kit: { // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. @@ -16,6 +17,18 @@ const config = { alias: { $i18n: path.resolve("./src/i18n"), }, + csp: { + mode: "hash", + directives: { + "script-src": [ + "'self'", + "'unsafe-inline'", + "'wasm-unsafe-eval'", + "'unsafe-eval'", + "https://unpkg.com", + ], + }, + }, }, }; diff --git a/tenant-migrations/0014_fluffy_ezekiel.sql b/tenant-migrations/0014_fluffy_ezekiel.sql new file mode 100644 index 0000000..870ed9c --- /dev/null +++ b/tenant-migrations/0014_fluffy_ezekiel.sql @@ -0,0 +1,2 @@ +ALTER TABLE "appointment" ADD COLUMN "timezone" text NOT NULL DEFAULT 'UTC'; +ALTER TABLE "appointment" ALTER COLUMN "timezone" DROP DEFAULT; \ No newline at end of file diff --git a/tenant-migrations/meta/0014_snapshot.json b/tenant-migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..ddc4cad --- /dev/null +++ b/tenant-migrations/meta/0014_snapshot.json @@ -0,0 +1,973 @@ +{ + "id": "7e551bff-abaf-48b5-bb08-0ba14cb3292d", + "prevId": "726f8502-95ab-458f-83e5-9897a8b46a03", + "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()" + }, + "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 + } + }, + "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 + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "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 312ab27..fa1c97d 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1773231146366, "tag": "0013_youthful_grim_reaper", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1775654963847, + "tag": "0014_fluffy_ezekiel", + "breakpoints": true } ] }