Merge pull request #245 from open-reception/fix/headers

Move headers and fix timezone and daylight-saving issues
This commit is contained in:
Karl Ludwig Weise
2026-04-23 16:50:03 +02:00
committed by GitHub
56 changed files with 1458 additions and 197 deletions
+27
View File
@@ -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.
+2 -1
View File
@@ -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": {
+2 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
);
});
+6
View File
@@ -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,
@@ -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",
})}
</Text>
{/if}
<Text style="md" class="flex pr-8 text-start whitespace-break-spaces">
@@ -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 @@
<div class="flex items-start gap-2">
<Calendar class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{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"))}
</Text>
</div>
{/if}
@@ -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 ?? "";
};
</script>
@@ -139,7 +139,7 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={localTimeToUTC(time)}>{value.label}</Select.Item>
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
@@ -162,7 +162,7 @@
</Select.Trigger>
<Select.Content>
{#each Object.entries(times) as [time, value] (time)}
<Select.Item value={localTimeToUTC(time)}>{value.label}</Select.Item>
<Select.Item value={timeLocalWithoutOffsetToUTC(time)}>{value.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
+2 -3
View File
@@ -48,9 +48,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
+2 -3
View File
@@ -45,9 +45,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
+2 -3
View File
@@ -45,9 +45,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
+2 -3
View File
@@ -48,9 +48,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
+2 -3
View File
@@ -45,9 +45,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{m["emails.oclock"]()}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
<EmailText variant="md">
+22 -10
View File
@@ -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}`;
};
+2
View File
@@ -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 */
@@ -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",
@@ -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,
};
@@ -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,
};
+31 -9
View File
@@ -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<string, string>;
timeZone: string;
}): Promise<DaySchedule[]> {
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)
);
});
}
+30
View File
@@ -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);
}
+1
View File
@@ -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: {
+17 -6
View File
@@ -11,7 +11,7 @@ export interface PasskeyAuthData {
export interface AuthState {
isAuthenticated: boolean;
isRefreshing: boolean;
refreshPromise: Promise<Response> | null;
user?: {
id: string;
email: string;
@@ -26,17 +26,17 @@ export interface AuthState {
function createAuthStore() {
const store = writable<AuthState>({
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<Response> | 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;
}
},
};
}
+1
View File
@@ -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: {
+1
View File
@@ -27,6 +27,7 @@ const createNotificationsStore = () => {
});
try {
await auth.waitForRefresh();
const res = await fetch(`/api/tenants/${tenantId}/notifications`, {
method: "GET",
headers: {
+1
View File
@@ -27,6 +27,7 @@ const createStaffStore = () => {
});
try {
await auth.waitForRefresh();
const res = await fetch(`/api/tenants/${tenantId}/staff`, {
method: "GET",
headers: {
+2
View File
@@ -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;
}
+142 -36
View File
@@ -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);
};
+6 -5
View File
@@ -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);
}
};
@@ -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,
),
);
};
</script>
@@ -230,9 +230,10 @@
{m["public.steps.slot.selectTime"]()}
</Text>
{#each slots as slot (slot.from)}
<Button onclick={() => selectSlot(slot)} class="w-full"
>{formatSlotTime(slot)}</Button
>
<Button onclick={() => selectSlot(slot)} class="w-full">
{formatSlotTime(slot)}
{slot.from}
</Button>
{/each}
</div>
</ScrollArea>
@@ -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,
@@ -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 || "",
});
@@ -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}
</Item.Title>
<Item.Description>
{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))}
</Item.Description>
</Item.Content>
<Item.Actions>
@@ -135,7 +127,7 @@
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(new Date(cancelling.appointmentDate), {
{toDisplayDateTime(utcToLocalWithoutDST(new Date(cancelling.appointmentDate)), {
year: "numeric",
month: "long",
day: "numeric",
+1 -2
View File
@@ -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 = () => {
@@ -1,13 +1,12 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { Text } from "$lib/components/ui/typography";
import { type CurAppointmentItem } from "$lib/stores/calendar";
import { getLocalTimeZone } from "@internationalized/date";
import { Calendar, Mail, Phone, User } from "@lucide/svelte";
import { cancelAppointment, denyAppointment, confirmAppointment } from "./utils";
import { toast } from "svelte-sonner";
import { m } from "$i18n/messages";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Mail, Phone, User } from "@lucide/svelte";
import { toast } from "svelte-sonner";
import { cancelAppointment, confirmAppointment, denyAppointment } from "./utils";
let {
tenantId,
@@ -121,15 +120,7 @@
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(item.appointment.appointment.dateTime, {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
})}
{toDisplayDateTime(item.appointment.appointment.dateTime)}
</Text>
</div>
<div class="mt-5 flex w-full flex-col gap-4">
@@ -65,6 +65,8 @@
>
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-px w-auto!" />
</div>
<!-- Hour markers -->
{#each shownHours as hour (`hour-${hour}`)}
<div
class="relative flex w-full items-start justify-between transition-all duration-200 select-none"
@@ -4,7 +4,7 @@
import { calendarStore } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels";
import { type TCalendarItem } from "$lib/types/calendar";
import { calendarItemToDate, toDisplayDateTime } from "$lib/utils/datetime";
import { toDisplayDateTime, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
let {
@@ -27,9 +27,10 @@
variant="ghost"
onclick={setCalendarItem}
>
<span class="sr-only">
<span class="">
{item.start}
{m["calendar.addAppointment.preview"]({
time: toDisplayDateTime(calendarItemToDate(item), {
time: toDisplayDateTime(utcToLocalWithoutDST(new Date(item.start)), {
hour: "2-digit",
minute: "2-digit",
}),
@@ -6,7 +6,6 @@
import { staffCrypto } from "$lib/stores/staff-crypto";
import { tenants } from "$lib/stores/tenants";
import type { TCalendarSlot } from "$lib/types/calendar";
import { calendarItemToDate } from "$lib/utils/datetime";
import { getDefaultAppointmentLocale } from "$lib/utils/localizations";
import { BanIcon, Check } from "@lucide/svelte";
import { get } from "svelte/store";
@@ -15,6 +14,7 @@
import SelectAgent from "./SelectAgent.svelte";
import Summary from "./Summary.svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import { utcToLocalWithoutDST } from "$lib/utils/datetime";
let {
tenantId,
@@ -26,10 +26,13 @@
updateCalendar: () => void;
} = $props();
// Set the utc time for it to be properly saved
const localTime = utcToLocalWithoutDST(new Date(item.start));
let step: TAddAppointmentStep = $state("email");
let newAppointment: TAddAppointment = $state({
locale: getDefaultAppointmentLocale(get(tenants).currentTenant),
dateTime: calendarItemToDate(item),
dateTime: localTime,
});
let isSubmitting = $state(false);
@@ -28,14 +28,7 @@
<div class="flex gap-2">
<Calendar class="size-4 " />
<Text style="sm">
{toDisplayDateTime(newAppointment.dateTime, {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
})}
{toDisplayDateTime(newAppointment.dateTime)}
</Text>
</div>
{#if newAppointment.name}
@@ -2,10 +2,12 @@ import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { calendarStore } from "$lib/stores/calendar";
import { staffCrypto } from "$lib/stores/staff-crypto";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import type { CalendarDate } from "@internationalized/date";
import { localToUTCWithoutDST } from "$lib/utils/datetime";
import { getLocalTimeZone, type CalendarDate } from "@internationalized/date";
import { get } from "svelte/store";
export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => {
@@ -21,7 +23,9 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD
const params = new URLSearchParams({
startDate: localStartDate.toISOString(),
endDate: localEndDate.toISOString(),
timeZone: getLocalTimeZone().toString(),
});
await auth.waitForRefresh();
const res = await fetch(`/api/tenants/${opts.tenant}/calendar?${params}`, {
method: "GET",
});
@@ -41,9 +45,9 @@ export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarD
};
// Convert time string to minutes since midnight
function timeToMinutes(time: string): number {
if (time.includes("T")) {
const parsedDate = new Date(time);
function timeToMinutes(dateTimeStr: string): number {
if (dateTimeStr.includes("T")) {
const parsedDate = new Date(dateTimeStr);
if (!Number.isNaN(parsedDate.getTime())) {
return (
parsedDate.getHours() * 60 + parsedDate.getMinutes() + parsedDate.getTimezoneOffset() + 60
@@ -51,15 +55,26 @@ function timeToMinutes(time: string): number {
}
}
const [hours, minutes] = time.split(":").map(Number);
const [hours, minutes] = dateTimeStr.split(":").map(Number);
return hours * 60 + minutes;
}
export function positionItems(items: TCalendarItem[] | undefined) {
if (!items) return [];
// Adjust actual appointments
const adjustedItems = [...items].map((item) => {
if (item.appointment) {
return {
...item,
start: localToUTCWithoutDST(item.appointment.dateTime).toISOString(),
};
}
return item;
});
// Sort items by start time
const sortedItems = [...items]
const sortedItems = [...adjustedItems]
.sort((a, b) => b.duration - a.duration)
.sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start));
@@ -13,6 +13,7 @@
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendar, TCalendarItem } from "$lib/types/calendar";
import { timeUTCToLocalWithoutOffset, utcToLocalWithoutDST } from "$lib/utils/datetime";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import {
getLocalTimeZone,
@@ -29,7 +30,6 @@
import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte";
import { fetchCalendar, openAppointmentById } from "./(components)/utils";
import { utcTimeToLocal } from "$lib/utils/datetime";
const convertDate = (dateStr: string) => {
const zonedDateTime = parseAbsoluteToLocal(dateStr);
@@ -55,14 +55,14 @@
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = utcTimeToLocal(time).split(":");
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
const to = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr] = utcTimeToLocal(time).split(":");
const [hourStr] = timeUTCToLocalWithoutOffset(time).split(":");
return parseInt(hourStr, 10);
});
return { from: Math.min(...from), to: Math.max(...to) };
@@ -136,7 +136,8 @@
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
if (new Date(slot.to) > new Date()) {
const isSlotInPast = utcToLocalWithoutDST(new Date(slot.to)) < new Date();
if (!isSlotInPast) {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
@@ -1,9 +1,9 @@
import type { TNewSlotTemplate } from "$lib/types/channel";
import { localTimeToUTC } from "$lib/utils/datetime";
import { timeLocalWithoutOffsetToUTC } from "$lib/utils/datetime";
export const DEFAULT_SLOT_TEMPLATE: TNewSlotTemplate = {
weekdays: 31,
from: localTimeToUTC("09:00:00"),
to: localTimeToUTC("17:00:00"),
from: timeLocalWithoutOffsetToUTC("09:00:00"),
to: timeLocalWithoutOffsetToUTC("17:00:00"),
duration: 15,
};
@@ -11,6 +11,10 @@
let { data } = $props();
</script>
<svelte:head>
<title>{m["settings.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout
breakcrumbs={[
{
@@ -27,6 +27,7 @@ const requestSchema = z.object({
channelId: z.string(),
agentId: z.string(),
appointmentDate: z.string(),
appointmentTimeZone: z.string(),
duration: z.number().int().positive(),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
@@ -254,6 +255,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
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,
};
@@ -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",
@@ -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",
@@ -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 || "",
@@ -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",
@@ -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,
});
@@ -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,
});
});
@@ -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)
@@ -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",
});
});
});
+2 -2
View File
@@ -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("; "));
+13
View File
@@ -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",
],
},
},
},
};
@@ -0,0 +1,2 @@
ALTER TABLE "appointment" ADD COLUMN "timezone" text NOT NULL DEFAULT 'UTC';
ALTER TABLE "appointment" ALTER COLUMN "timezone" DROP DEFAULT;
+973
View File
@@ -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": {}
}
}
+7
View File
@@ -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
}
]
}