Added flow for staff members to create appointments (#204)

* Added flow for staff members to create appointments

* Add calendar page title

* Remove security check npm audit for pull-requests

* Fix docker build to be multi-platform

* Attempt to fix tests

* Remove obsolete test

* Refresh session every 3 minutes

---------

Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
Karl Ludwig Weise
2026-03-06 10:22:28 +01:00
committed by GitHub
co-authored by Karl Ludwig Weise
parent b3c4257ec4
commit 09ffc21523
20 changed files with 364 additions and 192 deletions
-3
View File
@@ -22,9 +22,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Security audit
run: npm audit --audit-level=high
- name: Run linting
run: npm run lint
check:
+1 -1
View File
@@ -63,7 +63,7 @@ jobs:
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build Docker image
run: docker build -t openreception/open-reception:${{ github.ref_name }} .
run: docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:${{ github.ref_name }} .
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
+3
View File
@@ -10,6 +10,9 @@ COPY package*.json ./
RUN npm ci --ignore-scripts && \
npm cache clean --force
# Set node env
ENV NODE_ENV=production
# Copy source and build
COPY . .
RUN npm run build
+1 -1
View File
@@ -26,7 +26,7 @@
"docker:dev:down": "docker compose -f docker-compose.dev.yml down",
"docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
"docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans",
"docker:build": "docker build -t openreception/open-reception:latest .",
"docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:latest .",
"docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version",
"docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest",
"docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push",
+6 -2
View File
@@ -924,8 +924,9 @@
}
},
"calendar": {
"title": "Kalender",
"today": "Heute",
"loading": "Loading calendar",
"loading": "Kalender wird geladen",
"decrypting": "wird entschlüsselt...",
"decryptingError": "Entschlüsselung fehlgeschlagen. Bitte neu anmelden.",
"decryptingErrorNoKeyShare": "Entschlüsselung fehlgeschlagen. Bitten Sie ein Teammitglied, Ihnen Zugriff zu gewähren.",
@@ -957,11 +958,14 @@
"error": "Fehler beim Bestätigen des Termins"
},
"addAppointment": {
"title": "Neuen Termin erstellen",
"preview": "Neuer Termin {time} Uhr {channel}",
"steps": {
"selectClient": {
"hasNoEmail": "Klient hat keine E-Mail Adresse",
"action": "Klient suchen"
"action": "Klient suchen",
"confirmNewClient": "Dieser Klient hat noch kein Konto. Möchten Sie für ihn ein Konto erstellen?",
"proceedExistingClient": "Klient ausgewählt."
},
"agents": {
"title": "Akteur auswählen"
+5 -1
View File
@@ -933,6 +933,7 @@
}
},
"calendar": {
"title": "Calendar",
"today": "Today",
"loading": "Loading calendar",
"decrypting": "decrypting...",
@@ -966,11 +967,14 @@
"error": "Failed to confirm appointment"
},
"addAppointment": {
"title": "Add Appointment",
"preview": "Add {time} {channel}",
"steps": {
"selectClient": {
"hasNoEmail": "Client has no e-mail",
"action": "Search Client"
"action": "Search Client",
"confirmNewClient": "This client does not have an account yet. Do you want to create an account for them?",
"proceedExistingClient": "Client selected."
},
"agents": {
"title": "Select an Agent"
+157 -48
View File
@@ -48,8 +48,9 @@
*/
import { OptimizedArgon2 } from "$lib/crypto/hashing";
import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
import { pinThrottleStore } from "$lib/stores/pin-throttle";
import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils";
// Type definitions for unified cryptography
interface ClientKeyPair {
@@ -104,6 +105,17 @@ interface MyAppointmentsResponse {
encryptedData: EncryptedData;
}>;
}
type StaffKeyShares = Array<{ userId: string; encryptedTunnelKey: string }>;
type EncryptableTunnelConfig = {
emailHash: string;
decryptedTunnelKey: CryptoKey;
staffKeyShares: StaffKeyShares;
clientPublicKey: string;
tunnelId: string;
};
/**
* Generate deterministic SHA-256 hash of email for privacy-preserving lookups
*/
@@ -483,6 +495,143 @@ export class UnifiedAppointmentCrypto {
}
}
private createTunnelForNewClient = async (params: {
tenantId: string;
email: string;
}): Promise<EncryptableTunnelConfig> => {
const usedPin = crypto.randomUUID().slice(0, 6);
// Create tunnel
await this.initNewClient(params.email, usedPin, params.tenantId);
if (!this.tunnelId) {
throw new Error("Failed to use initialized client tunnel");
}
if (!this.tunnelKey) {
throw new Error("Failed to use initialized client tunnel key");
}
if (!this.clientKeyPair?.publicKey) {
throw new Error("Failed to use initialized client public key");
}
return {
tunnelId: this.tunnelId,
emailHash: await hashEmail(params.email),
clientPublicKey: this.clientKeyPair?.publicKey,
decryptedTunnelKey: this.tunnelKey,
staffKeyShares: await this.getStaffKeyShares(params.tenantId),
};
};
private useTunnelForExistingClient = async (params: {
tunnel: ClientTunnelResponse;
tenantId: string;
email: string;
}): Promise<EncryptableTunnelConfig> => {
const decryptedTunnelKey = await this.decryptTunnelKeyByStaff(
params.tunnel.currentStaffEncryptedTunnelKey!,
);
return {
tunnelId: params.tunnel.id,
emailHash: await hashEmail(params.email),
clientPublicKey: params.tunnel.clientPublicKey,
decryptedTunnelKey,
staffKeyShares: await this.getStaffKeyShares(params.tenantId, decryptedTunnelKey),
};
};
/**
* Creates a new encrypted appointment that is created by a staff member
*/
async createAppointmentByStaff(params: {
appointmentData: AppointmentDataByStaff;
appointmentDate: Date;
agentId: string;
channelId: string;
duration: number;
tenantId: string;
email?: string;
hasNoEmail: boolean;
tunnel: ClientTunnelResponse | undefined;
}): Promise<string> {
if (!this.staffAuthenticated || !this.staffKeyPair) {
throw new Error("Staff Member not authenticated");
}
try {
// If new client, create new tunnel and then get it
const usedEmail = params.email ?? `${crypto.randomUUID()}@client.noemail`;
const tunnelConfig = !params.tunnel
? await this.createTunnelForNewClient({ tenantId: params.tenantId, email: usedEmail })
: await this.useTunnelForExistingClient({
tunnel: params.tunnel,
tenantId: params.tenantId,
email: usedEmail,
});
// Encrypt appointment data
const encryptedAppointment = await this.encryptAppointmentData(
params.appointmentData,
tunnelConfig.decryptedTunnelKey,
);
// Set tunnelKey & clientKeyPair for encryptTunnelKeyForClient
this.tunnelKey = tunnelConfig.decryptedTunnelKey;
this.clientKeyPair = {
publicKey: tunnelConfig.clientPublicKey,
privateKey: "", // Not needed here
};
// Call endpoint
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(),
duration: params.duration,
agentId: params.agentId,
channelId: params.channelId,
encryptedAppointment,
sendEmail,
clientLanguage: params.appointmentData.locale,
tunnelId: tunnelConfig.tunnelId,
clientPublicKey: tunnelConfig.clientPublicKey,
staffKeyShares: tunnelConfig.staffKeyShares,
privateKeyShare: await this.getClientKeyShare(),
clientEncryptedTunnelKey: await this.encryptTunnelKeyForClient(),
};
const response = await fetch(`/api/tenants/${params.tenantId}/appointments/staff-create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error("Appointment could not be created");
}
const result = await response.json();
console.log("✅ Encrypted appointment created:", result.id);
return result.id;
} catch (error) {
console.error("❌ Error creating appointment:", error);
throw error;
} finally {
// Clear sensitive data from class properties to prevent leaks on reuse
this.tunnelKey = null;
this.clientKeyPair = null;
this.emailHash = null;
this.tunnelId = null;
this.serverPrivateKeyShare = null;
this.clientAuthenticated = false;
this.pin = null;
}
}
/**
* Allows to check, if a client is authenticated
*/
@@ -947,8 +1096,10 @@ export class UnifiedAppointmentCrypto {
*/
private async encryptAppointmentData(
data: AppointmentData | AppointmentDataByStaff,
tunnelKey?: CryptoKey,
): Promise<EncryptedData> {
if (!this.tunnelKey) throw new Error("No tunnel key available");
const usedTunnelKey = tunnelKey ?? this.tunnelKey;
if (!usedTunnelKey) throw new Error("No tunnel key available");
const encoder = new TextEncoder();
const plaintext = encoder.encode(JSON.stringify(data));
@@ -956,7 +1107,7 @@ export class UnifiedAppointmentCrypto {
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
this.tunnelKey,
usedTunnelKey,
plaintext,
);
@@ -1089,35 +1240,12 @@ export class UnifiedAppointmentCrypto {
// Public key is stored as Base64, not Hex
const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey);
console.log("🔐 Encrypting tunnel key for staff:", {
userId: staff.userId,
publicKeyLength: staffPublicKeyBytes.length,
publicKeyHex:
Array.from(staffPublicKeyBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("")
.substring(0, 64) + "...",
tunnelKeyLength: tunnelKeyArray.length,
});
// Kyber encapsulation creates a shared secret
const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(staffPublicKeyBytes);
console.log("🔑 Kyber encapsulation done:", {
sharedSecretLength: sharedSecret.length,
encapsulatedSecretLength: encapsulatedSecret.length,
});
// Use the first 32 bytes of shared secret as AES key (same as decryption)
const aesKeyBytes = sharedSecret.slice(0, 32);
console.log(
"🔑 AES key for encryption (hex):",
Array.from(aesKeyBytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Import as CryptoKey for Web Crypto API
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"encrypt",
@@ -1126,19 +1254,6 @@ export class UnifiedAppointmentCrypto {
// Generate IV for AES-GCM
const iv = BufferUtils.randomBytes(12);
console.log(
"📍 IV for encryption (hex):",
Array.from(iv)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
console.log(
"🔒 Tunnel key to encrypt (hex):",
Array.from(tunnelKeyArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Encrypt tunnel key with AES-GCM
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
@@ -1149,13 +1264,6 @@ export class UnifiedAppointmentCrypto {
// encrypted contains ciphertext + 16-byte auth tag
const encryptedArray = new Uint8Array(encrypted);
console.log(
"🔒 Encrypted tunnel key (hex):",
Array.from(encryptedArray)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
);
// Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag)
const combined = new Uint8Array(
encapsulatedSecret.length + iv.length + encryptedArray.length,
@@ -1386,10 +1494,11 @@ export class UnifiedAppointmentCrypto {
private async getStaffKeyShares(
tenantId: string,
): Promise<Array<{ userId: string; encryptedTunnelKey: string }>> {
decryptedTunnelKey?: CryptoKey,
): Promise<StaffKeyShares> {
// Fetch and encrypt tunnel key for all staff members
const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId);
return await this.encryptTunnelKeyForStaff(staffPublicKeys);
return await this.encryptTunnelKeyForStaff(staffPublicKeys, decryptedTunnelKey);
}
private async getClientKeyShare(): Promise<string> {
+16 -12
View File
@@ -348,7 +348,7 @@ export class AppointmentService {
*/
public async deleteAppointmentByStaff(
appointmentId: string,
clientEmail: string,
clientEmail: string | undefined,
clientLanguage: string = "de",
): Promise<void> {
const log = logger.setContext("AppointmentService");
@@ -396,18 +396,22 @@ export class AppointmentService {
const channelTitle = await getChannelTitle(this.tenantId, channelId, clientLanguage);
// Send cancellation email to client (async, don't wait)
const clientData = {
email: clientEmail,
language: clientLanguage,
};
if (clientEmail) {
const clientData = {
email: clientEmail,
language: clientLanguage,
};
sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch((error) => {
log.error("Failed to send appointment cancellation email", {
appointmentId,
clientEmail,
error: String(error),
});
});
sendAppointmentCancelledEmail(clientData, tenant, appointment, channelTitle).catch(
(error) => {
log.error("Failed to send appointment cancellation email", {
appointmentId,
clientEmail,
error: String(error),
});
},
);
}
// Create notifications for all staff members in the channel
const notificationService = await NotificationService.forTenant(this.tenantId);
+14
View File
@@ -1,6 +1,7 @@
import { getLocale } from "$i18n/runtime";
import type { supportedLocales } from "$lib/const/locales";
import type { TPublicTenant } from "$lib/types/public";
import type { TTenant } from "$lib/types/tenant";
export const removeEmptyTranslations = (object: { [key: string]: string } | undefined) => {
if (!object) return object;
@@ -25,3 +26,16 @@ export const getPublicLocale = (tenant: TPublicTenant): string => {
}
return tenant.defaultLanguage as unknown as string;
};
export const getDefaultAppointmentLocale = (tenant: TTenant | null): string => {
const locale = getLocale();
if (!tenant) return locale;
const availableLanguages = tenant.languages;
if (availableLanguages.includes(locale)) {
return locale;
}
// TODO: Not ideal
return availableLanguages[0];
};
+1 -1
View File
@@ -28,7 +28,7 @@
}
if (!intervalSession) {
refreshSession();
intervalSession = setInterval(refreshSession, 10 * 60 * 1000); // 10 minutes
intervalSession = setInterval(refreshSession, 3 * 60 * 1000); // 3 minutes
}
const unsubscribe = () => {
@@ -87,33 +87,37 @@
</script>
{#if item.appointment.appointment}
<div class="flex flex-col items-start gap-3">
<div class="flex flex-col items-start gap-2">
<div class="flex gap-2 p-1">
<User class="size-4 " />
<Text style="sm">
{item.decrypted.name}
</Text>
</div>
<div class="flex flex-col items-start gap-2">
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`mailto:${item.decrypted.email}`}
>
<Mail class="size-4 " />
{item.decrypted.email}
</Button>
{#if item.decrypted.phone}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`tel:${item.decrypted.phone}`}
>
<Phone class="size-4 " />
{item.decrypted.phone}
</Button>
{/if}
</div>
{#if item.decrypted.email || item.decrypted.phone}
<div class="flex flex-col items-start gap-2">
{#if item.decrypted.email}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`mailto:${item.decrypted.email}`}
>
<Mail class="size-4 " />
{item.decrypted.email}
</Button>
{/if}
{#if item.decrypted.phone}
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
variant="link"
href={`tel:${item.decrypted.phone}`}
>
<Phone class="size-4 " />
{item.decrypted.phone}
</Button>
{/if}
</div>
{/if}
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
@@ -1,15 +1,20 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { type AppointmentDataByStaff } from "$lib/client/appointment-crypto";
import { CenterState } from "$lib/components/templates/empty-state";
import Button from "$lib/components/ui/button/button.svelte";
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";
import { ClientDataForm } from "./client-data-form";
import { SearchClientForm } from "./search-client-form";
import SelectAgent from "./SelectAgent.svelte";
import Summary from "./Summary.svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import { ClientDataForm } from "./client-data-form";
let {
tenantId,
@@ -22,7 +27,11 @@
} = $props();
let step: TAddAppointmentStep = $state("email");
let newAppointment: TAddAppointment = $state({ dateTime: calendarItemToDate(item) });
let newAppointment: TAddAppointment = $state({
locale: getDefaultAppointmentLocale(get(tenants).currentTenant),
dateTime: calendarItemToDate(item),
});
let isSubmitting = $state(false);
const proceed = (data: TAddAppointment) => {
switch (true) {
@@ -56,19 +65,56 @@
};
const addAppointment = async () => {
console.log("adding appointment", newAppointment);
updateCalendar();
step = "success";
if (
newAppointment.name &&
newAppointment.agentId &&
typeof newAppointment.hasNoEmail !== "undefined"
) {
isSubmitting = true;
const appointmentData: AppointmentDataByStaff = {
name: newAppointment.name,
shareEmail: newAppointment.shareEmail || false,
email: newAppointment.email,
phone: newAppointment.phone,
locale: newAppointment.locale,
};
await $staffCrypto.crypto
?.createAppointmentByStaff({
appointmentData,
tenantId,
appointmentDate: newAppointment.dateTime,
duration: item.duration,
hasNoEmail: newAppointment.hasNoEmail,
agentId: newAppointment.agentId,
channelId: item.channelId,
tunnel: newAppointment.tunnel,
email: newAppointment.email,
})
.then(() => {
step = "success";
updateCalendar();
})
.catch(() => {
step = "error";
})
.finally(() => {
isSubmitting = false;
});
}
};
const onChangeLocale = (locale: string) => {
newAppointment = { ...newAppointment, locale };
};
</script>
<Summary {step} {newAppointment} />
<Summary {step} {newAppointment} {onChangeLocale} />
{#if step === "email"}
<SearchClientForm {tenantId} {newAppointment} {proceed} />
{:else if step === "agent" && item.availableAgents}
<SelectAgent availableAgents={item.availableAgents} {newAppointment} {proceed} />
{:else if step === "summary"}
<Button onclick={addAppointment} class="w-full">
<Button onclick={addAppointment} class="w-full" isLoading={isSubmitting} disabled={isSubmitting}>
{m["calendar.addAppointment.steps.summary.action"]()}
</Button>
{:else if step === "client"}
@@ -4,18 +4,24 @@
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { toDisplayDateTime } from "$lib/utils/datetime";
import { Calendar, Mail, Phone, User, UserStar } from "@lucide/svelte";
import { Calendar, Languages, Mail, Phone, User, UserStar } from "@lucide/svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import * as Select from "$lib/components/ui/select";
import { tenants } from "$lib/stores/tenants";
import { languageSwitchLocales } from "$lib/const/locales";
let {
step,
newAppointment,
onChangeLocale,
}: {
step: TAddAppointmentStep;
newAppointment: TAddAppointment;
onChangeLocale: (locale: string) => void;
} = $props();
let agent = $derived($agentsStore.agents.find((a) => a.id === newAppointment.agentId));
let languages = $derived($tenants.currentTenant?.languages);
</script>
<div class="flex flex-col items-start gap-3">
@@ -41,8 +47,29 @@
</div>
{/if}
{#if newAppointment.email}
<div class="flex w-auto items-center justify-start gap-2">
<Languages class="size-4" />
<Select.Root type="single" onValueChange={onChangeLocale} value={newAppointment.locale}>
<Select.Trigger
class="h-1! w-full grow border-0 py-0 pl-1 font-medium shadow-none"
size="sm"
>
{@const locale = newAppointment.locale
? languageSwitchLocales[newAppointment.locale as keyof typeof languageSwitchLocales]
: undefined}
{locale ? locale.label : "Select language"}
</Select.Trigger>
<Select.Content>
{#each languages as language (language)}
<Select.Item value={language}>
{languageSwitchLocales[language as keyof typeof languageSwitchLocales].label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Button
class="h-auto w-auto justify-start gap-2 rounded-sm p-1"
class="h-auto w-auto justify-start gap-2 rounded-sm"
variant="link"
href={`mailto:${newAppointment.email}`}
>
@@ -7,6 +7,9 @@
import { formSchema } from ".";
import type { TAddAppointment } from "../types";
import Button from "$lib/components/ui/button/button.svelte";
import { hashEmail } from "$lib/client/appointment-crypto";
import { fetchClientTunnels } from "../../../../staff/(components)/utils";
import { toast } from "svelte-sonner";
let {
tenantId,
@@ -27,25 +30,23 @@
if (validation.valid) {
cancel();
isSubmitting = true;
console.log("tenantId", tenantId);
console.log("email", $formData.email);
const hashedEmail = await hashEmail($formData.email);
console.log("hashed", hashedEmail);
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false });
// const success = await confirmAppointment({
// tenant: tenantId,
// appointment: item.appointment.id,
// email: item.decrypted.shareEmail ? item.decrypted.email : undefined,
// locale: "de", // TODO: Use client language as soon as available in appointment
// });
// if (success) {
// toast.success(m["calendar.confirmAppointment.success"]());
// updateCalendar();
// close();
// } else {
// toast.error(m["calendar.confirmAppointment.error"]());
// }
// Find client tunnel, if it exists
const tunnels = await fetchClientTunnels(tenantId);
const hashedEmail = await hashEmail($formData.email);
const tunnel = tunnels.find((t) => t.emailHash === hashedEmail);
if (tunnel) {
toast.success(m["calendar.addAppointment.steps.selectClient.proceedExistingClient"]());
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false, tunnel });
} else {
const isOk = confirm(
m["calendar.addAppointment.steps.selectClient.confirmNewClient"](),
);
if (isOk) {
proceed({ ...newAppointment, email: $formData.email, hasNoEmail: false, tunnel });
}
}
isSubmitting = false;
}
},
@@ -56,17 +57,6 @@
const { form: formData, enhance, validateForm } = form;
// TODO: Use the version from appointment-crypto
const hashEmail = async (email: string): Promise<string> => {
const emailNormalized = email.toLowerCase().trim();
const encoder = new TextEncoder();
const data = encoder.encode(emailNormalized);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
};
const proceedWithoutEmail = () => {
proceed({ ...newAppointment, email: undefined, hasNoEmail: true });
};
@@ -77,7 +67,13 @@
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["form.email"]()}</Form.Label>
<Input {...props} bind:value={$formData.email} type="email" />
<Input
{...props}
bind:value={$formData.email}
type="email"
autocomplete="off"
autocapitalize="off"
/>
{/snippet}
</Form.Control>
<Form.FieldErrors />
@@ -1,3 +1,5 @@
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
export type TAddAppointment = {
dateTime: Date;
agentId?: string;
@@ -6,6 +8,8 @@ export type TAddAppointment = {
shareEmail?: boolean;
name?: string;
phone?: string;
tunnel?: ClientTunnelResponse;
locale?: string;
};
export type TAddAppointmentStep = "email" | "agent" | "client" | "summary" | "success" | "error";
@@ -192,6 +192,10 @@
});
</script>
<svelte:head>
<title>{m["calendar.title"]()} - OpenReception</title>
</svelte:head>
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
<MaxPageWidth maxWidth="xl">
<div class="flex flex-col gap-10">
@@ -239,7 +243,7 @@
{@const channel = channels.find((c) => c.id === curEmptySlot.channelId)}
<ResponsiveDialog
id="current-calendar-slot"
title="Add Appointment"
title={m["calendar.addAppointment.title"]()}
description={channel ? getCurrentTranlslation(channel.names) : undefined}
triggerHidden={true}
>
@@ -8,8 +8,8 @@ import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
const requestSchema = z.object({
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
clientEmail: z.email().optional(),
clientLanguage: z.string().optional().default("en"),
});
// Register OpenAPI documentation for DELETE
@@ -105,11 +105,17 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete", () => {
expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith(
mockAppointmentId,
mockClientEmail,
"de",
"en",
);
});
it("should return 422 when clientEmail is missing", async () => {
it("should allow clientEmail to be is missing", async () => {
const mockDeleteAppointmentByStaff = vi.fn().mockResolvedValue(undefined);
vi.mocked(appointmentService.AppointmentService.forTenant).mockResolvedValue({
deleteAppointmentByStaff: mockDeleteAppointmentByStaff,
} as any);
const request = new Request("http://localhost", {
method: "DELETE",
body: JSON.stringify({}),
@@ -124,7 +130,8 @@ describe("DELETE /api/tenants/[id]/appointments/[appointmentId]/delete", () => {
locals: { user: { id: "user-123" } },
} as any);
expect(response.status).toBe(422);
expect(response.status).toBe(200);
expect(mockDeleteAppointmentByStaff).toHaveBeenCalledWith(mockAppointmentId, undefined, "en");
});
it("should return 422 when clientEmail is invalid", async () => {
@@ -390,7 +390,12 @@ export const POST: RequestHandler = async ({ params, request, locals }) => {
}
// Send email notification if requested and client has email
if (validatedData.sendEmail && validatedData.clientEmail && !validatedData.hasNoEmail) {
if (
existingTunnel &&
validatedData.sendEmail &&
validatedData.clientEmail &&
!validatedData.hasNoEmail
) {
try {
await appointmentService.sendAppointmentNotification(
result.id,
@@ -422,62 +422,6 @@ describe("POST /api/tenants/[id]/appointments/staff-create", () => {
});
describe("Email Sending", () => {
it("should send email for new client when sendEmail is true", async () => {
mockAppointmentService.getClientTunnels.mockResolvedValue([]);
mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({
id: "appointment-123",
appointmentDate: "2026-01-15T14:00:00.000Z",
status: "NEW",
requiresConfirmation: true,
});
mockPinResetService.createResetToken.mockResolvedValue("reset-token-123");
mockAppointmentService.sendAppointmentNotification.mockResolvedValue(undefined);
const request = new Request("http://localhost/api", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientEmail: "test@example.com",
emailHash,
appointmentDate: "2026-01-15T14:00:00.000Z",
duration: 30,
channelId: "channel-123",
agentId: "agent-123",
tunnelId: "tunnel-123",
clientPublicKey: "public-key",
privateKeyShare: "private-key-share",
clientEncryptedTunnelKey: "encrypted-tunnel-key",
staffKeyShares: [
{
userId: "staff-123",
encryptedTunnelKey: "encrypted-for-staff",
},
],
encryptedAppointment: {
encryptedPayload: "encrypted-payload",
iv: "iv",
authTag: "auth-tag",
},
sendEmail: true,
}),
});
await POST({
params: { id: tenantId },
request,
locals: { user: { id: "staff-123", role: "STAFF" } } as any,
} as any);
// Email sending is async, so we just verify it was called
expect(mockAppointmentService.sendAppointmentNotification).toHaveBeenCalledWith(
"appointment-123",
"channel-123",
"test@example.com",
"de",
false,
);
});
it("should not send email when sendEmail is false", async () => {
mockAppointmentService.getClientTunnels.mockResolvedValue([]);
mockAppointmentService.createNewClientWithAppointment.mockResolvedValue({