Feat/calendar (#129)

This commit is contained in:
Karl Ludwig Weise
2025-11-19 14:42:45 +01:00
committed by GitHub
parent 6a610b96b1
commit 56b7d4a9b2
52 changed files with 2528 additions and 64 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT:-5432}:5432"
- "${POSTGRES_PORT:-5433}:5432"
volumes:
- postgres_data_dev:/var/lib/postgresql/data
- postgres_run_dev:/var/run/postgresql
+14
View File
@@ -893,5 +893,19 @@
"success": "Anmeldung erfolgreich",
"error": "Anmeldung fehlgeschlagen. Bitte erneut versuchen"
}
},
"calendar": {
"today": "Heute",
"loading": "Loading calendar",
"decrypting": "wird entschlüsselt...",
"shownAppointments": {
"title": "Termine",
"options": {
"all": "alle",
"available": "verfügbar",
"booked": "gebucht",
"reserved": "reserviert"
}
}
}
}
+14
View File
@@ -902,5 +902,19 @@
"success": "Login successful",
"error": "Login failed. Please retry"
}
},
"calendar": {
"today": "Today",
"loading": "Loading calendar",
"decrypting": "decrypting...",
"shownAppointments": {
"title": "Appointments",
"options": {
"all": "all",
"available": "available",
"booked": "booked",
"reserved": "reserved"
}
}
}
}
+270 -26
View File
@@ -48,7 +48,7 @@
*/
import { OptimizedArgon2 } from "$lib/crypto/hashing";
import { KyberCrypto, AESCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils";
// Type definitions for unified cryptography
interface ClientKeyPair {
@@ -67,7 +67,7 @@ interface EncryptedData {
authTag: string;
}
interface AppointmentData {
export interface AppointmentData {
name: string;
email: string;
phone?: string;
@@ -257,6 +257,7 @@ export class UnifiedAppointmentCrypto {
appointmentDate: string,
agentId: string,
channelId: string,
duration: number,
tenantId: string,
isFirstAppointment: boolean = false,
clientLanguage: string = "de",
@@ -281,6 +282,7 @@ export class UnifiedAppointmentCrypto {
agentId,
channelId,
appointmentDate,
duration,
emailHash: this.emailHash,
clientEmail: appointmentData.email,
clientLanguage,
@@ -298,6 +300,7 @@ export class UnifiedAppointmentCrypto {
agentId,
channelId,
appointmentDate,
duration,
clientEmail: appointmentData.email,
clientLanguage,
encryptedAppointment,
@@ -376,8 +379,6 @@ export class UnifiedAppointmentCrypto {
*/
async authenticateStaff(staffId: string, tenantId: string): Promise<void> {
try {
console.log("🔐 Authenticasting staff member:", staffId, "for tenant:", tenantId);
// 1. Perform WebAuthn authentication
const webAuthnResponse = await this.performWebAuthnAuthentication(staffId);
@@ -428,6 +429,67 @@ export class UnifiedAppointmentCrypto {
}
}
/**
* Reconstruct staff keys from session data (after login)
* This avoids requiring WebAuthn on every page load
*/
async reconstructStaffKeysFromSession(
staffId: string,
tenantId: string,
passkeyId: string,
authenticatorDataBase64: string,
): Promise<void> {
try {
// 1. Fetch database shard from server
const shardResponse = await fetch(`/api/tenants/${tenantId}/staff/${staffId}/key-shard`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!shardResponse.ok) {
throw new Error(`Failed to fetch key shard: ${shardResponse.status}`);
}
const shardData = await shardResponse.json();
// 2. Convert base64 authenticatorData back to ArrayBuffer
const authenticatorDataBytes = this.base64ToUint8Array(authenticatorDataBase64);
// 3. Derive passkey-based shard from stored authenticator data
const passkeyBasedShard = await this.derivePasskeyBasedShard(
passkeyId,
authenticatorDataBytes.buffer as ArrayBuffer,
);
// 4. Decode database shard
const dbShard = this.base64ToUint8Array(shardData.privateKeyShare);
// 5. Reconstruct private key by XORing the two shards
const privateKey = new Uint8Array(dbShard.length);
for (let i = 0; i < dbShard.length; i++) {
privateKey[i] = dbShard[i] ^ passkeyBasedShard[i];
}
// 6. Store reconstructed key pair
this.staffKeyPair = {
publicKey: this.base64ToUint8Array(shardData.publicKey),
privateKey: privateKey,
};
this.staffId = staffId;
this.tenantId = tenantId;
this.staffAuthenticated = true;
this.keyExpiry = Date.now() + 60 * 60 * 1000; // 1 hour
console.log("✅ Staff keys reconstructed from session");
} catch (error) {
console.error("❌ Failed to reconstruct staff keys:", error);
throw new Error(
`Key reconstruction failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Decrypt appointment data for staff members
*/
@@ -444,24 +506,59 @@ export class UnifiedAppointmentCrypto {
}
try {
// 1. Decrypt the symmetric key using staff's private key
const encapsulatedSecret = this.hexToUint8Array(encryptedData.staffKeyShare);
// Parse the staffKeyShare which now contains: encapsulatedSecret || iv || encryptedTunnelKey
const staffKeyShareBytes = this.hexToUint8Array(encryptedData.staffKeyShare);
// ML-KEM-768 encapsulated secret is 1088 bytes
const ENCAPSULATED_SECRET_LENGTH = 1088;
const IV_LENGTH = 12;
if (staffKeyShareBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
throw new Error(
`staffKeyShare too short: ${staffKeyShareBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
);
}
const encapsulatedSecret = staffKeyShareBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
const iv = staffKeyShareBytes.slice(
ENCAPSULATED_SECRET_LENGTH,
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
);
const encryptedTunnelKey = staffKeyShareBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
// 1. Decapsulate to get shared secret
const sharedSecret = KyberCrypto.decapsulate(
this.staffKeyPair.privateKey,
encapsulatedSecret,
);
// 2. Import the symmetric key
const symmetricKey = await crypto.subtle.importKey(
// 2. Use first 32 bytes of shared secret as AES key
const aesKeyBytes = sharedSecret.slice(0, 32);
// Import as CryptoKey for Web Crypto API
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"decrypt",
]);
// 3. Decrypt the tunnel key with AES-GCM (encrypted already includes authTag)
const decryptedTunnelKey = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
encryptedTunnelKey,
);
const tunnelKeyBytes = new Uint8Array(decryptedTunnelKey);
// 4. Import tunnel key as CryptoKey
const tunnelKey = await crypto.subtle.importKey(
"raw",
new Uint8Array(sharedSecret),
tunnelKeyBytes,
{ name: "AES-GCM" },
false,
["decrypt"],
);
// 3. Decrypt the appointment data
const iv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
// 5. Now decrypt the actual appointment data
const appointmentIv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload);
const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag);
@@ -471,8 +568,8 @@ export class UnifiedAppointmentCrypto {
encrypted.set(authTag, ciphertext.length);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(iv) },
symmetricKey,
{ name: "AES-GCM", iv: appointmentIv },
tunnelKey,
encrypted,
);
@@ -545,7 +642,6 @@ export class UnifiedAppointmentCrypto {
this.tenantId = null;
this.staffAuthenticated = false;
this.keyExpiry = null;
console.log("🔒 Staff logged out");
}
/**
@@ -558,7 +654,6 @@ export class UnifiedAppointmentCrypto {
this.tunnelId = null;
this.serverPrivateKeyShare = null;
this.clientAuthenticated = false;
console.log("🔒 Client logged out");
}
// ===== SHARED PRIVATE METHODS =====
@@ -618,9 +713,8 @@ export class UnifiedAppointmentCrypto {
staffId: string,
passkeyId: string,
authenticatorData: ArrayBuffer,
keyPair: { publicKey: Uint8Array; privateKey: Uint8Array },
): Promise<void> {
const keyPair = KyberCrypto.generateKeyPair();
const passkeyBasedShard = await this.derivePasskeyBasedShard(passkeyId, authenticatorData);
const dbShard = new Uint8Array(keyPair.privateKey.length);
@@ -654,7 +748,7 @@ export class UnifiedAppointmentCrypto {
* - During authentication: Recreate same shard to reconstruct private key
*
* Uses HKDF (HMAC-based Key Derivation Function) with:
* - IKM: WebAuthn authenticatorData (contains randomness from authenticator)
* - IKM: First 32 bytes of authenticatorData (rpIdHash only, excluding flags and signCount)
* - Salt: "staff-crypto-shard-v1" (version-specific salt)
* - Info: "passkey:{passkeyId}" (domain separation per passkey)
* - Length: 2400 bytes (ML-KEM-768 private key size)
@@ -664,7 +758,16 @@ export class UnifiedAppointmentCrypto {
authenticatorData: ArrayBuffer,
): Promise<Uint8Array> {
// Extract randomness from authenticator data
const inputKeyMaterial = new Uint8Array(authenticatorData);
// IMPORTANT: Use only the first 32 bytes (rpIdHash)
// We CANNOT use flags (byte 32) because the AT flag differs between registration and authentication!
// We CANNOT use signCount (bytes 33-36) because it increments on every authentication!
// authenticatorData structure:
// - Bytes 0-31: rpIdHash (SHA-256 of RP ID) - CONSTANT ✓
// - Byte 32: flags - DIFFERS (AT flag set during registration) ✗
// - Bytes 33-36: signCount - CHANGES on every login ✗
// - Bytes 37+: attestedCredentialData (only during registration) ✗
const fullData = new Uint8Array(authenticatorData);
const inputKeyMaterial = fullData.slice(0, 32); // Only first 32 bytes (rpIdHash only)
// Import the IKM as a CryptoKey for HKDF
const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, "HKDF", false, [
@@ -863,16 +966,94 @@ export class UnifiedAppointmentCrypto {
): Promise<Array<{ userId: string; encryptedTunnelKey: string }>> {
if (!this.tunnelKey) throw new Error("No tunnel key available");
// Export tunnel key as raw bytes
const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey);
const tunnelKeyArray = new Uint8Array(tunnelKeyBytes);
const results = [];
for (const staff of staffKeys) {
// Public key is stored as Base64, not Hex
const staffPublicKeyBytes = this.base64ToUint8Array(staff.publicKey);
const encryptedKey = KyberCrypto.encapsulate(staffPublicKeyBytes);
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",
]);
// 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 },
aesKey,
tunnelKeyArray,
);
// 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,
);
combined.set(encapsulatedSecret, 0);
combined.set(iv, encapsulatedSecret.length);
combined.set(encryptedArray, encapsulatedSecret.length + iv.length);
results.push({
userId: staff.userId,
encryptedTunnelKey: this.uint8ArrayToHex(encryptedKey.encapsulatedSecret),
encryptedTunnelKey: this.uint8ArrayToHex(combined),
});
}
@@ -883,10 +1064,39 @@ export class UnifiedAppointmentCrypto {
if (!this.tunnelKey || !this.clientKeyPair)
throw new Error("Tunnel key or client key not available");
const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey);
const encryptedKey = KyberCrypto.encapsulate(clientPublicKeyBytes);
// Export tunnel key as raw bytes
const tunnelKeyBytes = await crypto.subtle.exportKey("raw", this.tunnelKey);
const tunnelKeyArray = new Uint8Array(tunnelKeyBytes);
return this.uint8ArrayToHex(encryptedKey.encapsulatedSecret);
const clientPublicKeyBytes = this.hexToUint8Array(this.clientKeyPair.publicKey);
// Kyber encapsulation creates a shared secret
const { sharedSecret, encapsulatedSecret } = KyberCrypto.encapsulate(clientPublicKeyBytes);
// Use the first 32 bytes of shared secret as AES key
const aesKeyBytes = sharedSecret.slice(0, 32);
// Import as CryptoKey for Web Crypto API
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"encrypt",
]);
// Generate IV for AES-GCM
const iv = BufferUtils.randomBytes(12);
// Encrypt tunnel key with AES-GCM
const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, tunnelKeyArray);
// encrypted contains ciphertext + 16-byte auth tag
const encryptedArray = new Uint8Array(encrypted);
// Store: encapsulatedSecret || iv || encrypted (ciphertext+authTag)
const combined = new Uint8Array(encapsulatedSecret.length + iv.length + encryptedArray.length);
combined.set(encapsulatedSecret, 0);
combined.set(iv, encapsulatedSecret.length);
combined.set(encryptedArray, encapsulatedSecret.length + iv.length);
return this.uint8ArrayToHex(combined);
}
private async reconstructPrivateKey(pin: string, serverShare: string): Promise<string> {
@@ -944,11 +1154,45 @@ export class UnifiedAppointmentCrypto {
const privateKeyBytes = this.hexToUint8Array(privateKey);
const encryptedKeyBytes = this.hexToUint8Array(encryptedTunnelKey);
const tunnelKeyBytes = KyberCrypto.decapsulate(privateKeyBytes, encryptedKeyBytes);
// Parse the encrypted data: encapsulatedSecret || iv || encryptedTunnelKey
const ENCAPSULATED_SECRET_LENGTH = 1088;
const IV_LENGTH = 12;
if (encryptedKeyBytes.length < ENCAPSULATED_SECRET_LENGTH + IV_LENGTH) {
throw new Error(
`encryptedTunnelKey too short: ${encryptedKeyBytes.length} bytes, expected at least ${ENCAPSULATED_SECRET_LENGTH + IV_LENGTH}`,
);
}
const encapsulatedSecret = encryptedKeyBytes.slice(0, ENCAPSULATED_SECRET_LENGTH);
const iv = encryptedKeyBytes.slice(
ENCAPSULATED_SECRET_LENGTH,
ENCAPSULATED_SECRET_LENGTH + IV_LENGTH,
);
const encryptedTunnel = encryptedKeyBytes.slice(ENCAPSULATED_SECRET_LENGTH + IV_LENGTH);
// 1. Decapsulate to get shared secret
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, encapsulatedSecret);
// 2. Use first 32 bytes as AES key
const aesKeyBytes = sharedSecret.slice(0, 32);
// 3. Import as CryptoKey
const aesKey = await crypto.subtle.importKey("raw", aesKeyBytes, { name: "AES-GCM" }, false, [
"decrypt",
]);
// 4. Decrypt the tunnel key
const decryptedTunnelKey = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv },
aesKey,
encryptedTunnel,
);
// 5. Import tunnel key as CryptoKey
return await crypto.subtle.importKey(
"raw",
new Uint8Array(tunnelKeyBytes),
new Uint8Array(decryptedTunnelKey),
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"],
@@ -6,12 +6,18 @@
import * as Sidebar from "$lib/components/ui/sidebar";
import type { HTMLAttributes } from "svelte/elements";
import { sidebar } from "$lib/stores/sidebar";
import type { Snippet } from "svelte";
let {
children,
sidebarRight,
headerRight,
breakcrumbs,
}: HTMLAttributes<HTMLDivElement> & { breakcrumbs?: Array<{ label: string; href: string }> } =
$props();
}: HTMLAttributes<HTMLDivElement> & {
breakcrumbs?: Array<{ label: string; href: string }>;
headerRight?: Snippet;
sidebarRight?: Snippet;
} = $props();
</script>
<Sidebar.Provider bind:open={$sidebar.isOpen} onOpenChange={(open) => sidebar.setOpen(open)}>
@@ -22,7 +28,7 @@
<header
class="mb-3 flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12"
>
<div class="flex items-center gap-2">
<div class="flex w-full items-center gap-2">
<Sidebar.Trigger
class="-ml-1"
onclick={() => {
@@ -52,10 +58,18 @@
</Breadcrumb.List>
</Breadcrumb.Root>
{/if}
{#if headerRight}
<div class="ml-auto">
{@render headerRight?.()}
</div>
{/if}
</div>
</header>
{@render children?.()}
</HorizontalPagePadding>
</PageWithClaim>
</Sidebar.Inset>
{#if sidebarRight}
{@render sidebarRight?.()}
{/if}
</Sidebar.Provider>
@@ -0,0 +1,20 @@
<script lang="ts">
import { cn } from "$lib/utils.js";
import type { ComponentProps } from "svelte";
import { Separator } from "$lib/components/ui/separator/index.js";
let {
ref = $bindable(null),
class: className,
orientation = "vertical",
...restProps
}: ComponentProps<typeof Separator> = $props();
</script>
<Separator
bind:ref
data-slot="button-group-separator"
{orientation}
class={cn("bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto", className)}
{...restProps}
/>
@@ -0,0 +1,30 @@
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
import type { Snippet } from "svelte";
let {
ref = $bindable(null),
class: className,
child,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
child?: Snippet<[{ props: Record<string, unknown> }]>;
} = $props();
const mergedProps = $derived({
...restProps,
class: cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className,
),
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div bind:this={ref} {...mergedProps}>
{@render mergedProps.children?.()}
</div>
{/if}
@@ -0,0 +1,46 @@
<script lang="ts" module>
import { tv, type VariantProps } from "tailwind-variants";
export const buttonGroupVariants = tv({
base: "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
});
export type ButtonGroupOrientation = VariantProps<typeof buttonGroupVariants>["orientation"];
</script>
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
orientation = "horizontal",
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
orientation?: ButtonGroupOrientation;
} = $props();
</script>
<div
bind:this={ref}
role="group"
data-slot="button-group"
data-orientation={orientation}
class={cn(buttonGroupVariants({ orientation }), className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,13 @@
import Root from "./button-group.svelte";
import Text from "./button-group-text.svelte";
import Separator from "./button-group-separator.svelte";
export {
Root,
Text,
Separator,
//
Root as ButtonGroup,
Text as ButtonGroupText,
Separator as ButtonGroupSeparator,
};
@@ -65,16 +65,16 @@
</div>
{/if}
{#if appointment.agent || appointment.agent === null}
<div class="flex items-center gap-2">
<User class="size-3" />
<div class="flex items-start gap-2">
<User class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{appointment.agent?.name || m["public.anyAgent"]()}
</Text>
</div>
{/if}
{#if appointment.slot}
<div class="flex items-center gap-2">
<Calendar class="size-3" />
<div class="flex items-start gap-2">
<Calendar class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{Intl.DateTimeFormat($publicStore.locale, {
year: "numeric",
@@ -89,13 +89,17 @@
{/if}
{#if appointment.data}
<div class="flex items-start gap-2">
<FileText class="mt-1 size-3" />
<FileText class="mt-1 size-3 shrink-0" />
<Text style="sm" class="font-normal">
{appointment.data.name}<br />
{appointment.data.email}
<a class="break-all underline" href={`mailto:${appointment.data.email}`}>
{appointment.data.email}
</a>
{#if appointment.data.phone}
<br />
{appointment.data.phone}
<a class="break-all underline" href={`tel:${appointment.data.phone}`}>
{appointment.data.phone}
</a>
{/if}
</Text>
</div>
@@ -0,0 +1,10 @@
import Root from "./radio-group.svelte";
import Item from "./radio-group-item.svelte";
export {
Root,
Item,
//
Root as RadioGroup,
Item as RadioGroupItem,
};
@@ -0,0 +1,31 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import CircleIcon from "@lucide/svelte/icons/circle";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildrenOrChild<RadioGroupPrimitive.ItemProps> = $props();
</script>
<RadioGroupPrimitive.Item
bind:ref
data-slot="radio-group-item"
class={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...restProps}
>
{#snippet children({ checked })}
<div data-slot="radio-group-indicator" class="relative flex items-center justify-center">
{#if checked}
<CircleIcon
class="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2"
/>
{/if}
</div>
{/snippet}
</RadioGroupPrimitive.Item>
@@ -0,0 +1,19 @@
<script lang="ts">
import { RadioGroup as RadioGroupPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
value = $bindable(""),
...restProps
}: RadioGroupPrimitive.RootProps = $props();
</script>
<RadioGroupPrimitive.Root
bind:ref
bind:value
data-slot="radio-group"
class={cn("grid gap-3", className)}
{...restProps}
/>
+2
View File
@@ -148,6 +148,8 @@ export const appointment = pgTable("appointment", {
.references(() => agent.id),
/** Date and time of the appointment */
appointmentDate: timestamp("appointment_date").notNull(),
/** Duration of the appointment in minutes */
duration: integer("duration").notNull(),
/** When appointment data expires and can be auto-deleted */
expiryDate: date("expiry_date"),
/** Current status of the appointment - defaults depend on channel's requiresConfirmation setting */
@@ -16,6 +16,7 @@ const mockAppointment = {
tunnelId: "tunnel-123",
channelId: "channel-123",
appointmentDate: new Date("2024-01-15T10:00:00Z"),
duration: 10,
status: "NEW" as const,
encryptedPayload: "encrypted-data",
iv: "iv-data",
@@ -38,6 +39,7 @@ const mockClientTunnelData = {
channelId: "channel-123",
agentId: "agent-123",
appointmentDate: "2024-01-15T10:00:00Z",
duration: 10,
emailHash: "email-hash-123",
clientEmail: "test@example.com",
clientLanguage: "de",
@@ -18,6 +18,7 @@ export interface ClientTunnelData {
channelId: string;
agentId: string;
appointmentDate: string;
duration: number;
emailHash: string;
clientEmail: string;
clientLanguage?: string;
@@ -394,6 +395,7 @@ export class AppointmentService {
channelId: clientData.channelId,
agentId: clientData.agentId,
appointmentDate: new Date(clientData.appointmentDate),
duration: clientData.duration,
encryptedPayload: clientData.encryptedAppointment.encryptedPayload,
iv: clientData.encryptedAppointment.iv,
authTag: clientData.encryptedAppointment.authTag,
+12 -1
View File
@@ -9,7 +9,18 @@ import { type SelectAgent, type SelectChannel, type SelectSlotTemplate } from ".
import { NotFoundError, ValidationError } from "../utils/errors";
import { TenantAdminService } from "./tenant-admin-service";
const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const;
const CHANNEL_COLORS = [
"#F3835C",
"#C8CA79",
"#F6DD74",
"#A0A3DC",
"#E9A56D",
"#D89CC8",
"#B0B49B",
"#F9A1B4",
"#88D7EF",
"#AB8A7A",
] as const;
const NEXT_COLOR_KEY = "nextChannelColor";
const slotTemplateSchema = z.object({
+46 -9
View File
@@ -8,7 +8,7 @@ import {
type SelectAgentAbsence,
} from "../db/tenant-schema";
import { eq, and, between, sql, or } from "drizzle-orm";
import { eq, and, between, sql, or, inArray } from "drizzle-orm";
import logger from "$lib/logger";
import { z } from "zod";
import { ValidationError } from "../utils/errors";
@@ -19,6 +19,7 @@ const scheduleRequestSchema = z.object({
tenantId: z.string().uuid({ message: "Invalid tenant ID format" }),
channelId: z.string().uuid({ message: "Invalid channel ID format" }).optional(),
agentId: z.string().uuid({ message: "Invalid agent ID format" }).optional(),
staffUserId: z.string().uuid({ message: "Invalid staff user ID format" }).optional(),
});
export type ScheduleRequest = z.infer<typeof scheduleRequestSchema>;
@@ -30,12 +31,16 @@ export interface TimeSlot {
availableAgents: SelectAgent[];
}
export interface AppointmentWithKeyShare extends SelectAppointment {
staffKeyShare?: string;
}
export interface DaySchedule {
date: string; // YYYY-MM-DD format
channels: {
[channelId: string]: {
channel: SelectChannel;
appointments: SelectAppointment[];
appointments: AppointmentWithKeyShare[];
availableSlots: TimeSlot[];
};
};
@@ -139,6 +144,30 @@ export class ScheduleService {
),
);
// 3a. If staffUserId is provided, get staffKeyShares for all appointment tunnels
let staffKeyShares: Record<string, string> = {};
if (request.staffUserId && appointments.length > 0) {
const tunnelIds = [...new Set(appointments.map((apt) => apt.tunnelId))];
const keyShares = await db
.select()
.from(tenantSchema.clientTunnelStaffKeyShare)
.where(
and(
eq(tenantSchema.clientTunnelStaffKeyShare.userId, request.staffUserId),
inArray(tenantSchema.clientTunnelStaffKeyShare.tunnelId, tunnelIds),
),
);
// Create a map of tunnelId -> encryptedTunnelKey
staffKeyShares = keyShares.reduce(
(acc, share) => {
acc[share.tunnelId] = share.encryptedTunnelKey;
return acc;
},
{} as Record<string, string>,
);
}
// 4. Get agent absences in the date range
const absences = await db
.select()
@@ -187,6 +216,7 @@ export class ScheduleService {
appointments,
absences,
channelAgents,
staffKeyShares,
});
log.debug("Schedule generated successfully", {
@@ -221,6 +251,7 @@ export class ScheduleService {
appointments,
absences,
channelAgents,
staffKeyShares,
}: {
startDate: Date;
endDate: Date;
@@ -229,6 +260,7 @@ export class ScheduleService {
appointments: SelectAppointment[];
absences: SelectAgentAbsence[];
channelAgents: { channelId: string; agent: SelectAgent }[];
staffKeyShares: Record<string, string>;
}): Promise<DaySchedule[]> {
const dailySchedules: DaySchedule[] = [];
@@ -247,13 +279,18 @@ export class ScheduleService {
// Process each channel
for (const channel of channels) {
// Get appointments for this channel on this day
const dayAppointments = appointments.filter(
(appointment) =>
appointment.channelId === channel.id &&
(typeof appointment.appointmentDate === "string"
? (appointment.appointmentDate as string).startsWith(dateString)
: appointment.appointmentDate.toISOString().startsWith(dateString)),
);
const dayAppointments = appointments
.filter(
(appointment) =>
appointment.channelId === channel.id &&
(typeof appointment.appointmentDate === "string"
? (appointment.appointmentDate as string).startsWith(dateString)
: appointment.appointmentDate.toISOString().startsWith(dateString)),
)
.map((appointment) => ({
...appointment,
staffKeyShare: staffKeyShares[appointment.tunnelId],
}));
// Get slot templates for this channel that apply to this weekday
const channelSlotTemplates = slotTemplates
+33
View File
@@ -1,6 +1,13 @@
import { browser } from "$app/environment";
import type { UserRole } from "$lib/server/auth/authorization-service";
import { writable } from "svelte/store";
export interface PasskeyAuthData {
authenticatorData: string;
passkeyId: string;
email: string;
}
export interface AuthState {
isAuthenticated: boolean;
isRefreshing: boolean;
@@ -12,6 +19,7 @@ export interface AuthState {
// The currently selected tenant
tenantId?: string | null;
};
passkeyAuthData?: PasskeyAuthData;
}
function createAuthStore() {
@@ -30,6 +38,14 @@ function createAuthStore() {
},
setUser: (user: AuthState["user"]) => {
store.update((state) => ({ ...state, isAuthenticated: true, user }));
if (browser && user) {
const storageItem = sessionStorage.getItem("passkeyAuthData");
if (storageItem) {
const passkeyAuthData: PasskeyAuthData = JSON.parse(storageItem);
store.update((state) => ({ ...state, passkeyAuthData }));
}
}
},
setTenantId: (tenantId: string | null) => {
store.update((state) => {
@@ -42,8 +58,25 @@ function createAuthStore() {
isAuthenticated: false,
isRefreshing: false,
user: undefined,
passkeyAuthData: undefined,
});
},
setPasskeyAuthData: (data: PasskeyAuthData) => {
store.update((state) => ({ ...state, passkeyAuthData: data }));
sessionStorage.setItem("passkeyAuthData", JSON.stringify(data));
},
getPasskeyAuthData: (): PasskeyAuthData | undefined => {
let authState: AuthState;
const unsubscribe = store.subscribe((state) => {
authState = state;
});
unsubscribe();
return authState!.passkeyAuthData;
},
clearPasskeyAuthData: () => {
store.update((state) => ({ ...state, passkeyAuthData: undefined }));
sessionStorage.removeItem("passkeyAuthData");
},
isAuthenticated: () => {
let authState: AuthState;
const unsubscribe = store.subscribe((state) => {
+34
View File
@@ -0,0 +1,34 @@
import type { AppointmentData } from "$lib/client/appointment-crypto";
import { openDialog } from "$lib/components/ui/responsive-dialog";
import type { TCalendarItem } from "$lib/types/calendar";
import { writable } from "svelte/store";
export type CurAppointmentItem = {
appointment: TCalendarItem;
decrypted: AppointmentData;
};
interface CalendarState {
curItem: CurAppointmentItem | null;
}
const createCalendarStore = () => {
const store = writable<CalendarState>({
curItem: null,
});
return {
...store,
setCurItem: (curItem: CurAppointmentItem | null) => {
store.update((state) => {
return { ...state, curItem };
});
if (curItem) {
openDialog("current-calendar-item");
}
},
};
};
export const calendarStore = createCalendarStore();
+52
View File
@@ -0,0 +1,52 @@
import { browser } from "$app/environment";
import { writable } from "svelte/store";
import { auth } from "./auth";
import type { TChannel } from "$lib/types/channel";
interface ChannelsState {
channels: TChannel[];
isLoading: boolean;
}
const createChannelsStore = () => {
const store = writable<ChannelsState>({
channels: [],
isLoading: false,
});
return {
...store,
load: async () => {
if (!browser) return;
store.update((state) => {
return { ...state, isLoading: true };
});
try {
const tenantId = auth.getTenant();
const res = await fetch(`/api/tenants/${tenantId}/channels`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
credentials: "same-origin",
});
const body = await res.json();
const channels = body.channels ?? ([] as TChannel[]);
store.update((state) => {
return { ...state, channels, isLoading: false };
});
} catch (error) {
store.update((state) => {
return { ...state, isLoading: false };
});
console.error("Failed to parse channels response", { error });
}
},
};
};
export const channels = createChannelsStore();
+5
View File
@@ -4,6 +4,7 @@ import { writable } from "svelte/store";
interface AuthState {
isOpen: boolean;
isCalendarExpanded: boolean;
isEducated: boolean;
}
@@ -15,6 +16,7 @@ function createSidebarStore() {
const isEducatedValue = browser ? getCookie(SIDEBAR_EDUCATION_STORAGE_KEY) : null;
const store = writable<AuthState>({
isOpen: isOpenValue === "true" ? true : false,
isCalendarExpanded: false,
isEducated: isEducatedValue === "true" ? true : false,
});
@@ -26,6 +28,9 @@ function createSidebarStore() {
}
store.update((state) => ({ ...state, isOpen }));
},
setCalendarExpanded: (isCalendarExpanded: boolean) => {
store.update((state) => ({ ...state, isCalendarExpanded }));
},
setEducated: (isEducated: boolean, isFinal?: boolean) => {
if (browser && isFinal) {
document.cookie = `${SIDEBAR_EDUCATION_STORAGE_KEY}=${isEducated}; path=/; max-age=604800`;
+108
View File
@@ -0,0 +1,108 @@
import { writable } from "svelte/store";
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
import { auth } from "./auth";
interface StaffCryptoState {
crypto: UnifiedAppointmentCrypto | null;
isAuthenticated: boolean;
error: string | null;
}
const createStaffCryptoStore = () => {
const store = writable<StaffCryptoState>({
crypto: null,
isAuthenticated: false,
error: null,
});
return {
...store,
/**
* Initialize crypto from stored authenticator data after login
* This reconstructs the private key using the passkey data from auth store
*/
async initFromSession(staffId: string, tenantId: string): Promise<boolean> {
try {
// Check if we have passkey auth data from login
const passkeyAuthData = auth.getPasskeyAuthData();
if (!passkeyAuthData) {
console.warn("No passkey auth data found in auth store");
return false;
}
const { authenticatorData, passkeyId } = passkeyAuthData;
// Reconstruct the private key using the stored data
const crypto = new UnifiedAppointmentCrypto();
// Use the stored authenticator data to reconstruct keys
await crypto.reconstructStaffKeysFromSession(
staffId,
tenantId,
passkeyId,
authenticatorData,
);
store.set({
crypto,
isAuthenticated: true,
error: null,
});
console.log("✅ Staff crypto initialized from session");
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to init from session";
store.set({
crypto: null,
isAuthenticated: false,
error: errorMessage,
});
console.error("Failed to initialize staff crypto from session:", error);
return false;
}
},
/**
* Authenticate with WebAuthn (for initial login or re-authentication)
*/
async authenticate(staffId: string, tenantId: string): Promise<boolean> {
try {
const crypto = new UnifiedAppointmentCrypto();
await crypto.authenticateStaff(staffId, tenantId);
store.set({
crypto,
isAuthenticated: true,
error: null,
});
return true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Authentication failed";
store.set({
crypto: null,
isAuthenticated: false,
error: errorMessage,
});
console.error("Failed to authenticate staff crypto:", error);
return false;
}
},
/**
* Clear the staff crypto state and auth data
*/
clear() {
auth.clearPasskeyAuthData();
store.set({
crypto: null,
isAuthenticated: false,
error: null,
});
},
};
};
export const staffCrypto = createStaffCryptoStore();
+2
View File
@@ -8,6 +8,7 @@ import { m } from "$i18n/messages";
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import { agents } from "./agents";
import { channels } from "./channels";
const log = logger.setContext("TenantsStore");
@@ -49,6 +50,7 @@ const createTenantsStore = () => {
return { ...state, currentTenant };
});
agents.load();
channels.load();
// Redirect to dashboard main if tenant changed to avaoid showing data from previous tenant
if (tenantId !== curTenant) {
+9
View File
@@ -0,0 +1,9 @@
import { getLocalTimeZone, now } from "@internationalized/date";
import { readable } from "svelte/store";
export const clock = readable(now(getLocalTimeZone()), (set) => {
const tick = () => set(now(getLocalTimeZone()));
const id = setInterval(tick, 10_000);
tick();
return () => clearInterval(id);
});
+32
View File
@@ -0,0 +1,32 @@
import type { DaySchedule } from "$lib/server/services/schedule-service";
export type AppointmentStatus = "available" | "booked" | "reserved";
export type TAppointmentFilter = "all" | AppointmentStatus;
export type TCalendar = {
period: {
startDate: string; // ISO date-time string
endDate: string; // ISO date-time string
};
calendar: DaySchedule[];
};
export type TCalendarItem = {
date: string; // YYYY-MM-DD
id: string;
start: string; // HH:mm
duration: number; // in minutes
status: AppointmentStatus;
color: string | null;
column: number;
channelId: string;
appointment?: {
dateTime: Date;
encryptedPayload: string | null;
tunnelId: string;
agentId: string;
staffKeyShare?: string;
iv?: string;
authTag?: string;
};
};
+1
View File
@@ -36,6 +36,7 @@ export type TPublicAppointment = {
} | null;
slot?: {
datetime: CalendarDateTime;
duration: number;
};
data?: {
name: string;
@@ -145,6 +145,7 @@
hour: parseInt(slot.from.split(":")[0], 10),
minute: parseInt(slot.from.split(":")[1], 10),
}),
duration: slot.duration,
},
});
}
@@ -32,6 +32,7 @@
toZoned(appointment.slot.datetime, getLocalTimeZone()).toAbsoluteString(),
appointment.agent.id,
channel.id,
appointment.slot.duration,
tenant.id,
Boolean(appointment.isNewClient),
getLocale() || "de",
@@ -29,6 +29,7 @@
let tenantId: string | undefined = $state();
let passkeyId: string | undefined = $state();
let authenticatorData: ArrayBuffer | undefined = $state();
let kyberKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | undefined = $state();
const form = superForm(data.form, {
validators: zodClient(formSchema),
@@ -65,6 +66,12 @@
const onSetPasskey = async () => {
$passkeyLoading = "loading";
// Generate Kyber keypair BEFORE passkey registration
// This keypair will be used to create the dbShard after authenticatorData is available
const { KyberCrypto } = await import("$lib/crypto/utils");
kyberKeyPair = KyberCrypto.generateKeyPair();
const challenge = await fetchChallenge($formData.email);
if (!challenge) {
@@ -129,10 +136,10 @@
});
const storeStaffKeyPair = async () => {
if (tenantId && passkeyId && authenticatorData) {
if (tenantId && passkeyId && authenticatorData && kyberKeyPair) {
const crypto = new UnifiedAppointmentCrypto();
return await crypto
.storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData)
.storeStaffKeyPair(tenantId, $formData.userId, passkeyId, authenticatorData, kyberKeyPair)
.then(() => {
toast.success(m["setupPasskey.successKeyPairSaved"]());
})
@@ -150,6 +157,7 @@
tenantId,
userId: $formData.userId,
passkeyId,
hasKyberKeyPair: !!kyberKeyPair,
});
toast.error(m["setupPasskey.errorKeyPairDataMissing"]());
}
@@ -4,6 +4,7 @@
import type { LayoutProps } from "./$types";
import { auth } from "$lib/stores/auth";
import { tenants } from "$lib/stores/tenants";
import { staffCrypto } from "$lib/stores/staff-crypto";
let { data, children }: LayoutProps = $props();
@@ -15,6 +16,7 @@
}
initTenants();
initStaffCrypto();
const unsubscribe = () => {
if (!intervalId) {
@@ -44,6 +46,18 @@
}
}
};
const initStaffCrypto = async () => {
if (data?.user?.id && data?.user?.tenantId) {
// Try to initialize from session storage (from login)
const success = await staffCrypto.initFromSession(data.user.id, data.user.tenantId);
if (success) {
console.log("✅ Staff crypto initialized successfully");
} else {
console.log("️ Staff crypto not initialized (no session data or error)");
}
}
};
</script>
{@render children()}
@@ -19,6 +19,7 @@
import { AddAgentForm } from "./(components)/add-agent-form";
import { DeleteAgentForm } from "./(components)/delete-agent-form";
import { EditAgentForm } from "./(components)/edit-agent-form";
import { channels } from "$lib/stores/channels";
import { tenants } from "$lib/stores/tenants";
const { data } = $props();
@@ -61,6 +62,7 @@
<AddAgentForm
done={() => {
agents.load();
channels.load();
tenants.reload();
invalidate(ROUTES.DASHBOARD.AGENTS);
closeDialog("add");
@@ -116,6 +118,7 @@
closeDialog("edit");
curItem = null;
agents.load();
channels.load();
invalidate(ROUTES.DASHBOARD.AGENTS);
}}
/>
@@ -129,6 +132,7 @@
closeDialog("delete");
curItem = null;
agents.load();
channels.load();
tenants.reload();
invalidate(ROUTES.DASHBOARD.AGENTS);
}}
@@ -0,0 +1,53 @@
<script lang="ts">
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
import { Text } from "$lib/components/ui/typography";
import { type CurAppointmentItem } from "$lib/stores/calendar";
import { getLocalTimeZone } from "@internationalized/date";
import { Calendar, Mail, Phone } from "@lucide/svelte";
let {
item,
}: {
item: CurAppointmentItem;
} = $props();
</script>
{#if item.appointment.appointment}
<div class="flex flex-col items-start gap-3">
<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>
<div class="flex gap-2 p-1">
<Calendar class="size-4 " />
<Text style="sm">
{Intl.DateTimeFormat(getLocale(), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(item.appointment.appointment.dateTime)}
</Text>
</div>
</div>
{/if}
@@ -0,0 +1,102 @@
<script lang="ts">
import type { AppointmentData } from "$lib/client/appointment-crypto";
import { Text } from "$lib/components/ui/typography";
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";
let {
item,
}: {
item: TCalendarItem;
} = $props();
let decrypted = $state<AppointmentData | undefined>();
let error = $state<string | undefined>();
onMount(() => {
decrypt();
});
const decrypt = async () => {
if (!item.appointment) {
console.error("Unable to decrypt appointment data - no appointment data in item.", item.id);
error = "Missing data";
return;
}
// Check if we have all required encrypted data
if (!item.appointment.encryptedPayload || !item.appointment.iv || !item.appointment.authTag) {
console.error("Unable to decrypt appointment data - missing encrypted fields.", item.id);
error = "Missing encrypted data";
return;
}
// Check if we have staffKeyShare
if (!item.appointment.staffKeyShare) {
console.error("Unable to decrypt appointment data - missing staffKeyShare.", item.id);
error = "Missing key share";
return;
}
// Wait for crypto to be initialized (max 5 seconds)
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
const maxWaitTime = 5000; // 5 seconds
const startTime = Date.now();
while (!$staffCrypto.isAuthenticated && Date.now() - startTime < maxWaitTime) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!$staffCrypto.isAuthenticated || !$staffCrypto.crypto) {
error = "Crypto not initialized";
console.error("Staff crypto not initialized after waiting");
return;
}
}
try {
// Client-side decryption using the appointment data from calendar response
decrypted = await $staffCrypto.crypto.decryptStaffAppointment({
encryptedAppointment: {
encryptedPayload: item.appointment.encryptedPayload,
iv: item.appointment.iv,
authTag: item.appointment.authTag,
},
staffKeyShare: item.appointment.staffKeyShare,
});
} catch (err) {
console.error("Error decrypting appointment:", err);
error = err instanceof Error ? err.message : "Decryption failed";
}
};
const setCalendarItem = () => {
if (decrypted) {
calendarStore.setCurItem({ appointment: item, decrypted });
}
};
</script>
{#if error}
<div class="text-destructive flex h-full items-center gap-1 px-1">
<Text style="xs" class="leading-none" title={error}>⚠️</Text>
</div>
{:else if decrypted === undefined}
<div class="flex h-full items-center gap-1 px-1">
<Loader2Icon class="h-3/4 max-h-4 w-auto animate-spin" />
<Text style="xs" class="leading-none">{m["calendar.decrypting"]()}</Text>
</div>
{:else if decrypted}
<Button
class="m-0 h-full w-full cursor-pointer justify-start rounded-none px-1 leading-none text-black hover:!bg-transparent hover:text-black focus:ring-1"
variant="ghost"
onclick={setCalendarItem}
>
{decrypted.name}
</Button>
{/if}
@@ -0,0 +1,142 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Separator } from "$lib/components/ui/separator";
import { Text } from "$lib/components/ui/typography";
import { clock } from "$lib/stores/time";
import { type TCalendarItem } from "$lib/types/calendar";
import { cn } from "$lib/utils";
import {
CalendarDate,
getLocalTimeZone,
toCalendarDate,
toCalendarDateTime,
today,
} from "@internationalized/date";
import Loader from "@lucide/svelte/icons/loader-2";
import { tv } from "tailwind-variants";
import { positionItems } from "./utils";
import AppointmentPreview from "./AppointmentPreview.svelte";
let {
day = $bindable(),
items,
earliestStartHour,
latestEndHour,
scale = $bindable(),
}: {
day: CalendarDate;
items: TCalendarItem[] | undefined;
earliestStartHour: number;
latestEndHour: number;
scale: number;
} = $props();
// Process items to handle overlaps
let processedItems = $derived(positionItems(items));
const hourSize = $derived(60 * scale);
const hours = Array.from({ length: 25 }, (_, i) => i);
const shownHours = $derived(hours.slice(0, latestEndHour + 1).slice(earliestStartHour));
const focusAdjustment = $derived(30 * scale);
const curTimeIndicator = $derived(
today(getLocalTimeZone()).toString() === day.toString() ? $clock : undefined,
);
const slotVariants = tv({
base: "",
variants: {
status: {
available: "border-1 border-[var(--channel-color)] bg-background",
booked: "border-none bg-[var(--channel-color)]",
reserved:
"bg-[var(--channel-color)]/20 border-1 border-[var(--channel-color)] border-dashed",
},
},
});
</script>
<div class="relative flex w-full flex-col">
<div
class="relative flex w-full items-start justify-between transition-all duration-200"
style:height={`${focusAdjustment}px`}
>
<Separator class="bg-secondary absolute top-0 right-0 left-16 h-0.25 !w-auto" />
</div>
{#each shownHours as hour (`hour-${hour}`)}
<div
class="relative flex w-full items-start justify-between transition-all duration-200 select-none"
style:height={`${hourSize}px`}
>
<Text style="xs" class="text-muted-foreground -mt-2 w-16 shrink-0">
{Intl.DateTimeFormat(getLocale(), {
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))}
</Text>
<Separator class="bg-muted-foreground h-0.25 w-auto! grow" />
<Separator class="bg-secondary absolute top-1/2 right-0 left-16 h-0.25 !w-auto" />
</div>
{/each}
<!-- Loading state -->
{#if items === undefined}
<div class="absolute top-0 left-0 -mt-5">
<Loader class="size-4 animate-spin" strokeWidth={1} />
<span class="sr-only">{m["calendar.loading"]()}</span>
</div>
{/if}
<!-- Day content area -->
<div class="absolute top-0 right-0 bottom-0 left-16">
{#each processedItems as item (item.id)}
{@const top =
(item.startMinutes / 60) * hourSize + focusAdjustment - earliestStartHour * hourSize}
{@const height = item.duration * scale}
{@const width = 100 / item.totalColumns}
{@const left = item.column * width}
<div
class="absolute flex items-center rounded p-0.25 transition-all duration-200 focus-within:z-10 focus-within:min-h-5 focus-within:scale-[1.02] focus-within:shadow-md focus-within:outline-3 hover:z-10 hover:min-h-5 hover:scale-[1.02] hover:shadow-md"
style:top={`${top}px`}
style:height={`${height}px`}
style:left={`${left}%`}
style:width={`${width}%`}
data-id={item.id}
>
<div
style="--channel-color: {item.color}"
class={cn(
"h-full w-full overflow-hidden rounded leading-none",
slotVariants({ status: item.status }),
)}
>
{#if ["booked", "reserved"].includes(item.status)}
<AppointmentPreview {item} />
{/if}
</div>
</div>
{/each}
</div>
{#if curTimeIndicator && toCalendarDate($clock).toString() === today(getLocalTimeZone()).toString() && latestEndHour + hourSize / 2 > curTimeIndicator.hour}
{@const top =
focusAdjustment +
curTimeIndicator.hour * hourSize +
(curTimeIndicator.minute / 60) * hourSize -
earliestStartHour * hourSize}
<div
class="absolute right-0 left-0 z-10 flex h-1 items-center transition-all duration-200 select-none"
style:top={`${top}px`}
>
<Text style="xs" class="z-10 -ml-1 rounded-full bg-red-500 px-1 text-white">
{Intl.DateTimeFormat(getLocale(), {
hour: "2-digit",
minute: "2-digit",
timeZone: getLocalTimeZone().toString(),
}).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))}
</Text>
<div class="absolute right-0 left-0 h-0.25 bg-red-500"></div>
</div>
{/if}
</div>
@@ -0,0 +1,155 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
import * as ButtonGroup from "$lib/components/ui/button-group";
import { CheckboxWithLabel } from "$lib/components/ui/checkbox-with-label";
import { Label } from "$lib/components/ui/label";
import { HorizontalPagePadding } from "$lib/components/ui/page";
import * as RadioGroup from "$lib/components/ui/radio-group";
import * as Sidebar from "$lib/components/ui/sidebar";
import { Text } from "$lib/components/ui/typography";
import { agents as agentsStore } from "$lib/stores/agents";
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar as sidebarStore } from "$lib/stores/sidebar";
import type { TAppointmentFilter } from "$lib/types/calendar";
import { cn } from "$lib/utils";
import { PanelRightClose, ZoomIn, ZoomOut } from "@lucide/svelte";
import { type ComponentProps } from "svelte";
let {
shownAppointments = $bindable(),
shownChannels = $bindable(),
shownAgents = $bindable(),
scale = $bindable(),
ref = $bindable(null),
...restProps
}: ComponentProps<typeof Sidebar.Root> & {
shownAppointments: TAppointmentFilter;
shownChannels: string[];
shownAgents: string[];
scale: number;
} = $props();
const appointmentStates: { value: TAppointmentFilter; label: string }[] = [
{ value: "all", label: m["calendar.shownAppointments.options.all"]() },
{ value: "available", label: m["calendar.shownAppointments.options.available"]() },
{ value: "booked", label: m["calendar.shownAppointments.options.booked"]() },
{ value: "reserved", label: m["calendar.shownAppointments.options.reserved"]() },
];
let sidebar = $derived($sidebarStore);
let channels = $derived($channelsStore.channels.filter((x) => !x.archived));
let agents = $derived($agentsStore.agents.filter((x) => !x.archived));
const zoomSteps = [1, 2, 3, 4];
const zoom = (direction: number) => {
const currentIndex = zoomSteps.indexOf(scale);
if (currentIndex === -1) return;
const nextIndex = currentIndex + direction;
scale = zoomSteps[nextIndex];
};
</script>
<Sidebar.Root
bind:ref
collapsible="none"
class={cn(
"fixed top-0 right-0 z-50 h-svh border-l shadow-xl lg:sticky lg:flex lg:shadow-none",
!sidebar.isCalendarExpanded ? "hidden lg:sticky" : "lg:sticky",
)}
{...restProps}
>
<Sidebar.Header class="border-sidebar-border h-16 border-b">
<div class="flex h-full items-center gap-5">
<Button
size="sm"
variant="ghost"
onclick={() => sidebarStore.setCalendarExpanded(!sidebar.isCalendarExpanded)}
class="lg:hidden"
>
<PanelRightClose />
</Button>
<ButtonGroup.Root aria-label="Media controls">
<Button
variant="outline"
size="icon"
disabled={zoomSteps[0] === scale}
onclick={() => zoom(-1)}
>
<ZoomOut />
</Button>
<Button
variant="outline"
size="icon"
disabled={zoomSteps.slice(-1)[0] === scale}
onclick={() => zoom(1)}
>
<ZoomIn />
</Button>
</ButtonGroup.Root>
</div>
</Sidebar.Header>
<Sidebar.Content>
<HorizontalPagePadding class="my-3">
<Text style="sm">{m["calendar.shownAppointments.title"]()}</Text>
<RadioGroup.Root bind:value={shownAppointments} class="mt-2 mb-1">
{#each appointmentStates as state (state.value)}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={state.value} id={state.value} />
<Label for={state.value}>
{state.label}
</Label>
</div>
{/each}
</RadioGroup.Root>
</HorizontalPagePadding>
<Sidebar.Separator class="mx-0" />
<HorizontalPagePadding class="flex flex-col gap-4">
{#if channels.length > 1}
<div>
<Text style="sm">{m["channels.title"]()}</Text>
{#each channels as channel (channel.id)}
{@const locale = getLocale()}
{@const name = channel.names[locale] || Object.values(channel.names)[0]}
<div>
<CheckboxWithLabel
value={shownChannels.includes(channel.id)}
label={name}
onCheckedChange={(v) => {
if (v) {
shownChannels = [...shownChannels, channel.id];
} else {
shownChannels = shownChannels.filter((id) => id !== channel.id);
}
}}
class="mt-2 mb-1"
/>
</div>
{/each}
</div>
{/if}
{#if agents.length > 1}
<div>
<Text style="sm">{m["agents.title"]()}</Text>
{#each agents as agent (agent.id)}
<div>
<CheckboxWithLabel
value={shownAgents.includes(agent.id)}
label={agent.name}
onCheckedChange={(v) => {
if (v) {
shownAgents = [...shownAgents, agent.id];
} else {
shownAgents = shownAgents.filter((id) => id !== agent.id);
}
}}
class="mt-2 mb-1"
/>
</div>
{/each}
</div>
{/if}
</HorizontalPagePadding>
</Sidebar.Content>
</Sidebar.Root>
@@ -0,0 +1,62 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { getLocale } from "$i18n/runtime";
import { Button } from "$lib/components/ui/button";
import { CalendarDate, getLocalTimeZone, today } from "@internationalized/date";
import { ChevronLeft, ChevronRight } from "@lucide/svelte";
let {
startDate = $bindable(),
}: {
startDate: CalendarDate;
} = $props();
const prev = () => {
const nextDate = new CalendarDate(startDate.year, startDate.month, startDate.day).subtract({
days: 1,
});
startDate = nextDate;
};
const next = () => {
const nextDate = new CalendarDate(startDate.year, startDate.month, startDate.day).add({
days: 1,
});
startDate = nextDate;
};
const setToToday = () => {
startDate = today(getLocalTimeZone());
};
</script>
<div
class="flex flex-col items-start justify-between gap-2 min-[500px]:flex-row min-[500px]:items-center"
>
<div class="-ml-1 flex w-[350px] items-center justify-between gap-5">
<Button size="sm" variant="ghost" class="h-6 !p-1" onclick={prev}>
<ChevronLeft />
</Button>
<div>
{Intl.DateTimeFormat(getLocale(), {
year: "numeric",
month: "long",
day: "numeric",
weekday: "short",
timeZone: getLocalTimeZone().toString(),
}).format(startDate.toDate(getLocalTimeZone()))}
</div>
<Button size="sm" variant="ghost" class="h-6 !p-1" onclick={next}>
<ChevronRight />
</Button>
</div>
<Button
size="sm"
variant="outline"
onclick={setToToday}
disabled={startDate.toString() === today(getLocalTimeZone()).toString()}
class="-order-1 ml-auto min-[500px]:order-0"
>
{m["calendar.today"]()}
</Button>
</div>
@@ -0,0 +1,116 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { ROUTES } from "$lib/const/routes";
import type { TCalendar, TCalendarItem } from "$lib/types/calendar";
import { toCalendarDateTime, toZoned, type CalendarDate } from "@internationalized/date";
export const fetchCalendar = async (opts: { tenant: string; startDate: CalendarDate }) => {
if (!browser) return;
const params = new URLSearchParams({
startDate: toZoned(opts.startDate, "UTC").toAbsoluteString(),
endDate: toZoned(
toCalendarDateTime(opts.startDate).set({
hour: 23,
minute: 59,
second: 59,
millisecond: 999,
}),
"UTC",
).toAbsoluteString(),
});
const res = await fetch(`/api/tenants/${opts.tenant}/calendar?${params}`, {
method: "GET",
});
if (res.status < 400) {
try {
const data = await res.json();
return data as TCalendar;
} catch (error) {
console.error("Unable to parse calendar response", error);
}
} else {
if (res.status === 401) {
goto(ROUTES.LOGIN);
} else {
console.error("Unable to fetch calendar", res.status, res.statusText);
}
}
};
// Convert time string to minutes since midnight
function timeToMinutes(time: string): number {
const [hours, minutes] = time.split(":").map(Number);
return hours * 60 + minutes;
}
export function positionItems(items: TCalendarItem[] | undefined) {
if (!items) return [];
// Sort items by start time
const sortedItems = [...items]
.sort((a, b) => b.duration - a.duration)
.sort((a, b) => timeToMinutes(a.start) - timeToMinutes(b.start));
// Calculate layout positions
const processedItems = sortedItems.map((item) => {
const startMinutes = timeToMinutes(item.start);
const endMinutes = startMinutes + item.duration;
return {
...item,
startMinutes,
endMinutes,
totalColumns: 1,
};
});
// Find overlapping groups and assign columns
for (let i = 0; i < processedItems.length; i++) {
const currentItem = processedItems[i];
const overlappingItems = [currentItem];
// Find all items that overlap with current item's time range
for (let j = i + 1; j < processedItems.length; j++) {
const nextItem = processedItems[j];
// Check if items overlap
if (nextItem.startMinutes < currentItem.endMinutes) {
overlappingItems.push(nextItem);
} else {
break;
}
}
// Assign columns to overlapping items
if (overlappingItems.length > 1) {
const columns: number[] = [];
overlappingItems.forEach((item) => {
// Find the first available column
let column = 0;
while (columns[column] && columns[column] > item.startMinutes) {
column++;
}
// Don't change columns back
if (item.column !== undefined && item.column > column) {
column = item.column;
}
item.column = column;
item.totalColumns = Math.max(item.totalColumns, column + 1);
columns[column] = item.endMinutes;
});
// Update totalColumns for all overlapping items
const maxColumns = Math.max(...overlappingItems.map((item) => item.column)) + 1;
overlappingItems.forEach((item) => {
item.totalColumns = maxColumns;
});
}
}
return processedItems;
}
@@ -1,8 +1,179 @@
<script lang="ts">
import { m } from "$i18n/messages";
import { MaxPageWidth } from "$lib/components/layouts/max-page-width";
import { SidebarLayout } from "$lib/components/layouts/sidebar-layout";
import { Button } from "$lib/components/ui/button";
import { ResponsiveDialog } from "$lib/components/ui/responsive-dialog";
import { ROUTES } from "$lib/const/routes";
import { auth } from "$lib/stores/auth";
import { calendarStore } from "$lib/stores/calendar";
import { channels as channelsStore } from "$lib/stores/channels";
import { sidebar } from "$lib/stores/sidebar";
import type { TAppointmentFilter, TCalendar, TCalendarItem } from "$lib/types/calendar";
import { getCurrentTranlslation } from "$lib/utils/localizations";
import {
DateFormatter,
getLocalTimeZone,
today,
type CalendarDate,
} from "@internationalized/date";
import { Funnel } from "@lucide/svelte";
import AppointmentDetail from "./(components)/AppointmentDetail.svelte";
import CalendarDay from "./(components)/CalendarDay.svelte";
import CalendarFilters from "./(components)/CalendarFilters.svelte";
import CalendarHeader from "./(components)/CalendarHeader.svelte";
import { fetchCalendar } from "./(components)/utils";
const tenantId = $derived($auth.user?.tenantId);
const curItem = $derived($calendarStore.curItem);
const channels = $derived($channelsStore.channels);
let startDate: CalendarDate = $state(today(getLocalTimeZone()));
let calender: TCalendar | undefined = $state();
let shownAppointments: TAppointmentFilter = $state("all");
let shownChannels: string[] = $state([]);
let shownAgents: string[] = $state([]);
let hours = $derived.by(() => {
const from = channels
.map((c) => c.slotTemplates.map((t) => t.from))
.flat()
.map((time) => {
const [hourStr] = time.split(":");
return parseInt(hourStr, 10);
});
const to = channels
.map((c) => c.slotTemplates.map((t) => t.to))
.flat()
.map((time) => {
const [hourStr] = time.split(":");
return parseInt(hourStr, 10);
});
return { from: Math.min(...from), to: Math.max(...to) };
});
let scale = $state(1);
$effect(() => {
updateCalendar();
});
const updateCalendar = async () => {
if (tenantId) {
calender = undefined;
calender = await fetchCalendar({ startDate, tenant: tenantId });
}
};
let items: TCalendarItem[] | undefined = $derived.by(() => {
if (!calender) return undefined;
const dayEntry = calender.calendar.find((d) => d.date === startDate.toString());
if (!dayEntry) return [];
return Object.keys(dayEntry.channels).reduce<TCalendarItem[]>((allItems, channelId) => {
const channelData = dayEntry.channels[channelId];
const channelItems: TCalendarItem[] = [];
// Available slots
if (["all", "available"].includes(shownAppointments)) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
channelData.availableSlots.forEach((slot) => {
if (
shownAgents.length === 0 ||
shownAgents.some((id) => slot.availableAgents.map((a) => a.id).includes(id))
) {
channelItems.push({
id: `${channelId}-${slot.from}`,
date: dayEntry.date,
start: slot.from,
duration: slot.duration,
channelId,
color: channelData.channel.color,
column: 0,
status: "available",
});
}
});
}
}
// Appointments
if (["all", "booked", "reserved"].includes(shownAppointments)) {
const formatter = new DateFormatter(navigator.language, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
channelData.appointments.forEach((appointment) => {
const status = appointment.status === "CONFIRMED" ? "booked" : "reserved";
if (shownAppointments === "all" || shownAppointments === status) {
if (shownChannels.length === 0 || shownChannels.includes(channelId)) {
if (shownAgents.length === 0 || shownAgents.includes(appointment.agentId)) {
channelItems.push({
id: appointment.id,
date: dayEntry.date,
// TODO: Fix incoming date type is actually string
start: formatter.format(new Date(appointment.appointmentDate)),
duration: appointment.duration,
channelId,
color: channelData.channel.color,
column: 0,
status,
appointment: {
dateTime: new Date(appointment.appointmentDate),
encryptedPayload: appointment.encryptedPayload,
tunnelId: appointment.tunnelId,
agentId: appointment.agentId,
staffKeyShare: appointment.staffKeyShare,
iv: appointment.iv || undefined,
authTag: appointment.authTag || undefined,
},
});
}
}
}
});
}
return [...allItems, ...channelItems];
}, []);
});
</script>
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}
></SidebarLayout>
<SidebarLayout breakcrumbs={[{ label: m["nav.calendar"](), href: ROUTES.DASHBOARD.CALENDAR }]}>
<MaxPageWidth maxWidth="xl">
<div class="flex flex-col gap-10">
<CalendarHeader bind:startDate />
<div>
<CalendarDay
day={startDate}
{items}
earliestStartHour={hours.from}
latestEndHour={hours.to}
bind:scale
/>
</div>
</div>
</MaxPageWidth>
{#snippet headerRight()}
<Button
size="sm"
variant="ghost"
onclick={() => sidebar.setCalendarExpanded(!$sidebar.isCalendarExpanded)}
class="lg:hidden"
>
<Funnel />
</Button>
{/snippet}
{#snippet sidebarRight()}
<CalendarFilters bind:shownAppointments bind:shownChannels bind:shownAgents bind:scale />
{/snippet}
</SidebarLayout>
{#if curItem}
{@const channel = channels.find((c) => c.id === curItem.appointment.channelId)}
<ResponsiveDialog
id="current-calendar-item"
title={curItem.decrypted.name}
description={channel ? getCurrentTranlslation(channel.names) : undefined}
triggerHidden={true}
>
<AppointmentDetail item={curItem} />
</ResponsiveDialog>
{/if}
@@ -118,6 +118,14 @@
signatureBase64,
};
// Store authenticatorData for later key reconstruction
const passkeyId = credentialResp.id;
auth.setPasskeyAuthData({
authenticatorData: authenticatorDataBase64,
passkeyId,
email: $formData.email,
});
// Update UI to show passkey is ready
$passkeyLoading = "success";
+2
View File
@@ -7,6 +7,7 @@
import { Skeleton } from "$lib/components/ui/skeleton";
import { ROUTES } from "$lib/const/routes.js";
import { auth } from "$lib/stores/auth.js";
import { staffCrypto } from "$lib/stores/staff-crypto.js";
import Check from "@lucide/svelte/icons/check";
import { onMount } from "svelte";
@@ -14,6 +15,7 @@
onMount(() => {
auth.reset();
staffCrypto.clear();
});
</script>
@@ -26,6 +26,7 @@ const requestSchema = z.object({
channelId: z.string(),
agentId: z.string(),
appointmentDate: z.string(),
duration: z.number().int().positive(),
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
encryptedAppointment: z.object({
@@ -250,6 +251,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
channelId: validatedData.channelId,
agentId: validatedData.agentId,
appointmentDate: new Date(validatedData.appointmentDate),
duration: validatedData.duration,
encryptedPayload: validatedData.encryptedAppointment.encryptedPayload,
iv: validatedData.encryptedAppointment.iv,
authTag: validatedData.encryptedAppointment.authTag,
@@ -264,6 +266,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
expiryDate: appointment.expiryDate,
status: appointment.status,
encryptedPayload: appointment.encryptedPayload,
duration: appointment.duration,
iv: appointment.iv,
authTag: appointment.authTag,
encryptedData: appointment.encryptedData,
@@ -10,6 +10,7 @@ const requestSchema = z.object({
channelId: z.string(),
agentId: z.string(),
appointmentDate: z.string(),
duration: z.number().int().positive(),
emailHash: z.string(),
clientEmail: z.string().email(),
clientLanguage: z.string().optional().default("de"),
@@ -232,6 +233,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
tenantId,
tunnelId: validatedData.tunnelId,
appointmentDate: validatedData.appointmentDate,
duration: validatedData.duration,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
@@ -29,6 +29,7 @@ describe("Create New Client API Route", () => {
channelId: mockChannelId,
agentId: "agent-123", // Missing field added
appointmentDate: "2024-12-25T14:30:00.000Z",
duration: 10,
emailHash: "test-email-hash",
clientEmail: "test@example.com",
clientLanguage: "de",
@@ -99,6 +100,7 @@ describe("Create New Client API Route", () => {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
duration: validRequestBody.duration,
emailHashPrefix: "test-ema",
});
});
@@ -197,6 +199,7 @@ describe("Create New Client API Route", () => {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
duration: validRequestBody.duration,
emailHashPrefix: "test-ema",
});
});
@@ -257,6 +260,7 @@ describe("Create New Client API Route", () => {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
duration: 10,
emailHashPrefix: "test-ema", // First 8 chars of "test-email-hash"
});
});
@@ -110,7 +110,7 @@ registerOpenAPIRoute("/tenants/{id}/calendar", "GET", {
},
});
export const GET: RequestHandler = async ({ params, url }) => {
export const GET: RequestHandler = async ({ params, url, locals }) => {
const log = logger.setContext("CalendarAPI");
try {
@@ -138,7 +138,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
);
}
if (startDate >= endDate) {
if (startDate > endDate) {
throw new ValidationError("Start date must be before end date");
}
@@ -155,10 +155,15 @@ export const GET: RequestHandler = async ({ params, url }) => {
});
const scheduleService = await ScheduleService.forTenant(tenantId);
// Get the staff user ID if authenticated (for including staffKeyShares)
const staffUserId = locals.user?.id;
const schedule = await scheduleService.getSchedule({
tenantId,
startDate: startDateParam,
endDate: endDateParam,
staffUserId,
});
// Return full calendar data (including appointments and detailed agent info)
@@ -22,12 +22,17 @@ vi.mock("$lib/logger", () => ({
}));
describe("Calendar API", () => {
const mockGetSchedule = vi.fn();
const mockScheduleService = {
getSchedule: vi.fn(),
getSchedule: mockGetSchedule,
tenantId: "tenant-123",
};
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock implementation
mockGetSchedule.mockReset();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(ScheduleService.forTenant).mockResolvedValue(mockScheduleService as any);
});
@@ -40,6 +45,7 @@ describe("Calendar API", () => {
return {
params: { id: tenantId },
url,
locals: {}, // Add locals object
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
@@ -110,7 +116,7 @@ describe("Calendar API", () => {
describe("GET", () => {
it("should return calendar with full schedule data", async () => {
mockScheduleService.getSchedule.mockResolvedValue(mockScheduleResponse);
mockGetSchedule.mockResolvedValue(mockScheduleResponse);
const request = createRequest(
"tenant-123",
@@ -132,15 +138,16 @@ describe("Calendar API", () => {
});
expect(ScheduleService.forTenant).toHaveBeenCalledWith("tenant-123");
expect(mockScheduleService.getSchedule).toHaveBeenCalledWith({
expect(mockGetSchedule).toHaveBeenCalledWith({
tenantId: "tenant-123",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
staffUserId: undefined,
});
});
it("should include full appointment and agent information in calendar response", async () => {
mockScheduleService.getSchedule.mockResolvedValue(mockScheduleResponse);
mockGetSchedule.mockResolvedValue(mockScheduleResponse);
const request = createRequest(
"tenant-123",
@@ -213,7 +220,7 @@ describe("Calendar API", () => {
});
it("should handle ScheduleService errors", async () => {
mockScheduleService.getSchedule.mockRejectedValue(new Error("Database error"));
mockGetSchedule.mockRejectedValue(new Error("Database error"));
const request = createRequest(
"tenant-123",
@@ -234,7 +241,7 @@ describe("Calendar API", () => {
schedule: [],
};
mockScheduleService.getSchedule.mockResolvedValue(emptyScheduleResponse);
mockGetSchedule.mockResolvedValue(emptyScheduleResponse);
const request = createRequest(
"tenant-123",
@@ -277,7 +284,7 @@ describe("Calendar API", () => {
],
};
mockScheduleService.getSchedule.mockResolvedValue(scheduleWithNoSlots);
mockGetSchedule.mockResolvedValue(scheduleWithNoSlots);
const request = createRequest(
"tenant-123",
@@ -351,7 +351,7 @@ export const GET: RequestHandler = async ({ params, locals }) => {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
}
checkPermission(locals, tenantId, true);
checkPermission(locals, tenantId);
log.debug("Getting all channels", {
tenantId,
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "appointment" ADD COLUMN "duration" integer NOT NULL;
+63 -1
View File
@@ -1,5 +1,5 @@
{
"id": "8777f842-17fa-4e3d-a697-d13425671771",
"id": "f77f8d01-2ffd-4f99-a059-665ca5f6f4c3",
"prevId": "7c47f4a4-f7e8-4a25-933e-4c1356fb8f35",
"version": "7",
"dialect": "postgresql",
@@ -145,6 +145,12 @@
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expiry_date": {
"name": "expiry_date",
"type": "date",
@@ -490,6 +496,62 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client": {
"name": "client",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"hash_key": {
"name": "hash_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_hash_key_unique": {
"name": "client_hash_key_unique",
"nullsNotDistinct": false,
"columns": ["hash_key"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_appointment_tunnel": {
"name": "client_appointment_tunnel",
"schema": "",
+745
View File
@@ -0,0 +1,745 @@
{
"id": "29c30482-bb21-472c-acbc-6b802aaf0614",
"prevId": "f77f8d01-2ffd-4f99-a059-665ca5f6f4c3",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent": {
"name": "agent",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar(250000)",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_absence": {
"name": "agent_absence",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"absence_type": {
"name": "absence_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_absence_agent_id_agent_id_fk": {
"name": "agent_absence_agent_id_agent_id_fk",
"tableFrom": "agent_absence",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment": {
"name": "appointment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expiry_date": {
"name": "expiry_date",
"type": "date",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"encrypted_data": {
"name": "encrypted_data",
"type": "text",
"primaryKey": false,
"notNull": false
},
"data_key": {
"name": "data_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"encrypted_payload": {
"name": "encrypted_payload",
"type": "text",
"primaryKey": false,
"notNull": false
},
"iv": {
"name": "iv",
"type": "text",
"primaryKey": false,
"notNull": false
},
"auth_tag": {
"name": "auth_tag",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"appointment_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "appointment_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "appointment",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_channel_id_channel_id_fk": {
"name": "appointment_channel_id_channel_id_fk",
"tableFrom": "appointment",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"appointment_agent_id_agent_id_fk": {
"name": "appointment_agent_id_agent_id_fk",
"tableFrom": "appointment",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment_key_share": {
"name": "appointment_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"appointment_id": {
"name": "appointment_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_key": {
"name": "encrypted_key",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"appointment_key_share_appointment_id_appointment_id_fk": {
"name": "appointment_key_share_appointment_id_appointment_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "appointment",
"columnsFrom": ["appointment_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.auth_challenge": {
"name": "auth_challenge",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"challenge": {
"name": "challenge",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"consumed": {
"name": "consumed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel": {
"name": "channel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"names": {
"name": "names",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"paused": {
"name": "paused",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"requires_confirmation": {
"name": "requires_confirmation",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_agent": {
"name": "channel_agent",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_agent_channel_id_channel_id_fk": {
"name": "channel_agent_channel_id_channel_id_fk",
"tableFrom": "channel_agent",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_agent_agent_id_agent_id_fk": {
"name": "channel_agent_agent_id_agent_id_fk",
"tableFrom": "channel_agent",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_slot_template": {
"name": "channel_slot_template",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slot_template_id": {
"name": "slot_template_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_slot_template_channel_id_channel_id_fk": {
"name": "channel_slot_template_channel_id_channel_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_slot_template_slot_template_id_slotTemplate_id_fk": {
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "slotTemplate",
"columnsFrom": ["slot_template_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_appointment_tunnel": {
"name": "client_appointment_tunnel",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email_hash": {
"name": "email_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_public_key": {
"name": "client_public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"client_key_share": {
"name": "client_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_appointment_tunnel_email_hash_unique": {
"name": "client_appointment_tunnel_email_hash_unique",
"nullsNotDistinct": false,
"columns": ["email_hash"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client_tunnel_staff_key_share": {
"name": "client_tunnel_staff_key_share",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tunnel_id": {
"name": "tunnel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"encrypted_tunnel_key": {
"name": "encrypted_tunnel_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": {
"name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "client_appointment_tunnel",
"columnsFrom": ["tunnel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slotTemplate": {
"name": "slotTemplate",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"weekdays": {
"name": "weekdays",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "time",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "time",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.staff_crypto": {
"name": "staff_crypto",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"passkey_id": {
"name": "passkey_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+9 -2
View File
@@ -47,8 +47,15 @@
{
"idx": 6,
"version": "7",
"when": 1763052916263,
"tag": "0006_gray_richard_fisk",
"when": 1763111569823,
"tag": "0006_flimsy_zarek",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1763455361426,
"tag": "0007_mighty_tinkerer",
"breakpoints": true
}
]