Merge pull request #326 from open-reception/feat/resolve-missing-staff-crypto-situations

Resolve missing staff crypto situations
This commit is contained in:
Karl Ludwig Weise
2026-09-10 22:30:51 +02:00
committed by GitHub
45 changed files with 444 additions and 236 deletions
+4 -4
View File
@@ -47,7 +47,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.15.4",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
@@ -3803,9 +3803,9 @@
"license": "MIT"
},
"node_modules/bits-ui": {
"version": "2.18.1",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz",
"integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==",
"version": "2.19.0",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.19.0.tgz",
"integrity": "sha512-dHDm5Jw2NZJgLRvHAwPeuR4BnZij+AqTIq33OqVfo+qiMXontoC/yIvGT5TZaviv7OuBdJ2QUy2JPQ2yKBvF6w==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -57,7 +57,7 @@
"@types/dotenv": "^6.1.1",
"@types/node": "^24",
"@types/nodemailer": "^6.4.17",
"bits-ui": "^2.15.4",
"bits-ui": "^2.19.0",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.1",
"eslint": "^9.29.0",
+12
View File
@@ -458,6 +458,18 @@
}
],
"error": "Fehler beim Senden"
},
"missingCryptoKeys": {
"title": "Kryptografische Schlüssel fehlen",
"description": "Dieser Tab enthält nicht die kryptografischen Schlüssel zum Entschlüsseln und Verschlüsseln von Daten. Lade die Schlüssel, um Termine/Daten anzuzeigen und hinzuzufügen.",
"cancel": "Ich will nur Einstellungen ändern",
"success": "Kryptografische Schlüssel geladen",
"error": "Fehler beim Laden der kryptografischen Schlüssel"
},
"inactiveSession": {
"title": "Bist du noch da?",
"description": "Du wirst in Kürze automatisch abgemeldet.",
"cancel": "Ich bin noch da"
}
},
"agents": {
+12
View File
@@ -466,6 +466,18 @@
}
],
"error": "Failed to send reminders"
},
"missingCryptoKeys": {
"title": "Cryptography Keys Missing",
"description": "This tab is missing the cryptographic keys to decrypt and encrypt data. Load keys to view and add appointments/data.",
"cancel": "I just want to change settings",
"success": "Cryptography keys loaded",
"error": "Failed to load cryptography keys"
},
"inactiveSession": {
"title": "Are you still here?",
"description": "You will be automatically logged out soon.",
"cancel": "I am still here"
}
},
"agents": {
-14
View File
@@ -153,9 +153,7 @@ export class UnifiedAppointmentCrypto {
// Staff-specific properties
private staffKeyPair: StaffKeyPair | null = null;
private staffId: string | null = null;
private tenantId: string | null = null;
private staffAuthenticated: boolean = false;
private keyExpiry: number | null = null;
// Shared crypto utilities
private kyberCrypto: KyberCrypto = new KyberCrypto();
@@ -786,9 +784,7 @@ export class UnifiedAppointmentCrypto {
};
this.staffId = staffId;
this.tenantId = tenantId;
this.staffAuthenticated = true;
this.keyExpiry = Date.now() + 10 * 60 * 1000; // 10 minutes
console.log("✅ Staff authentication successful with PRF");
} catch (error) {
@@ -820,10 +816,6 @@ export class UnifiedAppointmentCrypto {
throw new Error("Staff not authenticated");
}
if (this.keyExpiry && Date.now() > this.keyExpiry) {
throw new Error("Staff session expired - please authenticate again");
}
try {
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
const staffKeyShareBytes = this.hexToUint8Array(encryptedData.staffKeyShare);
@@ -958,9 +950,7 @@ export class UnifiedAppointmentCrypto {
logoutStaff(): void {
this.staffKeyPair = null;
this.staffId = null;
this.tenantId = null;
this.staffAuthenticated = false;
this.keyExpiry = null;
}
/**
@@ -1727,10 +1717,6 @@ export class UnifiedAppointmentCrypto {
throw new Error("Staff not authenticated");
}
if (this.keyExpiry && Date.now() > this.keyExpiry) {
throw new Error("Staff session expired - please authenticate again");
}
try {
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
const staffKeyShareBytes = this.hexToUint8Array(staffKeyShare);
@@ -52,10 +52,10 @@
}
};
const decryptPayload = async () => {
const decryptPayload = async (retry = true) => {
if (item.metaData?.encryptedPayload) {
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const maxWaitTime = 5000;
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
@@ -63,6 +63,13 @@
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const user = $auth.user;
if (retry && user && user.tenantId) {
console.warn("Staff crypto not initialized, retrying notification item decryption...");
await staffCrypto.authenticateAndWait(user.id, user.tenantId);
decryptPayload(false);
return;
}
console.error("Staff crypto not initialized after waiting");
return;
}
@@ -52,6 +52,7 @@
triggerHidden = false,
triggerVariant = "default",
isActionLoading = false,
isDismissable = true,
actions,
children,
}: HTMLAttributes<HTMLDivElement> & {
@@ -62,6 +63,7 @@
description?: string;
triggerVariant?: ButtonVariant;
isActionLoading?: boolean;
isDismissable?: boolean;
actions?: ListItemAction[];
} = $props();
@@ -150,40 +152,49 @@
{/if}
</Dialog.Trigger>
{/if}
<Dialog.Content
class={cn(
"max-h-[95vh] sm:max-w-106.25",
actions && actions.length > 0 && "[&>button:last-child]:hidden", // hides default close button
)}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
<div class="flex flex-col gap-1 text-left">
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
{#if description}
<Dialog.Description>
{description}
</Dialog.Description>
{/if}
</div>
{#if actions && actions.length > 0}
<div class="flex items-center gap-2">
{@render actionsSnippet?.()}
<Dialog.Close>
<X class="size-4" />
</Dialog.Close>
<Dialog.Portal>
<Dialog.Overlay class={cn(isDismissable === true ? "" : "z-60!")} />
<Dialog.Content
class={cn(
"max-h-[95vh] sm:max-w-106.25",
// pull non-dismissable dialogs to the top
isDismissable === true ? "" : "z-70!",
// hides default close button
actions && actions.length > 0 ? "[&>button:last-child]:hidden" : "",
)}
onOpenAutoFocus={(e) => e.preventDefault()}
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
showCloseButton={isDismissable === true}
>
<Dialog.Header class="flex flex-row items-start justify-between gap-2">
<div class="flex flex-col gap-1 text-left">
<Dialog.Title class={cn(description ? "" : "-mb-1")}>{title}</Dialog.Title>
{#if description}
<Dialog.Description>
{description}
</Dialog.Description>
{/if}
</div>
{/if}
</Dialog.Header>
<ScrollArea class="-mx-1 max-h-[75vh] overflow-hidden">
<div class="px-1 pt-2 pb-3">
{@render children?.()}
</div>
</ScrollArea>
</Dialog.Content>
{#if actions && actions.length > 0}
<div class="flex items-center gap-2">
{@render actionsSnippet?.()}
<Dialog.Close>
<X class="size-4" />
</Dialog.Close>
</div>
{/if}
</Dialog.Header>
<ScrollArea class="-mx-1 max-h-[75vh] overflow-hidden">
<div class="px-1 pt-2 pb-3">
{@render children?.()}
</div>
</ScrollArea>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{:else}
<Drawer.Root bind:open>
<Drawer.Root bind:open dismissible={isDismissable}>
{#if !triggerHidden}
<Drawer.Trigger class={buttonVariants({ variant: triggerVariant })}>
{#if typeof triggerLabel === "string"}
@@ -193,29 +204,40 @@
{/if}
</Drawer.Trigger>
{/if}
<Drawer.Content
class="data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
<div>
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
{#if description}
<Drawer.Description>
{description}
</Drawer.Description>
{/if}
</div>
<div>
{@render actionsSnippet?.()}
</div>
</Drawer.Header>
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
{@render children?.()}
</HorizontalPagePadding>
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
</Drawer.Footer>
</Drawer.Content>
<Drawer.Portal>
<Drawer.Overlay class={cn(isDismissable === true ? "" : "z-60!")} />
<Drawer.Content
class={cn(
"data-[vaul-drawer-direction=bottom]:max-h-[95vh] data-[vaul-drawer-direction=top]:max-h-[95vh]",
// pull non-dismissable dialogs to the top
isDismissable === true ? "" : "z-70!",
)}
onOpenAutoFocus={(e) => e.preventDefault()}
escapeKeydownBehavior={isDismissable === false ? "ignore" : "close"}
interactOutsideBehavior={isDismissable === false ? "ignore" : "close"}
>
<Drawer.Header class="flex flex-row justify-between gap-2 text-left">
<div>
<Drawer.Title class={cn(description ? "" : "-mb-1")}>{title}</Drawer.Title>
{#if description}
<Drawer.Description>
{description}
</Drawer.Description>
{/if}
</div>
<div>
{@render actionsSnippet?.()}
</div>
</Drawer.Header>
<HorizontalPagePadding class="max-h-[95vh] overflow-y-scroll pt-2">
{@render children?.()}
</HorizontalPagePadding>
{#if isDismissable !== false}
<Drawer.Footer class="pt-2">
<Drawer.Close class={buttonVariants({ variant: "outline" })}>{m.cancel()}</Drawer.Close>
</Drawer.Footer>
{/if}
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
{/if}
+28 -1
View File
@@ -1,7 +1,10 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import type { SupportedLocale } from "$lib/const/locales";
import { ROUTES } from "$lib/const/routes";
import type { UserRole } from "$lib/server/auth/authorization-service";
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
export interface PasskeyAuthData {
authenticatorData: string;
@@ -15,6 +18,8 @@ export interface AuthState {
refreshPromise: Promise<Response> | null;
user?: AuthStateUser;
passkeyAuthData?: PasskeyAuthData;
lastActive?: Date | undefined;
isInactive?: boolean;
}
export type AuthStateUser = {
@@ -31,6 +36,7 @@ function createAuthStore() {
const store = writable<AuthState>({
isAuthenticated: false,
refreshPromise: null,
isInactive: false,
});
return {
@@ -64,6 +70,7 @@ function createAuthStore() {
refreshPromise: null,
user: undefined,
passkeyAuthData: undefined,
lastActive: undefined,
});
},
setPasskeyAuthData: (data: PasskeyAuthData) => {
@@ -98,6 +105,26 @@ function createAuthStore() {
unsubscribe();
return authState!.user?.tenantId || null;
},
refreshLastActive: () => {
store.update((state) => ({ ...state, lastActive: new Date(), isInactive: false }));
},
checkLastActive: () => {
const lastActive = get(store).lastActive;
if (!lastActive) return;
const now = new Date();
const diff = now.getTime() - lastActive.getTime();
// Logs user out after 15 minutes of inactivity
if (diff > 15 * 60 * 1000) {
goto(resolve(ROUTES.LOGOUT));
}
// Triggers inactivity modal after 10 minutes
if (diff > 10 * 60 * 1000) {
store.update((state) => ({ ...state, isInactive: true }));
}
},
waitForRefresh: async () => {
let authState: AuthState;
const unsubscribe = store.subscribe((state) => {
+30 -1
View File
@@ -1,9 +1,10 @@
import { writable } from "svelte/store";
import { get, writable } from "svelte/store";
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { auth } from "./auth";
interface StaffCryptoState {
crypto: UnifiedAppointmentCrypto | null;
isAuthenticating: boolean;
isAuthenticated: boolean;
error: string | null;
}
@@ -11,6 +12,7 @@ interface StaffCryptoState {
const createStaffCryptoStore = () => {
const store = writable<StaffCryptoState>({
crypto: null,
isAuthenticating: false,
isAuthenticated: false,
error: null,
});
@@ -37,11 +39,18 @@ const createStaffCryptoStore = () => {
*/
async authenticate(staffId: string, tenantId: string): Promise<boolean> {
try {
store.set({
...get(store),
isAuthenticating: true,
error: null,
});
const crypto = new UnifiedAppointmentCrypto();
await crypto.authenticateStaff(staffId, tenantId);
store.set({
crypto,
isAuthenticating: false,
isAuthenticated: true,
error: null,
});
@@ -51,6 +60,7 @@ const createStaffCryptoStore = () => {
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
store.set({
crypto: null,
isAuthenticating: false,
isAuthenticated: false,
error: errorMessage,
});
@@ -59,6 +69,24 @@ const createStaffCryptoStore = () => {
}
},
async authenticateAndWait(staffId: string, tenantId: string): Promise<boolean> {
const isAuthenticating = get(store).isAuthenticating;
if (isAuthenticating) {
// Wait for the ongoing authentication to complete
return new Promise((resolve) => {
const unsubscribe = store.subscribe((state) => {
if (!state.isAuthenticating) {
unsubscribe();
resolve(state.isAuthenticated);
}
});
});
} else {
// Start a new authentication process
return await this.authenticate(staffId, tenantId);
}
},
/**
* Clear the staff crypto state and auth data
*/
@@ -66,6 +94,7 @@ const createStaffCryptoStore = () => {
auth.clearPasskeyAuthData();
store.set({
crypto: null,
isAuthenticating: false,
isAuthenticated: false,
error: null,
});
@@ -10,6 +10,7 @@
import { onMount } from "svelte";
import { getNextAppointments, sendAppointmentReminders } from "./utils";
import { m } from "$i18n/messages";
import { auth } from "$lib/stores/auth";
let status: "init" | "loading" | "sending" | "success" | "error" = $state("init");
let appointments: TEmailAppointmentReminder[] = $state([]);
@@ -27,6 +28,8 @@
// Also gives staff crypto time to load, if not already loaded
await new Promise((resolve) => setTimeout(resolve, 1000));
await auth.waitForRefresh();
const allAppointments = await getNextAppointments(tenantId);
const decryptPromises = allAppointments.map(async (appointment) => {
if (
@@ -0,0 +1,33 @@
<script lang="ts">
import { resolve } from "$app/paths";
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { openDialog, closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
$effect(() => {
if ($auth.isInactive) {
openDialog("inactivity-warning");
} else {
closeDialog("inactivity-warning");
}
});
</script>
<ResponsiveDialog
id="inactivity-warning"
title={m["dashboard.inactiveSession.title"]()}
description={m["dashboard.inactiveSession.description"]()}
triggerHidden={true}
isDismissable={false}
>
<div class="flex flex-col gap-2">
<Button onclick={() => auth.refreshLastActive()} class="w-full">
{m["dashboard.inactiveSession.cancel"]()}
</Button>
<Button href={resolve(ROUTES.LOGOUT)} variant="link" class="w-full">
{m["logout.title"]()}
</Button>
</div>
</ResponsiveDialog>
@@ -0,0 +1,100 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { Button } from "$lib/components/ui/button";
import { Passkey } from "$lib/components/ui/passkey";
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
import { closeDialog, ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import logger from "$lib/logger";
import { auth } from "$lib/stores/auth";
import { staffCrypto } from "$lib/stores/staff-crypto";
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
import { toast } from "svelte-sonner";
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
const passkeyLoading: Writable<PasskeyState> = writable("initial");
const onSetPasskey = async () => {
$passkeyLoading = "loading";
if (!$auth.user?.email || !$auth.user.tenantId) {
$passkeyLoading = "error";
return;
}
const challenge = await fetchChallenge($auth.user.email);
if (!challenge) {
$passkeyLoading = "error";
logger.error("Failed to fetch challenge", { email: $auth.user.email });
} else {
$passkeyLoading = "user";
// Call WebAuthn with PRF enabled (uses email as salt for multi-passkey support)
const credentialResp = await getCredential({
...challenge,
email: $auth.user.email,
enablePRF: true,
}).catch((error) => {
$passkeyLoading = "error";
logger.error("Failed to get credential", { ...challenge, error });
});
if (!credentialResp) {
$passkeyLoading = "error";
logger.error("Credential response is falsy");
return;
}
// Update form data with passkey info
const authenticatorDataBase64 = arrayBufferToBase64(
// @ts-expect-error response type needs to be fixed
credentialResp.response.authenticatorData,
);
// Store authenticatorData and PRF output for later key reconstruction
const passkeyId = credentialResp.id;
// Extract PRF output from WebAuthn response (if PRF was enabled)
let prfOutputBase64: string | undefined;
if (credentialResp.prfOutput) {
prfOutputBase64 = arrayBufferToBase64(credentialResp.prfOutput);
logger.info("PRF output retrieved from login", {
prfOutputLength: credentialResp.prfOutput.byteLength,
});
} else {
logger.warn("No PRF output in login response - crypto features may not work", {
email: $auth.user.email,
});
}
auth.setPasskeyAuthData({
authenticatorData: authenticatorDataBase64,
passkeyId,
email: $auth.user.email,
prfOutput: prfOutputBase64,
});
await staffCrypto.authenticate($auth.user.id, $auth.user.tenantId);
// Update UI to show passkey is ready
$passkeyLoading = "success";
closeDialog("missing-staff-crypto");
toast.success(m["dashboard.missingCryptoKeys.success"]());
}
};
</script>
<ResponsiveDialog
id="missing-staff-crypto"
title={m["dashboard.missingCryptoKeys.title"]()}
description={m["dashboard.missingCryptoKeys.description"]()}
triggerHidden={true}
isDismissable={false}
>
<div class="flex flex-col gap-2">
<Passkey.State state="click" onclick={onSetPasskey} class="justify-center" />
<Button onclick={() => closeDialog("missing-staff-crypto")} variant="link" class="w-full">
{m["dashboard.missingCryptoKeys.cancel"]()}
</Button>
</div>
</ResponsiveDialog>
@@ -9,6 +9,11 @@
import { staff } from "$lib/stores/staff";
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { browser } from "$app/environment";
import { page } from "$app/state";
import MissingStaffCrypto from "./(components)/MissingStaffCrypto.svelte";
import { openDialog } from "$lib/components/ui/responsive-dialog";
import { clock } from "$lib/stores/time";
import InactivityWarning from "./(components)/InactivityWarning.svelte";
let { data, children }: LayoutProps = $props();
@@ -59,6 +64,18 @@
};
});
$effect(() => {
if (page.url.pathname) {
auth.refreshLastActive();
}
});
$effect(() => {
if ($clock) {
auth.checkLastActive();
}
});
const updateStores = async () => {
staff.load();
notifications.load();
@@ -80,6 +97,7 @@
if (success) {
console.log("✅ Staff crypto initialized successfully");
} else {
openDialog("missing-staff-crypto");
console.log("ℹ️ Staff crypto not initialized (no session data or error)");
}
}
@@ -88,4 +106,6 @@
<QueryClientProvider client={queryClient}>
{@render children()}
<MissingStaffCrypto />
<InactivityWarning />
</QueryClientProvider>
@@ -6,8 +6,11 @@
import { InputDateTime } from "$lib/components/ui/input-date-time";
import RadioCards from "$lib/components/ui/radio-cards/radio-cards.svelte";
import * as Select from "$lib/components/ui/select";
import { times } from "$lib/components/ui/slot-template/utils";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { cn } from "$lib/utils";
import {
getDefaultEndTime,
getDefaultStartTime,
@@ -24,8 +27,6 @@
import type { AbsenceType } from "../types";
import { reasons, types } from "../utils";
import { formSchema } from "./schema";
import { cn } from "$lib/utils";
import { times } from "$lib/components/ui/slot-template/utils";
let { done }: { done: () => void } = $props();
@@ -46,6 +47,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["absences.add.success"]());
done();
@@ -4,6 +4,7 @@
import { Input } from "$lib/components/ui/input";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import type { TAbsence } from "$lib/types/absence";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
@@ -20,6 +21,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["absences.delete.success"]());
done();
@@ -4,14 +4,13 @@
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { InputDateTime } from "$lib/components/ui/input-date-time";
import { RadioCards } from "$lib/components/ui/radio-cards";
import * as Select from "$lib/components/ui/select";
import { times } from "$lib/components/ui/slot-template/utils";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import type { TAbsence } from "$lib/types/absence";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { reasons, types } from "../utils";
import { cn } from "$lib/utils";
import {
timeLocalWithoutOffsetToUTC,
timeUTCToLocalWithoutOffset,
@@ -19,10 +18,12 @@
toWeekdaysLabel,
weekdays,
} from "$lib/utils/datetime";
import { toast } from "svelte-sonner";
import { SvelteDate } from "svelte/reactivity";
import { times } from "$lib/components/ui/slot-template/utils";
import { RadioCards } from "$lib/components/ui/radio-cards";
import { cn } from "$lib/utils";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { reasons, types } from "../utils";
let { entity, done }: { entity: TAbsence; done: () => void } = $props();
@@ -45,6 +46,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["absences.edit.success"]());
done();
@@ -19,6 +19,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.change-passphrase.success"]());
} else if (event.result.type === "failure") {
@@ -21,6 +21,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.general.success"]());
} else if (event.result.type === "failure") {
@@ -38,6 +38,9 @@
},
{
validators: zodClient(formSchema),
onResult: () => {
auth.refreshLastActive();
},
onChange: (event) => {
if (event.paths.includes("email")) {
setProperPasskeyState();
@@ -5,6 +5,7 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
@@ -29,6 +30,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.passkeys.delete.success"]());
done();
@@ -2,6 +2,7 @@
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { auth } from "$lib/stores/auth";
import type { RedactedPasskeyHydrated } from "$lib/types/passkeys";
import { untrack } from "svelte";
import { toast } from "svelte-sonner";
@@ -20,6 +21,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["account.passkeys.edit.success"]());
done();
@@ -5,13 +5,14 @@
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import { Textarea } from "$lib/components/ui/textarea";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import ItemIcon from "@lucide/svelte/icons/user-star";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { tenants } from "$lib/stores/tenants";
import { get } from "svelte/store";
let { done }: { done: () => void } = $props();
@@ -29,6 +30,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["agents.add.success"]());
done();
@@ -11,6 +11,7 @@
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TAgent; done: () => void } = $props();
@@ -30,6 +31,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["agents.delete.success"]());
done();
@@ -5,14 +5,15 @@
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import { LanguageTabs } from "$lib/components/ui/language-tabs";
import { Textarea } from "$lib/components/ui/textarea";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TAgent } from "$lib/types/agent";
import ItemIcon from "@lucide/svelte/icons/user-star";
import { toast } from "svelte-sonner";
import { get } from "svelte/store";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { tenants } from "$lib/stores/tenants";
import { get } from "svelte/store";
let { entity, done }: { entity: TAgent; done: () => void } = $props();
@@ -33,6 +34,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["agents.edit.success"]());
done();
@@ -6,11 +6,13 @@
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { type SupportedLocale } from "$lib/const/locales";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { type CurAppointmentItem } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels";
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { CalendarPlus, Move, Trash2 } from "@lucide/svelte";
import { onMount } from "svelte";
import { toast } from "svelte-sonner";
import { cancelAppointment, confirmAppointment, denyAppointment } from "./utils";
@@ -41,6 +43,7 @@
let isDeleting = $state(false);
const denyItem = async () => {
auth.refreshLastActive();
const proceed = confirm(
`${m["calendar.notificationHint"]()} ${m["calendar.denyAppointment.confirm"]()}`,
);
@@ -64,6 +67,7 @@
};
const confirmItem = async () => {
auth.refreshLastActive();
isConfirming = true;
const success = await confirmAppointment({
tenant: tenantId,
@@ -80,6 +84,10 @@
}
isConfirming = false;
};
onMount(() => {
auth.refreshLastActive();
});
</script>
<ResponsiveDialog
@@ -4,11 +4,11 @@
import { staffCrypto } from "$lib/stores/staff-crypto";
import { type TCalendarItem } from "$lib/types/calendar";
import Loader2Icon from "@lucide/svelte/icons/loader-2";
import { onMount } from "svelte";
import { calendarStore } from "$lib/stores/calendar";
import { Button } from "$lib/components/ui/button";
import { m } from "$i18n/messages";
import { cn } from "$lib/utils";
import { auth } from "$lib/stores/auth";
let {
item,
@@ -21,15 +21,23 @@
let decrypted = $state<AppointmentData | undefined>();
let error = $state<string | undefined>();
onMount(() => {
decrypt();
$effect(() => {
if (!decrypted) {
if ($staffCrypto.isAuthenticated) {
decrypt();
} else {
error = "Keys missing";
}
}
});
const errors = {
missingKeyShare: "Missing key share",
};
const decrypt = async () => {
const decrypt = async (retry = true) => {
error = undefined;
if (!item.appointment) {
console.error("Unable to decrypt appointment data - no appointment data in item.", item.id);
error = "Missing data";
@@ -52,7 +60,7 @@
// Wait for crypto to be initialized (max 5 seconds)
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const maxWaitTime = 5000;
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
@@ -60,6 +68,13 @@
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const user = $auth.user;
if (retry && user && user.tenantId) {
console.warn("Staff crypto not initialized, retrying decryption...");
await staffCrypto.authenticateAndWait(user.id, user.tenantId);
decrypt(false);
return;
}
error = "Crypto not initialized";
console.error("Staff crypto not initialized after waiting");
return;
@@ -12,6 +12,7 @@
import * as Sidebar from "$lib/components/ui/sidebar";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
import type { TAppointmentFilter } from "$lib/types/calendar";
@@ -52,6 +53,7 @@
let agents = $derived($agentsStore.agents.filter((x) => !x.archived));
const zoom = (direction: number) => {
auth.refreshLastActive();
const currentIndex = CALENDAR_ZOOM_STEPS.indexOf(scale);
if (currentIndex === -1) return;
const nextIndex = currentIndex + direction;
@@ -60,11 +62,13 @@
};
const changeView = (newView: CalendarView) => {
auth.refreshLastActive();
view = newView;
document.cookie = `calendarView=${newView}; path=/; max-age=${60 * 60 * 24 * 365}; SameSite=Strict`;
};
const clearFilters = () => {
auth.refreshLastActive();
shownAppointments = "all";
shownChannels = [];
shownAgents = [];
@@ -239,6 +243,7 @@
{shownAgents}
{shownChannels}
onSelectDay={(v: DateValue | undefined) => {
auth.refreshLastActive();
sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded);
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
changeView("week");
@@ -3,6 +3,7 @@
import { getLocale } from "$i18n/runtime";
import { Button, buttonVariants } from "$lib/components/ui/button";
import * as Popover from "$lib/components/ui/popover/index.js";
import { auth } from "$lib/stores/auth";
import type { TAppointmentFilter, TCalendarMode } from "$lib/types/calendar";
import { cn } from "$lib/utils";
import {
@@ -37,6 +38,7 @@
let isWeekView = $derived(view !== "day");
const prev = () => {
auth.refreshLastActive();
const nextDate = new CalendarDate(
selectedDate.year,
selectedDate.month,
@@ -48,6 +50,7 @@
};
const next = () => {
auth.refreshLastActive();
const nextDate = new CalendarDate(selectedDate.year, selectedDate.month, selectedDate.day).add({
days: isWeekView ? 7 : 1,
});
@@ -55,10 +58,12 @@
};
const setToToday = () => {
auth.refreshLastActive();
selectedDate = today(getLocalTimeZone());
};
const onSelectDay = (v: DateValue | undefined) => {
auth.refreshLastActive();
open = false;
if (view === "week-workdays" && v && isWeekend(v, getLocale())) {
view = "week";
@@ -4,6 +4,7 @@
import Button from "$lib/components/ui/button/button.svelte";
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import { channels as channelsStore } from "$lib/stores/channels";
import type { TCalendarModeMove, TCalendarSlot } from "$lib/types/calendar";
import { utcToLocalWithoutDST } from "$lib/utils/datetime";
@@ -66,15 +67,15 @@
});
if (success) {
toast.success(m["calendar.moveAppointment.success"]());
isSubmitting = false;
updateCalendar();
} else {
toast.error(m["calendar.moveAppointment.error"]());
}
isSubmitting = false;
};
onMount(() => {
console.log("item.start", item.start);
auth.refreshLastActive();
if (mode.agentId && item.availableAgents) {
const availableAgents = item.availableAgents.map((it) => it.id);
if (availableAgents.includes(mode.agentId)) {
@@ -16,6 +16,7 @@
import SelectAgent from "./SelectAgent.svelte";
import Summary from "./Summary.svelte";
import type { TAddAppointment, TAddAppointmentStep } from "./types";
import { auth } from "$lib/stores/auth";
let {
tenantId,
@@ -46,6 +47,7 @@
let isSubmitting = $state(false);
const proceed = (data: TAddAppointment) => {
auth.refreshLastActive();
switch (true) {
case !data.agentId: {
newAppointment = {
@@ -77,6 +79,7 @@
};
const addAppointment = async () => {
auth.refreshLastActive();
if (
newAppointment.name &&
newAppointment.agentId &&
@@ -10,6 +10,7 @@
import { Textarea } from "$lib/components/ui/textarea";
import { Headline, Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { auth } from "$lib/stores/auth";
import { staff as staffStore } from "$lib/stores/staff";
import { tenants } from "$lib/stores/tenants";
import { toast } from "svelte-sonner";
@@ -44,6 +45,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["channels.add.success"]());
done();
@@ -5,13 +5,14 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
import type { TChannelWithFullAgents } from "$lib/types/channel";
import { getCurrentTranlslation } from "$lib/utils/localizations";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
@@ -31,6 +32,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["channels.delete.success"]());
done();
@@ -19,6 +19,7 @@
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { DEFAULT_SLOT_TEMPLATE } from "../utils";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
const agents = $derived($agentsStore.agents ?? []);
@@ -47,6 +48,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["channels.edit.success"]());
done();
@@ -12,6 +12,7 @@
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { Checkbox } from "$lib/components/ui/checkbox";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TChannelWithFullAgents; done: () => void } = $props();
@@ -21,6 +22,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(
$formData.pause ? m["channels.pause.success"]() : m["channels.unpause.success"](),
@@ -9,13 +9,14 @@
import * as Select from "$lib/components/ui/select";
import { Textarea } from "$lib/components/ui/textarea";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TTenantSettings } from "$lib/types/tenant";
import DefaultOrgIcon from "@lucide/svelte/icons/landmark";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from "./schema";
import { tenants } from "$lib/stores/tenants";
let { entity }: { entity: TTenantSettings } = $props();
@@ -57,6 +58,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
tenants.reload();
toast.success(m["tenants.edit.success"]());
@@ -4,6 +4,7 @@
import { Input } from "$lib/components/ui/input";
import * as Select from "$lib/components/ui/select";
import { supportedLocales, translatedLocales } from "$lib/const/locales";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
@@ -29,6 +30,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.add.success"]());
done();
@@ -5,12 +5,13 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { z } from "zod";
import { formSchema } from ".";
import type { TStaff } from "$lib/types/users";
let { entity, done }: { entity: TStaff; done: () => void } = $props();
@@ -30,6 +31,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.delete.success"]());
done();
@@ -10,6 +10,7 @@
import { formSchema } from ".";
import RolePermissions from "../role-permissions.svelte";
import { roles } from "../utils";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TStaff; done: () => void } = $props();
@@ -27,6 +28,7 @@
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.edit.success"]());
done();
@@ -23,6 +23,7 @@
let tunnels: ClientTunnelResponse[] = $state([]);
const grantAccess = async () => {
auth.refreshLastActive();
if (!tenantId || !$staffCrypto.crypto) return;
const cryptoClient = $staffCrypto.crypto;
@@ -5,6 +5,7 @@
import { Input } from "$lib/components/ui/input";
import { TranslationWithComponent } from "$lib/components/ui/translation-with-component";
import { Text } from "$lib/components/ui/typography";
import { auth } from "$lib/stores/auth";
import type { TStaff } from "$lib/types/users";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
@@ -19,6 +20,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["staff.resendInvite.success"]());
done();
@@ -1,16 +1,17 @@
<script lang="ts">
import { page } from "$app/state";
import { m } from "$i18n/messages.js";
import CheckboxWithLabel from "$lib/components/ui/checkbox-with-label/checkbox-with-label.svelte";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
import { ERRORS } from "$lib/errors";
import * as Select from "$lib/components/ui/select";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { ERRORS } from "$lib/errors";
import { auth } from "$lib/stores/auth";
import { toast } from "svelte-sonner";
import { superForm } from "sveltekit-superforms";
import { zod4Client as zodClient } from "sveltekit-superforms/adapters";
import { formSchema } from ".";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { page } from "$app/state";
let { done }: { done: () => void } = $props();
@@ -25,6 +26,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["tenants.add.success"]());
done();
@@ -33,6 +33,7 @@
),
),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["tenants.delete.success"]());
done();
@@ -10,6 +10,7 @@
import * as Select from "$lib/components/ui/select";
import { TENANT_FEATURE_FLAGS } from "$lib/const/tenants";
import { page } from "$app/state";
import { auth } from "$lib/stores/auth";
let { entity, done }: { entity: TTenant; done: () => void } = $props();
@@ -19,6 +20,7 @@
{
validators: zodClient(formSchema),
onResult: async (event) => {
auth.refreshLastActive();
if (event.result.type === "success") {
toast.success(m["tenants.edit.success"]());
done();
@@ -43,6 +43,10 @@
onResult: async (event) => {
if (event.result.type === "success") {
auth.setUser(event.result.data?.user);
// Wait for cookies to be set before navigating to dashboard
await new Promise((resolve) => setTimeout(resolve, 200));
await goto(resolve(ROUTES.DASHBOARD.MAIN));
} else {
if ($formData.type === "passkey" && $formData.id === "") {
@@ -1,6 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { DELETE, PUT } from "../+server";
import { PUT } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
@@ -242,133 +242,4 @@ describe("Appointment Detail API Routes", () => {
expect(result.error).toBe("Internal server error");
});
});
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]", () => {
it("should delete appointment for tenant admin", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment deleted successfully");
expect(mockAppointmentService.deleteAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should allow global admin to delete any tenant's appointments", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment deleted successfully");
});
it("should return 403 for staff users trying to delete appointments", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthorizationError("Insufficient permissions");
});
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
});
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
expect(mockAppointmentService.deleteAppointment).not.toHaveBeenCalled();
});
it("should return 401 for unauthenticated requests", async () => {
vi.mocked(checkPermission).mockImplementationOnce(() => {
throw new AuthenticationError("Authentication required");
});
const event = createMockRequestEvent({ locals: { user: null } as any });
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined, appointmentId: mockAppointmentId },
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle missing appointment ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, appointmentId: undefined },
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle appointment not found", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(false);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Appointment not found");
});
it("should handle service errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(
new NotFoundError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Appointment not found");
});
it("should handle internal server errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});