Save timezone, use timezone in appointment mails.

This commit is contained in:
Hendrik Belitz
2026-04-08 15:43:27 +02:00
parent cd33d707ee
commit 527e1f49e9
15 changed files with 1098 additions and 21 deletions
+4
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,
+2 -2
View File
@@ -48,8 +48,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
+2 -2
View File
@@ -45,8 +45,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
+2 -2
View File
@@ -45,8 +45,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
+2 -2
View File
@@ -48,8 +48,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
+2 -2
View File
@@ -45,8 +45,8 @@
<EmailHeadline>{channel}</EmailHeadline>
<EmailText variant="md">
{appointment.agentName}<br />
{renderAppointmentDate(appointment.appointmentDate, locale)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale)}
{renderAppointmentDate(appointment.appointmentDate, locale, appointment.timezone)}<br />
{renderAppointmentTime(appointment.appointmentDate, locale, appointment.timezone)}
{m["emails.oclock"]()}
</EmailText>
<EmailHeadline>{tenant.longName}</EmailHeadline>
+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 */
@@ -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,
};
+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;
}
@@ -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",
@@ -0,0 +1,2 @@
ALTER TABLE "appointment" ADD COLUMN "timezone" text NOT NULL DEFAULT 'UTC';
ALTER TABLE "appointment" ALTER COLUMN "timezone" DROP DEFAULT;
File diff suppressed because it is too large Load Diff
+8 -1
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
}
]
}
}