From 56b7d4a9b2eb49989e2f42e871a158ba492fddc4 Mon Sep 17 00:00:00 2001 From: Karl Ludwig Weise Date: Wed, 19 Nov 2025 14:42:45 +0100 Subject: [PATCH] Feat/calendar (#129) --- docker-compose.dev.yml | 2 +- project.inlang/messages/de.json | 14 + project.inlang/messages/en.json | 14 + src/lib/client/appointment-crypto.ts | 296 ++++++- .../layouts/sidebar-layout/root.svelte | 20 +- .../button-group-separator.svelte | 20 + .../ui/button-group/button-group-text.svelte | 30 + .../ui/button-group/button-group.svelte | 46 ++ src/lib/components/ui/button-group/index.ts | 13 + .../ui/public/appointment-card.svelte | 18 +- src/lib/components/ui/radio-group/index.ts | 10 + .../ui/radio-group/radio-group-item.svelte | 31 + .../ui/radio-group/radio-group.svelte | 19 + src/lib/server/db/tenant-schema.ts | 2 + .../__tests__/appointment-service.test.ts | 2 + .../server/services/appointment-service.ts | 2 + src/lib/server/services/channel-service.ts | 13 +- src/lib/server/services/schedule-service.ts | 55 +- src/lib/stores/auth.ts | 33 + src/lib/stores/calendar.ts | 34 + src/lib/stores/channels.ts | 52 ++ src/lib/stores/sidebar.ts | 5 + src/lib/stores/staff-crypto.ts | 108 +++ src/lib/stores/tenants.ts | 2 + src/lib/stores/time.ts | 9 + src/lib/types/calendar.ts | 32 + src/lib/types/public.ts | 1 + .../[[id]]/(components)/select-slot.svelte | 1 + .../[[id]]/(components)/summary.svelte | 1 + .../setup-passkey/setup-passkey-form.svelte | 12 +- src/routes/(pages)/dashboard/+layout.svelte | 14 + .../(pages)/dashboard/agents/+page.svelte | 4 + .../(components)/AppointmentDetail.svelte | 53 ++ .../(components)/AppointmentPreview.svelte | 102 +++ .../calendar/(components)/CalendarDay.svelte | 142 ++++ .../(components)/CalendarFilters.svelte | 155 ++++ .../(components)/CalendarHeader.svelte | 62 ++ .../dashboard/calendar/(components)/utils.ts | 116 +++ .../(pages)/dashboard/calendar/+page.svelte | 175 +++- src/routes/(pages)/login/login-form.svelte | 8 + src/routes/(pages)/logout/+page.svelte | 2 + .../appointments/add-to-tunnel/+server.ts | 3 + .../appointments/create-new-client/+server.ts | 2 + .../__tests__/create-new-client-api.test.ts | 4 + .../api/tenants/[id]/calendar/+server.ts | 9 +- .../calendar/__tests__/calendar-api.test.ts | 21 +- .../api/tenants/[id]/channels/+server.ts | 2 +- tenant-migrations/0006_flimsy_zarek.sql | 1 + ...hard_fisk.sql => 0007_mighty_tinkerer.sql} | 0 tenant-migrations/meta/0006_snapshot.json | 64 +- tenant-migrations/meta/0007_snapshot.json | 745 ++++++++++++++++++ tenant-migrations/meta/_journal.json | 11 +- 52 files changed, 2528 insertions(+), 64 deletions(-) create mode 100644 src/lib/components/ui/button-group/button-group-separator.svelte create mode 100644 src/lib/components/ui/button-group/button-group-text.svelte create mode 100644 src/lib/components/ui/button-group/button-group.svelte create mode 100644 src/lib/components/ui/button-group/index.ts create mode 100644 src/lib/components/ui/radio-group/index.ts create mode 100644 src/lib/components/ui/radio-group/radio-group-item.svelte create mode 100644 src/lib/components/ui/radio-group/radio-group.svelte create mode 100644 src/lib/stores/calendar.ts create mode 100644 src/lib/stores/channels.ts create mode 100644 src/lib/stores/staff-crypto.ts create mode 100644 src/lib/stores/time.ts create mode 100644 src/lib/types/calendar.ts create mode 100644 src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte create mode 100644 src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte create mode 100644 src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte create mode 100644 src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte create mode 100644 src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte create mode 100644 src/routes/(pages)/dashboard/calendar/(components)/utils.ts create mode 100644 tenant-migrations/0006_flimsy_zarek.sql rename tenant-migrations/{0006_gray_richard_fisk.sql => 0007_mighty_tinkerer.sql} (100%) create mode 100644 tenant-migrations/meta/0007_snapshot.json diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 9e85a7d..a60a1e8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -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 diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index bca8966..ac6c353 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -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" + } + } } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 6c0a5c4..1af7752 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -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" + } + } } } diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index ee7c73c..32d2080 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -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 { 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 { + 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 { - 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 { // 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> { 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 { @@ -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"], diff --git a/src/lib/components/layouts/sidebar-layout/root.svelte b/src/lib/components/layouts/sidebar-layout/root.svelte index ebbe5ab..a10e848 100644 --- a/src/lib/components/layouts/sidebar-layout/root.svelte +++ b/src/lib/components/layouts/sidebar-layout/root.svelte @@ -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 & { breakcrumbs?: Array<{ label: string; href: string }> } = - $props(); + }: HTMLAttributes & { + breakcrumbs?: Array<{ label: string; href: string }>; + headerRight?: Snippet; + sidebarRight?: Snippet; + } = $props(); sidebar.setOpen(open)}> @@ -22,7 +28,7 @@
-
+
{ @@ -52,10 +58,18 @@ {/if} + {#if headerRight} +
+ {@render headerRight?.()} +
+ {/if}
{@render children?.()} + {#if sidebarRight} + {@render sidebarRight?.()} + {/if}
diff --git a/src/lib/components/ui/button-group/button-group-separator.svelte b/src/lib/components/ui/button-group/button-group-separator.svelte new file mode 100644 index 0000000..5f5bd26 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group-separator.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/button-group/button-group-text.svelte b/src/lib/components/ui/button-group/button-group-text.svelte new file mode 100644 index 0000000..4ec9e07 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group-text.svelte @@ -0,0 +1,30 @@ + + +{#if child} + {@render child({ props: mergedProps })} +{:else} +
+ {@render mergedProps.children?.()} +
+{/if} diff --git a/src/lib/components/ui/button-group/button-group.svelte b/src/lib/components/ui/button-group/button-group.svelte new file mode 100644 index 0000000..3d42387 --- /dev/null +++ b/src/lib/components/ui/button-group/button-group.svelte @@ -0,0 +1,46 @@ + + + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/button-group/index.ts b/src/lib/components/ui/button-group/index.ts new file mode 100644 index 0000000..177d11b --- /dev/null +++ b/src/lib/components/ui/button-group/index.ts @@ -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, +}; diff --git a/src/lib/components/ui/public/appointment-card.svelte b/src/lib/components/ui/public/appointment-card.svelte index bb26784..c9ccaeb 100644 --- a/src/lib/components/ui/public/appointment-card.svelte +++ b/src/lib/components/ui/public/appointment-card.svelte @@ -65,16 +65,16 @@ {/if} {#if appointment.agent || appointment.agent === null} -
- +
+ {appointment.agent?.name || m["public.anyAgent"]()}
{/if} {#if appointment.slot} -
- +
+ {Intl.DateTimeFormat($publicStore.locale, { year: "numeric", @@ -89,13 +89,17 @@ {/if} {#if appointment.data}
- + {appointment.data.name}
- {appointment.data.email} + + {appointment.data.email} + {#if appointment.data.phone}
- {appointment.data.phone} + + {appointment.data.phone} + {/if}
diff --git a/src/lib/components/ui/radio-group/index.ts b/src/lib/components/ui/radio-group/index.ts new file mode 100644 index 0000000..b608946 --- /dev/null +++ b/src/lib/components/ui/radio-group/index.ts @@ -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, +}; diff --git a/src/lib/components/ui/radio-group/radio-group-item.svelte b/src/lib/components/ui/radio-group/radio-group-item.svelte new file mode 100644 index 0000000..f9238fd --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group-item.svelte @@ -0,0 +1,31 @@ + + + + {#snippet children({ checked })} +
+ {#if checked} + + {/if} +
+ {/snippet} +
diff --git a/src/lib/components/ui/radio-group/radio-group.svelte b/src/lib/components/ui/radio-group/radio-group.svelte new file mode 100644 index 0000000..c755bf5 --- /dev/null +++ b/src/lib/components/ui/radio-group/radio-group.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index c046482..cd44d24 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -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 */ diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index 49d0178..9ed63d2 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -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", diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index 06678f7..3e08eb0 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -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, diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index 9898091..bf35731 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -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({ diff --git a/src/lib/server/services/schedule-service.ts b/src/lib/server/services/schedule-service.ts index 7fe72f0..2459b51 100644 --- a/src/lib/server/services/schedule-service.ts +++ b/src/lib/server/services/schedule-service.ts @@ -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; @@ -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 = {}; + 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, + ); + } + // 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; }): Promise { 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 diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 41e25f1..0034d46 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -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) => { diff --git a/src/lib/stores/calendar.ts b/src/lib/stores/calendar.ts new file mode 100644 index 0000000..98e90d0 --- /dev/null +++ b/src/lib/stores/calendar.ts @@ -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({ + curItem: null, + }); + + return { + ...store, + setCurItem: (curItem: CurAppointmentItem | null) => { + store.update((state) => { + return { ...state, curItem }; + }); + + if (curItem) { + openDialog("current-calendar-item"); + } + }, + }; +}; + +export const calendarStore = createCalendarStore(); diff --git a/src/lib/stores/channels.ts b/src/lib/stores/channels.ts new file mode 100644 index 0000000..9dfbb20 --- /dev/null +++ b/src/lib/stores/channels.ts @@ -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({ + 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(); diff --git a/src/lib/stores/sidebar.ts b/src/lib/stores/sidebar.ts index 97d0cab..b6f5ca3 100644 --- a/src/lib/stores/sidebar.ts +++ b/src/lib/stores/sidebar.ts @@ -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({ 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`; diff --git a/src/lib/stores/staff-crypto.ts b/src/lib/stores/staff-crypto.ts new file mode 100644 index 0000000..00f354a --- /dev/null +++ b/src/lib/stores/staff-crypto.ts @@ -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({ + 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 { + 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 { + 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(); diff --git a/src/lib/stores/tenants.ts b/src/lib/stores/tenants.ts index e58acfa..ef297eb 100644 --- a/src/lib/stores/tenants.ts +++ b/src/lib/stores/tenants.ts @@ -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) { diff --git a/src/lib/stores/time.ts b/src/lib/stores/time.ts new file mode 100644 index 0000000..ad61aea --- /dev/null +++ b/src/lib/stores/time.ts @@ -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); +}); diff --git a/src/lib/types/calendar.ts b/src/lib/types/calendar.ts new file mode 100644 index 0000000..bab7a16 --- /dev/null +++ b/src/lib/types/calendar.ts @@ -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; + }; +}; diff --git a/src/lib/types/public.ts b/src/lib/types/public.ts index a46588a..9528cfc 100644 --- a/src/lib/types/public.ts +++ b/src/lib/types/public.ts @@ -36,6 +36,7 @@ export type TPublicAppointment = { } | null; slot?: { datetime: CalendarDateTime; + duration: number; }; data?: { name: string; diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte index e3e7616..36defa8 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/select-slot.svelte @@ -145,6 +145,7 @@ hour: parseInt(slot.from.split(":")[0], 10), minute: parseInt(slot.from.split(":")[1], 10), }), + duration: slot.duration, }, }); } diff --git a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte index f63f661..65152ee 100644 --- a/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte +++ b/src/routes/(pages)/(clients)/book-appointment/[[id]]/(components)/summary.svelte @@ -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", diff --git a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte index 0efd20a..a9c29e3 100644 --- a/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte +++ b/src/routes/(pages)/confirm/setup-passkey/setup-passkey-form.svelte @@ -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"]()); } diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 0c6385c..a2f884c 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -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)"); + } + } + }; {@render children()} diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index f4c8994..2e9a33d 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -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 @@ { 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); }} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte new file mode 100644 index 0000000..a019021 --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentDetail.svelte @@ -0,0 +1,53 @@ + + +{#if item.appointment.appointment} +
+
+ + {#if item.decrypted.phone} + + {/if} +
+
+ + + {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)} + +
+
+{/if} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte new file mode 100644 index 0000000..e0488fa --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/AppointmentPreview.svelte @@ -0,0 +1,102 @@ + + +{#if error} +
+ โš ๏ธ +
+{:else if decrypted === undefined} +
+ + {m["calendar.decrypting"]()} +
+{:else if decrypted} + +{/if} diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte new file mode 100644 index 0000000..5b4f04e --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarDay.svelte @@ -0,0 +1,142 @@ + + +
+
+ +
+ {#each shownHours as hour (`hour-${hour}`)} +
+ + {Intl.DateTimeFormat(getLocale(), { + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(toCalendarDateTime(day).set({ hour }).toDate(getLocalTimeZone()))} + + + +
+ {/each} + + + {#if items === undefined} +
+ + {m["calendar.loading"]()} +
+ {/if} + + +
+ {#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} +
+
+ {#if ["booked", "reserved"].includes(item.status)} + + {/if} +
+
+ {/each} +
+ + {#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} +
+ + {Intl.DateTimeFormat(getLocale(), { + hour: "2-digit", + minute: "2-digit", + timeZone: getLocalTimeZone().toString(), + }).format(toCalendarDateTime($clock).toDate(getLocalTimeZone()))} + +
+
+ {/if} +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte new file mode 100644 index 0000000..afea0ef --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarFilters.svelte @@ -0,0 +1,155 @@ + + + + +
+ + + + + +
+
+ + + {m["calendar.shownAppointments.title"]()} + + {#each appointmentStates as state (state.value)} +
+ + +
+ {/each} +
+
+ + + {#if channels.length > 1} +
+ {m["channels.title"]()} + {#each channels as channel (channel.id)} + {@const locale = getLocale()} + {@const name = channel.names[locale] || Object.values(channel.names)[0]} +
+ { + if (v) { + shownChannels = [...shownChannels, channel.id]; + } else { + shownChannels = shownChannels.filter((id) => id !== channel.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} + {#if agents.length > 1} +
+ {m["agents.title"]()} + {#each agents as agent (agent.id)} +
+ { + if (v) { + shownAgents = [...shownAgents, agent.id]; + } else { + shownAgents = shownAgents.filter((id) => id !== agent.id); + } + }} + class="mt-2 mb-1" + /> +
+ {/each} +
+ {/if} +
+
+
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte new file mode 100644 index 0000000..e3d4e9d --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/CalendarHeader.svelte @@ -0,0 +1,62 @@ + + +
+
+ +
+ {Intl.DateTimeFormat(getLocale(), { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + timeZone: getLocalTimeZone().toString(), + }).format(startDate.toDate(getLocalTimeZone()))} +
+ +
+ +
diff --git a/src/routes/(pages)/dashboard/calendar/(components)/utils.ts b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts new file mode 100644 index 0000000..12457fd --- /dev/null +++ b/src/routes/(pages)/dashboard/calendar/(components)/utils.ts @@ -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; +} diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 6c5174d..a94eff2 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -1,8 +1,179 @@ - + + +
+ +
+ +
+
+
+ {#snippet headerRight()} + + {/snippet} + {#snippet sidebarRight()} + + {/snippet} +
+ +{#if curItem} + {@const channel = channels.find((c) => c.id === curItem.appointment.channelId)} + + + +{/if} diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 949e24f..9a6e492 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -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"; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte index 48cb9ba..f92b15d 100644 --- a/src/routes/(pages)/logout/+page.svelte +++ b/src/routes/(pages)/logout/+page.svelte @@ -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(); }); diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts index fd53623..725006d 100644 --- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts @@ -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, diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index d836d3c..bf2dd80 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -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), }); diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts index 07f061b..1bd3168 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts @@ -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" }); }); diff --git a/src/routes/api/tenants/[id]/calendar/+server.ts b/src/routes/api/tenants/[id]/calendar/+server.ts index 5908d48..a990754 100644 --- a/src/routes/api/tenants/[id]/calendar/+server.ts +++ b/src/routes/api/tenants/[id]/calendar/+server.ts @@ -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) diff --git a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts index 65ac679..b795c1c 100644 --- a/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts +++ b/src/routes/api/tenants/[id]/calendar/__tests__/calendar-api.test.ts @@ -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", diff --git a/src/routes/api/tenants/[id]/channels/+server.ts b/src/routes/api/tenants/[id]/channels/+server.ts index 844734a..eb27132 100644 --- a/src/routes/api/tenants/[id]/channels/+server.ts +++ b/src/routes/api/tenants/[id]/channels/+server.ts @@ -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, diff --git a/tenant-migrations/0006_flimsy_zarek.sql b/tenant-migrations/0006_flimsy_zarek.sql new file mode 100644 index 0000000..0105e6d --- /dev/null +++ b/tenant-migrations/0006_flimsy_zarek.sql @@ -0,0 +1 @@ +ALTER TABLE "appointment" ADD COLUMN "duration" integer NOT NULL; \ No newline at end of file diff --git a/tenant-migrations/0006_gray_richard_fisk.sql b/tenant-migrations/0007_mighty_tinkerer.sql similarity index 100% rename from tenant-migrations/0006_gray_richard_fisk.sql rename to tenant-migrations/0007_mighty_tinkerer.sql diff --git a/tenant-migrations/meta/0006_snapshot.json b/tenant-migrations/meta/0006_snapshot.json index 5e4fa51..297fd52 100644 --- a/tenant-migrations/meta/0006_snapshot.json +++ b/tenant-migrations/meta/0006_snapshot.json @@ -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": "", diff --git a/tenant-migrations/meta/0007_snapshot.json b/tenant-migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..18a4fd1 --- /dev/null +++ b/tenant-migrations/meta/0007_snapshot.json @@ -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": {} + } +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 15ad2f3..5341886 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -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 } ]