mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Feat/calendar (#129)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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`;
|
||||
|
||||
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "appointment" ADD COLUMN "duration" integer NOT NULL;
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user