mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
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:
co-authored by
Karl Ludwig Weise
parent
b3c4257ec4
commit
09ffc21523
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
+53
-7
@@ -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}`}
|
||||
>
|
||||
|
||||
+26
-30
@@ -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
|
||||
|
||||
+10
-3
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user