Client and staff access to appointments and schedules (#90)

* Web workers for crypto functions. Crypto key storage in db.

* First prototype appointment service and api

* Basic flows für clients.

* Staff crypto

* Browser crypto functions

* Get appointments from a date range

* API docs

* Schedule API, fixed schedule errors

* Tests and bugfixes

* Formatting

* enhanced and unified Browser-Crypto-Utilities
trying to create a new tunnel when tenant has no users now results in an error.
user creation now has a confirmation state.

* Staff API, getting and updating tunnel data.

* Tests for staff endpoints
Fixed for confirmationState
Fixed tests

* Moved logic to staff service

* Moved logic to appoointment service

* calendar route for staff members. schedule route for clients.

* Fix client access to certain routes

* Update src/routes/api/tenants/[id]/staff/__tests__/staff-api.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/routes/api/tenants/[id]/staff/[staffId]/public-key/__tests__/public-key.test.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/routes/api/tenants/[id]/appointments/tunnels/add-staff-key-shares/+server.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Security fixes for staff-crypto key retrieval.

* LInting errors

* DB Migrations

* Removed isEncrypted property

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Hendrik
2025-10-05 15:49:11 +02:00
committed by GitHub
co-authored by Copilot
parent 636ff6dd0b
commit 4572352341
65 changed files with 12281 additions and 2100 deletions
+8 -1
View File
@@ -17,5 +17,12 @@
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"prettier.requireConfig": true,
"cSpell.words": ["bytea", "jose", "postgres", "postgresql", "uuidv", "Yubi"]
"cSpell.words": [
"bytea",
"jose",
"postgres",
"postgresql",
"uuidv",
"Yubi"
]
}
+179
View File
@@ -0,0 +1,179 @@
# Client-Side End-to-End Encryption for Appointment Bookings
## Overview
This document describes the revised concept for end-to-end encrypted appointment bookings with client-side encryption. The system implements a zero-knowledge architecture where the server never has access to unencrypted personal data.
## Security Principles
1. **Zero-Knowledge Server**: Server never sees plaintext data
2. **Client-Side Encryption**: All crypto operations in the browser
3. **PIN stays secret**: PIN never leaves the client
4. **Challenge-Response**: Secure authentication without PIN transmission
5. **Post-Quantum Cryptography**: ML-KEM-768 (Kyber) for key exchange
6. **ClientAppointments Tunnel**: Scalable appointment encryption like chat channels
## Architecture Components
### 1. ClientAppointments Tunnel Concept
Each client has an encrypted "tunnel" with all their appointments for a tenant:
```
ClientAppointmentsTunnel:
├── Encrypted Appointments (List)
├── Tunnel Key (AES-256)
├── Client Key Share (PIN-derived)
└── Staff Key Shares (Kyber-encrypted)
```
### 2. Cryptographic Primitives
- **ML-KEM-768 (Kyber)**: Post-Quantum Key Exchange (encrypts the tunnel key)
- **AES-256-GCM**: Symmetric encryption of appointment data (encryption unique per tunnel)
- **Shamir Secret Sharing**: Private Key Split (2-of-2)
- **Argon2**: PIN-based key derivation
- **Challenge-Response**: Secure authentication for clients (based on the PIN)
## Flow Diagrams
### Flow 1: New Client
```mermaid
sequenceDiagram
participant C as Client (Browser)
participant S as Server
Note over C,S: Step 1: Initialization
C->>S: GET /api/appointments/staff-public-keys?tenantId=xxx
Note right of C: Get staff public keys for encryption
S->>S: Get staff public keys for tenant
S->>C: 200 Staff Public Keys
Note left of S: { staffPublicKeys: [{ userId, publicKey }] }
Note over C,S: Step 2: Client-Side Encryption
C->>C: Enter PIN
C->>C: Generate tunnel key (AES-256)
C->>C: Encrypt appointment data with tunnel key
C->>C: Generate client keypair (Kyber)
C->>C: Split private key with PIN (Shamir 2-of-2)
C->>C: Encrypt tunnel key for each staff member
Note over C,S: Step 3: Encrypted Transmission
C->>S: POST /api/appointments/create
Note right of C: {<br/> encryptedAppointment,<br/> staffKeyShares,<br/> clientPublicKey,<br/> privateKeyShare,<br/> clientKeyShare<br/>}
S->>S: Store encrypted data
S->>S: Create ClientAppointments Tunnel
S->>C: 201 Success
Note left of S: { appointmentId, appointmentDate, status }
```
### Flow 2: Existing Client
```mermaid
sequenceDiagram
participant C as Client (Browser)
participant S as Server
Note over C,S: Step 1: Challenge-Response Authentication
C->>S: POST /api/appointments/challenge
Note right of C: { emailHash }
S->>S: Generate challenge (UUID)
S->>S: Encrypt challenge with client public key
S->>C: 200 Challenge + Private Key Share
Note left of S: {<br/> challenge,<br/> encryptedChallenge,<br/> privateKeyShare<br/>}
Note over C,S: Step 2: Client-Side Challenge Solution
C->>C: Enter PIN
C->>C: Reconstruct private key (PIN + privateKeyShare)
C->>C: Decrypt challenge with private key
C->>S: POST /api/appointments/verify-challenge
Note right of C: { emailHash, challengeResponse }
S->>S: Verify challenge response
alt Challenge correct
S->>S: Get staff public keys + tunnel
S->>C: 200 Authenticated
Note left of S: {<br/> staffPublicKeys,<br/> encryptedTunnelKey<br/>}
else Challenge incorrect
S->>C: 401 Unauthorized
end
Note over C,S: Step 3: Add new appointment
C->>C: Decrypt tunnel key with private key
C->>C: Encrypt new appointment with tunnel key
C->>S: POST add-to-tunnel
Note right of C: {<br/> emailHash,<br/> tunnelId,<br/> appointmentDate,<br/> encryptedAppointment<br/>}
S->>S: Add to ClientAppointments Tunnel
S->>C: 201 Success
```
### Flow 3: Client loads an apppointment
```mermaid
sequenceDiagram
participant C as Client (Browser)
participant S as Server
Note over C,S: Authentication (like Flow 2, Steps 1-2)
C->>S: Challenge-Response Authentication
S->>C: Authenticated + Tunnel Access
Note over C,S: Load and decrypt appointment
C->>S: GET appointments/(id)
S->>S: Get encrypted appointment for client
S->>C: 200 Encrypted appointment
Note left of S: {<br/> appointments: [{<br/> id, appointmentDate, status,<br/> encryptedData: { encryptedPayload, iv, authTag }<br/> }],<br/> encryptedTunnelKey<br/>}
Note over C,S: Client-Side Decryption
C->>C: Decrypt tunnel key with private key
C->>C: Decrypt each appointment with tunnel key
C->>C: Display decrypted appointments in UI
```
## Security Considerations
### What the server never sees:
- ✅ Client's PIN
- ✅ Plaintext names, email, phone
- ✅ Complete private key
- ✅ Tunnel key in plaintext
### What the server sees:
- ✅ Email hash (for client identification)
- ✅ Appointment date and time
- ✅ Tunnel ID (public)
- ✅ Encrypted payloads
- ✅ Appointment status
### Attack vectors and countermeasures:
1. **Server compromise**: Server cannot decrypt encrypted data
2. **Man-in-the-Middle**: HTTPS + Key-Pinning can protect transmission
3. **Brute-Force PIN**: Argon2 + Client-Side Rate-Limiting
4. **Timing attacks**: Constant response times for challenge-response
5. **Side-Channel**: All crypto operations in Web Crypto API
## Performance Considerations
### Client-Side:
- Kyber Key Generation: ~10ms
- AES Encryption: <1ms per appointment
- Challenge-Response: <5ms
- **Total time new client**: ~50ms
- **Total time existing client**: ~20ms
### Server-Side:
- No crypto operations
- Only database I/O
- **Highly scalable** due to stateless design
+566
View File
@@ -0,0 +1,566 @@
# Staff Crypto Management Documentation
## Overview
The Staff Crypto system implements browser-based end-to-end encryption for staff members accessing patient appointment data. All cryptographic operations happen in the browser using WebAuthn-backed key derivation with split-key architecture for maximum security.
## Architecture
```
Browser (Staff App) ↔ StaffCryptoService API ↔ Tenant Database
WebAuthn Passkeys ↔ Hardware Keys
```
- **Browser**: Generates Kyber keys, performs encryption/decryption
- **StaffCryptoService**: Stores/retrieves key shards and public keys
- **WebAuthn**: Hardware-backed authentication for deterministic key derivation
- **Split-Key Storage**: Private keys split between database and passkey-derived shards
## Key Features
- **Browser-Based Cryptography**: All key generation and crypto operations in browser
- **Split-Key Architecture**: Private keys split using XOR between database and passkey shards
- **WebAuthn Integration**: Hardware-backed deterministic key derivation from passkey data
- **Zero-Knowledge Server**: Server never sees complete private keys
- **Per-Passkey Keys**: Each staff passkey has its own unique keypair
- **ML-KEM-768 Encryption**: Post-quantum cryptography using Kyber
## Browser Integration Workflow
### 1. Staff Passkey Registration & Key Generation
When a staff member registers a new passkey, the browser automatically generates crypto keys:
```javascript
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
async function registerStaffPasskey(userId, tenantId) {
// 1. Complete WebAuthn passkey registration
const credential = await navigator.credentials.create({
publicKey: registrationOptions,
});
// 2. Generate Kyber keypair in browser
const keyPair = KyberCrypto.generateKeyPair();
// 3. Derive deterministic shard from WebAuthn authenticatorData
const crypto = new UnifiedAppointmentCrypto();
const passkeyBasedShard = await crypto.derivePasskeyBasedShard(
credential.id,
credential.response.authenticatorData,
);
// 4. Create database shard using XOR
const dbShard = new Uint8Array(keyPair.privateKey.length);
for (let i = 0; i < keyPair.privateKey.length; i++) {
dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i];
}
// 5. Store via StaffCryptoService API
await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
passkeyId: credential.id,
publicKey: bufferToBase64(keyPair.publicKey),
privateKeyShare: bufferToBase64(dbShard),
}),
});
console.log("Staff crypto keys generated and stored");
}
```
### 2. Staff Authentication & Key Reconstruction
When a staff member authenticates, their private key is reconstructed from shards:
```javascript
async function authenticateStaff(userId, tenantId) {
// 1. Perform WebAuthn authentication
const assertion = await navigator.credentials.get({
publicKey: authenticationOptions,
});
// 2. Get database shard from API
const response = await fetch(`/api/tenants/${tenantId}/staff/${userId}/key-shard`);
const { publicKey, privateKeyShare, passkeyId } = await response.json();
// 3. Derive same passkey-based shard
const crypto = new UnifiedAppointmentCrypto();
const passkeyBasedShard = await crypto.derivePasskeyBasedShard(
passkeyId,
assertion.response.authenticatorData,
);
// 4. Reconstruct private key using XOR
const dbShard = base64ToBuffer(privateKeyShare);
const privateKey = new Uint8Array(dbShard.length);
for (let i = 0; i < dbShard.length; i++) {
privateKey[i] = dbShard[i] ^ passkeyBasedShard[i];
}
// 5. Store reconstructed keypair for session
crypto.staffKeyPair = {
publicKey: base64ToBuffer(publicKey),
privateKey: privateKey,
};
console.log("Staff authenticated and keys reconstructed");
return crypto;
}
```
## StaffCryptoService API
The backend API provides these endpoints for managing staff cryptographic keys:
### Store Staff Keypair
```
POST /api/tenants/{tenantId}/staff/{userId}/crypto
```
**Body:**
```json
{
"passkeyId": "credential-id-from-webauthn",
"publicKey": "base64-encoded-kyber-public-key",
"privateKeyShare": "base64-encoded-database-shard"
}
```
### Get Staff Key Shard
```
GET /api/tenants/{tenantId}/staff/{userId}/key-shard
```
**Response:**
```json
{
"publicKey": "base64-encoded-kyber-public-key",
"privateKeyShare": "base64-encoded-database-shard",
"passkeyId": "associated-passkey-id"
}
```
### Get All Staff Public Keys
```
GET /api/tenants/{tenantId}/appointments/staff-public-keys
```
**Response:**
```json
{
"staffPublicKeys": [
{
"userId": "staff-user-id",
"publicKey": "base64-encoded-kyber-public-key"
}
]
}
```
## Browser Implementation
### Initialize Crypto System
```javascript
import { UnifiedAppointmentCrypto } from "$lib/client/appointment-crypto";
const crypto = new UnifiedAppointmentCrypto();
```
### Staff Authentication
```javascript
async function authenticateStaff(staffId, tenantId) {
try {
await crypto.authenticateStaff(staffId, tenantId);
console.log("Staff authenticated successfully");
// Crypto system is now ready for encryption/decryption
return crypto;
} catch (error) {
console.error("Authentication failed:", error);
throw error;
}
}
```
### Decrypt Appointment Data
```javascript
// Appointment data from API
const appointmentData = {
encryptedData: "base64-encoded-encrypted-data",
staffKeyShare: "base64-encoded-encrypted-key",
};
try {
const decrypted = await cryptoWorker.decryptAppointment(appointmentData);
// decrypted contains:
// {
// title: 'Appointment Title',
// description: 'Appointment Description',
// clientEmail: 'patient@example.com',
// decryptedAt: '2023-12-07T10:30:00.000Z'
// }
console.log("Patient:", decrypted.clientEmail);
console.log("Appointment:", decrypted.title);
} catch (error) {
if (error.message.includes("Not authenticated")) {
// Need to authenticate first
await cryptoWorker.authenticate(staffId);
// Retry decryption
}
}
```
### Check Worker Status
```javascript
const status = await cryptoWorker.getStatus();
console.log("Authenticated:", status.authenticated);
console.log("Staff ID:", status.staffId);
console.log("Session expires at:", new Date(status.expiresAt));
console.log("Time remaining (ms):", status.timeRemaining);
```
### Logout
```javascript
await cryptoWorker.logout();
console.log("All cryptographic data cleared");
```
## Advanced Usage
### Custom Event Handlers
```javascript
// Handle session expiration
cryptoWorker.onSessionExpired = (data) => {
console.log(`Session expired for staff ${data.staffId}`);
// Show re-authentication dialog
showReAuthDialog();
};
// Handle authentication requirement
cryptoWorker.onAuthenticationRequired = () => {
console.log("Authentication required");
// Redirect to login or show auth dialog
redirectToLogin();
};
```
### Batch Processing Multiple Appointments
```javascript
async function decryptMultipleAppointments(appointments) {
const decryptedAppointments = [];
for (const appointment of appointments) {
try {
const decrypted = await cryptoWorker.decryptAppointment({
encryptedData: appointment.encryptedData,
staffKeyShare: appointment.staffKeyShare,
});
decryptedAppointments.push({
...appointment,
decryptedData: decrypted,
});
} catch (error) {
console.error(`Failed to decrypt appointment ${appointment.id}:`, error);
// Handle individual failures gracefully
}
}
return decryptedAppointments;
}
```
### Session Management
```javascript
// Check if authentication is needed before operations
async function ensureAuthenticated(staffId) {
const status = await cryptoWorker.getStatus();
if (!status.authenticated || status.timeRemaining < 60000) {
// Less than 1 minute
console.log("Re-authenticating...");
await cryptoWorker.authenticate(staffId);
}
}
// Use before sensitive operations
await ensureAuthenticated("staff-id-uuid");
const decrypted = await cryptoWorker.decryptAppointment(appointmentData);
```
## Error Handling
### Common Errors
| Error Message | Cause | Solution |
| ----------------------------------------------- | ------------------------- | ----------------------------------------- |
| `Worker not initialized` | Worker not initialized | Call `cryptoWorker.initialize()` |
| `Not authenticated - please authenticate first` | No active session | Call `cryptoWorker.authenticate(staffId)` |
| `Session expired - please authenticate again` | 10-minute timeout reached | Re-authenticate with WebAuthn |
| `Failed to decrypt appointment data` | Corrupted or invalid data | Check API response format |
| `Authentication failed` | WebAuthn failure | Check hardware key or browser support |
### Error Handling Pattern
```javascript
async function safeDecryptAppointment(appointmentData, staffId) {
try {
return await cryptoWorker.decryptAppointment(appointmentData);
} catch (error) {
if (error.message.includes("Not authenticated") || error.message.includes("expired")) {
// Try to re-authenticate
try {
await cryptoWorker.authenticate(staffId);
return await cryptoWorker.decryptAppointment(appointmentData);
} catch (authError) {
throw new Error("Re-authentication failed: " + authError.message);
}
}
// Re-throw other errors
throw error;
}
}
```
## Security Considerations
### What the Worker Protects Against
- **XSS Attacks**: Private keys are isolated from DOM and main thread
- **Memory Dumps**: Keys are automatically cleared after timeout
- **Debugging**: Private keys cannot be inspected via browser dev tools
- **Extensions**: Malicious browser extensions cannot access worker memory
### What You Still Need to Protect
- **CSRF**: Implement proper CSRF protection on your APIs
- **Network Security**: Use HTTPS for all communications
- **Authentication**: Ensure proper staff authentication before worker access
- **Authorization**: Verify staff permissions server-side
### Best Practices
1. **Always authenticate before operations**:
```javascript
await cryptoWorker.authenticate(staffId);
```
2. **Handle session expiration gracefully**:
```javascript
cryptoWorker.onSessionExpired = () => redirectToLogin();
```
3. **Clear data on logout**:
```javascript
await cryptoWorker.logout();
```
4. **Terminate worker on page unload**:
```javascript
window.addEventListener("beforeunload", () => {
cryptoWorker.terminate();
});
```
## Integration with Appointment System
### Fetching Staff Appointments
```javascript
// Fetch appointments from API
const response = await fetch(`/api/tenants/${tenantId}/staff/appointments?staffId=${staffId}`);
const { data: appointments } = await response.json();
// Decrypt each appointment
for (const appointment of appointments) {
const decrypted = await cryptoWorker.decryptAppointment({
encryptedData: appointment.encryptedData,
staffKeyShare: appointment.staffKeyShare,
});
// Display decrypted data in UI
displayAppointment(appointment, decrypted);
}
```
### Complete Staff Dashboard Example
```javascript
import { cryptoWorker } from "./crypto-worker-client.js";
class StaffDashboard {
constructor(staffId, tenantId) {
this.staffId = staffId;
this.tenantId = tenantId;
this.appointments = [];
}
async initialize() {
// Initialize crypto worker
await cryptoWorker.initialize();
// Set up event handlers
cryptoWorker.onSessionExpired = () => this.handleSessionExpired();
cryptoWorker.onAuthenticationRequired = () => this.handleAuthRequired();
// Authenticate staff
await this.authenticate();
// Load appointments
await this.loadAppointments();
}
async authenticate() {
try {
const result = await cryptoWorker.authenticate(this.staffId);
console.log("Authenticated until:", new Date(result.expiresAt));
return result;
} catch (error) {
throw new Error("Authentication failed: " + error.message);
}
}
async loadAppointments() {
try {
// Fetch encrypted appointments
const response = await fetch(
`/api/tenants/${this.tenantId}/staff/appointments?staffId=${this.staffId}`,
);
const { data: encryptedAppointments } = await response.json();
// Decrypt appointments
this.appointments = [];
for (const appointment of encryptedAppointments) {
try {
const decrypted = await cryptoWorker.decryptAppointment({
encryptedData: appointment.encryptedData,
staffKeyShare: appointment.staffKeyShare,
});
this.appointments.push({
...appointment,
decrypted,
});
} catch (error) {
console.error(`Failed to decrypt appointment ${appointment.id}:`, error);
}
}
this.renderAppointments();
} catch (error) {
console.error("Failed to load appointments:", error);
}
}
renderAppointments() {
const container = document.getElementById("appointments");
container.innerHTML = "";
this.appointments.forEach((appointment) => {
const div = document.createElement("div");
div.className = "appointment-card";
div.innerHTML = `
<h3>${appointment.decrypted.title}</h3>
<p>Date: ${appointment.appointmentDate}</p>
<p>Patient: ${appointment.decrypted.clientEmail}</p>
<p>Description: ${appointment.decrypted.description || "No description"}</p>
<p>Status: ${appointment.status}</p>
`;
container.appendChild(div);
});
}
handleSessionExpired() {
alert("Your session has expired. Please log in again.");
window.location.href = "/staff/login";
}
handleAuthRequired() {
this.authenticate().catch((error) => {
console.error("Re-authentication failed:", error);
this.handleSessionExpired();
});
}
async cleanup() {
await cryptoWorker.logout();
cryptoWorker.terminate();
}
}
// Usage
const dashboard = new StaffDashboard("staff-id-uuid", "tenant-id-uuid");
dashboard.initialize().catch(console.error);
// Cleanup on page unload
window.addEventListener("beforeunload", () => {
dashboard.cleanup();
});
```
## Browser Compatibility
- **Chrome/Edge**: Full support
- **Firefox**: Full support
- **Safari**: Full support (iOS 16.4+)
- **WebAuthn**: Required for hardware key authentication
## Troubleshooting
### Worker Not Loading
- Check that `crypto-worker.js` is accessible at the correct path
- Verify CORS settings allow worker loading
- Check browser console for loading errors
### Authentication Failures
- Ensure WebAuthn is supported in the browser
- Check that hardware keys are properly registered
- Verify staff credentials are valid
### Decryption Failures
- Ensure appointment data format matches expected structure
- Check that staff has access to the specific appointment
- Verify encryption/decryption key compatibility
### Performance Issues
- Consider implementing appointment pagination
- Use batch processing for multiple appointments
- Monitor worker memory usage
## API Integration
The crypto worker is designed to work with the Open Reception appointment API:
- `GET /api/tenants/{tenantId}/staff/appointments` - Get all staff appointments
- `POST /api/tenants/{tenantId}/staff/appointments` - Get specific appointment
See the main API documentation for complete endpoint specifications.
+3
View File
@@ -0,0 +1,3 @@
CREATE TYPE "public"."confirmation_state" AS ENUM('INVITED', 'CONFIRMED', 'ACCESS_GRANTED');--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "confirmation_state" "confirmation_state" DEFAULT 'INVITED';--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "confirmed";
+779
View File
@@ -0,0 +1,779 @@
{
"id": "27656a23-f444-413b-8c60-57b59232a615",
"prevId": "805d1d9a-094d-4a4d-a998-2f74ca7dde8d",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.tenant": {
"name": "tenant",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"short_name": {
"name": "short_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"long_name": {
"name": "long_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"languages": {
"name": "languages",
"type": "json",
"primaryKey": false,
"notNull": true
},
"logo": {
"name": "logo",
"type": "varchar(100000)",
"primaryKey": false,
"notNull": false
},
"database_url": {
"name": "database_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"setup_state": {
"name": "setup_state",
"type": "setup_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'NEW'"
},
"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": {
"tenant_database_url_idx": {
"name": "tenant_database_url_idx",
"columns": [
{
"expression": "database_url",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tenant_short_name_unique": {
"name": "tenant_short_name_unique",
"nullsNotDistinct": false,
"columns": ["short_name"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tenant_config": {
"name": "tenant_config",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "config_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"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": {
"tenant_config_tenant_name_idx": {
"name": "tenant_config_tenant_name_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "name",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"tenant_config_tenant_id_tenant_id_fk": {
"name": "tenant_config_tenant_id_tenant_id_fk",
"tableFrom": "tenant_config",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'STAFF'"
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"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()"
},
"last_login_at": {
"name": "last_login_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": false,
"default": true
},
"confirmation_state": {
"name": "confirmation_state",
"type": "confirmation_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false,
"default": "'INVITED'"
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"token_valid_until": {
"name": "token_valid_until",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"passphrase_hash": {
"name": "passphrase_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"recovery_passphrase": {
"name": "recovery_passphrase",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
}
},
"indexes": {
"user_email_idx": {
"name": "user_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_tenant_id_tenant_id_fk": {
"name": "user_tenant_id_tenant_id_fk",
"tableFrom": "user",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": ["email"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_invite": {
"name": "user_invite",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"invite_code": {
"name": "invite_code",
"type": "uuid",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "user_role",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"tenant_id": {
"name": "tenant_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"invited_by": {
"name": "invited_by",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'de'"
},
"used": {
"name": "used",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"used_at": {
"name": "used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"created_user_id": {
"name": "created_user_id",
"type": "uuid",
"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()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"user_invite_code_idx": {
"name": "user_invite_code_idx",
"columns": [
{
"expression": "invite_code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_email_idx": {
"name": "user_invite_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_invite_tenant_idx": {
"name": "user_invite_tenant_idx",
"columns": [
{
"expression": "tenant_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_invite_tenant_id_tenant_id_fk": {
"name": "user_invite_tenant_id_tenant_id_fk",
"tableFrom": "user_invite",
"tableTo": "tenant",
"columnsFrom": ["tenant_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_invited_by_user_id_fk": {
"name": "user_invite_invited_by_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["invited_by"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"user_invite_created_user_id_user_id_fk": {
"name": "user_invite_created_user_id_user_id_fk",
"tableFrom": "user_invite",
"tableTo": "user",
"columnsFrom": ["created_user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_invite_invite_code_unique": {
"name": "user_invite_invite_code_unique",
"nullsNotDistinct": false,
"columns": ["invite_code"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_passkey": {
"name": "user_passkey",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"counter": {
"name": "counter",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"device_name": {
"name": "device_name",
"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()"
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"user_passkey_user_idx": {
"name": "user_passkey_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_passkey_user_id_user_id_fk": {
"name": "user_passkey_user_id_user_id_fk",
"tableFrom": "user_passkey",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user_session": {
"name": "user_session",
"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
},
"session_token": {
"name": "session_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"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()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"last_used_at": {
"name": "last_used_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "now()"
}
},
"indexes": {
"user_session_user_idx": {
"name": "user_session_user_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"user_session_token_idx": {
"name": "user_session_token_idx",
"columns": [
{
"expression": "session_token",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"user_session_user_id_user_id_fk": {
"name": "user_session_user_id_user_id_fk",
"tableFrom": "user_session",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_session_session_token_unique": {
"name": "user_session_session_token_unique",
"nullsNotDistinct": false,
"columns": ["session_token"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.config_type": {
"name": "config_type",
"schema": "public",
"values": ["BOOLEAN", "NUMBER", "STRING"]
},
"public.confirmation_state": {
"name": "confirmation_state",
"schema": "public",
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
},
"public.setup_state": {
"name": "setup_state",
"schema": "public",
"values": ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]
},
"public.user_role": {
"name": "user_role",
"schema": "public",
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -36,6 +36,13 @@
"when": 1759144205727,
"tag": "0004_mixed_caretaker",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1759421310945,
"tag": "0005_confused_rockslide",
"breakpoints": true
}
]
}
+867
View File
@@ -0,0 +1,867 @@
/**
* Unified End-to-End Encryption for Appointments
*
* This class implements browser-side cryptography for both clients and staff members.
* All sensitive operations happen in the browser with zero-knowledge architecture.
*
* ## Client (Patient) Usage:
* ```javascript
* const crypto = new UnifiedAppointmentCrypto();
* await crypto.initNewClient(email, pin, tenantId);
* await crypto.loginExistingClient(email, pin, tenantId);
* const appointment = await crypto.createAppointment(appointmentData, appointmentDate, channelId, tenantId);
* const myAppointments = await crypto.getMyAppointments(tenantId);
* ```
*
* ## Staff Usage:
* ```javascript
* const crypto = new UnifiedAppointmentCrypto();
* await crypto.authenticateStaff(staffId, tenantId);
* const appointments = await crypto.getStaffAppointments(tenantId);
* const decrypted = await crypto.decryptStaffAppointment(encryptedData);
* ```
*
* ## Staff Key Management (during Passkey Registration):
* ```javascript
* // 1. Generate Kyber keypair in browser
* const keyPair = KyberCrypto.generateKeyPair();
*
* // 2. Derive passkey-based shard from WebAuthn authenticatorData
* const passkeyBasedShard = await this.derivePasskeyBasedShard(passkeyId, authenticatorData);
*
* // 3. Create database shard using XOR split
* const dbShard = new Uint8Array(keyPair.privateKey.length);
* for (let i = 0; i < keyPair.privateKey.length; i++) {
* dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i];
* }
*
* // 4. Store keys via StaffCryptoService API
* await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, {
* method: 'POST',
* body: JSON.stringify({
* passkeyId,
* publicKey: this.uint8ArrayToBase64(keyPair.publicKey),
* privateKeyShare: this.uint8ArrayToBase64(dbShard)
* })
* });
* ```
*/
import { OptimizedArgon2 } from "$lib/crypto/hashing";
import { KyberCrypto, AESCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
// Type definitions for unified cryptography
interface ClientKeyPair {
publicKey: string;
privateKey: string;
}
interface StaffKeyPair {
publicKey: Uint8Array;
privateKey: Uint8Array;
}
interface EncryptedData {
encryptedPayload: string;
iv: string;
authTag: string;
}
interface AppointmentData {
name: string;
email: string;
phone?: string;
}
interface StaffPublicKey {
userId: string;
publicKey: string;
}
interface DecryptedAppointment {
id: string;
appointmentDate: string;
status: string;
name: string;
email: string;
phone?: string;
}
interface MyAppointmentsResponse {
appointments: Array<{
id: string;
appointmentDate: string;
status: string;
encryptedData: EncryptedData;
}>;
}
export class UnifiedAppointmentCrypto {
// Client-specific properties
private tunnelKey: CryptoKey | null = null;
private clientKeyPair: ClientKeyPair | null = null;
private emailHash: string | null = null;
private tunnelId: string | null = null;
private clientAuthenticated: boolean = false;
// Staff-specific properties
private staffKeyPair: StaffKeyPair | null = null;
private staffId: string | null = null;
private tenantId: string | null = null;
private staffAuthenticated: boolean = false;
private keyExpiry: number | null = null;
// Shared crypto utilities
private kyberCrypto: KyberCrypto = new KyberCrypto();
private aesCrypto: AESCrypto = new AESCrypto();
private shamirSharing: ShamirSecretSharing = new ShamirSecretSharing();
// ===== CLIENT (PATIENT) METHODS =====
/**
* Initializes a new client with E2E encryption
*/
async initNewClient(email: string, pin: string, tenantId: string): Promise<void> {
try {
// 1. Generate email hash for privacy-preserving lookup
this.emailHash = await this.hashEmail(email);
// 2. Generate tunnel ID
this.tunnelId = this.generateTunnelId();
// 3. Generate ML-KEM-768 keypair
this.clientKeyPair = await this.generateClientKeyPair();
// 4. Generate tunnel key for AES encryption
this.tunnelKey = await this.generateTunnelKey();
// 5. Split private key into Shamir shares (2-of-2)
// Note: privateKeyShare will be used during actual appointment creation
await this.createPrivateKeyShare(this.clientKeyPair.privateKey, pin);
// 6. Fetch staff public keys from server
const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId);
// 7. Encrypt tunnel key for all staff members
// Note: staffKeyShares will be used during actual appointment creation
await this.encryptTunnelKeyForStaff(staffPublicKeys);
// 8. Encrypt tunnel key for client (for later use)
// Note: clientKeyShare will be used during actual appointment creation
await this.encryptTunnelKeyForClient();
console.log("✅ New client initialized", {
tunnelId: this.tunnelId,
emailHashPrefix: this.emailHash.slice(0, 8),
staffCount: staffPublicKeys.length,
});
this.clientAuthenticated = true;
} catch (error) {
console.error("❌ Error during client initialization:", error);
throw error;
}
}
/**
* Authenticates an existing client using challenge-response
*/
async loginExistingClient(email: string, pin: string, tenantId: string): Promise<void> {
try {
// 1. Generate email hash
this.emailHash = await this.hashEmail(email);
// 2. Request challenge from server
const challengeResponse = await fetch(`/api/tenants/${tenantId}/appointments/challenge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emailHash: this.emailHash }),
});
if (!challengeResponse.ok) {
throw new Error("Challenge could not be retrieved");
}
const challengeData = await challengeResponse.json();
// 3. Reconstruct private key from PIN and server share
const privateKey = await this.reconstructPrivateKey(pin, challengeData.privateKeyShare);
// 4. Decrypt challenge
const decryptedChallenge = await this.decryptChallenge(
challengeData.encryptedChallenge,
privateKey,
);
// 5. Send challenge response to server
const verificationResponse = await fetch(
`/api/tenants/${tenantId}/appointments/verify-challenge`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
emailHash: this.emailHash,
challengeResponse: decryptedChallenge,
}),
},
);
if (!verificationResponse.ok) {
throw new Error("Challenge verification failed");
}
const verificationData = await verificationResponse.json();
// 6. Decrypt tunnel key
this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey);
console.log("✅ Existing client authenticated");
this.clientAuthenticated = true;
} catch (error) {
console.error("❌ Error during client login:", error);
throw error;
}
}
/**
* Creates a new encrypted appointment
*/
async createAppointment(
appointmentData: AppointmentData,
appointmentDate: string,
channelId: string,
tenantId: string,
isFirstAppointment: boolean = false,
): Promise<string> {
if (!this.clientAuthenticated || !this.tunnelKey) {
throw new Error("Client not authenticated");
}
try {
// 1. Encrypt appointment data
const encryptedAppointment = await this.encryptAppointmentData(appointmentData);
// 2. Call appropriate endpoint
const endpoint = isFirstAppointment
? `/api/tenants/${tenantId}/appointments/create-new-client`
: `/api/tenants/${tenantId}/appointments/add-to-tunnel`;
const requestData = isFirstAppointment
? {
// New client
tunnelId: this.tunnelId,
channelId,
appointmentDate,
emailHash: this.emailHash,
clientPublicKey: this.clientKeyPair?.publicKey,
privateKeyShare: await this.getPrivateKeyShare(),
encryptedAppointment,
staffKeyShares: await this.getStaffKeyShares(tenantId),
clientKeyShare: await this.getClientKeyShare(),
}
: {
// Existing client
emailHash: this.emailHash,
tunnelId: this.tunnelId!,
channelId,
appointmentDate,
encryptedAppointment,
};
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(requestData),
});
if (!response.ok) {
throw new Error("Appointment could not be created");
}
const result = await response.json();
console.log("✅ Encrypted appointment created:", result.id);
return result.id;
} catch (error) {
console.error("❌ Error creating appointment:", error);
throw error;
}
}
/**
* Retrieves all appointments for this client
*/
async getMyAppointments(tenantId: string): Promise<DecryptedAppointment[]> {
if (!this.clientAuthenticated || !this.tunnelKey) {
throw new Error("Client not authenticated");
}
try {
const response = await fetch(`/api/tenants/${tenantId}/appointments/my-appointments`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Email-Hash": this.emailHash!,
},
});
if (!response.ok) {
throw new Error("Appointments could not be retrieved");
}
const data: MyAppointmentsResponse = await response.json();
// Decrypt each appointment
const decryptedAppointments: DecryptedAppointment[] = [];
for (const encryptedAppt of data.appointments) {
try {
const decryptedData = await this.decryptAppointmentData(encryptedAppt.encryptedData);
decryptedAppointments.push({
id: encryptedAppt.id,
appointmentDate: encryptedAppt.appointmentDate,
status: encryptedAppt.status,
...decryptedData,
});
} catch (error) {
console.warn("Failed to decrypt appointment", encryptedAppt.id, error);
}
}
return decryptedAppointments;
} catch (error) {
console.error("❌ Error retrieving appointments:", error);
throw error;
}
}
// ===== STAFF METHODS =====
/**
* Authenticate staff member using WebAuthn and reconstruct private key from shards
*/
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);
// 2. 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();
// 3. Derive passkey-based shard from WebAuthn response
const passkeyBasedShard = await this.derivePasskeyBasedShard(
shardData.passkeyId,
webAuthnResponse.authenticatorData,
);
// 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() + 10 * 60 * 1000; // 10 minutes
console.log("✅ Staff authentication successful");
} catch (error) {
console.error("❌ Staff authentication failed:", error);
throw new Error(
`Staff authentication failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Decrypt appointment data for staff members
*/
async decryptStaffAppointment(encryptedData: {
encryptedAppointment: EncryptedData;
staffKeyShare: string;
}): Promise<AppointmentData> {
if (!this.staffAuthenticated || !this.staffKeyPair) {
throw new Error("Staff not authenticated");
}
if (this.keyExpiry && Date.now() > this.keyExpiry) {
throw new Error("Staff session expired - please authenticate again");
}
try {
// 1. Decrypt the symmetric key using staff's private key
const encapsulatedSecret = this.hexToUint8Array(encryptedData.staffKeyShare);
const sharedSecret = KyberCrypto.decapsulate(
this.staffKeyPair.privateKey,
encapsulatedSecret,
);
// 2. Import the symmetric key
const symmetricKey = await crypto.subtle.importKey(
"raw",
new Uint8Array(sharedSecret),
{ name: "AES-GCM" },
false,
["decrypt"],
);
// 3. Decrypt the appointment data
const iv = this.hexToUint8Array(encryptedData.encryptedAppointment.iv);
const ciphertext = this.hexToUint8Array(encryptedData.encryptedAppointment.encryptedPayload);
const authTag = this.hexToUint8Array(encryptedData.encryptedAppointment.authTag);
// Combine ciphertext and auth tag for Web Crypto API
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
encrypted.set(ciphertext);
encrypted.set(authTag, ciphertext.length);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(iv) },
symmetricKey,
encrypted,
);
const decoder = new TextDecoder();
const plaintext = decoder.decode(decrypted);
return JSON.parse(plaintext);
} catch (error) {
console.error("❌ Failed to decrypt staff appointment:", error);
throw new Error(
`Decryption failed: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Get all appointments for staff in a tenant
*/
async getStaffAppointments(tenantId: string): Promise<DecryptedAppointment[]> {
if (!this.staffAuthenticated) {
throw new Error("Staff not authenticated");
}
try {
const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-appointments`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"X-Staff-ID": this.staffId!,
},
});
if (!response.ok) {
throw new Error("Failed to fetch staff appointments");
}
const data = await response.json();
const decryptedAppointments: DecryptedAppointment[] = [];
for (const encryptedAppt of data.appointments) {
try {
const decryptedData = await this.decryptStaffAppointment({
encryptedAppointment: encryptedAppt.encryptedData,
staffKeyShare: encryptedAppt.staffKeyShare,
});
decryptedAppointments.push({
id: encryptedAppt.id,
appointmentDate: encryptedAppt.appointmentDate,
status: encryptedAppt.status,
...decryptedData,
});
} catch (error) {
console.warn("Failed to decrypt staff appointment", encryptedAppt.id, error);
}
}
return decryptedAppointments;
} catch (error) {
console.error("❌ Error retrieving staff appointments:", error);
throw error;
}
}
/**
* Logout staff member and clear sensitive data
*/
logoutStaff(): void {
this.staffKeyPair = null;
this.staffId = null;
this.tenantId = null;
this.staffAuthenticated = false;
this.keyExpiry = null;
console.log("🔒 Staff logged out");
}
/**
* Logout client and clear sensitive data
*/
logoutClient(): void {
this.tunnelKey = null;
this.clientKeyPair = null;
this.emailHash = null;
this.tunnelId = null;
this.clientAuthenticated = false;
console.log("🔒 Client logged out");
}
// ===== SHARED PRIVATE METHODS =====
/**
* Perform WebAuthn authentication for staff
*/
private async performWebAuthnAuthentication(staffId: string): Promise<{
authenticatorData: ArrayBuffer;
signature: ArrayBuffer;
userHandle: ArrayBuffer | null;
}> {
// 1. Get authentication options from server
const optionsResponse = await fetch("/api/auth/webauthn/authenticate/begin", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: staffId }),
});
if (!optionsResponse.ok) {
throw new Error("Failed to get WebAuthn options");
}
const options = await optionsResponse.json();
// 2. Perform WebAuthn authentication
const credential = (await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(this.base64ToUint8Array(options.challenge)),
allowCredentials: options.allowCredentials?.map((cred: { id: string; type: string }) => ({
id: new Uint8Array(this.base64ToUint8Array(cred.id)),
type: cred.type as PublicKeyCredentialType,
})),
timeout: options.timeout,
userVerification: options.userVerification,
},
})) as PublicKeyCredential;
if (!credential) {
throw new Error("WebAuthn authentication failed");
}
const response = credential.response as AuthenticatorAssertionResponse;
return {
authenticatorData: response.authenticatorData,
signature: response.signature,
userHandle: response.userHandle,
};
}
/**
* Derive a deterministic shard from passkey authentication data
*
* This method creates a consistent private key shard from WebAuthn authenticatorData.
* The same passkey will always produce the same shard, enabling key reconstruction.
*
* Used in Staff Key Management:
* - During registration: Create shard to XOR with private key for database storage
* - During authentication: Recreate same shard to reconstruct private key
*
* Uses HKDF (HMAC-based Key Derivation Function) with:
* - IKM: WebAuthn authenticatorData (contains randomness from authenticator)
* - Salt: "staff-crypto-shard-v1" (version-specific salt)
* - Info: "passkey:{passkeyId}" (domain separation per passkey)
* - Length: 2400 bytes (ML-KEM-768 private key size)
*/
private async derivePasskeyBasedShard(
passkeyId: string,
authenticatorData: ArrayBuffer,
): Promise<Uint8Array> {
// Extract randomness from authenticator data
const inputKeyMaterial = new Uint8Array(authenticatorData);
// Import the IKM as a CryptoKey for HKDF
const ikmKey = await crypto.subtle.importKey("raw", inputKeyMaterial, "HKDF", false, [
"deriveBits",
]);
// Salt for HKDF
const salt = new TextEncoder().encode("staff-crypto-shard-v1");
// Info for HKDF (domain separation with passkey ID)
const info = new TextEncoder().encode(`passkey:${passkeyId}`);
// Derive key material with the length needed for Kyber private key (2400 bytes for ML-KEM-768)
const keyMaterial = await crypto.subtle.deriveBits(
{
name: "HKDF",
hash: "SHA-256",
salt: salt,
info: info,
},
ikmKey,
2400 * 8, // 2400 bytes * 8 bits
);
return new Uint8Array(keyMaterial);
}
/**
* Generate deterministic SHA-256 hash of email for privacy-preserving lookups
*/
private async hashEmail(email: string): Promise<string> {
const emailNormalized = email.toLowerCase().trim();
const encoder = new TextEncoder();
const data = encoder.encode(emailNormalized);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuffer))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Generate ML-KEM-768 keypair for clients
*/
private async generateClientKeyPair(): Promise<ClientKeyPair> {
const keyPair = KyberCrypto.generateKeyPair();
return {
publicKey: Array.from(keyPair.publicKey)
.map((b: number) => b.toString(16).padStart(2, "0"))
.join(""),
privateKey: Array.from(keyPair.privateKey)
.map((b: number) => b.toString(16).padStart(2, "0"))
.join(""),
};
}
/**
* Generate AES-256-GCM tunnel key
*/
private async generateTunnelKey(): Promise<CryptoKey> {
return await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, [
"encrypt",
"decrypt",
]);
}
/**
* Encrypt appointment data with the tunnel key
*/
private async encryptAppointmentData(data: AppointmentData): Promise<EncryptedData> {
if (!this.tunnelKey) throw new Error("No tunnel key available");
const encoder = new TextEncoder();
const plaintext = encoder.encode(JSON.stringify(data));
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
this.tunnelKey,
plaintext,
);
const encryptedArray = new Uint8Array(encrypted);
const authTag = encryptedArray.slice(-16);
const ciphertext = encryptedArray.slice(0, -16);
return {
encryptedPayload: Array.from(ciphertext)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
iv: Array.from(iv)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
authTag: Array.from(authTag)
.map((b) => b.toString(16).padStart(2, "0"))
.join(""),
};
}
/**
* Decrypt appointment data with the tunnel key
*/
private async decryptAppointmentData(encryptedData: EncryptedData): Promise<AppointmentData> {
if (!this.tunnelKey) throw new Error("No tunnel key available");
const iv = this.hexToUint8Array(encryptedData.iv);
const ciphertext = this.hexToUint8Array(encryptedData.encryptedPayload);
const authTag = this.hexToUint8Array(encryptedData.authTag);
// Combine ciphertext and auth tag
const encrypted = new Uint8Array(ciphertext.length + authTag.length);
encrypted.set(ciphertext);
encrypted.set(authTag, ciphertext.length);
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: new Uint8Array(iv) },
this.tunnelKey,
encrypted,
);
const decoder = new TextDecoder();
const plaintext = decoder.decode(decrypted);
return JSON.parse(plaintext);
}
// ===== UTILITY METHODS =====
private generateTunnelId(): string {
return "tunnel_" + crypto.randomUUID();
}
private async createPrivateKeyShare(privateKey: string, pin: string): Promise<string> {
// Use Shamir Secret Sharing for private key
const privateKeyBytes = this.hexToUint8Array(privateKey);
const shares = ShamirSecretSharing.splitSecret(privateKeyBytes, 2, 2);
// One share is PIN-encrypted and stored on client
const clientShare = shares[0].y;
const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "");
const encryptedShare = await AESCrypto.encrypt(this.uint8ArrayToHex(clientShare), pinHash);
return this.uint8ArrayToHex(encryptedShare.encrypted);
}
private async fetchStaffPublicKeys(tenantId: string): Promise<StaffPublicKey[]> {
const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-public-keys`, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch staff public keys: ${response.statusText}`);
}
const data = await response.json();
return data.staffPublicKeys;
}
// getTenantId method removed - tenantId is now always passed explicitly
private async encryptTunnelKeyForStaff(
staffKeys: StaffPublicKey[],
): Promise<Array<{ userId: string; encryptedTunnelKey: string }>> {
if (!this.tunnelKey) throw new Error("No tunnel key available");
const results = [];
for (const staff of staffKeys) {
const staffPublicKeyBytes = this.hexToUint8Array(staff.publicKey);
const encryptedKey = KyberCrypto.encapsulate(staffPublicKeyBytes);
results.push({
userId: staff.userId,
encryptedTunnelKey: this.uint8ArrayToHex(encryptedKey.encapsulatedSecret),
});
}
return results;
}
private async encryptTunnelKeyForClient(): Promise<string> {
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);
return this.uint8ArrayToHex(encryptedKey.encapsulatedSecret);
}
private async reconstructPrivateKey(pin: string, serverShare: string): Promise<string> {
// For now, simplified reconstruction - in real implementation this would be more complex
// TODO: Implement proper Shamir reconstruction with PIN-derived decryption
const serverShareBytes = this.hexToUint8Array(serverShare);
const clientShareBytes = serverShareBytes; // Simplified for now
// In future: derive pinHash and use it to decrypt the client share
// const pinHash = await OptimizedArgon2.deriveKeyFromPIN(pin, this.emailHash || "");
// Reconstruct private key from both shares
const shares = [
{ x: 1, y: clientShareBytes },
{ x: 2, y: serverShareBytes },
];
const reconstructedKey = ShamirSecretSharing.reconstructSecret(shares);
return this.uint8ArrayToHex(reconstructedKey);
}
private async decryptChallenge(encryptedChallenge: string, privateKey: string): Promise<string> {
const privateKeyBytes = this.hexToUint8Array(privateKey);
const challengeBytes = this.hexToUint8Array(encryptedChallenge);
const sharedSecret = KyberCrypto.decapsulate(privateKeyBytes, challengeBytes);
return this.uint8ArrayToHex(sharedSecret);
}
private async decryptTunnelKey(
encryptedTunnelKey: string,
privateKey: string,
): Promise<CryptoKey> {
const privateKeyBytes = this.hexToUint8Array(privateKey);
const encryptedKeyBytes = this.hexToUint8Array(encryptedTunnelKey);
const tunnelKeyBytes = KyberCrypto.decapsulate(privateKeyBytes, encryptedKeyBytes);
return await crypto.subtle.importKey(
"raw",
new Uint8Array(tunnelKeyBytes),
{ name: "AES-GCM" },
true,
["encrypt", "decrypt"],
);
}
// Helper methods for completing the API
private async getPrivateKeyShare(): Promise<string> {
if (!this.clientKeyPair) throw new Error("Client keypair not available");
// TODO: Return the actual server share from Shamir splitting
return this.clientKeyPair.privateKey; // Simplified for now
}
private async getStaffKeyShares(
tenantId: string,
): Promise<Array<{ userId: string; encryptedTunnelKey: string }>> {
// Fetch and encrypt tunnel key for all staff members
const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId);
return await this.encryptTunnelKeyForStaff(staffPublicKeys);
}
private async getClientKeyShare(): Promise<string> {
// Return the client's encrypted tunnel key
return await this.encryptTunnelKeyForClient();
}
// Encoding/decoding utilities
private hexToUint8Array(hex: string): Uint8Array {
return new Uint8Array(hex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16)));
}
private uint8ArrayToHex(array: Uint8Array): string {
return Array.from(array)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
private base64ToUint8Array(base64: string): Uint8Array {
return new Uint8Array(Array.from(atob(base64)).map((c) => c.charCodeAt(0)));
}
}
@@ -0,0 +1,265 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock crypto utilities
vi.mock("$lib/crypto/utils", () => ({
KyberCrypto: {
generateKeyPair: vi.fn(),
encapsulate: vi.fn(),
decapsulate: vi.fn(),
},
AESCrypto: {
generateSessionKey: vi.fn(),
encrypt: vi.fn(),
decrypt: vi.fn(),
},
BufferUtils: {
from: vi.fn(() => new Uint8Array()),
toString: vi.fn(() => "mock-string"),
concat: vi.fn(() => new Uint8Array()),
randomBytes: vi.fn((length: number) => new Uint8Array(length)),
equals: vi.fn(),
xor: vi.fn(),
},
ShamirSecretSharing: {
splitSecret: vi.fn(),
reconstructSecret: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
})),
},
}));
// Import after mocking
import { KyberCrypto, AESCrypto, BufferUtils, ShamirSecretSharing } from "$lib/crypto/utils";
describe("Crypto Utilities for Appointment System", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("KyberCrypto Operations", () => {
it("should generate ML-KEM-768 key pair", () => {
const mockKeyPair = {
publicKey: new Uint8Array(32),
privateKey: new Uint8Array(64),
};
vi.mocked(KyberCrypto.generateKeyPair).mockReturnValue(mockKeyPair);
const keyPair = KyberCrypto.generateKeyPair();
expect(keyPair).toEqual(mockKeyPair);
expect(KyberCrypto.generateKeyPair).toHaveBeenCalledOnce();
});
it("should perform key encapsulation", () => {
const mockPublicKey = new Uint8Array(32);
const mockEncapsulationResult = {
sharedSecret: new Uint8Array(32),
encapsulatedSecret: new Uint8Array(48),
};
vi.mocked(KyberCrypto.encapsulate).mockReturnValue(mockEncapsulationResult);
const result = KyberCrypto.encapsulate(mockPublicKey);
expect(result).toEqual(mockEncapsulationResult);
expect(KyberCrypto.encapsulate).toHaveBeenCalledWith(mockPublicKey);
});
it("should perform key decapsulation", () => {
const mockPrivateKey = new Uint8Array(64);
const mockCiphertext = new Uint8Array(48);
const mockSharedSecret = new Uint8Array(32);
vi.mocked(KyberCrypto.decapsulate).mockReturnValue(mockSharedSecret);
const result = KyberCrypto.decapsulate(mockPrivateKey, mockCiphertext);
expect(result).toEqual(mockSharedSecret);
expect(KyberCrypto.decapsulate).toHaveBeenCalledWith(mockPrivateKey, mockCiphertext);
});
});
describe("AES-256-GCM Operations", () => {
it("should generate 256-bit AES session key", () => {
const mockKey = new Uint8Array(32);
vi.mocked(AESCrypto.generateSessionKey).mockReturnValue(mockKey);
const key = AESCrypto.generateSessionKey();
expect(key).toEqual(mockKey);
expect(key.length).toBe(32); // 256 bits = 32 bytes
expect(AESCrypto.generateSessionKey).toHaveBeenCalledOnce();
});
it("should encrypt data with AES-256-GCM", async () => {
const mockData = "sensitive appointment data";
const mockKey = new Uint8Array(32);
const mockEncryptedResult = {
encrypted: new Uint8Array(24),
iv: new Uint8Array(16),
tag: new Uint8Array(16),
};
vi.mocked(AESCrypto.encrypt).mockResolvedValue(mockEncryptedResult);
const result = await AESCrypto.encrypt(mockData, mockKey);
expect(result).toEqual(mockEncryptedResult);
expect(AESCrypto.encrypt).toHaveBeenCalledWith(mockData, mockKey);
});
it("should decrypt data with AES-256-GCM", async () => {
const mockCiphertext = new Uint8Array(24);
const mockKey = new Uint8Array(32);
const mockIv = new Uint8Array(16);
const mockTag = new Uint8Array(16);
const mockDecryptedData = "original appointment data";
vi.mocked(AESCrypto.decrypt).mockResolvedValue(mockDecryptedData);
const result = await AESCrypto.decrypt(mockCiphertext, mockKey, mockIv, mockTag);
expect(result).toEqual(mockDecryptedData);
expect(AESCrypto.decrypt).toHaveBeenCalledWith(mockCiphertext, mockKey, mockIv, mockTag);
});
it("should fail decryption with invalid authentication tag", async () => {
const mockCiphertext = new Uint8Array(24);
const mockKey = new Uint8Array(32);
const mockIv = new Uint8Array(16);
const mockInvalidTag = new Uint8Array(16);
vi.mocked(AESCrypto.decrypt).mockRejectedValue(
new Error("Authentication verification failed"),
);
await expect(
AESCrypto.decrypt(mockCiphertext, mockKey, mockIv, mockInvalidTag),
).rejects.toThrow("Authentication verification failed");
});
});
describe("Buffer Utilities", () => {
it("should convert string to CryptoBuffer", () => {
const testString = "test data";
const expectedBuffer = new Uint8Array(8);
vi.mocked(BufferUtils.from).mockReturnValue(expectedBuffer);
const result = BufferUtils.from(testString);
expect(result).toEqual(expectedBuffer);
expect(BufferUtils.from).toHaveBeenCalledWith(testString);
});
it("should convert CryptoBuffer to string", () => {
const testBuffer = new Uint8Array(8);
const expectedString = "test data";
vi.mocked(BufferUtils.toString).mockReturnValue(expectedString);
const result = BufferUtils.toString(testBuffer);
expect(result).toBe(expectedString);
expect(BufferUtils.toString).toHaveBeenCalledWith(testBuffer);
});
it("should concatenate multiple CryptoBuffers", () => {
const buffer1 = new Uint8Array(5);
const buffer2 = new Uint8Array(5);
const expectedBuffer = new Uint8Array(10);
vi.mocked(BufferUtils.concat).mockReturnValue(expectedBuffer);
const result = BufferUtils.concat([buffer1, buffer2]);
expect(result).toEqual(expectedBuffer);
expect(BufferUtils.concat).toHaveBeenCalledWith([buffer1, buffer2]);
});
it("should generate random bytes", () => {
const mockRandomBytes = new Uint8Array(32);
vi.mocked(BufferUtils.randomBytes).mockReturnValue(mockRandomBytes);
const result = BufferUtils.randomBytes(32);
expect(result).toEqual(mockRandomBytes);
expect(result.length).toBe(32);
expect(BufferUtils.randomBytes).toHaveBeenCalledWith(32);
});
});
describe("Shamir Secret Sharing", () => {
it("should split secret into shares", () => {
const mockSecret = new Uint8Array(32);
const mockShares = [
{ x: 1, y: new Uint8Array(32) },
{ x: 2, y: new Uint8Array(32) },
];
vi.mocked(ShamirSecretSharing.splitSecret).mockReturnValue(mockShares);
const result = ShamirSecretSharing.splitSecret(mockSecret, 2, 2);
expect(result).toEqual(mockShares);
expect(result).toHaveLength(2);
expect(ShamirSecretSharing.splitSecret).toHaveBeenCalledWith(mockSecret, 2, 2);
});
it("should combine shares to reconstruct secret", () => {
const mockShares = [
{ x: 1, y: new Uint8Array(32) },
{ x: 2, y: new Uint8Array(32) },
];
const mockReconstructedSecret = new Uint8Array(32);
vi.mocked(ShamirSecretSharing.reconstructSecret).mockReturnValue(mockReconstructedSecret);
const result = ShamirSecretSharing.reconstructSecret(mockShares);
expect(result).toEqual(mockReconstructedSecret);
expect(ShamirSecretSharing.reconstructSecret).toHaveBeenCalledWith(mockShares);
});
});
describe("Basic Error Handling", () => {
it("should handle crypto operation failures", () => {
expect(() => {
// Mock error case for testing error handling patterns
throw new Error("Crypto operation failed");
}).toThrow("Crypto operation failed");
});
});
describe("Error Handling in Crypto Operations", () => {
it("should handle key generation failures", () => {
vi.mocked(KyberCrypto.generateKeyPair).mockImplementation(() => {
throw new Error("Random number generation failed");
});
expect(() => KyberCrypto.generateKeyPair()).toThrow("Random number generation failed");
});
it("should handle AES encryption failures", async () => {
const mockData = "data to encrypt";
const invalidKey = new Uint8Array(16); // Invalid key length (should be 32)
vi.mocked(AESCrypto.encrypt).mockRejectedValue(new Error("Invalid key length for AES-256"));
await expect(AESCrypto.encrypt(mockData, invalidKey)).rejects.toThrow(
"Invalid key length for AES-256",
);
});
});
});
@@ -19,7 +19,7 @@ const mockUser: SelectUser = {
updatedAt: new Date(),
lastLoginAt: new Date(),
isActive: true,
confirmed: true,
confirmationState: "ACCESS_GRANTED" as const,
token: null,
tokenValidUntil: null,
passphraseHash: null,
@@ -9,6 +9,10 @@ vi.mock("$lib/server/db", () => ({
select: vi.fn(),
update: vi.fn(),
},
centralDb: {
select: vi.fn(),
update: vi.fn(),
},
}));
vi.mock("../jwt-utils");
@@ -19,7 +23,7 @@ const mockUser = {
role: "STAFF" as const,
tenantId: "tenant-123",
isActive: true,
confirmed: true,
confirmationState: "ACCESS_GRANTED" as const,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: null,
@@ -174,7 +178,7 @@ describe("SessionService.validateTokenWithDB", () => {
const unconfirmedUser = {
...mockUser,
confirmed: false,
confirmationState: "PENDING_CONFIRMATION" as const,
};
const mockQuery = vi.fn().mockResolvedValue([
+4 -4
View File
@@ -56,8 +56,8 @@ export class SessionService {
throw new ValidationError("User account is inactive");
}
if (!userData.confirmed) {
throw new ValidationError("User account is not confirmed");
if (userData.confirmationState !== "ACCESS_GRANTED") {
throw new ValidationError("User account is not fully confirmed");
}
// Create session entry first to get the ID
@@ -143,8 +143,8 @@ export class SessionService {
return null;
}
if (!session.user.confirmed) {
logger.debug("User account is not confirmed");
if (session.user.confirmationState !== "ACCESS_GRANTED") {
logger.debug("User account is not fully confirmed");
return null;
}
+27 -1
View File
@@ -1,6 +1,6 @@
import { centralDb as db } from "$lib/server/db";
import { userPasskey } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
import { eq, desc } from "drizzle-orm";
import { randomBytes, createHash } from "node:crypto";
import { UniversalLogger } from "$lib/logger";
@@ -275,6 +275,32 @@ export class WebAuthnService {
return await db.select().from(userPasskey).where(eq(userPasskey.userId, userId));
}
/**
* Get the most recently used passkey for a user
* Used when we need to determine which passkey was used for authentication
* but the session doesn't store this information directly
*/
static async getMostRecentPasskey(userId: string): Promise<{
id: string;
lastUsedAt: Date | null;
} | null> {
const passkeys = await db
.select({
id: userPasskey.id,
lastUsedAt: userPasskey.lastUsedAt,
})
.from(userPasskey)
.where(eq(userPasskey.userId, userId))
.orderBy(desc(userPasskey.lastUsedAt))
.limit(1);
if (passkeys.length === 0) {
return null;
}
return passkeys[0];
}
/**
* Get allowed origins for WebAuthn verification
* Uses SERVER_DOMAIN in production, localhost variants in development
+6 -1
View File
@@ -22,6 +22,11 @@ export const configTypeEnum = pgEnum("config_type", ["BOOLEAN", "NUMBER", "STRIN
* User role enumeration - defines the different user roles in the system
*/
export const userRoleEnum = pgEnum("user_role", ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]);
export const confirmationStateEnum = pgEnum("confirmation_state", [
"INVITED",
"CONFIRMED",
"ACCESS_GRANTED",
]);
export const tenantSetupState = pgEnum("setup_state", [
"NEW", // newly created
@@ -105,7 +110,7 @@ export const user = pgTable(
updatedAt: timestamp("updated_at").defaultNow(),
lastLoginAt: timestamp("last_login_at"),
isActive: boolean("is_active").default(true),
confirmed: boolean("confirmed").default(false),
confirmationState: confirmationStateEnum("confirmation_state").default("INVITED"),
token: text("token"),
tokenValidUntil: timestamp("token_valid_until"),
/** Hashed passphrase for password authentication (optional, alternative to WebAuthn) */
+19
View File
@@ -49,6 +49,25 @@ export async function getTenantDb(
return tenantDb;
}
/**
* Get tenant information by ID
* @param tenantId - The tenant's UUID
* @returns Promise<SelectTenant> - The tenant object
*/
export async function getTenant(tenantId: string): Promise<centralSchema.SelectTenant> {
const tenant = await centralDb
.select()
.from(centralSchema.tenant)
.where(eq(centralSchema.tenant.id, tenantId))
.limit(1);
if (tenant.length === 0) {
throw new Error(`Tenant with ID ${tenantId} not found`);
}
return tenant[0];
}
/**
* Clear cached database connections (useful for testing or tenant updates)
*/
+164 -12
View File
@@ -1,4 +1,5 @@
import type { InferSelectModel } from "drizzle-orm";
import { eq } from "drizzle-orm";
import {
pgTable,
boolean,
@@ -10,9 +11,12 @@ import {
integer,
json,
timestamp,
uniqueIndex,
varchar,
} from "drizzle-orm/pg-core";
import { user } from "./central-schema";
/**
* Database enums for tenant-specific entities
*/
@@ -144,31 +148,41 @@ export const client = pgTable("client", {
/**
* Appointment table - represents scheduled appointments between clients and channels
* Contains encrypted appointment data for privacy protection
* Uses hybrid encryption: symmetric key for data, asymmetric for key sharing
* Stored in tenant-specific database
* @table appointment
*/
export const appointment = pgTable("appointment", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to client who booked the appointment */
clientId: uuid("client_id")
/** Foreign key to client appointment tunnel */
tunnelId: uuid("tunnel_id")
.notNull()
.references(() => client.id),
.references(() => clientAppointmentTunnel.id),
/** Foreign key to channel/resource being booked */
channelId: uuid("channel_id")
.notNull()
.references(() => channel.id),
/** Date and time of the appointment */
appointmentDate: date("appointment_date").notNull(),
appointmentDate: timestamp("appointment_date").notNull(),
/** When appointment data expires and can be auto-deleted */
expiryDate: date("expiry_date").notNull(),
/** Current status of the appointment */
status: appointmentStatusEnum("status").notNull().default("NEW"),
/** Appointment title/subject */
name: text("name").notNull(),
/** Optional detailed description of the appointment */
phone: text("phone"), // TODO Sensible information will be removed in future (appointment) branch since it will be stored in an encrypted blob
expiryDate: date("expiry_date"),
/** Current status of the appointment - defaults depend on channel's requiresConfirmation setting */
status: appointmentStatusEnum("status").notNull(),
/** Encrypted appointment data (name, email, phone) - Legacy field */
encryptedData: text("encrypted_data"),
/** Symmetric key for encrypting appointment data - Legacy field */
dataKey: text("data_key"),
/** AES-encrypted payload for end-to-end encryption (new system) */
encryptedPayload: text("encrypted_payload"),
/** Initialization vector for AES encryption (new system) */
iv: text("iv"),
/** Authentication tag for AES-GCM (new system) */
authTag: text("auth_tag"),
/** Timestamp when the appointment was created */
createdAt: timestamp("created_at").defaultNow(),
/** Timestamp when the appointment was last updated */
updatedAt: timestamp("updated_at").defaultNow(),
});
/**
@@ -194,6 +208,26 @@ export const agentAbsence = pgTable("agent_absence", {
description: text("description"),
});
/**
* AppointmentKeyShare table - stores the symmetric key encrypted for each staff member
* Allows staff and admins to decrypt appointment data
* @table appointmentKeyShare
*/
export const appointmentKeyShare = pgTable("appointment_key_share", {
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to appointment */
appointmentId: uuid("appointment_id")
.notNull()
.references(() => appointment.id),
/** Foreign key to staff member who can decrypt */
userId: uuid("user_id")
.notNull()
.references(() => user.id),
/** Symmetric key encrypted with this staff member's public key */
encryptedKey: text("encrypted_key").notNull(),
});
/**
* TypeScript type exports for use in application code
* These types represent the shape of data when queried from the database
@@ -222,3 +256,121 @@ export type SelectChannelSlotTemplate = InferSelectModel<typeof channelSlotTempl
/** Agent absence record type for database queries */
export type SelectAgentAbsence = InferSelectModel<typeof agentAbsence>;
/**
* StaffCrypto table - stores encryption keys for staff members within tenant scope
* Enables end-to-end encryption for appointments in tenant database
* @table staffCrypto
*/
export const staffCrypto = pgTable(
"staff_crypto",
{
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to central user table */
userId: uuid("user_id")
.notNull()
.references(() => user.id),
/** ML-KEM-768 (Kyber) public key for this staff member (Base64 encoded) */
publicKey: text("public_key").notNull(),
/** Database-stored shard of the private key (Base64 encoded) */
privateKeyShare: text("private_key_share").notNull(),
/** Associated passkey ID for key derivation */
passkeyId: text("passkey_id").notNull(),
/** Timestamp when the key was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** Timestamp when the key was last updated */
updatedAt: timestamp("updated_at").defaultNow().notNull(),
/** Whether this key is currently active */
isActive: boolean("is_active").default(true).notNull(),
},
(table) => ({
/** Ensure one active key per user per tenant */
userKeyUnique: uniqueIndex("staff_crypto_user_active_idx")
.on(table.userId)
.where(eq(table.isActive, true)),
}),
);
/**
* ClientAppointmentTunnels table - represents encrypted appointment tunnels for clients
* Each tunnel represents a client and contains their encrypted appointment data
* @table clientAppointmentTunnel
*/
export const clientAppointmentTunnel = pgTable("client_appointment_tunnel", {
/** Primary key - unique identifier (this IS the client identifier) */
id: uuid("id").primaryKey().defaultRandom(),
/** SHA-256 hash of client email for privacy-preserving lookups */
emailHash: text("email_hash").notNull().unique(),
/** Client's ML-KEM-768 public key for this tunnel (Base64 encoded) */
clientPublicKey: text("client_public_key").notNull(),
/** Server-stored share of client's private key (encrypted, for PIN recovery) */
privateKeyShare: text("private_key_share").notNull(),
/** Tunnel encryption key encrypted with client's public key */
clientEncryptedTunnelKey: text("client_key_share").notNull(),
/** Timestamp when the tunnel was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** Timestamp when the tunnel was last updated */
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
/**
* ClientTunnelStaffKeyShares table - stores tunnel keys encrypted for each staff member
* Allows staff to decrypt appointments in client tunnels
* @table clientTunnelStaffKeyShare
*/
export const clientTunnelStaffKeyShare = pgTable(
"client_tunnel_staff_key_share",
{
/** Primary key - unique identifier */
id: uuid("id").primaryKey().defaultRandom(),
/** Foreign key to client appointment tunnel */
tunnelId: uuid("tunnel_id")
.notNull()
.references(() => clientAppointmentTunnel.id),
/** Foreign key to staff user */
userId: uuid("user_id")
.notNull()
.references(() => user.id),
/** Tunnel key encrypted with staff member's public key */
encryptedTunnelKey: text("encrypted_tunnel_key").notNull(),
/** Timestamp when this key share was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(table) => ({
/** Ensure one key share per tunnel per staff member */
tunnelStaffUnique: uniqueIndex("client_tunnel_staff_key_unique_idx").on(
table.tunnelId,
table.userId,
),
}),
);
/**
* AuthChallenge table - stores temporary authentication challenges
* Used for client authentication during tunnel access
* @table authChallenge
*/
export const authChallenge = pgTable("auth_challenge", {
/** Primary key - challenge ID */
id: text("id").primaryKey(),
/** The challenge value (base64 encoded) */
challenge: text("challenge").notNull(),
/** Email hash of the client this challenge is for */
emailHash: text("email_hash").notNull(),
/** When this challenge was created */
createdAt: timestamp("created_at").defaultNow().notNull(),
/** When this challenge expires */
expiresAt: timestamp("expires_at").notNull(),
/** Whether this challenge has been consumed (one-time use) */
consumed: boolean("consumed").default(false).notNull(),
});
/** StaffCrypto record type for database queries */
export type SelectStaffCrypto = InferSelectModel<typeof staffCrypto>;
/** ClientAppointmentTunnel record type for database queries */
export type SelectClientAppointmentTunnel = InferSelectModel<typeof clientAppointmentTunnel>;
/** ClientTunnelStaffKeyShare record type for database queries */
export type SelectClientTunnelStaffKeyShare = InferSelectModel<typeof clientTunnelStaffKeyShare>;
@@ -246,7 +246,7 @@ describe("Email System", () => {
updatedAt: null,
lastLoginAt: null,
isActive: null,
confirmed: null,
confirmationState: "INVITED" as const,
token: null,
tokenValidUntil: null,
passphraseHash: null,
@@ -309,7 +309,7 @@ describe("Email System", () => {
updatedAt: null,
lastLoginAt: null,
isActive: null,
confirmed: null,
confirmationState: "INVITED" as const,
token: null,
tokenValidUntil: null,
passphraseHash: null,
@@ -1,604 +1,381 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ValidationError, NotFoundError, ConflictError } from "../../utils/errors";
import { AppointmentService } from "../appointment-service";
import { NotFoundError, ConflictError } from "../../utils/errors";
// Mock dependencies before imports
// Mock dependencies
vi.mock("../../db", () => ({
getTenantDb: vi.fn(),
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
})),
centralDb: {
select: vi.fn(),
},
}));
// Import after mocking
import { AppointmentService, type AppointmentCreationRequest } from "../appointment-service";
import { getTenantDb } from "../../db";
const mockAppointment = {
id: "appointment-123",
tunnelId: "tunnel-123",
channelId: "channel-123",
appointmentDate: new Date("2024-01-15T10:00:00Z"),
status: "NEW" as const,
encryptedPayload: "encrypted-data",
iv: "iv-data",
authTag: "auth-tag",
createdAt: new Date(),
updatedAt: new Date(),
expiryDate: null,
};
// Mock database operations
const mockDb = {
insert: vi.fn(() => ({
values: vi.fn(() => ({
returning: vi.fn(),
})),
})),
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(),
})),
leftJoin: vi.fn(() => ({
leftJoin: vi.fn(() => ({
where: vi.fn(() => ({
limit: vi.fn(),
})),
})),
where: vi.fn(() => ({
orderBy: vi.fn(),
})),
})),
orderBy: vi.fn(),
})),
})),
update: vi.fn(() => ({
set: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(),
})),
})),
})),
delete: vi.fn(() => ({
where: vi.fn(() => ({
returning: vi.fn(),
})),
})),
const mockClientTunnel = {
id: "tunnel-123",
emailHash: "email-hash-123",
clientPublicKey: "client-public-key",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockClientTunnelData = {
tunnelId: "tunnel-123",
channelId: "channel-123",
appointmentDate: "2024-01-15T10:00:00Z",
emailHash: "email-hash-123",
clientPublicKey: "client-public-key",
privateKeyShare: "private-key-share",
encryptedAppointment: {
encryptedPayload: "encrypted-data",
iv: "iv-data",
authTag: "auth-tag",
},
staffKeyShares: [
{
userId: "staff-123",
encryptedTunnelKey: "encrypted-tunnel-key",
},
],
clientEncryptedTunnelKey: "client-encrypted-tunnel-key",
};
describe("AppointmentService", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
beforeEach(() => {
vi.clearAllMocks();
(getTenantDb as any).mockResolvedValue(mockDb);
});
describe("forTenant", () => {
it("should create an appointment service instance", async () => {
const service = await AppointmentService.forTenant(mockTenantId);
it("should create service for valid tenant", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = { select: vi.fn() };
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
expect(service).toBeInstanceOf(AppointmentService);
expect(service.tenantId).toBe(mockTenantId);
expect(getTenantDb).toHaveBeenCalledWith(mockTenantId);
const service = await AppointmentService.forTenant("tenant-123");
expect(service.tenantId).toBe("tenant-123");
expect(getTenantDb).toHaveBeenCalledWith("tenant-123");
});
it("should throw error if database connection fails", async () => {
(getTenantDb as any).mockRejectedValue(new Error("Database connection failed"));
it("should handle database connection errors", async () => {
const { getTenantDb } = await import("../../db");
vi.mocked(getTenantDb).mockRejectedValue(new Error("Database connection failed"));
await expect(AppointmentService.forTenant(mockTenantId)).rejects.toThrow(
"Database connection failed",
);
await expect(AppointmentService.forTenant("tenant-123")).rejects.toThrow();
});
});
describe("createAppointment", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should validate appointment creation request", async () => {
const invalidRequest = {
clientId: "invalid-uuid",
channelId: "123e4567-e89b-12d3-a456-426614174001",
appointmentDate: "invalid-date",
expiryDate: "2024-01-02",
phone: "",
name: "",
};
await expect(
service.createAppointment(invalidRequest as AppointmentCreationRequest),
).rejects.toThrow(ValidationError);
});
it("should create appointment successfully", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174001",
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
description: "Test Description",
status: "NEW",
};
// Mock database responses
const mockClient = [
{
id: validRequest.clientId,
hashKey: "test",
publicKey: "test",
privateKeyShare: "test",
email: "test@test.com",
language: "de",
},
];
const mockChannel = [
{
id: validRequest.channelId,
names: ["Test Channel"],
pause: false,
descriptions: ["Test"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: null,
},
];
const mockConflictingAppointments: any[] = [];
const mockCreatedAppointment = {
id: "appointment-id",
...validRequest,
status: "NEW",
};
let selectCallCount = 0;
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => {
selectCallCount++;
switch (selectCallCount) {
case 1:
return mockClient;
case 2:
return mockChannel;
case 3:
return mockConflictingAppointments;
default:
return [];
}
},
describe("getClientTunnels", () => {
it("should return client tunnels", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue([mockClientTunnel]),
}),
}),
}));
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
(mockDb.insert as any).mockImplementation(() => ({
values: () => ({
returning: () => [mockCreatedAppointment],
}),
}));
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.getClientTunnels();
const result = await service.createAppointment(validRequest);
expect(result).toEqual(mockCreatedAppointment);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("tunnel-123");
expect(result[0].emailHash).toBe("email-hash-123");
expect(result[0].clientPublicKey).toBe("client-public-key");
expect(result[0].createdAt).toBeDefined();
});
it("should throw NotFoundError if client does not exist", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174099", // Valid UUID format
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
status: "NEW",
};
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => [], // No client found
it("should return empty array when no tunnels exist", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue([]),
}),
}),
}));
await expect(service.createAppointment(validRequest)).rejects.toThrow(NotFoundError);
});
it("should throw ConflictError if channel is paused", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174001",
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
status: "NEW",
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const mockClient = [{ id: validRequest.clientId }];
const mockPausedChannel = [{ id: validRequest.channelId, pause: true }];
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.getClientTunnels();
let selectCallCount = 0;
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => ({
limit: () => {
selectCallCount++;
return selectCallCount === 1 ? mockClient : mockPausedChannel;
},
}),
}),
}));
await expect(service.createAppointment(validRequest)).rejects.toThrow(ConflictError);
});
it("should throw ConflictError if time slot is already booked", async () => {
const validRequest: AppointmentCreationRequest = {
clientId: "123e4567-e89b-12d3-a456-426614174001",
channelId: "123e4567-e89b-12d3-a456-426614174002",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
name: "Test Appointment",
phone: "123-456-7890",
status: "NEW",
};
const mockClient = [{ id: validRequest.clientId }];
const mockChannel = [{ id: validRequest.channelId, pause: false }];
const mockConflictingAppointment = [
{ id: "existing-appointment", appointmentDate: validRequest.appointmentDate },
];
let selectCallCount = 0;
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
where: () => {
selectCallCount++;
switch (selectCallCount) {
case 1:
return { limit: () => mockClient }; // client check
case 2:
return { limit: () => mockChannel }; // channel check
case 3:
return mockConflictingAppointment; // conflict check (no limit)
default:
return { limit: () => [] };
}
},
}),
}));
await expect(service.createAppointment(validRequest)).rejects.toThrow(ConflictError);
expect(result).toEqual([]);
});
});
describe("getAppointmentById", () => {
let service: AppointmentService;
describe("createNewClientWithAppointment", () => {
it("should create client tunnel and appointment successfully", async () => {
const { getTenantDb, centralDb } = await import("../../db");
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
// Mock authorization check - users exist
const mockAuthBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
};
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
it("should return appointment with details", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockResult = [
{
appointment: {
id: appointmentId,
clientId: "client-id",
channelId: "channel-id",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
title: "Test Appointment",
description: null,
status: "NEW",
},
client: {
id: "client-id",
hashKey: "test",
publicKey: "test",
privateKeyShare: "test",
email: "test@test.com",
language: "de",
},
channel: {
id: "channel-id",
names: ["Test Channel"],
pause: false,
descriptions: ["Test"],
languages: ["de"],
isPublic: true,
requiresConfirmation: false,
color: null,
},
},
];
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
leftJoin: () => ({
leftJoin: () => ({
where: () => ({
limit: () => mockResult,
// Mock tenant database transaction
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
insert: vi
.fn()
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: "tunnel-123" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockResolvedValue(undefined),
})
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
id: "appointment-123",
appointmentDate: new Date("2024-01-15T10:00:00Z"),
status: "NEW",
},
]),
}),
}),
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ requiresConfirmation: true }]),
}),
}),
}),
}),
}));
const result = await service.getAppointmentById(appointmentId);
expect(result).toEqual({
...mockResult[0].appointment,
client: mockResult[0].client,
channel: mockResult[0].channel,
};
return await callback(tx);
});
const mockDb = { transaction: mockTransaction };
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.createNewClientWithAppointment(mockClientTunnelData);
expect(result.id).toBe("appointment-123");
expect(result.status).toBe("NEW");
expect(result.appointmentDate).toBe("2024-01-15T10:00:00.000Z");
});
it("should return null if appointment not found", async () => {
const appointmentId = "non-existent";
it("should block creation when no authorized users exist", async () => {
const { centralDb } = await import("../../db");
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
leftJoin: () => ({
leftJoin: () => ({
where: () => ({
limit: () => [],
}),
}),
}),
}),
}));
const result = await service.getAppointmentById(appointmentId);
expect(result).toBeNull();
});
});
describe("updateAppointment", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should validate update request", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const invalidUpdate = {
title: "", // Invalid empty title
status: "INVALID_STATUS" as any,
// Mock authorization check - no users exist
const mockAuthBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
await expect(service.updateAppointment(appointmentId, invalidUpdate)).rejects.toThrow(
ValidationError,
const service = await AppointmentService.forTenant("tenant-123");
await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow(
ConflictError,
);
});
it("should update appointment successfully", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const updateData = {
title: "Updated Title",
status: "CONFIRMED" as const,
it("should throw NotFoundError when channel not found", async () => {
const { getTenantDb, centralDb } = await import("../../db");
// Mock authorization check - users exist
const mockAuthBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
};
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
const mockUpdatedAppointment = {
id: appointmentId,
clientId: "client-id",
channelId: "channel-id",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: "2024-01-31",
title: updateData.title,
description: null,
status: updateData.status,
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
// Mock tenant database transaction with channel not found
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
insert: vi
.fn()
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: "tunnel-123" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockResolvedValue(undefined),
}),
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]), // Channel not found
}),
}),
}),
}),
}));
};
return await callback(tx);
});
const result = await service.updateAppointment(appointmentId, updateData);
expect(result).toEqual(mockUpdatedAppointment);
});
const mockDb = { transaction: mockTransaction };
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
it("should throw NotFoundError if appointment does not exist", async () => {
const appointmentId = "non-existent";
const updateData = { title: "Updated Title" };
const service = await AppointmentService.forTenant("tenant-123");
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [], // No rows updated
}),
}),
}));
await expect(service.updateAppointment(appointmentId, updateData)).rejects.toThrow(
await expect(service.createNewClientWithAppointment(mockClientTunnelData)).rejects.toThrow(
NotFoundError,
);
});
});
describe("status update methods", () => {
let service: AppointmentService;
it("should create appointment with CONFIRMED status when channel doesn't require confirmation", async () => {
const { getTenantDb, centralDb } = await import("../../db");
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should cancel appointment", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "REJECTED",
// Mock authorization check - users exist
const mockAuthBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ count: "1" }]),
};
vi.mocked(centralDb.select).mockReturnValue(mockAuthBuilder as any);
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
// Mock tenant database transaction with channel that doesn't require confirmation
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
insert: vi
.fn()
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([{ id: "tunnel-123" }]),
}),
})
.mockReturnValueOnce({
values: vi.fn().mockResolvedValue(undefined),
})
.mockReturnValueOnce({
values: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
id: "appointment-123",
appointmentDate: new Date("2024-01-15T10:00:00Z"),
status: "CONFIRMED",
},
]),
}),
}),
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ requiresConfirmation: false }]),
}),
}),
}),
}),
}));
};
return await callback(tx);
});
const result = await service.cancelAppointment(appointmentId);
expect(result.status).toBe("REJECTED");
});
const mockDb = { transaction: mockTransaction };
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
it("should confirm appointment", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "CONFIRMED",
};
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.createNewClientWithAppointment(mockClientTunnelData);
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
}),
}),
}));
const result = await service.confirmAppointment(appointmentId);
expect(result.status).toBe("CONFIRMED");
});
});
it("should complete appointment", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "HELD",
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
describe("getAppointmentsByTimeRange", () => {
it("should return appointments within time range", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue([mockAppointment]),
}),
}),
}),
}));
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const result = await service.completeAppointment(appointmentId);
expect(result.status).toBe("HELD");
const service = await AppointmentService.forTenant("tenant-123");
const startDate = new Date("2024-01-01T00:00:00Z");
const endDate = new Date("2024-01-31T23:59:59Z");
const result = await service.getAppointmentsByTimeRange(startDate, endDate);
expect(result).toHaveLength(1);
expect(result[0].id).toBe("appointment-123");
});
it("should mark as no-show", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockUpdatedAppointment = {
id: appointmentId,
status: "NO_SHOW",
};
(mockDb.update as any).mockImplementation(() => ({
set: () => ({
where: () => ({
returning: () => [mockUpdatedAppointment],
it("should return empty array when no appointments in range", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
orderBy: vi.fn().mockResolvedValue([]),
}),
}),
}),
}));
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const result = await service.markNoShow(appointmentId);
expect(result.status).toBe("NO_SHOW");
const service = await AppointmentService.forTenant("tenant-123");
const startDate = new Date("2024-01-01T00:00:00Z");
const endDate = new Date("2024-01-31T23:59:59Z");
const result = await service.getAppointmentsByTimeRange(startDate, endDate);
expect(result).toEqual([]);
});
});
describe("deleteAppointment", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should delete appointment successfully", async () => {
const appointmentId = "123e4567-e89b-12d3-a456-426614174003";
(mockDb.delete as any).mockImplementation(() => ({
where: () => ({
returning: () => [{ id: appointmentId }], // Appointment was deleted
const { getTenantDb } = await import("../../db");
const mockDb = {
delete: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([mockAppointment]),
}),
}),
}));
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.deleteAppointment("appointment-123");
const result = await service.deleteAppointment(appointmentId);
expect(result).toBe(true);
});
it("should return false if appointment not found", async () => {
const appointmentId = "non-existent";
(mockDb.delete as any).mockImplementation(() => ({
where: () => ({
returning: () => [], // No rows deleted
it("should return false when appointment not found", async () => {
const { getTenantDb } = await import("../../db");
const mockDb = {
delete: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([]),
}),
}),
}));
};
vi.mocked(getTenantDb).mockResolvedValue(mockDb as any);
const service = await AppointmentService.forTenant("tenant-123");
const result = await service.deleteAppointment("appointment-123");
const result = await service.deleteAppointment(appointmentId);
expect(result).toBe(false);
});
});
describe("queryAppointments", () => {
let service: AppointmentService;
beforeEach(async () => {
service = await AppointmentService.forTenant(mockTenantId);
});
it("should validate query request", async () => {
const invalidQuery = {
startDate: "invalid-date",
endDate: "2024-01-02T00:00:00.000Z",
};
await expect(service.queryAppointments(invalidQuery as any)).rejects.toThrow(ValidationError);
});
it("should query appointments with filters", async () => {
const validQuery = {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
channelId: "123e4567-e89b-12d3-a456-426614174002",
status: "NEW" as const,
};
const mockResults = [
{
appointment: {
id: "appointment-id",
clientId: "client-id",
channelId: validQuery.channelId,
appointmentDate: "2024-01-15T10:00:00.000Z",
expiryDate: "2024-01-31",
title: "Test Appointment",
description: null,
status: validQuery.status,
},
client: { id: "client-id" },
channel: { id: validQuery.channelId },
},
];
(mockDb.select as any).mockImplementation(() => ({
from: () => ({
leftJoin: () => ({
leftJoin: () => ({
where: () => ({
orderBy: () => mockResults,
}),
}),
}),
}),
}));
const result = await service.queryAppointments(validQuery);
expect(result).toHaveLength(1);
expect(result[0].channelId).toBe(validQuery.channelId);
expect(result[0].status).toBe(validQuery.status);
});
});
});
@@ -0,0 +1,322 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { StaffService } from "../staff-service";
import { NotFoundError, ValidationError, InternalError } from "../../utils/errors";
// Mock dependencies
vi.mock("../../db", () => ({
centralDb: {
select: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
},
getTenantDb: vi.fn(),
}));
vi.mock("../staff-crypto.service", () => ({
StaffCryptoService: vi.fn().mockImplementation(() => ({
getStaffPublicKey: vi.fn(),
})),
}));
const mockStaffMember = {
id: "staff-123",
email: "staff@example.com",
name: "Staff Member",
role: "STAFF" as const,
isActive: true,
confirmationState: "ACCESS_GRANTED" as const,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
};
describe("StaffService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getStaffMembers", () => {
it("should return staff members for a tenant", async () => {
const { centralDb } = await import("../../db");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([mockStaffMember]),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
const result = await StaffService.getStaffMembers("tenant-123");
expect(centralDb.select).toHaveBeenCalled();
expect(mockSelectBuilder.from).toHaveBeenCalled();
expect(mockSelectBuilder.where).toHaveBeenCalled();
expect(result).toEqual([mockStaffMember]);
});
it("should handle database errors", async () => {
const { centralDb } = await import("../../db");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockRejectedValue(new Error("Database error")),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
await expect(StaffService.getStaffMembers("tenant-123")).rejects.toThrow(InternalError);
});
});
describe("updateStaffMember", () => {
it("should update a staff member successfully", async () => {
const { centralDb } = await import("../../db");
const mockUpdateBuilder = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockStaffMember]),
};
vi.mocked(centralDb.update).mockReturnValue(mockUpdateBuilder as any);
const updateData = { name: "Updated Name" };
const result = await StaffService.updateStaffMember("tenant-123", "staff-123", updateData);
expect(centralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
...updateData,
updatedAt: expect.any(Date),
});
expect(mockUpdateBuilder.where).toHaveBeenCalled();
expect(mockUpdateBuilder.returning).toHaveBeenCalled();
expect(result).toEqual(mockStaffMember);
});
it("should prevent self-deactivation", async () => {
const updateData = { isActive: false };
await expect(
StaffService.updateStaffMember("tenant-123", "staff-123", updateData, "staff-123"),
).rejects.toThrow(ValidationError);
});
it("should throw NotFoundError when staff member not found", async () => {
const { centralDb } = await import("../../db");
const mockUpdateBuilder = {
set: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([]),
};
vi.mocked(centralDb.update).mockReturnValue(mockUpdateBuilder as any);
const updateData = { name: "Updated Name" };
await expect(
StaffService.updateStaffMember("tenant-123", "staff-123", updateData),
).rejects.toThrow(NotFoundError);
});
});
describe("deleteStaffMember", () => {
it("should delete a staff member successfully", async () => {
const { centralDb, getTenantDb } = await import("../../db");
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([
{
id: "staff-123",
email: "staff@example.com",
name: "Staff Member",
role: "STAFF",
tenantId: "tenant-123",
},
]),
}),
}),
}),
delete: vi
.fn()
.mockReturnValueOnce({
where: vi.fn().mockResolvedValue({ count: 2 }),
})
.mockReturnValueOnce({
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue([
{
id: "staff-123",
email: "staff@example.com",
name: "Staff Member",
role: "STAFF",
},
]),
}),
}),
};
return await callback(tx);
});
const mockTenantDb = {
delete: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue({ count: 1 }),
}),
};
vi.mocked(centralDb.transaction).mockImplementation(mockTransaction);
vi.mocked(getTenantDb).mockResolvedValue(mockTenantDb as any);
const result = await StaffService.deleteStaffMember("tenant-123", "staff-123");
expect(result.success).toBe(true);
expect(result.deletedUser.id).toBe("staff-123");
expect(result.deletedPasskeysCount).toBe(2);
expect(result.deletedKeySharesCount).toBe(1);
});
it("should prevent self-deletion", async () => {
await expect(
StaffService.deleteStaffMember("tenant-123", "staff-123", "staff-123"),
).rejects.toThrow(ValidationError);
});
it("should throw NotFoundError when staff member not found", async () => {
const { centralDb } = await import("../../db");
const mockTransaction = vi.fn().mockImplementation(async (callback) => {
const tx = {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([]),
}),
}),
}),
};
return await callback(tx);
});
vi.mocked(centralDb.transaction).mockImplementation(mockTransaction);
await expect(StaffService.deleteStaffMember("tenant-123", "staff-123")).rejects.toThrow(
NotFoundError,
);
});
});
describe("getStaffPublicKey", () => {
it("should return staff public key", async () => {
const { centralDb } = await import("../../db");
const { StaffCryptoService } = await import("../staff-crypto.service");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([
{
id: "staff-123",
tenantId: "tenant-123",
isActive: true,
},
]),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
const mockStaffCryptoService = {
getStaffPublicKey: vi.fn().mockResolvedValue("mock-public-key"),
};
vi.mocked(StaffCryptoService).mockImplementation(() => mockStaffCryptoService as any);
const result = await StaffService.getStaffPublicKey("tenant-123", "staff-123");
expect(result.userId).toBe("staff-123");
expect(result.publicKey).toBe("mock-public-key");
});
it("should throw ValidationError for inactive staff", async () => {
const { centralDb } = await import("../../db");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([
{
id: "staff-123",
tenantId: "tenant-123",
isActive: false,
},
]),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
await expect(StaffService.getStaffPublicKey("tenant-123", "staff-123")).rejects.toThrow(
ValidationError,
);
});
it("should throw ValidationError for staff from different tenant", async () => {
const { centralDb } = await import("../../db");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([
{
id: "staff-123",
tenantId: "different-tenant",
isActive: true,
},
]),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
await expect(StaffService.getStaffPublicKey("tenant-123", "staff-123")).rejects.toThrow(
ValidationError,
);
});
});
describe("validateStaffMember", () => {
it("should validate staff member successfully", async () => {
const { centralDb } = await import("../../db");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([mockStaffMember]),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
const result = await StaffService.validateStaffMember("tenant-123", "staff-123");
expect(result).toEqual(mockStaffMember);
});
it("should throw NotFoundError when staff member not found", async () => {
const { centralDb } = await import("../../db");
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
vi.mocked(centralDb.select).mockReturnValue(mockSelectBuilder as any);
await expect(StaffService.validateStaffMember("tenant-123", "staff-123")).rejects.toThrow(
NotFoundError,
);
});
});
});
@@ -95,7 +95,7 @@ describe("UserService", () => {
email: "test@example.com",
token: "018f-a1b2-c3d4-e5f6-789abcdef012",
tokenValidUntil: new Date("2024-01-01T12:10:00Z"),
confirmed: false,
confirmationState: "INVITED" as const,
isActive: false,
};
@@ -113,7 +113,7 @@ describe("UserService", () => {
...adminData,
token: "018f-a1b2-c3d4-e5f6-789abcdef012",
tokenValidUntil: expect.any(Date),
confirmed: false,
confirmationState: "INVITED" as const,
isActive: false,
});
expect(result).toEqual(mockCreatedAdmin);
@@ -197,7 +197,7 @@ describe("UserService", () => {
expect(mockCentralDb.select).toHaveBeenCalledTimes(2);
expect(mockCentralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
confirmed: true,
confirmationState: "CONFIRMED" as const,
isActive: true,
recoveryPassphrase: null,
});
@@ -228,7 +228,7 @@ describe("UserService", () => {
id: "018f-a1b2-c3d4-e5f6-789abcdef012",
name: "Test Admin",
email: "test@example.com",
confirmed: true,
confirmationState: "ACCESS_GRANTED" as const,
isActive: true,
};
@@ -334,15 +334,27 @@ describe("UserService", () => {
updatedAt: new Date(),
};
// Mock user exists check
const mockSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([{ id: adminId }]),
};
const mockInsertBuilder = {
values: vi.fn().mockReturnThis(),
returning: vi.fn().mockResolvedValue([mockCreatedPasskey]),
};
mockCentralDb.select.mockReturnValue(mockSelectBuilder);
mockCentralDb.insert.mockReturnValue(mockInsertBuilder);
const result = await UserService.addPasskey(adminId, passkeyData);
expect(mockCentralDb.select).toHaveBeenCalled();
expect(mockSelectBuilder.from).toHaveBeenCalled();
expect(mockSelectBuilder.where).toHaveBeenCalled();
expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1);
expect(mockCentralDb.insert).toHaveBeenCalled();
expect(mockInsertBuilder.values).toHaveBeenCalledWith({
...passkeyData,
+285 -418
View File
@@ -1,47 +1,37 @@
import { getTenantDb } from "../db";
import { getTenantDb, centralDb } from "../db";
import * as tenantSchema from "../db/tenant-schema";
import { type SelectAppointment, type SelectClient, type SelectChannel } from "../db/tenant-schema";
import { eq, and, between, or } from "drizzle-orm";
import { type SelectAppointment } from "../db/tenant-schema";
import { user } from "../db/central-schema";
import logger from "$lib/logger";
import z from "zod/v4";
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
import { ValidationError, NotFoundError, InternalError, ConflictError } from "../utils/errors";
import { and, eq, gte, lte, asc } from "drizzle-orm";
import type { AppointmentResponse } from "$lib/types/appointment";
const appointmentCreationSchema = z.object({
clientId: z.string().uuid(),
channelId: z.string().uuid(),
appointmentDate: z.string().datetime(),
expiryDate: z.string().date(),
name: z.string().min(1).max(200),
phone: z.string().min(1).max(200).optional().default(""),
description: z.string().optional(),
status: z.enum(["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]).default("NEW"),
});
export interface ClientTunnelData {
tunnelId: string;
channelId: string;
appointmentDate: string;
emailHash: string;
clientPublicKey: string;
privateKeyShare: string;
encryptedAppointment: {
encryptedPayload: string;
iv: string;
authTag: string;
};
staffKeyShares: {
userId: string;
encryptedTunnelKey: string;
}[];
clientEncryptedTunnelKey: string;
}
const appointmentUpdateSchema = z.object({
appointmentDate: z.string().datetime().optional(),
expiryDate: z.string().date().optional(),
title: z.string().min(1).max(200).optional(),
name: z.string().optional(),
phone: z.string().min(1).max(200).optional(),
status: z.enum(["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]).optional(),
});
const appointmentQuerySchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
channelId: z.string().uuid().optional(),
clientId: z.string().uuid().optional(),
status: z.enum(["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]).optional(),
});
export type AppointmentCreationRequest = z.infer<typeof appointmentCreationSchema>;
export type AppointmentUpdateRequest = z.infer<typeof appointmentUpdateSchema>;
export type AppointmentQueryRequest = z.infer<typeof appointmentQuerySchema>;
export interface AppointmentWithDetails extends SelectAppointment {
client?: SelectClient;
channel?: SelectChannel;
export interface ClientTunnelResponse {
id: string;
emailHash: string;
clientPublicKey: string;
createdAt?: string;
updatedAt?: string;
}
export class AppointmentService {
@@ -71,417 +61,294 @@ export class AppointmentService {
}
/**
* Create a new appointment
* @param request Appointment creation request data
* @returns Created appointment
* Send appointment notification email
*/
async createAppointment(request: AppointmentCreationRequest): Promise<SelectAppointment> {
private async sendAppointmentNotification(
email: string,
appointment: SelectAppointment,
status: "NEW" | "CONFIRMED",
): Promise<void> {
// TODO: Implement email service integration
const log = logger.setContext("AppointmentService");
log.debug("Sending appointment notification", {
email,
appointmentId: appointment.id,
status,
tenantId: this.tenantId,
});
}
const validation = appointmentCreationSchema.safeParse(request);
if (!validation.success) {
throw new ValidationError("Invalid appointment creation request");
public async getAppointmentById(id: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.select()
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, id))
.limit(1);
if (result.length === 0) {
log.warn("Appointment not found", { appointmentId: id, tenantId: this.tenantId });
throw new ValidationError("Appointment not found");
}
log.debug("Creating new appointment", {
const row = result[0];
return row;
}
public async confirmAppointment(id: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.update(tenantSchema.appointment)
.set({ status: "CONFIRMED" })
.where(and(eq(tenantSchema.appointment.id, id), eq(tenantSchema.appointment.status, "NEW")))
.returning();
if (result.length === 0) {
log.warn("Appointment not found or in wrong state", {
appointmentId: id,
state: result[0] ? result[0].status : undefined,
tenantId: this.tenantId,
});
throw new ValidationError("Appointment not found or in wrong state");
}
const row = result[0];
return row;
}
public async cancelAppointment(id: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.update(tenantSchema.appointment)
.set({ status: "REJECTED" })
.where(eq(tenantSchema.appointment.id, id))
.returning();
if (result.length === 0) {
log.warn("Appointment not found or in wrong state", {
appointmentId: id,
state: result[0] ? result[0].status : undefined,
tenantId: this.tenantId,
});
throw new ValidationError("Appointment not found or in wrong state");
}
const row = result[0];
return row;
}
public async deleteAppointment(id: string): Promise<boolean> {
const log = logger.setContext("AppointmentService");
log.debug("Deleting appointment by ID", { appointmentId: id, tenantId: this.tenantId });
const db = await this.getDb();
const result = await db
.delete(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, id))
.returning();
if (result.length === 0) {
log.debug("Appointment not found for deletion", {
appointmentId: id,
tenantId: this.tenantId,
});
return false;
}
log.debug("Appointment deleted successfully", { appointmentId: id, tenantId: this.tenantId });
return true;
}
/**
* Get all client tunnels for the tenant
*/
public async getClientTunnels(): Promise<ClientTunnelResponse[]> {
const log = logger.setContext("AppointmentService");
log.debug("Fetching client tunnels", { tenantId: this.tenantId });
const db = await this.getDb();
const tunnels = await db
.select({
id: tenantSchema.clientAppointmentTunnel.id,
emailHash: tenantSchema.clientAppointmentTunnel.emailHash,
clientPublicKey: tenantSchema.clientAppointmentTunnel.clientPublicKey,
createdAt: tenantSchema.clientAppointmentTunnel.createdAt,
updatedAt: tenantSchema.clientAppointmentTunnel.updatedAt,
})
.from(tenantSchema.clientAppointmentTunnel)
.orderBy(tenantSchema.clientAppointmentTunnel.createdAt);
log.debug("Client tunnels retrieved successfully", {
tenantId: this.tenantId,
clientId: request.clientId,
channelId: request.channelId,
appointmentDate: request.appointmentDate,
tunnelCount: tunnels.length,
});
try {
const db = await this.getDb();
return tunnels.map((tunnel) => ({
id: tunnel.id,
emailHash: tunnel.emailHash,
clientPublicKey: tunnel.clientPublicKey,
createdAt: tunnel.createdAt?.toISOString(),
updatedAt: tunnel.updatedAt?.toISOString(),
}));
}
// Check if client exists
const client = await db
.select()
.from(tenantSchema.client)
.where(eq(tenantSchema.client.id, request.clientId))
.limit(1);
/**
* Create a new client tunnel with their first appointment
*/
public async createNewClientWithAppointment(
clientData: ClientTunnelData,
): Promise<AppointmentResponse> {
const log = logger.setContext("AppointmentService");
if (client.length === 0) {
throw new NotFoundError(`Client with ID ${request.clientId} not found`);
log.info("Creating new client appointment tunnel", {
tenantId: this.tenantId,
tunnelId: clientData.tunnelId,
appointmentDate: clientData.appointmentDate,
emailHashPrefix: clientData.emailHash.slice(0, 8),
});
// Check if there are any authorized users (ACCESS_GRANTED) in this tenant
const authorizedUsers = await centralDb
.select({ count: user.id })
.from(user)
.where(and(eq(user.tenantId, this.tenantId), eq(user.confirmationState, "ACCESS_GRANTED")))
.limit(1);
if (authorizedUsers.length === 0) {
log.warn("Client creation blocked: No authorized users in tenant", {
tenantId: this.tenantId,
tunnelId: clientData.tunnelId,
});
throw new ConflictError(
"Cannot create client appointments: No authorized users found in tenant. At least one user must have ACCESS_GRANTED status.",
);
}
const db = await this.getDb();
// Transactional: Create tunnel and appointment
const result = await db.transaction(async (tx) => {
// 1. Create client appointment tunnel
const tunnelResult = await tx
.insert(tenantSchema.clientAppointmentTunnel)
.values({
id: clientData.tunnelId,
emailHash: clientData.emailHash,
clientPublicKey: clientData.clientPublicKey,
privateKeyShare: clientData.privateKeyShare,
clientEncryptedTunnelKey: clientData.clientEncryptedTunnelKey,
})
.returning({ id: tenantSchema.clientAppointmentTunnel.id });
if (tunnelResult.length === 0) {
throw new InternalError("Failed to create client appointment tunnel");
}
// Check if channel exists and is not paused
const channel = await db
.select()
.from(tenantSchema.channel)
.where(eq(tenantSchema.channel.id, request.channelId))
.limit(1);
if (channel.length === 0) {
throw new NotFoundError(`Channel with ID ${request.channelId} not found`);
}
if (channel[0].pause) {
throw new ConflictError("Channel is currently paused and not accepting appointments");
}
// Check for conflicting appointments at the same time slot
const conflictingAppointments = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.channelId, request.channelId),
eq(tenantSchema.appointment.appointmentDate, request.appointmentDate),
or(
eq(tenantSchema.appointment.status, "NEW"),
eq(tenantSchema.appointment.status, "CONFIRMED"),
eq(tenantSchema.appointment.status, "HELD"),
),
),
// 2. Store staff key shares for the tunnel
if (clientData.staffKeyShares.length > 0) {
await tx.insert(tenantSchema.clientTunnelStaffKeyShare).values(
clientData.staffKeyShares.map((share) => ({
tunnelId: clientData.tunnelId,
userId: share.userId,
encryptedTunnelKey: share.encryptedTunnelKey,
})),
);
if (conflictingAppointments.length > 0) {
throw new ConflictError("Time slot is already booked");
}
// Create the appointment
const result = await db
// 3. Get channel configuration to determine initial status
const channelResult = await tx
.select({ requiresConfirmation: tenantSchema.channel.requiresConfirmation })
.from(tenantSchema.channel)
.where(eq(tenantSchema.channel.id, clientData.channelId))
.limit(1);
if (channelResult.length === 0) {
throw new NotFoundError("Channel not found");
}
const initialStatus = channelResult[0].requiresConfirmation ? "NEW" : "CONFIRMED";
// 4. Create encrypted appointment
const appointmentResult = await tx
.insert(tenantSchema.appointment)
.values({
clientId: request.clientId,
channelId: request.channelId,
appointmentDate: request.appointmentDate,
expiryDate: request.expiryDate,
name: request.name,
phone: request.phone ?? "",
status: request.status,
tunnelId: clientData.tunnelId,
channelId: clientData.channelId,
appointmentDate: new Date(clientData.appointmentDate),
encryptedPayload: clientData.encryptedAppointment.encryptedPayload,
iv: clientData.encryptedAppointment.iv,
authTag: clientData.encryptedAppointment.authTag,
status: initialStatus,
})
.returning();
.returning({
id: tenantSchema.appointment.id,
appointmentDate: tenantSchema.appointment.appointmentDate,
status: tenantSchema.appointment.status,
});
log.debug("Appointment created successfully", {
tenantId: this.tenantId,
appointmentId: result[0].id,
clientId: request.clientId,
channelId: request.channelId,
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError || error instanceof ConflictError) throw error;
log.error("Failed to create appointment", {
tenantId: this.tenantId,
clientId: request.clientId,
channelId: request.channelId,
error: String(error),
});
throw error;
}
}
/**
* Get an appointment by ID
* @param appointmentId Appointment ID
* @returns Appointment with details or null if not found
*/
async getAppointmentById(appointmentId: string): Promise<AppointmentWithDetails | null> {
const log = logger.setContext("AppointmentService");
log.debug("Getting appointment by ID", { tenantId: this.tenantId, appointmentId });
try {
const db = await this.getDb();
const result = await db
.select({
appointment: tenantSchema.appointment,
client: tenantSchema.client,
channel: tenantSchema.channel,
})
.from(tenantSchema.appointment)
.leftJoin(
tenantSchema.client,
eq(tenantSchema.appointment.clientId, tenantSchema.client.id),
)
.leftJoin(
tenantSchema.channel,
eq(tenantSchema.appointment.channelId, tenantSchema.channel.id),
)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (result.length === 0) {
log.debug("Appointment not found", { tenantId: this.tenantId, appointmentId });
return null;
if (appointmentResult.length === 0) {
throw new InternalError("Failed to create appointment");
}
const row = result[0];
log.debug("Appointment found", { tenantId: this.tenantId, appointmentId });
return {
...row.appointment,
client: row.client || undefined,
channel: row.channel || undefined,
};
} catch (error) {
log.error("Failed to get appointment by ID", {
tenantId: this.tenantId,
appointmentId,
error: String(error),
});
throw error;
}
}
/**
* Query appointments with filters
* @param query Query parameters
* @returns Array of appointments matching criteria
*/
async queryAppointments(query: AppointmentQueryRequest): Promise<AppointmentWithDetails[]> {
const log = logger.setContext("AppointmentService");
const validation = appointmentQuerySchema.safeParse(query);
if (!validation.success) {
throw new ValidationError("Invalid appointment query request");
}
log.debug("Querying appointments", {
tenantId: this.tenantId,
startDate: query.startDate,
endDate: query.endDate,
channelId: query.channelId,
clientId: query.clientId,
status: query.status,
return appointmentResult[0];
});
try {
const db = await this.getDb();
const response: AppointmentResponse = {
id: result.id,
appointmentDate: result.appointmentDate.toISOString(),
status: result.status,
};
// Build where conditions dynamically
const conditions = [
between(tenantSchema.appointment.appointmentDate, query.startDate, query.endDate),
];
if (query.channelId) {
conditions.push(eq(tenantSchema.appointment.channelId, query.channelId));
}
if (query.clientId) {
conditions.push(eq(tenantSchema.appointment.clientId, query.clientId));
}
if (query.status) {
conditions.push(eq(tenantSchema.appointment.status, query.status));
}
const result = await db
.select({
appointment: tenantSchema.appointment,
client: tenantSchema.client,
channel: tenantSchema.channel,
})
.from(tenantSchema.appointment)
.leftJoin(
tenantSchema.client,
eq(tenantSchema.appointment.clientId, tenantSchema.client.id),
)
.leftJoin(
tenantSchema.channel,
eq(tenantSchema.appointment.channelId, tenantSchema.channel.id),
)
.where(and(...conditions))
.orderBy(tenantSchema.appointment.appointmentDate);
log.debug("Retrieved appointments", {
tenantId: this.tenantId,
count: result.length,
});
return result.map((row) => ({
...row.appointment,
client: row.client || undefined,
channel: row.channel || undefined,
}));
} catch (error) {
log.error("Failed to query appointments", {
tenantId: this.tenantId,
error: String(error),
});
throw error;
}
}
/**
* Update an existing appointment
* @param appointmentId Appointment ID
* @param updateData Appointment update data
* @returns Updated appointment
*/
async updateAppointment(
appointmentId: string,
updateData: AppointmentUpdateRequest,
): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
const validation = appointmentUpdateSchema.safeParse(updateData);
if (!validation.success) {
throw new ValidationError("Invalid appointment update request");
}
log.debug("Updating appointment", {
log.info("Successfully created new client appointment tunnel", {
tenantId: this.tenantId,
appointmentId,
updateFields: Object.keys(updateData),
tunnelId: clientData.tunnelId,
appointmentId: result.id,
staffSharesCount: clientData.staffKeyShares.length,
});
try {
const db = await this.getDb();
// If updating appointment date, check for conflicts
if (updateData.appointmentDate) {
const existingAppointment = await db
.select()
.from(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.limit(1);
if (existingAppointment.length === 0) {
throw new NotFoundError(`Appointment with ID ${appointmentId} not found`);
}
// Check for conflicting appointments at the new time slot
const conflictingAppointments = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
eq(tenantSchema.appointment.channelId, existingAppointment[0].channelId),
eq(tenantSchema.appointment.appointmentDate, updateData.appointmentDate),
eq(tenantSchema.appointment.id, appointmentId), // Exclude current appointment
or(
eq(tenantSchema.appointment.status, "NEW"),
eq(tenantSchema.appointment.status, "CONFIRMED"),
eq(tenantSchema.appointment.status, "HELD"),
),
),
);
if (conflictingAppointments.length > 0) {
throw new ConflictError("New time slot is already booked");
}
}
const result = await db
.update(tenantSchema.appointment)
.set(updateData)
.where(eq(tenantSchema.appointment.id, appointmentId))
.returning();
if (result.length === 0) {
log.warn("Appointment update failed: Appointment not found", {
tenantId: this.tenantId,
appointmentId,
});
throw new NotFoundError(`Appointment with ID ${appointmentId} not found`);
}
log.debug("Appointment updated successfully", {
tenantId: this.tenantId,
appointmentId,
updateFields: Object.keys(updateData),
});
return result[0];
} catch (error) {
if (error instanceof NotFoundError || error instanceof ConflictError) throw error;
log.error("Failed to update appointment", {
tenantId: this.tenantId,
appointmentId,
error: String(error),
});
throw error;
}
return response;
}
/**
* Cancel an appointment (set status to REJECTED)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async cancelAppointment(appointmentId: string): Promise<SelectAppointment> {
public async getAppointmentsByTimeRange(
startDate: Date,
endDate: Date,
): Promise<SelectAppointment[]> {
const log = logger.setContext("AppointmentService");
log.debug("Cancelling appointment", { tenantId: this.tenantId, appointmentId });
log.debug("Fetching appointments by time range", {
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
tenantId: this.tenantId,
});
const db = await this.getDb();
return this.updateAppointment(appointmentId, { status: "REJECTED" });
}
const result = await db
.select()
.from(tenantSchema.appointment)
.where(
and(
gte(tenantSchema.appointment.appointmentDate, startDate),
lte(tenantSchema.appointment.appointmentDate, endDate),
),
)
.orderBy(asc(tenantSchema.appointment.appointmentDate));
/**
* Confirm an appointment (set status to CONFIRMED)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async confirmAppointment(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Confirming appointment", { tenantId: this.tenantId, appointmentId });
log.debug("Appointments retrieved successfully", {
count: result.length,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
tenantId: this.tenantId,
});
return this.updateAppointment(appointmentId, { status: "CONFIRMED" });
}
/**
* Mark appointment as completed (set status to HELD)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async completeAppointment(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Completing appointment", { tenantId: this.tenantId, appointmentId });
return this.updateAppointment(appointmentId, { status: "HELD" });
}
/**
* Mark appointment as no-show (set status to NO_SHOW)
* @param appointmentId Appointment ID
* @returns Updated appointment
*/
async markNoShow(appointmentId: string): Promise<SelectAppointment> {
const log = logger.setContext("AppointmentService");
log.debug("Marking appointment as no-show", { tenantId: this.tenantId, appointmentId });
return this.updateAppointment(appointmentId, { status: "NO_SHOW" });
}
/**
* Delete an appointment
* @param appointmentId Appointment ID
* @returns true if deleted, false if not found
*/
async deleteAppointment(appointmentId: string): Promise<boolean> {
const log = logger.setContext("AppointmentService");
log.debug("Deleting appointment", { tenantId: this.tenantId, appointmentId });
try {
const db = await this.getDb();
const result = await db
.delete(tenantSchema.appointment)
.where(eq(tenantSchema.appointment.id, appointmentId))
.returning();
if (result.length === 0) {
log.debug("Appointment deletion failed: Appointment not found", {
tenantId: this.tenantId,
appointmentId,
});
return false;
}
log.debug("Appointment deleted successfully", {
tenantId: this.tenantId,
appointmentId,
});
return true;
} catch (error) {
log.error("Failed to delete appointment", {
tenantId: this.tenantId,
appointmentId,
error: String(error),
});
throw error;
}
return result;
}
/**
+129
View File
@@ -0,0 +1,129 @@
/**
* Database-backed Challenge Storage
*
* Stores authentication challenges in the tenant database for persistence and scalability.
*/
import { getTenantDb } from "$lib/server/db";
import { authChallenge } from "$lib/server/db/tenant-schema";
import { eq, and, lt, gt } from "drizzle-orm";
import { logger } from "$lib/logger";
interface StoredChallenge {
challenge: string;
emailHash: string;
createdAt: Date;
expiresAt: Date;
}
class ChallengeStore {
private readonly CHALLENGE_TTL = 5 * 60 * 1000; // 5 minutes
/**
* Store a challenge in the tenant database
*/
async store(
challengeId: string,
challenge: string,
emailHash: string,
tenantId: string,
): Promise<void> {
const db = await getTenantDb(tenantId);
const now = new Date();
const expiresAt = new Date(now.getTime() + this.CHALLENGE_TTL);
await db.insert(authChallenge).values({
id: challengeId,
challenge,
emailHash,
createdAt: now,
expiresAt,
consumed: false,
});
// Clean up expired challenges periodically
await this.cleanup(tenantId);
}
/**
* Retrieve and consume a challenge (one-time use)
*/
async consume(challengeId: string, tenantId: string): Promise<StoredChallenge | null> {
const db = await getTenantDb(tenantId);
const now = new Date();
// Find the challenge
const results = await db
.select({
challenge: authChallenge.challenge,
emailHash: authChallenge.emailHash,
createdAt: authChallenge.createdAt,
expiresAt: authChallenge.expiresAt,
consumed: authChallenge.consumed,
})
.from(authChallenge)
.where(eq(authChallenge.id, challengeId))
.limit(1);
if (results.length === 0) {
return null;
}
const result = results[0];
// Check if expired or already consumed
if (now > result.expiresAt || result.consumed) {
// Delete expired/consumed challenge
await db.delete(authChallenge).where(eq(authChallenge.id, challengeId));
return null;
}
// Mark as consumed (one-time use)
await db.update(authChallenge).set({ consumed: true }).where(eq(authChallenge.id, challengeId));
return {
challenge: result.challenge,
emailHash: result.emailHash,
createdAt: result.createdAt,
expiresAt: result.expiresAt,
};
}
/**
* Clean up expired and consumed challenges
*/
private async cleanup(tenantId: string): Promise<void> {
try {
const db = await getTenantDb(tenantId);
const now = new Date();
await db.delete(authChallenge).where(lt(authChallenge.expiresAt, now));
logger.debug("Cleaned up expired challenges", {
tenantId,
});
} catch (error) {
logger.warn("Failed to cleanup expired challenges", {
tenantId,
error: String(error),
});
}
}
/**
* Get current number of active challenges for a tenant (for debugging)
*/
async size(tenantId: string): Promise<number> {
const db = await getTenantDb(tenantId);
const now = new Date();
const results = await db
.select({ count: authChallenge.id })
.from(authChallenge)
.where(and(eq(authChallenge.consumed, false), gt(authChallenge.expiresAt, now)));
return results.length;
}
}
export const challengeStore = new ChallengeStore();
+8 -2
View File
@@ -119,7 +119,11 @@ export class ScheduleService {
.from(tenantSchema.appointment)
.where(
and(
between(tenantSchema.appointment.appointmentDate, request.startDate, request.endDate),
between(
tenantSchema.appointment.appointmentDate,
new Date(request.startDate),
new Date(request.endDate),
),
or(
eq(tenantSchema.appointment.status, "NEW"),
eq(tenantSchema.appointment.status, "CONFIRMED"),
@@ -236,7 +240,9 @@ export class ScheduleService {
const dayAppointments = appointments.filter(
(appointment) =>
appointment.channelId === channel.id &&
appointment.appointmentDate.startsWith(dateString),
(typeof appointment.appointmentDate === "string"
? (appointment.appointmentDate as string).startsWith(dateString)
: appointment.appointmentDate.toISOString().startsWith(dateString)),
);
// Get slot templates for this channel that apply to this weekday
@@ -0,0 +1,345 @@
/**
* Staff Crypto Service
*
* Manages cryptographic keys for staff members with browser-based split-key architecture:
*
* ## Architecture Overview:
* - **Kyber Key Generation**: Happens entirely in the browser using KyberCrypto.generateKeyPair()
* - **Split-Key Storage**: Private keys are split into two shards using XOR:
* - Passkey-based shard: Derived in browser from WebAuthn authenticatorData
* - Database shard: Stored encrypted in tenant database
* - **Public Keys**: Stored in tenant database for client-side encryption
*
* ## Browser Integration Workflow:
*
* ### 1. Staff Passkey Registration (in browser):
* ```javascript
* // Generate Kyber keypair in browser
* const keyPair = KyberCrypto.generateKeyPair();
*
* // Derive passkey-based shard from WebAuthn
* const passkeyBasedShard = await derivePasskeyBasedShard(passkeyId, authenticatorData);
*
* // Create database shard using XOR
* const dbShard = new Uint8Array(keyPair.privateKey.length);
* for (let i = 0; i < keyPair.privateKey.length; i++) {
* dbShard[i] = keyPair.privateKey[i] ^ passkeyBasedShard[i];
* }
*
* // Store via API
* await fetch(`/api/tenants/${tenantId}/staff/${userId}/crypto`, {
* method: 'POST',
* body: JSON.stringify({
* passkeyId,
* publicKey: bufferToBase64(keyPair.publicKey),
* privateKeyShare: bufferToBase64(dbShard)
* })
* });
* ```
*
* ### 2. Staff Authentication & Key Reconstruction (in browser):
* ```javascript
* // Get database shard from API
* const response = await fetch(`/api/tenants/${tenantId}/staff/${userId}/key-shard`);
* const { publicKey, privateKeyShare, passkeyId } = await response.json();
*
* // Derive same passkey-based shard from WebAuthn
* const passkeyBasedShard = await derivePasskeyBasedShard(passkeyId, authenticatorData);
*
* // Reconstruct private key using XOR
* const dbShard = base64ToBuffer(privateKeyShare);
* const privateKey = new Uint8Array(dbShard.length);
* for (let i = 0; i < dbShard.length; i++) {
* privateKey[i] = dbShard[i] ^ passkeyBasedShard[i];
* }
* ```
*
* ## API Methods:
* - `storeStaffKeypair()`: Store browser-generated keys (called after passkey registration)
* - `getStaffCryptoForPasskey()`: Get specific passkey's crypto data for reconstruction
* - `getAllStaffCryptoData()`: Get all crypto entries for a staff member (multiple passkeys)
* - `deleteStaffCryptoForPasskey()`: Delete crypto data when passkey is removed
* - `getStaffPublicKeys()`: Get all staff public keys for client-side encryption
*/
import { logger } from "$lib/logger";
import { getTenantDb } from "$lib/server/db";
import { staffCrypto } from "$lib/server/db/tenant-schema";
import { eq, and } from "drizzle-orm";
export class StaffCryptoService {
/**
* Store keypair for staff member (generated in browser)
* Keys are already split - we just store the database shard and public key
*/
async storeStaffKeypair(
tenantId: string,
userId: string,
passkeyId: string,
publicKey: string, // Base64 encoded public key from browser
privateKeyShare: string, // Base64 encoded database shard from browser
): Promise<void> {
const log = logger.setContext("StaffCryptoService.storeStaffKeypair");
try {
log.debug("Storing staff keypair", { tenantId, userId, passkeyId });
// Store in database
const db = await getTenantDb(tenantId);
await db.insert(staffCrypto).values({
userId,
publicKey,
privateKeyShare,
passkeyId,
isActive: true,
});
log.info("Staff keypair stored successfully", {
tenantId,
userId,
passkeyId,
hasPublicKey: !!publicKey,
hasPrivateKeyShare: !!privateKeyShare,
});
} catch (error) {
log.error("Failed to store staff keypair", {
tenantId,
userId,
passkeyId,
error: String(error),
});
throw error;
}
}
/**
* Get crypto data for specific staff member and passkey combination
*/
async getStaffCryptoForPasskey(
tenantId: string,
userId: string,
passkeyId: string,
): Promise<{
publicKey: string;
privateKeyShare: string;
passkeyId: string;
} | null> {
const log = logger.setContext("StaffCryptoService.getStaffCryptoForPasskey");
try {
log.debug("Getting staff crypto for passkey", { tenantId, userId, passkeyId });
const db = await getTenantDb(tenantId);
const result = await db
.select({
publicKey: staffCrypto.publicKey,
privateKeyShare: staffCrypto.privateKeyShare,
passkeyId: staffCrypto.passkeyId,
})
.from(staffCrypto)
.where(and(eq(staffCrypto.userId, userId), eq(staffCrypto.passkeyId, passkeyId)))
.limit(1);
if (result.length === 0) {
log.warn("Staff crypto not found for passkey", { tenantId, userId, passkeyId });
return null;
}
const data = result[0];
log.debug("Staff crypto retrieved for passkey", {
tenantId,
userId,
passkeyId,
hasPublicKey: !!data.publicKey,
hasPrivateKeyShare: !!data.privateKeyShare,
});
return {
publicKey: data.publicKey,
privateKeyShare: data.privateKeyShare,
passkeyId: data.passkeyId,
};
} catch (error) {
log.error("Failed to get staff crypto for passkey", {
tenantId,
userId,
passkeyId,
error: String(error),
});
throw error;
}
}
/**
* Get all staff public keys for a tenant
*/
async getStaffPublicKeys(
tenantId: string,
): Promise<Array<{ userId: string; publicKey: string }>> {
const log = logger.setContext("StaffCryptoService.getStaffPublicKeys");
try {
const db = await getTenantDb(tenantId);
const staffWithKeys = await db
.select({
userId: staffCrypto.userId,
publicKey: staffCrypto.publicKey,
})
.from(staffCrypto)
.where(eq(staffCrypto.isActive, true));
const validStaffKeys = staffWithKeys.map((staff) => ({
userId: staff.userId,
publicKey: staff.publicKey,
}));
log.info("Retrieved staff public keys", {
tenantId,
validKeys: validStaffKeys.length,
});
return validStaffKeys;
} catch (error) {
log.error("Failed to retrieve staff public keys", {
tenantId,
error: String(error),
});
throw error;
}
}
/**
* Get public key for specific staff member
*/
async getStaffPublicKey(tenantId: string, userId: string): Promise<string | null> {
const log = logger.setContext("StaffCryptoService.getStaffPublicKey");
try {
const db = await getTenantDb(tenantId);
const result = await db
.select({
publicKey: staffCrypto.publicKey,
})
.from(staffCrypto)
.where(eq(staffCrypto.userId, userId))
.limit(1);
if (result.length === 0) {
log.warn("Staff crypto not found", { tenantId, userId });
return null;
}
const publicKey = result[0].publicKey;
log.debug("Retrieved staff public key", {
tenantId,
userId,
hasKey: !!publicKey,
});
return publicKey;
} catch (error) {
log.error("Failed to retrieve staff public key", {
tenantId,
userId,
error: String(error),
});
throw error;
}
}
/**
* Get all crypto data entries for a staff member (multiple passkeys)
*/
async getAllStaffCryptoData(
tenantId: string,
userId: string,
): Promise<
Array<{
publicKey: string;
privateKeyShare: string;
passkeyId: string;
}>
> {
const log = logger.setContext("StaffCryptoService.getAllStaffCryptoData");
try {
const db = await getTenantDb(tenantId);
const results = await db
.select({
publicKey: staffCrypto.publicKey,
privateKeyShare: staffCrypto.privateKeyShare,
passkeyId: staffCrypto.passkeyId,
})
.from(staffCrypto)
.where(eq(staffCrypto.userId, userId));
log.debug("Retrieved all staff crypto data", {
tenantId,
userId,
entryCount: results.length,
});
return results.map((data) => ({
publicKey: data.publicKey,
privateKeyShare: data.privateKeyShare,
passkeyId: data.passkeyId,
}));
} catch (error) {
log.error("Failed to retrieve all staff crypto data", {
tenantId,
userId,
error: String(error),
});
throw error;
}
}
/**
* Delete crypto data for a specific passkey
*/
async deleteStaffCryptoForPasskey(
tenantId: string,
userId: string,
passkeyId: string,
): Promise<boolean> {
const log = logger.setContext("StaffCryptoService.deleteStaffCryptoForPasskey");
try {
log.debug("Deleting staff crypto for passkey", { tenantId, userId, passkeyId });
const db = await getTenantDb(tenantId);
const result = await db
.delete(staffCrypto)
.where(and(eq(staffCrypto.userId, userId), eq(staffCrypto.passkeyId, passkeyId)));
const deleted = result.count > 0;
log.info("Staff crypto deletion completed", {
tenantId,
userId,
passkeyId,
deleted,
});
return deleted;
} catch (error) {
log.error("Failed to delete staff crypto for passkey", {
tenantId,
userId,
passkeyId,
error: String(error),
});
throw error;
}
}
/**
* Helper functions for encoding/decoding
*/
private bufferToBase64(buffer: Uint8Array): string {
return Buffer.from(buffer).toString("base64");
}
private base64ToBuffer(base64: string): Uint8Array {
return new Uint8Array(Buffer.from(base64, "base64"));
}
}
+382
View File
@@ -0,0 +1,382 @@
import { centralDb, getTenantDb } from "../db";
import { user, userPasskey } from "../db/central-schema";
import { clientTunnelStaffKeyShare } from "../db/tenant-schema";
import { eq, and } from "drizzle-orm";
import { NotFoundError, ValidationError, InternalError } from "../utils/errors";
import { StaffCryptoService } from "./staff-crypto.service";
import { UniversalLogger } from "$lib/logger";
import type { InferSelectModel } from "drizzle-orm";
const logger = new UniversalLogger().setContext("StaffService");
export type SelectUser = InferSelectModel<typeof user>;
export interface StaffMember {
id: string;
email: string;
name: string;
role: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF";
isActive: boolean | null;
confirmationState: "INVITED" | "CONFIRMED" | "ACCESS_GRANTED" | null;
createdAt: Date | null;
updatedAt: Date | null;
lastLoginAt: Date | null;
}
export interface StaffUpdateData {
email?: string;
name?: string;
role?: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF";
isActive?: boolean;
}
export interface StaffDeletionResult {
success: boolean;
deletedUser: {
id: string;
email: string;
name: string;
role: "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF";
};
deletedPasskeysCount: number;
deletedKeySharesCount: number;
}
export interface StaffPublicKeyResponse {
userId: string;
publicKey: string;
}
export class StaffService {
/**
* Get all staff members for a tenant
*/
static async getStaffMembers(tenantId: string): Promise<StaffMember[]> {
logger.debug("Fetching staff members", { tenantId });
try {
const staff: StaffMember[] = await centralDb
.select({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
confirmationState: user.confirmationState,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt,
})
.from(user)
.where(eq(user.tenantId, tenantId));
logger.debug("Staff members fetched successfully", {
tenantId,
count: staff.length,
});
return staff;
} catch (error) {
logger.error("Failed to fetch staff members", {
tenantId,
error: String(error),
});
throw new InternalError("Failed to fetch staff members");
}
}
/**
* Update a staff member
*/
static async updateStaffMember(
tenantId: string,
userId: string,
updateData: StaffUpdateData,
currentUserId?: string,
): Promise<StaffMember> {
logger.debug("Updating staff member", {
tenantId,
userId,
updateFields: Object.keys(updateData),
currentUserId,
});
// Prevent self-deactivation
if (updateData.isActive === false && currentUserId === userId) {
throw new ValidationError("You cannot deactivate your own account");
}
try {
const updatedUser = await centralDb
.update(user)
.set({
...updateData,
updatedAt: new Date(),
})
.where(and(eq(user.id, userId), eq(user.tenantId, tenantId)))
.returning({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
isActive: user.isActive,
confirmationState: user.confirmationState,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
lastLoginAt: user.lastLoginAt,
});
if (updatedUser.length === 0) {
logger.warn("Staff member not found for update", { tenantId, userId });
throw new NotFoundError("Staff member not found in this tenant");
}
logger.debug("Staff member updated successfully", {
tenantId,
userId,
updateFields: Object.keys(updateData),
});
return updatedUser[0];
} catch (error) {
if (error instanceof ValidationError || error instanceof NotFoundError) {
throw error;
}
logger.error("Failed to update staff member", {
tenantId,
userId,
error: String(error),
});
throw new InternalError("Failed to update staff member");
}
}
/**
* Delete a staff member and all associated data
*/
static async deleteStaffMember(
tenantId: string,
staffId: string,
currentUserId?: string,
): Promise<StaffDeletionResult> {
logger.debug("Deleting staff member", { tenantId, staffId, currentUserId });
// Prevent self-deletion
if (currentUserId === staffId) {
throw new ValidationError("You cannot delete your own account");
}
try {
// Use transaction to ensure all related data is deleted consistently
const result = await centralDb.transaction(async (tx) => {
// First, verify the user exists and belongs to this tenant
const userToDelete = await tx
.select({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
tenantId: user.tenantId,
})
.from(user)
.where(and(eq(user.id, staffId), eq(user.tenantId, tenantId)))
.limit(1);
if (userToDelete.length === 0) {
throw new NotFoundError("Staff member not found in this tenant");
}
// Delete associated passkeys from central database
const passkeyDeletionResult = await tx
.delete(userPasskey)
.where(eq(userPasskey.userId, staffId));
const deletedPasskeysCount = passkeyDeletionResult.count || 0;
logger.debug("Deleted user passkeys", {
staffId,
tenantId,
deletedCount: deletedPasskeysCount,
});
// Delete client tunnel key shares from tenant database
let deletedKeySharesCount = 0;
try {
const tenantDb = await getTenantDb(tenantId);
const keyShareDeletionResult = await tenantDb
.delete(clientTunnelStaffKeyShare)
.where(eq(clientTunnelStaffKeyShare.userId, staffId));
deletedKeySharesCount = keyShareDeletionResult.count || 0;
logger.debug("Deleted client tunnel key shares", {
staffId,
tenantId,
deletedCount: deletedKeySharesCount,
});
} catch (error) {
logger.warn("Failed to delete client tunnel key shares", {
staffId,
tenantId,
error: String(error),
});
// Continue with user deletion even if key share deletion fails
}
// Finally, delete the user account from central database
const deletedUsers = await tx.delete(user).where(eq(user.id, staffId)).returning({
id: user.id,
email: user.email,
name: user.name,
role: user.role,
});
if (deletedUsers.length === 0) {
throw new InternalError("Failed to delete user account");
}
const deletionResult = {
success: true,
deletedUser: deletedUsers[0],
deletedPasskeysCount,
deletedKeySharesCount,
};
logger.info("Staff member deleted successfully", {
staffId,
tenantId,
deletedUser: deletedUsers[0],
deletedPasskeysCount,
deletedKeySharesCount,
});
return deletionResult;
});
return result;
} catch (error) {
if (error instanceof ValidationError || error instanceof NotFoundError) {
throw error;
}
logger.error("Failed to delete staff member", {
tenantId,
staffId,
error: String(error),
});
throw new InternalError("Failed to delete staff member");
}
}
/**
* Get a staff member's public key
*/
static async getStaffPublicKey(
tenantId: string,
staffId: string,
): Promise<StaffPublicKeyResponse> {
logger.debug("Fetching staff public key", { tenantId, staffId });
try {
// Check if the requested staff user belongs to the tenant
const staffUser = await centralDb
.select({
id: user.id,
tenantId: user.tenantId,
isActive: user.isActive,
})
.from(user)
.where(eq(user.id, staffId))
.limit(1);
if (staffUser.length === 0) {
logger.warn("Staff user not found", { tenantId, staffId });
throw new NotFoundError("Staff user not found");
}
if (staffUser[0].tenantId !== tenantId) {
logger.warn("Staff user does not belong to tenant", {
tenantId,
staffId,
staffTenantId: staffUser[0].tenantId,
});
throw new ValidationError("Staff user does not belong to this tenant");
}
if (!staffUser[0].isActive) {
logger.warn("Staff user is inactive", { tenantId, staffId });
throw new ValidationError("Staff user is inactive");
}
const staffCryptoService = new StaffCryptoService();
const publicKey = await staffCryptoService.getStaffPublicKey(tenantId, staffId);
if (!publicKey) {
logger.warn("Staff public key not found", { tenantId, staffId });
throw new NotFoundError("Staff public key not found");
}
logger.debug("Staff public key retrieved successfully", {
tenantId,
staffId,
hasPublicKey: !!publicKey,
});
return {
userId: staffId,
publicKey: publicKey,
};
} catch (error) {
if (error instanceof ValidationError || error instanceof NotFoundError) {
throw error;
}
logger.error("Failed to fetch staff public key", {
tenantId,
staffId,
error: String(error),
});
throw new InternalError("Failed to fetch staff public key");
}
}
/**
* Validate that a staff member exists and belongs to the tenant
*/
static async validateStaffMember(tenantId: string, staffId: string): Promise<SelectUser> {
logger.debug("Validating staff member", { tenantId, staffId });
try {
const staffUser = await centralDb
.select()
.from(user)
.where(and(eq(user.id, staffId), eq(user.tenantId, tenantId)))
.limit(1);
if (staffUser.length === 0) {
logger.warn("Staff member not found in tenant", { tenantId, staffId });
throw new NotFoundError("Staff member not found in this tenant");
}
logger.debug("Staff member validated successfully", {
tenantId,
staffId,
isActive: staffUser[0].isActive,
});
return staffUser[0];
} catch (error) {
if (error instanceof NotFoundError) {
throw error;
}
logger.error("Failed to validate staff member", {
tenantId,
staffId,
error: String(error),
});
throw new InternalError("Failed to validate staff member");
}
}
}
+50 -8
View File
@@ -107,10 +107,15 @@ export class UserService {
token: userData.token,
tokenValidUntil: userData.tokenValidUntil,
language: userData.language || "de",
confirmed: false,
confirmationState: "INVITED",
isActive: false,
};
if (userData.role === "GLOBAL_ADMIN") {
userDataForDb.confirmationState = "ACCESS_GRANTED"; // Admin account is active immediately after email confirmation
userDataForDb.isActive = true;
}
// Handle passphrase or generate recovery passphrase
if (userData.passphrase) {
// User provided a passphrase, hash it
@@ -237,6 +242,7 @@ export class UserService {
.select({
id: centralSchema.user.id,
recoveryPassphrase: centralSchema.user.recoveryPassphrase,
tenantId: centralSchema.user.tenantId,
})
.from(centralSchema.user)
.where(
@@ -256,11 +262,28 @@ export class UserService {
const user = userData[0];
// Check if this is the first tenant admin for the tenant
let shouldGrantAccess = false;
if (user.tenantId) {
const existingTenantAdmins = await centralDb
.select({ count: count() })
.from(centralSchema.user)
.where(
and(
eq(centralSchema.user.tenantId, user.tenantId),
eq(centralSchema.user.role, "TENANT_ADMIN"),
),
);
shouldGrantAccess = existingTenantAdmins[0].count === 0;
}
const confirmationState = shouldGrantAccess ? "ACCESS_GRANTED" : "CONFIRMED";
// Update the user to confirmed and active, and clear the recovery passphrase
const result = await centralDb
.update(centralSchema.user)
.set({
confirmed: true,
confirmationState,
isActive: true,
recoveryPassphrase: null, // Clear it after showing it once
})
@@ -303,7 +326,10 @@ export class UserService {
try {
// Check if user exists and is active
const user = await centralDb
.select({ id: centralSchema.user.id, confirmed: centralSchema.user.confirmed })
.select({
id: centralSchema.user.id,
confirmationState: centralSchema.user.confirmationState,
})
.from(centralSchema.user)
.where(eq(centralSchema.user.id, userId))
.limit(1);
@@ -312,7 +338,7 @@ export class UserService {
throw new NotFoundError("User not found");
}
if (!user[0].confirmed) {
if (user[0].confirmationState === "INVITED") {
throw new ValidationError(
"User account must be confirmed before adding additional passkeys",
);
@@ -322,10 +348,11 @@ export class UserService {
await centralDb.insert(centralSchema.userPasskey).values({
...passkeyData,
userId,
createdAt: new Date(),
updatedAt: new Date(),
});
// Note: Crypto keypairs for STAFF/TENANT_ADMIN are generated in the browser
// and stored via separate API calls, not automatically here
log.debug("Additional passkey added successfully", { userId, passkeyId: passkeyData.id });
} catch (error) {
if (error instanceof NotFoundError || error instanceof ValidationError) throw error;
@@ -356,7 +383,7 @@ export class UserService {
log.debug("User found by email", {
email,
userId: result[0].id,
confirmed: result[0].confirmed,
confirmationState: result[0].confirmationState,
});
return result[0];
} catch (error) {
@@ -487,7 +514,7 @@ export class UserService {
}
/**
* Add a passkey for a uaer
* Add a passkey for a user
*/
static async addPasskey(
userId: string,
@@ -501,6 +528,18 @@ export class UserService {
});
try {
// Verify user exists
const user = await centralDb
.select({ id: centralSchema.user.id })
.from(centralSchema.user)
.where(eq(centralSchema.user.id, userId))
.limit(1);
if (user.length === 0) {
throw new NotFoundError("User not found");
}
// Add the passkey to the database
const result = await centralDb
.insert(centralSchema.userPasskey)
.values({
@@ -509,6 +548,9 @@ export class UserService {
})
.returning();
// Note: Crypto keypairs for STAFF/TENANT_ADMIN are generated in the browser
// and stored via separate API calls, not automatically here
log.debug("Passkey added successfully", {
userId,
passkeyId: result[0].id,
+110
View File
@@ -0,0 +1,110 @@
// ===== CLIENT-SIDE END-TO-END ENCRYPTION TYPES =====
// Step 1: New Client - Server Preparation
export interface InitNewClientRequest {
tunnelId: string;
appointmentDate: string;
emailHash: string; // SHA-256 hash of email
}
export interface InitNewClientResponse {
staffPublicKeys: Array<{
userId: string;
publicKey: string; // Kyber Public Keys
}>;
}
// Step 2: New Client - Appointment Creation
export interface CreateNewClientAppointmentRequest {
tunnelId: string;
appointmentDate: string;
emailHash: string;
// Client-generated data
clientPublicKey: string; // Kyber Public Key
privateKeyShare: string; // Server part of private key
// Encrypted appointment data
encryptedAppointment: EncryptedAppointmentData;
// Tunnel key encrypted for each staff user
staffKeyShares: Array<{
userId: string;
encryptedTunnelKey: string; // Encrypted with staff public key
}>;
// Tunnel key encrypted for client (for future appointments)
clientKeyShare: string; // Encrypted with client public key
}
// Existing Client - Challenge Request
export interface ChallengeRequest {
emailHash: string;
}
export interface ChallengeResponse {
challengeId: string; // Unique ID to reference this challenge during verification
encryptedChallenge: string; // Encrypted with client public key
privateKeyShare: string; // Server part of private key
}
// Existing Client - Challenge Verification
export interface ChallengeVerificationRequest {
challengeId: string; // ID of the challenge to verify
challengeResponse: string; // Decrypted challenge
}
export interface ChallengeVerificationResponse {
valid: boolean;
encryptedTunnelKey: string; // Encrypted with client public key
tunnelId: string; // ID of client tunnel (to save or load appointments)
}
// Existing Client - New Appointment
export interface AddAppointmentToTunnelRequest {
emailHash: string;
tunnelId: string;
appointmentDate: string;
encryptedAppointment: EncryptedAppointmentData;
}
// Encrypted appointment data (encrypted in browser)
export interface EncryptedAppointmentData {
encryptedPayload: string; // AES-encrypted: { name, email, phone }
iv: string;
authTag: string;
}
// Client Appointments "Tunnel" (like an encrypted tunnel)
export interface ClientAppointmentsTunnel {
id: string;
clientId: string;
tenantId: string;
emailHash: string;
// Encrypted appointments (list like chat messages)
encryptedAppointments: Array<{
id: string;
tunnelId: string;
appointmentDate: string;
encryptedData: EncryptedAppointmentData;
status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW";
createdAt: string;
}>;
// Tunnel key encrypted for all authorized parties
keyShares: {
clientKeyShare: string; // Encrypted with client public key
staffKeyShares: Array<{
userId: string;
encryptedKey: string; // Encrypted with staff public key
}>;
};
}
// Responses
export interface AppointmentResponse {
id: string;
appointmentDate: string;
status: "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW";
}
@@ -1,182 +1,16 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
import {
BackendError,
ConflictError,
InternalError,
logError,
ValidationError,
} from "$lib/server/utils/errors";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
import { ERRORS } from "$lib/errors";
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments", "POST", {
summary: "Create a new appointment",
description:
"Creates a new appointment for a specific tenant. Only global admins, tenant admins, and staff can create appointments for existing clients.",
tags: ["Appointments"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Appointment creation data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
clientId: {
type: "string",
format: "uuid",
description: "Client ID (client must already exist)",
},
channelId: {
type: "string",
format: "uuid",
description: "Channel ID",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "When appointment data expires",
},
title: {
type: "string",
minLength: 1,
maxLength: 200,
description: "Appointment title",
},
description: {
type: "string",
description: "Appointment description",
},
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
description: "Appointment status",
default: "NEW",
},
},
required: ["clientId", "channelId", "appointmentDate", "expiryDate", "title"],
},
},
},
},
responses: {
"201": {
description: "Appointment created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
message: { type: "string", description: "Success message" },
appointment: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
},
},
required: ["message", "appointment"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant, client, or channel not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"409": {
description: "Appointment conflict or channel paused",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments", "GET", {
summary: "List appointments",
summary: "Get appointments by time range",
description:
"Retrieves appointments for a specific tenant with optional filters. Global admins, tenant admins, and staff can view appointments.",
"Retrieves all appointments within a specified time range for a tenant. Only staff and tenant admins can access appointments.",
tags: ["Appointments"],
parameters: [
{
@@ -191,32 +25,14 @@ registerOpenAPIRoute("/tenants/{id}/appointments", "GET", {
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "Start date for appointment search",
description: "Start date for the time range (ISO 8601 format: 2024-01-01T00:00:00.000Z)",
},
{
name: "endDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "End date for appointment search",
},
{
name: "channelId",
in: "query",
schema: { type: "string", format: "uuid" },
description: "Filter by channel ID",
},
{
name: "clientId",
in: "query",
schema: { type: "string", format: "uuid" },
description: "Filter by client ID",
},
{
name: "status",
in: "query",
schema: { type: "string", enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] },
description: "Filter by appointment status",
description: "End date for the time range (ISO 8601 format: 2024-12-31T23:59:59.999Z)",
},
],
responses: {
@@ -233,58 +49,70 @@ registerOpenAPIRoute("/tenants/{id}/appointments", "GET", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
tunnelId: { type: "string", format: "uuid", description: "Client tunnel ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "Data expiry date (nullable)",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
description: "Appointment status",
},
client: {
type: "object",
description: "Client details",
properties: {
id: { type: "string", format: "uuid" },
hashKey: { type: "string" },
email: { type: "string" },
},
encryptedPayload: {
type: "string",
description: "Encrypted appointment data (nullable)",
},
channel: {
type: "object",
description: "Channel details",
properties: {
id: { type: "string", format: "uuid" },
names: { type: "array", items: { type: "string" } },
color: { type: "string" },
},
iv: {
type: "string",
description: "Initialization vector for encryption (nullable)",
},
authTag: {
type: "string",
description: "Authentication tag for encryption (nullable)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (nullable)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (nullable)",
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
required: ["id", "tunnelId", "channelId", "appointmentDate", "status"],
},
},
meta: {
type: "object",
properties: {
count: { type: "number", description: "Number of appointments found" },
startDate: {
type: "string",
format: "date-time",
description: "Query start date",
},
endDate: { type: "string", format: "date-time", description: "Query end date" },
},
required: ["count", "startDate", "endDate"],
},
},
required: ["appointments"],
required: ["appointments", "meta"],
},
},
},
},
"400": {
description: "Invalid query parameters",
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
@@ -326,60 +154,6 @@ registerOpenAPIRoute("/tenants/{id}/appointments", "GET", {
},
});
export const POST: RequestHandler = async ({ params, request, locals }) => {
const log = logger.setContext("API");
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
}
checkPermission(locals, tenantId);
const body = await request.json();
log.debug("Creating new appointment", {
tenantId,
requestedBy: locals.user?.userId,
clientId: body.clientId,
channelId: body.channelId,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const newAppointment = await appointmentService.createAppointment(body);
log.debug("Appointment created successfully", {
tenantId,
appointmentId: newAppointment.id,
requestedBy: locals.user?.userId,
});
return json(
{
message: "Appointment created successfully",
appointment: newAppointment,
},
{ status: 201 },
);
} catch (error) {
logError(log)("Error creating appointment", error, locals.user?.userId, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
if (
(error instanceof Error && error.message.includes("conflict")) ||
(error instanceof Error && error.message.includes("paused"))
) {
return new ConflictError(error.message).toJson();
}
return new InternalError().toJson();
}
};
export const GET: RequestHandler = async ({ params, url, locals }) => {
const log = logger.setContext("API");
@@ -387,50 +161,74 @@ export const GET: RequestHandler = async ({ params, url, locals }) => {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
throw new ValidationError("Tenant ID is required");
}
checkPermission(locals, tenantId);
// Check permissions - only STAFF and TENANT_ADMIN can access appointments
checkPermission(locals, tenantId, false);
// Extract query parameters
const startDate = url.searchParams.get("startDate");
const endDate = url.searchParams.get("endDate");
const channelId = url.searchParams.get("channelId");
const clientId = url.searchParams.get("clientId");
const status = url.searchParams.get("status");
// Parse query parameters for date range
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
if (!startDate || !endDate) {
throw new ValidationError("startDate and endDate are required");
if (!startDateParam || !endDateParam) {
throw new ValidationError("Both startDate and endDate query parameters are required");
}
const query = {
startDate,
endDate,
...(channelId && { channelId }),
...(clientId && { clientId }),
...(status && { status: status as "NEW" | "CONFIRMED" | "HELD" | "REJECTED" | "NO_SHOW" }),
};
const startDate = new Date(startDateParam);
const endDate = new Date(endDateParam);
log.debug("Getting appointments", {
// Validate dates
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new ValidationError(
"Invalid date format. Use ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ)",
);
}
if (startDate >= endDate) {
throw new ValidationError("Start date must be before end date");
}
// Limit the time range to prevent excessive queries (max 1 year)
const maxRangeMs = 366 * 24 * 60 * 60 * 1000; // 1 year in milliseconds (accounting for leap years)
if (endDate.getTime() - startDate.getTime() > maxRangeMs) {
throw new ValidationError("Date range cannot exceed 1 year");
}
log.debug("Getting appointments by time range", {
tenantId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
requestedBy: locals.user?.userId,
query,
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const appointments = await appointmentService.queryAppointments(query);
const appointments = await appointmentService.getAppointmentsByTimeRange(startDate, endDate);
log.debug("Appointments retrieved successfully", {
tenantId,
count: appointments.length,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
requestedBy: locals.user?.userId,
});
return json({
appointments,
meta: {
count: appointments.length,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
},
});
} catch (error) {
logError(log)("Error getting appointments", error, locals.user?.userId, params.id);
logError(log)(
"Error getting appointments by time range",
error,
locals.user?.userId,
params.id,
);
if (error instanceof BackendError) {
return error.toJson();
}
@@ -16,7 +16,7 @@ import { checkPermission } from "$lib/server/utils/permissions";
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
summary: "Get appointment by ID",
description:
"Retrieves a specific appointment by ID. Global admins, tenant admins, and staff can view appointments.",
"Retrieves a specific appointment by its ID. Accessible to staff and tenant admins and also clients.",
tags: ["Appointments"],
parameters: [
{
@@ -46,48 +46,47 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
tunnelId: { type: "string", format: "uuid", description: "Client tunnel ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "Data expiry date (nullable)",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
description: "Appointment status",
},
client: {
type: "object",
description: "Client details",
properties: {
id: { type: "string", format: "uuid" },
hashKey: { type: "string" },
email: { type: "string" },
},
encryptedPayload: {
type: "string",
description: "Encrypted appointment data (nullable)",
},
channel: {
type: "object",
description: "Channel details",
properties: {
id: { type: "string", format: "uuid" },
names: { type: "array", items: { type: "string" } },
color: { type: "string" },
},
iv: {
type: "string",
description: "Initialization vector for encryption (nullable)",
},
authTag: {
type: "string",
description: "Authentication tag for encryption (nullable)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (nullable)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (nullable)",
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
required: ["id", "tunnelId", "channelId", "appointmentDate", "status"],
},
},
required: ["appointment"],
@@ -95,6 +94,14 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
@@ -112,7 +119,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
},
},
"404": {
description: "Tenant or appointment not found",
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
@@ -133,8 +140,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
summary: "Delete appointment",
description:
"Permanently deletes an appointment. Only global admins and tenant admins can delete appointments.",
description: "Deletes a specific appointment. Only tenant admins can delete appointments.",
tags: ["Appointments"],
parameters: [
{
@@ -167,6 +173,14 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
@@ -184,7 +198,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
},
},
"404": {
description: "Tenant or appointment not found",
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
@@ -213,8 +227,6 @@ export const GET: RequestHandler = async ({ params, locals }) => {
throw new ValidationError("Tenant ID and appointment ID are required");
}
checkPermission(locals, tenantId);
log.debug("Getting appointment by ID", {
tenantId,
appointmentId,
@@ -20,11 +20,11 @@ vi.mock("$lib/logger", () => ({
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
import { NotFoundError, ValidationError } from "$lib/server/utils/errors";
describe("Appointment Detail API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockAppointmentId = "456e7890-e12b-34d5-a678-901234567890";
const mockAppointmentService = {
getAppointmentById: vi.fn(),
deleteAppointment: vi.fn(),
@@ -41,163 +41,197 @@ describe("Appointment Detail API Routes", () => {
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
} as any,
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/{id}/appointments/{appointmentId}", () => {
it("should get appointment by ID successfully", async () => {
const mockAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
client: { id: "client123", email: "test@example.com" },
channel: { id: "channel123", names: ["Room 1"] },
};
const mockAppointment = {
id: mockAppointmentId,
tunnelId: "tunnel-123",
channelId: "channel-123",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: null,
status: "CONFIRMED",
encryptedData: null,
dataKey: null,
encryptedPayload: "encrypted-payload",
iv: "iv-data",
authTag: "auth-tag",
createdAt: "2024-01-01T09:00:00.000Z",
updatedAt: "2024-01-01T09:00:00.000Z",
} as any;
describe("GET /api/tenants/[id]/appointments/[appointmentId]", () => {
it("should return appointment for authenticated user", async () => {
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(200);
expect(result.appointment).toEqual(mockAppointment);
expect(data.appointment).toEqual(mockAppointment);
expect(mockAppointmentService.getAppointmentById).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 404 if appointment not found", async () => {
mockAppointmentService.getAppointmentById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
it("should allow staff to view appointments", async () => {
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({ locals: {} });
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session456",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to view appointments for any tenant", async () => {
const mockAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session789",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
expect(response.status).toBe(200);
});
it("should allow staff to view appointments for their tenant", async () => {
const mockAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session101",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
},
} as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointment).toEqual(mockAppointment);
});
it("should allow global admin to view any tenant's appointments", async () => {
mockAppointmentService.getAppointmentById.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointment).toEqual(mockAppointment);
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined, appointmentId: mockAppointmentId },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle missing appointment ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, appointmentId: undefined },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle appointment not found", async () => {
mockAppointmentService.getAppointmentById.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Appointment not found");
});
it("should handle service errors", async () => {
mockAppointmentService.getAppointmentById.mockRejectedValue(
new ValidationError("Invalid appointment ID"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Invalid appointment ID");
});
it("should handle internal server errors", async () => {
mockAppointmentService.getAppointmentById.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
describe("DELETE /api/tenants/{id}/appointments/{appointmentId}", () => {
it("should delete appointment successfully", async () => {
describe("DELETE /api/tenants/[id]/appointments/[appointmentId]", () => {
it("should delete appointment for tenant admin", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await DELETE(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Appointment deleted successfully");
expect(data.message).toBe("Appointment deleted successfully");
expect(mockAppointmentService.deleteAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 404 if appointment not found for deletion", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(false);
it("should allow global admin to delete any tenant's appointments", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment deleted successfully");
});
it("should return 403 for staff users trying to delete appointments", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
});
const event = createMockRequestEvent();
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
expect(mockAppointmentService.deleteAppointment).not.toHaveBeenCalled();
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
it("should return 401 for unauthenticated requests", async () => {
const event = createMockRequestEvent({ locals: { user: null } as any });
const response = await DELETE(event);
const result = await response.json();
@@ -205,84 +239,63 @@ describe("Appointment Detail API Routes", () => {
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions (staff cannot delete)", async () => {
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session102",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
params: { id: undefined, appointmentId: mockAppointmentId },
});
const response = await DELETE(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to delete appointments for any tenant", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session103",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await DELETE(event);
expect(response.status).toBe(200);
});
it("should allow tenant admin to delete appointments for their tenant", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(true);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session104",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
} as any,
},
});
const response = await DELETE(event);
expect(response.status).toBe(200);
});
it("should return 422 if tenant ID or appointment ID is missing", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId },
});
const response = await DELETE(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("Tenant ID and appointment ID are required");
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should return 404 for not found errors", async () => {
it("should handle missing appointment ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, appointmentId: undefined },
});
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle appointment not found", async () => {
mockAppointmentService.deleteAppointment.mockResolvedValue(false);
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Appointment not found");
});
it("should handle service errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(
new NotFoundError("Tenant not found"),
new NotFoundError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await DELETE(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Tenant not found");
expect(data.error).toBe("Appointment not found");
});
it("should handle internal server errors", async () => {
mockAppointmentService.deleteAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await DELETE(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});
@@ -10,7 +10,7 @@ import { checkPermission } from "$lib/server/utils/permissions";
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/cancel", "PUT", {
summary: "Cancel appointment",
description:
"Cancels an appointment by setting its status to REJECTED. Global admins, tenant admins, and staff can cancel appointments.",
"Cancels an appointment, changing its status to REJECTED. Accessible to staff and tenant admins.",
tags: ["Appointments"],
parameters: [
{
@@ -41,30 +41,47 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/cancel", "PUT",
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
tunnelId: { type: "string", format: "uuid", description: "Client tunnel ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "Data expiry date (nullable)",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
enum: ["REJECTED"],
description: "Appointment status (will be REJECTED after successful operation)",
},
encryptedPayload: {
type: "string",
description: "Encrypted appointment data (nullable)",
},
iv: {
type: "string",
description: "Initialization vector for encryption (nullable)",
},
authTag: {
type: "string",
description: "Authentication tag for encryption (nullable)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (nullable)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (nullable)",
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
required: ["id", "tunnelId", "channelId", "appointmentDate", "status"],
},
},
required: ["message", "appointment"],
@@ -72,6 +89,14 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/cancel", "PUT",
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
@@ -89,7 +114,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/cancel", "PUT",
},
},
"404": {
description: "Tenant or appointment not found",
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
@@ -20,11 +20,11 @@ vi.mock("$lib/logger", () => ({
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
import { ValidationError } from "$lib/server/utils/errors";
describe("Appointment Cancel API", () => {
describe("Appointment Cancel API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockAppointmentId = "456e7890-e12b-34d5-a678-901234567890";
const mockAppointmentService = {
cancelAppointment: vi.fn(),
};
@@ -40,41 +40,88 @@ describe("Appointment Cancel API", () => {
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
} as any,
...overrides,
} as RequestEvent;
}
describe("PUT /api/tenants/{id}/appointments/{appointmentId}/cancel", () => {
it("should cancel appointment successfully", async () => {
const mockCancelledAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "REJECTED",
};
const mockCancelledAppointment = {
id: mockAppointmentId,
tunnelId: "tunnel-123",
channelId: "channel-123",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: null,
status: "REJECTED",
encryptedData: null,
dataKey: null,
encryptedPayload: "encrypted-payload",
iv: "iv-data",
authTag: "auth-tag",
createdAt: "2024-01-01T09:00:00.000Z",
updatedAt: "2024-01-01T09:00:00.000Z",
} as any;
describe("PUT /api/tenants/[id]/appointments/[appointmentId]/cancel", () => {
it("should cancel appointment for tenant admin", async () => {
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Appointment cancelled successfully");
expect(result.appointment).toEqual(mockCancelledAppointment);
expect(data.message).toBe("Appointment cancelled successfully");
expect(data.appointment).toEqual(mockCancelledAppointment);
expect(mockAppointmentService.cancelAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({ locals: {} });
it("should allow staff to cancel appointments", async () => {
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment cancelled successfully");
expect(data.appointment).toEqual(mockCancelledAppointment);
});
it("should allow global admin to cancel any tenant's appointments", async () => {
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment cancelled successfully");
expect(data.appointment).toEqual(mockCancelledAppointment);
});
it("should return 401 for unauthenticated requests", async () => {
const event = createMockRequestEvent({ locals: { user: null } as any });
const response = await PUT(event);
const result = await response.json();
@@ -82,17 +129,17 @@ describe("Appointment Cancel API", () => {
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
it("should return 403 for users from different tenant", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session456",
role: "CLIENT",
role: "STAFF",
tenantId: "different-tenant",
} as any,
},
},
} as any,
});
const response = await PUT(event);
const result = await response.json();
@@ -100,86 +147,65 @@ describe("Appointment Cancel API", () => {
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to cancel appointments for any tenant", async () => {
const mockCancelledAppointment = {
id: mockAppointmentId,
status: "REJECTED",
};
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session789",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
params: { id: undefined, appointmentId: mockAppointmentId },
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should allow staff to cancel appointments for their tenant", async () => {
const mockCancelledAppointment = {
id: mockAppointmentId,
status: "REJECTED",
};
mockAppointmentService.cancelAppointment.mockResolvedValue(mockCancelledAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session101",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should return 422 if tenant ID or appointment ID is missing", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId },
});
const response = await PUT(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("Tenant ID and appointment ID are required");
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should return 404 for not found errors", async () => {
it("should handle missing appointment ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, appointmentId: undefined },
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle appointment not found", async () => {
mockAppointmentService.cancelAppointment.mockRejectedValue(
new NotFoundError("Appointment not found"),
new ValidationError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
expect(response.status).toBe(422);
expect(data.error).toBe("Appointment not found");
});
it("should return 500 for unexpected errors", async () => {
it("should handle service validation errors", async () => {
mockAppointmentService.cancelAppointment.mockRejectedValue(
new ValidationError("Appointment already cancelled"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Appointment already cancelled");
});
it("should handle internal server errors", async () => {
mockAppointmentService.cancelAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
const data = await response.json();
expect(response.status).toBe(500);
expect(result.error).toBe("Internal server error");
expect(data.error).toBe("Internal server error");
});
});
});
@@ -10,7 +10,7 @@ import { checkPermission } from "$lib/server/utils/permissions";
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/confirm", "PUT", {
summary: "Confirm appointment",
description:
"Confirms an appointment by setting its status to CONFIRMED. Global admins, tenant admins, and staff can confirm appointments.",
"Confirms a pending appointment, changing its status from NEW to CONFIRMED. Accessible to staff and tenant admins.",
tags: ["Appointments"],
parameters: [
{
@@ -41,30 +41,48 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/confirm", "PUT"
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Appointment ID" },
clientId: { type: "string", format: "uuid", description: "Client ID" },
tunnelId: { type: "string", format: "uuid", description: "Client tunnel ID" },
channelId: { type: "string", format: "uuid", description: "Channel ID" },
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date",
description: "Appointment date and time",
},
expiryDate: {
type: "string",
format: "date",
description: "Data expiry date (nullable)",
},
expiryDate: { type: "string", format: "date", description: "Expiry date" },
title: { type: "string", description: "Appointment title" },
description: { type: "string", description: "Appointment description" },
status: {
type: "string",
enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
enum: ["CONFIRMED"],
description:
"Appointment status (will be CONFIRMED after successful operation)",
},
encryptedPayload: {
type: "string",
description: "Encrypted appointment data (nullable)",
},
iv: {
type: "string",
description: "Initialization vector for encryption (nullable)",
},
authTag: {
type: "string",
description: "Authentication tag for encryption (nullable)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (nullable)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (nullable)",
},
},
required: [
"id",
"clientId",
"channelId",
"appointmentDate",
"expiryDate",
"title",
"status",
],
required: ["id", "tunnelId", "channelId", "appointmentDate", "status"],
},
},
required: ["message", "appointment"],
@@ -72,6 +90,14 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/confirm", "PUT"
},
},
},
"400": {
description: "Invalid input data or appointment not in NEW status",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
@@ -89,7 +115,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}/confirm", "PUT"
},
},
"404": {
description: "Tenant or appointment not found",
description: "Appointment not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
@@ -1,185 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PUT } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { NotFoundError } from "$lib/server/utils/errors";
describe("Appointment Confirm API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "123e4567-e89b-12d3-a456-426614174003";
const mockAppointmentService = {
confirmAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, appointmentId: mockAppointmentId },
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
...overrides,
} as RequestEvent;
}
describe("PUT /api/tenants/{id}/appointments/{appointmentId}/confirm", () => {
it("should confirm appointment successfully", async () => {
const mockConfirmedAppointment = {
id: mockAppointmentId,
clientId: "client123",
channelId: "channel123",
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "CONFIRMED",
};
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.message).toBe("Appointment confirmed successfully");
expect(result.appointment).toEqual(mockConfirmedAppointment);
expect(mockAppointmentService.confirmAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({ locals: {} });
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session456",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to confirm appointments for any tenant", async () => {
const mockConfirmedAppointment = {
id: mockAppointmentId,
status: "CONFIRMED",
};
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session789",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should allow staff to confirm appointments for their tenant", async () => {
const mockConfirmedAppointment = {
id: mockAppointmentId,
status: "CONFIRMED",
};
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session101",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await PUT(event);
expect(response.status).toBe(200);
});
it("should return 422 if tenant ID or appointment ID is missing", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId },
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("Tenant ID and appointment ID are required");
});
it("should return 404 for not found errors", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(
new NotFoundError("Appointment not found"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Appointment not found");
});
it("should return 500 for unexpected errors", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(500);
expect(result.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,211 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PUT } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { ValidationError } from "$lib/server/utils/errors";
describe("Appointment Confirm API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentId = "456e7890-e12b-34d5-a678-901234567890";
const mockAppointmentService = {
confirmAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, appointmentId: mockAppointmentId },
locals: {
user: {
userId: "user123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
} as any,
...overrides,
} as RequestEvent;
}
const mockConfirmedAppointment = {
id: mockAppointmentId,
tunnelId: "tunnel-123",
channelId: "channel-123",
appointmentDate: "2024-01-01T10:00:00.000Z",
expiryDate: null,
status: "CONFIRMED",
encryptedData: null,
dataKey: null,
encryptedPayload: "encrypted-payload",
iv: "iv-data",
authTag: "auth-tag",
createdAt: "2024-01-01T09:00:00.000Z",
updatedAt: "2024-01-01T09:00:00.000Z",
} as any;
describe("PUT /api/tenants/[id]/appointments/[appointmentId]/confirm", () => {
it("should confirm appointment for tenant admin", async () => {
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment confirmed successfully");
expect(data.appointment).toEqual(mockConfirmedAppointment);
expect(mockAppointmentService.confirmAppointment).toHaveBeenCalledWith(mockAppointmentId);
});
it("should allow staff to confirm appointments", async () => {
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment confirmed successfully");
expect(data.appointment).toEqual(mockConfirmedAppointment);
});
it("should allow global admin to confirm any tenant's appointments", async () => {
mockAppointmentService.confirmAppointment.mockResolvedValue(mockConfirmedAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.message).toBe("Appointment confirmed successfully");
expect(data.appointment).toEqual(mockConfirmedAppointment);
});
it("should return 401 for unauthenticated requests", async () => {
const event = createMockRequestEvent({ locals: { user: null } as any });
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 for users from different tenant", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
},
} as any,
});
const response = await PUT(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined, appointmentId: mockAppointmentId },
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle missing appointment ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, appointmentId: undefined },
});
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID and appointment ID are required");
});
it("should handle appointment not found or in wrong state", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(
new ValidationError("Appointment not found or in wrong state"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Appointment not found or in wrong state");
});
it("should handle service validation errors", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(
new ValidationError("Appointment already confirmed"),
);
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Appointment already confirmed");
});
it("should handle internal server errors", async () => {
mockAppointmentService.confirmAppointment.mockRejectedValue(new Error("Database error"));
const event = createMockRequestEvent();
const response = await PUT(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});
@@ -1,322 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET, POST } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import { ValidationError, NotFoundError } from "$lib/server/utils/errors";
describe("Appointment API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockClientId = "123e4567-e89b-12d3-a456-426614174001";
const mockChannelId = "123e4567-e89b-12d3-a456-426614174002";
const mockAppointmentService = {
queryAppointments: vi.fn(),
createAppointment: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId },
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
},
request: {
json: vi.fn().mockResolvedValue({
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
}),
} as any,
url: new URL("http://localhost?startDate=2024-12-01T00:00:00Z&endDate=2024-12-31T23:59:59Z"),
...overrides,
} as RequestEvent;
}
describe("POST /api/tenants/{id}/appointments", () => {
it("should create appointment successfully", async () => {
const mockAppointment = {
id: "appt123",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.createAppointment.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(201);
expect(result.message).toBe("Appointment created successfully");
expect(result.appointment).toEqual(mockAppointment);
expect(mockAppointmentService.createAppointment).toHaveBeenCalledWith({
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
});
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({ locals: {} });
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should allow global admin to create appointments for any tenant", async () => {
const mockAppointment = {
id: "appt123",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.createAppointment.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "admin123",
sessionId: "session456",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
} as any,
},
});
const response = await POST(event);
expect(response.status).toBe(201);
});
it("should allow staff to create appointments for their tenant", async () => {
const mockAppointment = {
id: "appt123",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Test Appointment",
status: "NEW",
};
mockAppointmentService.createAppointment.mockResolvedValue(mockAppointment);
const event = createMockRequestEvent({
locals: {
user: {
userId: "staff123",
sessionId: "session789",
role: "STAFF",
tenantId: mockTenantId,
} as any,
},
});
const response = await POST(event);
expect(response.status).toBe(201);
});
it("should return 422 for validation errors", async () => {
mockAppointmentService.createAppointment.mockRejectedValue(
new ValidationError("Invalid data"),
);
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("Invalid data");
});
it("should return 404 for not found errors", async () => {
mockAppointmentService.createAppointment.mockRejectedValue(
new NotFoundError("Client not found"),
);
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(404);
expect(result.error).toBe("Client not found");
});
it("should return 409 for conflict errors", async () => {
mockAppointmentService.createAppointment.mockRejectedValue(new Error("Time slot conflict"));
const event = createMockRequestEvent();
const response = await POST(event);
const result = await response.json();
expect(response.status).toBe(409);
expect(result.error).toBe("Time slot conflict");
});
});
describe("GET /api/tenants/{id}/appointments", () => {
it("should get appointments successfully", async () => {
const mockAppointments = [
{
id: "appt1",
clientId: mockClientId,
channelId: mockChannelId,
appointmentDate: "2024-12-01T10:00:00Z",
expiryDate: "2024-12-31",
title: "Appointment 1",
status: "NEW",
client: { id: mockClientId, email: "test@example.com" },
channel: { id: mockChannelId, names: ["Room 1"] },
},
];
mockAppointmentService.queryAppointments.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(200);
expect(result.appointments).toEqual(mockAppointments);
expect(mockAppointmentService.queryAppointments).toHaveBeenCalledWith({
startDate: "2024-12-01T00:00:00Z",
endDate: "2024-12-31T23:59:59Z",
});
});
it("should return 401 if user is not authenticated", async () => {
const event = createMockRequestEvent({
locals: {},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 if user has insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
sessionId: "session123",
role: "CLIENT",
tenantId: "different-tenant",
} as any,
},
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should return 422 if startDate or endDate is missing", async () => {
const event = createMockRequestEvent({
url: new URL("http://localhost?startDate=2024-12-01T00:00:00Z"),
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("startDate and endDate are required");
});
it("should include optional filters in query", async () => {
mockAppointmentService.queryAppointments.mockResolvedValue([]);
const event = createMockRequestEvent({
url: new URL(
"http://localhost?startDate=2024-12-01T00:00:00Z&endDate=2024-12-31T23:59:59Z&channelId=" +
mockChannelId +
"&status=CONFIRMED",
),
});
await GET(event);
expect(mockAppointmentService.queryAppointments).toHaveBeenCalledWith({
startDate: "2024-12-01T00:00:00Z",
endDate: "2024-12-31T23:59:59Z",
channelId: mockChannelId,
status: "CONFIRMED",
});
});
it("should return 422 for validation errors", async () => {
mockAppointmentService.queryAppointments.mockRejectedValue(
new ValidationError("Invalid query"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(422);
expect(result.error).toBe("Invalid query");
});
});
});
@@ -0,0 +1,324 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { AppointmentService } from "$lib/server/services/appointment-service";
import type { SelectAppointment } from "$lib/server/db/tenant-schema";
describe("Appointments API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockAppointmentService = {
getAppointmentsByTimeRange: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(AppointmentService.forTenant as any).mockResolvedValue(mockAppointmentService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00.000Z");
searchParams.set("endDate", "2024-01-07T23:59:59.999Z");
return {
params: { id: mockTenantId },
url: {
searchParams,
} as any,
locals: {
user: {
userId: "user123",
role: "TENANT_ADMIN",
tenantId: mockTenantId,
},
} as any,
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/[id]/appointments", () => {
it("should return appointments for authenticated tenant admin", async () => {
const mockAppointments = [
{
id: "appointment-1",
tunnelId: "tunnel-1",
channelId: "channel-1",
appointmentDate: "2024-01-01T10:00:00.000Z",
status: "CONFIRMED",
encryptedPayload: "encrypted-data",
iv: "iv-data",
authTag: "auth-tag",
createdAt: "2024-01-01T09:00:00.000Z",
updatedAt: "2024-01-01T09:00:00.000Z",
},
];
mockAppointmentService.getAppointmentsByTimeRange.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointments).toEqual(mockAppointments);
expect(data.meta).toEqual({
count: 1,
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-07T23:59:59.999Z",
});
expect(mockAppointmentService.getAppointmentsByTimeRange).toHaveBeenCalledWith(
new Date("2024-01-01T00:00:00.000Z"),
new Date("2024-01-07T23:59:59.999Z"),
);
});
it("should allow staff to view appointments", async () => {
const mockAppointments: SelectAppointment[] = [];
mockAppointmentService.getAppointmentsByTimeRange.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointments).toEqual(mockAppointments);
});
it("should allow global admin to view any tenant's appointments", async () => {
const mockAppointments: SelectAppointment[] = [];
mockAppointmentService.getAppointmentsByTimeRange.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "GLOBAL_ADMIN",
tenantId: "different-tenant",
},
} as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointments).toEqual(mockAppointments);
});
it("should return 401 for unauthenticated requests", async () => {
const event = createMockRequestEvent({ locals: { user: null } as any });
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(401);
expect(result.error).toBe("Authentication required");
});
it("should return 403 for insufficient permissions", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: "different-tenant",
},
} as any,
});
const response = await GET(event);
const result = await response.json();
expect(response.status).toBe(403);
expect(result.error).toBe("Insufficient permissions");
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID is required");
});
it("should handle missing startDate parameter", async () => {
const searchParams = new URLSearchParams();
searchParams.set("endDate", "2024-01-07T23:59:59.999Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Both startDate and endDate query parameters are required");
});
it("should handle missing endDate parameter", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00.000Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Both startDate and endDate query parameters are required");
});
it("should handle invalid date format", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "invalid-date");
searchParams.set("endDate", "2024-01-07T23:59:59.999Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe(
"Invalid date format. Use ISO 8601 format (YYYY-MM-DDTHH:mm:ss.sssZ)",
);
});
it("should handle startDate after endDate", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-07T00:00:00.000Z");
searchParams.set("endDate", "2024-01-01T23:59:59.999Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Start date must be before end date");
});
it("should handle date range exceeding 1 year", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00.000Z");
searchParams.set("endDate", "2025-01-02T23:59:59.999Z"); // More than 1 year
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Date range cannot exceed 1 year");
});
it("should handle service errors gracefully", async () => {
mockAppointmentService.getAppointmentsByTimeRange.mockRejectedValue(
new Error("Database connection failed"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
it("should handle maximum allowed date range (1 year)", async () => {
const startDate = "2024-01-01T00:00:00.000Z";
const endDate = "2024-12-31T23:59:59.999Z"; // Exactly 1 year
const searchParams = new URLSearchParams();
searchParams.set("startDate", startDate);
searchParams.set("endDate", endDate);
const mockAppointments: SelectAppointment[] = [];
mockAppointmentService.getAppointmentsByTimeRange.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
expect(response.status).toBe(200);
expect(mockAppointmentService.getAppointmentsByTimeRange).toHaveBeenCalledWith(
new Date(startDate),
new Date(endDate),
);
});
it("should return empty appointments list when no appointments found", async () => {
const mockAppointments: SelectAppointment[] = [];
mockAppointmentService.getAppointmentsByTimeRange.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.appointments).toEqual([]);
expect(data.meta.count).toBe(0);
});
it("should handle different timezone formats", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00+01:00");
searchParams.set("endDate", "2024-01-07T23:59:59+01:00");
const mockAppointments: SelectAppointment[] = [];
mockAppointmentService.getAppointmentsByTimeRange.mockResolvedValue(mockAppointments);
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
expect(response.status).toBe(200);
expect(mockAppointmentService.getAppointmentsByTimeRange).toHaveBeenCalledWith(
new Date("2024-01-01T00:00:00+01:00"),
new Date("2024-01-07T23:59:59+01:00"),
);
});
});
});
@@ -0,0 +1,273 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { getTenantDb } from "$lib/server/db";
import { clientAppointmentTunnel, appointment, channel } from "$lib/server/db/tenant-schema.js";
import type { AppointmentResponse } from "$lib/types/appointment";
import { and, eq } from "drizzle-orm";
import {
ValidationError,
InternalError,
BackendError,
NotFoundError,
logError,
} from "$lib/server/utils/errors";
import { registerOpenAPIRoute } from "$lib/server/openapi";
const requestSchema = z.object({
emailHash: z.string(),
tunnelId: z.string(),
channelId: z.string(),
appointmentDate: z.string(),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
iv: z.string(),
authTag: z.string(),
}),
});
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments/add-to-tunnel", "POST", {
summary: "Add appointment to existing tunnel",
description:
"Adds a new appointment to an existing client tunnel. For clients who already have appointments and want to book another one.",
tags: ["Appointments", "Clients"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Appointment data for existing tunnel",
content: {
"application/json": {
schema: {
type: "object",
properties: {
emailHash: {
type: "string",
description: "SHA-256 hash of client email for verification",
example: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
},
tunnelId: {
type: "string",
format: "uuid",
description: "Existing client tunnel identifier",
example: "550e8400-e29b-41d4-a716-446655440000",
},
channelId: {
type: "string",
format: "uuid",
description: "Channel ID for the appointment",
example: "550e8400-e29b-41d4-a716-446655440001",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time (ISO 8601)",
example: "2024-12-31T14:30:00.000Z",
},
encryptedAppointment: {
type: "object",
properties: {
encryptedPayload: {
type: "string",
description: "AES-encrypted appointment data",
example: "deadbeef123456789abcdef...",
},
iv: {
type: "string",
description: "Initialization vector for AES encryption",
example: "123456789abcdef...",
},
authTag: {
type: "string",
description: "Authentication tag for AES-GCM",
example: "fedcba9876543210...",
},
},
required: ["encryptedPayload", "iv", "authTag"],
description: "Encrypted appointment data",
},
},
required: [
"emailHash",
"tunnelId",
"channelId",
"appointmentDate",
"encryptedAppointment",
],
},
},
},
},
responses: {
"200": {
description: "Appointment added to tunnel successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: {
type: "string",
format: "uuid",
description: "Created appointment ID",
example: "550e8400-e29b-41d4-a716-446655440002",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time",
example: "2024-12-31T14:30:00.000Z",
},
status: {
type: "string",
enum: ["NEW", "CONFIRMED"],
description: "Initial appointment status (depends on channel configuration)",
example: "NEW",
},
},
required: ["id", "appointmentDate", "status"],
},
},
},
},
"400": {
description: "Invalid request data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tunnel or channel not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* POST /api/tenants/[id]/appointments/add-to-tunnel
*
* Adds a new appointment to an existing client tunnel.
* For clients who already have appointments and want to book another one.
*/
export const POST: RequestHandler = async ({ request, params }) => {
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
const body = await request.json();
const validatedData = requestSchema.parse(body);
logger.info("Adding appointment to existing tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
appointmentDate: validatedData.appointmentDate,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
const db = await getTenantDb(tenantId);
// Check if tunnel exists and belongs to client
const tunnelResult = await db
.select({
id: clientAppointmentTunnel.id,
})
.from(clientAppointmentTunnel)
.where(eq(clientAppointmentTunnel.emailHash, validatedData.emailHash))
.limit(1);
if (tunnelResult.length === 0) {
logger.warn("Client tunnel not found", {
tenantId,
tunnelId: validatedData.tunnelId,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
throw new NotFoundError("Tunnel not found or access denied");
}
// Get channel configuration to determine initial status
const channelResult = await db
.select({ requiresConfirmation: channel.requiresConfirmation })
.from(channel)
.where(and(eq(channel.id, validatedData.channelId), eq(channel.isPublic, true)))
.limit(1);
if (channelResult.length === 0) {
throw new NotFoundError("Active channel not found");
}
const initialStatus = channelResult[0].requiresConfirmation ? "NEW" : "CONFIRMED";
// Create encrypted appointment
const appointmentResult = await db
.insert(appointment)
.values({
tunnelId: validatedData.tunnelId,
channelId: validatedData.channelId,
appointmentDate: new Date(validatedData.appointmentDate),
encryptedPayload: validatedData.encryptedAppointment.encryptedPayload,
iv: validatedData.encryptedAppointment.iv,
authTag: validatedData.encryptedAppointment.authTag,
status: initialStatus,
})
.returning({
id: appointment.id,
appointmentDate: appointment.appointmentDate,
status: appointment.status,
});
if (appointmentResult.length === 0) {
throw new InternalError("Failed to create appointment");
}
const result = appointmentResult[0];
const response: AppointmentResponse = {
id: result.id,
appointmentDate: result.appointmentDate.toISOString(),
status: result.status,
};
logger.info("Successfully added appointment to tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
appointmentId: result.id,
});
return json(response);
} catch (error) {
logError(logger)("Failed to add appointment to tunnel", error);
if (error instanceof z.ZodError) {
return json({ error: "Invalid request data", details: error.errors }, { status: 400 });
}
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,201 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { getTenantDb } from "$lib/server/db";
import { clientAppointmentTunnel } from "$lib/server/db/tenant-schema";
import type { ChallengeResponse } from "$lib/types/appointment";
import { eq } from "drizzle-orm";
import { randomBytes } from "crypto";
import { KyberCrypto, BufferUtils } from "$lib/crypto/utils";
import { challengeStore } from "$lib/server/services/challenge-store";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { registerOpenAPIRoute } from "$lib/server/openapi";
const requestSchema = z.object({
emailHash: z.string(),
});
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments/challenge", "POST", {
summary: "Create authentication challenge",
description:
"Creates a cryptographic challenge for client authentication. Used to verify client identity before accessing appointments.",
tags: ["Appointments", "Authentication"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Challenge request data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
emailHash: {
type: "string",
description: "SHA-256 hash of client email address",
example: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
},
},
required: ["emailHash"],
},
},
},
},
responses: {
"200": {
description: "Challenge created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
challengeId: {
type: "string",
description: "Unique identifier for this challenge",
example: "a1b2c3d4e5f6789012345678",
},
encryptedChallenge: {
type: "string",
description: "Challenge encrypted with client's public key (hex encoded)",
example: "deadbeef123456789abcdef...",
},
privateKeyShare: {
type: "string",
description: "Server-stored share of client's private key",
example: "fedcba9876543210fedcba98...",
},
},
required: ["challengeId", "encryptedChallenge", "privateKeyShare"],
},
},
},
},
"400": {
description: "Invalid request data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Client not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* POST /api/tenants/[id]/appointments/challenge
*
* Creates a challenge for an existing client to verify their identity.
* Returns encrypted challenge and private key share for client authentication.
*/
export const POST: RequestHandler = async ({ request, params }) => {
try {
const tenantId = params.id;
if (!tenantId) {
return json({ error: "Tenant ID is required" }, { status: 400 });
}
const body = await request.json();
const { emailHash } = requestSchema.parse(body);
logger.info("Creating challenge for existing client", {
tenantId,
emailHashPrefix: emailHash.slice(0, 8),
});
const db = await getTenantDb(tenantId);
// Retrieve client tunnel
const tunnelResult = await db
.select({
id: clientAppointmentTunnel.id,
clientPublicKey: clientAppointmentTunnel.clientPublicKey,
privateKeyShare: clientAppointmentTunnel.privateKeyShare,
})
.from(clientAppointmentTunnel)
.where(eq(clientAppointmentTunnel.emailHash, emailHash))
.limit(1);
if (tunnelResult.length === 0) {
logger.warn("Client tunnel not found for challenge", {
tenantId,
emailHashPrefix: emailHash.slice(0, 8),
});
return json({ error: "Client not found" }, { status: 404 });
}
const tunnel = tunnelResult[0];
// Generate random challenge and unique challenge ID
const challenge = randomBytes(32).toString("base64");
const challengeId = randomBytes(16).toString("hex");
// Store challenge in database for verification
await challengeStore.store(challengeId, challenge, emailHash, tenantId);
// Encrypt challenge with Client's ML-KEM-768 Public Key
const clientPublicKeyBuffer = BufferUtils.from(tunnel.clientPublicKey, "hex");
const encapsulation = KyberCrypto.encapsulate(clientPublicKeyBuffer);
// Use the shared secret to encrypt the challenge
const challengeBuffer = BufferUtils.from(challenge);
const encryptedChallengeBuffer = BufferUtils.xor(
challengeBuffer,
encapsulation.sharedSecret.slice(0, challengeBuffer.length),
);
// Combine encapsulated secret + encrypted challenge
const fullEncryptedChallenge = BufferUtils.concat([
encapsulation.encapsulatedSecret,
encryptedChallengeBuffer,
]);
const encryptedChallenge = BufferUtils.toString(fullEncryptedChallenge, "hex");
const response: ChallengeResponse = {
challengeId, // ID to reference this challenge during verification
encryptedChallenge,
privateKeyShare: tunnel.privateKeyShare,
};
logger.info("Successfully created challenge", {
tenantId,
tunnelId: tunnel.id,
challengeLength: challenge.length,
});
return json(response);
} catch (error) {
logError(logger)("Failed to create challenge", error);
if (error instanceof z.ZodError) {
return new ValidationError("Invalid request data").toJson();
}
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,258 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/logger", () => {
const mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
return {
default: mockLogger,
logger: mockLogger,
};
});
vi.mock("$lib/server/db", () => ({
getTenantDb: vi.fn(),
}));
vi.mock("$lib/crypto/utils", () => ({
BufferUtils: {
from: vi.fn(),
toString: vi.fn(),
concat: vi.fn(),
xor: vi.fn(),
},
KyberCrypto: {
encapsulate: vi.fn(),
},
}));
vi.mock("$lib/server/services/challenge-store", () => ({
challengeStore: {
store: vi.fn(),
},
}));
vi.mock("crypto", () => ({
randomBytes: vi.fn(),
}));
import { getTenantDb } from "$lib/server/db";
import { randomBytes } from "crypto";
import { KyberCrypto, BufferUtils } from "$lib/crypto/utils";
import { challengeStore } from "$lib/server/services/challenge-store";
describe("Challenge API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockDb = {
select: vi.fn().mockReturnThis(),
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(getTenantDb as any).mockResolvedValue(mockDb);
(randomBytes as any).mockImplementation((size: number) => {
if (size === 32) return Buffer.from("challenge-data-32-bytes-long-string");
if (size === 16) return Buffer.from("challenge-id-16b");
return Buffer.alloc(size);
});
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId },
request: {
json: vi.fn().mockResolvedValue({
emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
}),
} as any,
locals: { user: null } as any, // Public endpoint, no authentication required
...overrides,
} as RequestEvent;
}
describe("POST /api/tenants/[id]/appointments/challenge", () => {
it("should create challenge for existing client", async () => {
const mockTunnel = {
id: "tunnel-123",
clientPublicKey: "deadbeef123456789abcdef",
privateKeyShare: "fedcba9876543210fedcba98",
};
mockDb.limit.mockResolvedValue([mockTunnel]);
const mockEncapsulation = {
encapsulatedSecret: Buffer.from("encapsulated-secret"),
sharedSecret: Buffer.from("shared-secret-32-bytes-long-string"),
};
(KyberCrypto.encapsulate as any).mockReturnValue(mockEncapsulation);
(BufferUtils.from as any).mockImplementation((data: any, encoding?: string) => {
if (encoding === "hex") return Buffer.from(data, "hex");
return Buffer.from(data);
});
(BufferUtils.xor as any).mockReturnValue(Buffer.from("encrypted-challenge"));
(BufferUtils.concat as any).mockReturnValue(Buffer.from("full-encrypted-challenge"));
(BufferUtils.toString as any).mockReturnValue("hex-encoded-challenge");
(challengeStore.store as any).mockResolvedValue(true);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveProperty("challengeId");
expect(data).toHaveProperty("encryptedChallenge");
expect(data).toHaveProperty("privateKeyShare");
expect(data.privateKeyShare).toBe(mockTunnel.privateKeyShare);
expect(challengeStore.store).toHaveBeenCalledWith(
expect.any(String),
expect.any(String),
"a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3",
mockTenantId,
);
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined },
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Tenant ID is required");
});
it("should handle invalid request body", async () => {
const event = createMockRequestEvent({
request: {
json: vi.fn().mockResolvedValue({
// Missing emailHash
}),
} as any,
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Invalid request data");
});
it("should handle client not found", async () => {
mockDb.limit.mockResolvedValue([]); // No tunnel found
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Client not found");
});
it("should handle database errors", async () => {
mockDb.limit.mockRejectedValue(new Error("Database connection failed"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
it("should handle invalid email hash format", async () => {
const event = createMockRequestEvent({
request: {
json: vi.fn().mockResolvedValue({
// Missing emailHash field to trigger Zod validation error
}),
} as any,
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Invalid request data");
});
it("should work without authentication (public endpoint)", async () => {
const mockTunnel = {
id: "tunnel-123",
clientPublicKey: "deadbeef123456789abcdef",
privateKeyShare: "fedcba9876543210fedcba98",
};
mockDb.limit.mockResolvedValue([mockTunnel]);
const mockEncapsulation = {
encapsulatedSecret: Buffer.from("encapsulated-secret"),
sharedSecret: Buffer.from("shared-secret-32-bytes-long-string"),
};
(KyberCrypto.encapsulate as any).mockReturnValue(mockEncapsulation);
(BufferUtils.from as any).mockImplementation((data: any, encoding?: string) => {
if (encoding === "hex") return Buffer.from(data, "hex");
return Buffer.from(data);
});
(BufferUtils.xor as any).mockReturnValue(Buffer.from("encrypted-challenge"));
(BufferUtils.concat as any).mockReturnValue(Buffer.from("full-encrypted-challenge"));
(BufferUtils.toString as any).mockReturnValue("hex-encoded-challenge");
(challengeStore.store as any).mockResolvedValue(true);
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await POST(event);
expect(response.status).toBe(200);
// Should work without authentication
});
it("should handle challenge store failures", async () => {
const mockTunnel = {
id: "tunnel-123",
clientPublicKey: "deadbeef123456789abcdef",
privateKeyShare: "fedcba9876543210fedcba98",
};
mockDb.limit.mockResolvedValue([mockTunnel]);
const mockEncapsulation = {
encapsulatedSecret: Buffer.from("encapsulated-secret"),
sharedSecret: Buffer.from("shared-secret-32-bytes-long-string"),
};
(KyberCrypto.encapsulate as any).mockReturnValue(mockEncapsulation);
(BufferUtils.from as any).mockImplementation((data: any, encoding?: string) => {
if (encoding === "hex") return Buffer.from(data, "hex");
return Buffer.from(data);
});
(BufferUtils.xor as any).mockReturnValue(Buffer.from("encrypted-challenge"));
(BufferUtils.concat as any).mockReturnValue(Buffer.from("full-encrypted-challenge"));
(BufferUtils.toString as any).mockReturnValue("hex-encoded-challenge");
(challengeStore.store as any).mockRejectedValue(new Error("Redis connection failed"));
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
});
@@ -0,0 +1,237 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { registerOpenAPIRoute } from "$lib/server/openapi";
const requestSchema = z.object({
tunnelId: z.string(),
channelId: z.string(),
appointmentDate: z.string(),
emailHash: z.string(),
clientPublicKey: z.string(),
privateKeyShare: z.string(),
encryptedAppointment: z.object({
encryptedPayload: z.string(),
iv: z.string(),
authTag: z.string(),
}),
staffKeyShares: z.array(
z.object({
userId: z.string(),
encryptedTunnelKey: z.string(),
}),
),
clientEncryptedTunnelKey: z.string(),
});
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", {
summary: "Create new client with appointment",
description:
"Creates a new client appointment tunnel with encrypted appointment data. This handles the complete setup for new clients including tunnel creation and their first appointment.",
tags: ["Appointments", "Clients"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "New client appointment data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
tunnelId: {
type: "string",
format: "uuid",
description: "Client tunnel identifier",
},
channelId: {
type: "string",
format: "uuid",
description: "Channel ID for the appointment",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time (ISO 8601)",
},
emailHash: {
type: "string",
description: "SHA-256 hash of client email",
},
clientPublicKey: {
type: "string",
description: "Client's ML-KEM-768 public key (hex encoded)",
},
privateKeyShare: {
type: "string",
description: "Server-stored share of client's private key",
},
encryptedAppointment: {
type: "object",
properties: {
encryptedPayload: {
type: "string",
description: "AES-encrypted appointment data",
},
iv: {
type: "string",
description: "Initialization vector for encryption",
},
authTag: {
type: "string",
description: "Authentication tag for AES-GCM",
},
},
required: ["encryptedPayload", "iv", "authTag"],
},
staffKeyShares: {
type: "array",
items: {
type: "object",
properties: {
userId: {
type: "string",
format: "uuid",
description: "Staff member's user ID",
},
encryptedTunnelKey: {
type: "string",
description: "Tunnel key encrypted with staff member's public key",
},
},
required: ["userId", "encryptedTunnelKey"],
},
description: "Tunnel key shares for staff members",
},
clientEncryptedTunnelKey: {
type: "string",
description: "Tunnel key encrypted with client's public key",
},
},
required: [
"tunnelId",
"channelId",
"appointmentDate",
"emailHash",
"clientPublicKey",
"privateKeyShare",
"encryptedAppointment",
"staffKeyShares",
"clientEncryptedTunnelKey",
],
},
},
},
},
responses: {
"200": {
description: "Client and appointment created successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: {
type: "string",
format: "uuid",
description: "Created appointment ID",
},
appointmentDate: {
type: "string",
format: "date-time",
description: "Appointment date and time",
},
status: {
type: "string",
enum: ["NEW", "CONFIRMED"],
description: "Initial appointment status (depends on channel configuration)",
},
},
required: ["id", "appointmentDate", "status"],
},
},
},
},
"400": {
description: "Invalid request data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Channel not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"409": {
description: "No authorized users in tenant - cannot create client appointments",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* POST /api/tenants/[id]/appointments/create-new-client
*
* Creates a new client tunnel with their first appointment.
* This handles the complete setup for new clients including tunnel creation.
*/
export const POST: RequestHandler = async ({ request, params }) => {
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
const body = await request.json();
const validatedData = requestSchema.parse(body);
logger.info("Creating new client appointment tunnel", {
tenantId,
tunnelId: validatedData.tunnelId,
appointmentDate: validatedData.appointmentDate,
emailHashPrefix: validatedData.emailHash.slice(0, 8),
});
const appointmentService = await AppointmentService.forTenant(tenantId);
const response = await appointmentService.createNewClientWithAppointment(validatedData);
return json(response);
} catch (error) {
logError(logger)("Failed to create new client appointment", error);
if (error instanceof z.ZodError) {
return new ValidationError("Invalid request data").toJson();
}
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,269 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { POST } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/appointment-service", () => ({
AppointmentService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe("Create New Client API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockTunnelId = "tunnel-123";
const mockChannelId = "channel-456";
const validRequestBody = {
tunnelId: mockTunnelId,
channelId: mockChannelId,
appointmentDate: "2024-12-25T14:30:00.000Z",
emailHash: "test-email-hash",
clientPublicKey: "test-public-key",
privateKeyShare: "test-private-key-share",
encryptedAppointment: {
encryptedPayload: "encrypted-data",
iv: "iv-data",
authTag: "auth-tag-data",
},
staffKeyShares: [
{
userId: "staff-123",
encryptedTunnelKey: "encrypted-tunnel-key",
},
],
clientEncryptedTunnelKey: "client-encrypted-tunnel-key",
};
beforeEach(() => {
vi.clearAllMocks();
});
function createMockRequestEvent(
body: any = validRequestBody,
overrides: Partial<RequestEvent> = {},
): RequestEvent {
return {
params: { id: mockTenantId },
request: {
json: vi.fn().mockResolvedValue(body),
} as any,
locals: {
user: {
userId: "user123",
role: "STAFF",
tenantId: mockTenantId,
},
} as any,
...overrides,
} as RequestEvent;
}
describe("Success Cases", () => {
it("should return 200 when service successfully creates appointment", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const { logger } = await import("$lib/logger");
// Mock successful service response
const mockAppointment = {
id: "appointment-123",
appointmentDate: "2024-12-25T14:30:00.000Z",
status: "NEW",
};
const mockService = {
createNewClientWithAppointment: vi.fn().mockResolvedValue(mockAppointment),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(mockAppointment);
expect(mockService.createNewClientWithAppointment).toHaveBeenCalledWith(validRequestBody);
expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
emailHashPrefix: "test-ema",
});
});
it("should return 200 with CONFIRMED status when service returns it", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockAppointment = {
id: "appointment-123",
appointmentDate: "2024-12-25T14:30:00.000Z",
status: "CONFIRMED",
};
const mockService = {
createNewClientWithAppointment: vi.fn().mockResolvedValue(mockAppointment),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.status).toBe("CONFIRMED");
});
});
describe("Validation Errors", () => {
it("should return 422 for invalid request data", async () => {
const invalidBody = {
tunnelId: mockTunnelId,
// Missing required fields
};
const event = createMockRequestEvent(invalidBody);
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Invalid request data");
});
it("should return 422 for missing tenant ID", async () => {
const event = createMockRequestEvent(validRequestBody, {
params: {}, // No id parameter
});
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID is required");
});
it("should return 500 for invalid JSON in request", async () => {
const event = {
params: { id: mockTenantId },
request: {
json: vi.fn().mockRejectedValue(new Error("Invalid JSON")),
},
} as any;
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
describe("Service Errors", () => {
it("should return 500 when service throws ConflictError for no authorized users", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const { logger } = await import("$lib/logger");
// Mock service to throw error (should be caught and return 500)
const mockService = {
createNewClientWithAppointment: vi
.fn()
.mockRejectedValue(
new Error("Cannot create client appointments: No authorized users found in tenant"),
),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
emailHashPrefix: "test-ema",
});
});
it("should return 500 for service initialization errors", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
// Mock service initialization failure
vi.mocked(AppointmentService.forTenant).mockRejectedValue(
new Error("Database connection failed"),
);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
it("should return 500 when service throws NotFoundError", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockService = {
createNewClientWithAppointment: vi.fn().mockRejectedValue(new Error("Channel not found")),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequestEvent();
const response = await POST(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
});
describe("Logging", () => {
it("should log appointment creation attempts", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const { logger } = await import("$lib/logger");
const mockService = {
createNewClientWithAppointment: vi.fn().mockResolvedValue({
id: "appointment-123",
appointmentDate: "2024-12-25T14:30:00.000Z",
status: "NEW",
}),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequestEvent();
await POST(event);
expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
appointmentDate: validRequestBody.appointmentDate,
emailHashPrefix: "test-ema", // First 8 chars of "test-email-hash"
});
});
it("should log errors when service fails", async () => {
const { AppointmentService } = await import("$lib/server/services/appointment-service");
const mockService = {
createNewClientWithAppointment: vi.fn().mockRejectedValue(new Error("Service error")),
};
vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any);
const event = createMockRequestEvent();
await POST(event);
// Logger error calls are handled by logError function, so we just verify the endpoint doesn't crash
expect(true).toBe(true);
});
});
});
@@ -0,0 +1,112 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import { logger } from "$lib/logger";
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", {
summary: "Get staff public keys",
description:
"Returns public encryption keys for all staff members. Used by clients to encrypt appointment data for staff access. This is a public endpoint that doesn't require authentication.",
tags: ["Appointments", "Encryption"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
responses: {
"200": {
description: "Staff public keys retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
staffPublicKeys: {
type: "array",
items: {
type: "object",
properties: {
userId: {
type: "string",
format: "uuid",
description: "Staff member's user ID",
},
publicKey: {
type: "string",
description: "ML-KEM-768 public key (hex encoded)",
example: "deadbeef123456789abcdef...",
},
},
required: ["userId", "publicKey"],
},
description: "List of staff public keys for encryption",
},
},
required: ["staffPublicKeys"],
},
},
},
},
"400": {
description: "Invalid tenant ID",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error or no staff keys found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* GET /api/tenants/[id]/appointments/staff-public-keys
*
* Returns the public keys of all staff members for encryption.
* This is a public endpoint used by clients to encrypt appointment data.
*/
export const GET: RequestHandler = async ({ params }) => {
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
logger.info("Fetching staff public keys", { tenantId });
// Get staff public keys for encryption
const staffCryptoService = new StaffCryptoService();
const staffPublicKeys = await staffCryptoService.getStaffPublicKeys(tenantId);
if (staffPublicKeys.length === 0) {
logger.warn("No staff public keys found for tenant", { tenantId });
throw new InternalError("No staff members with encryption keys found");
}
logger.info("Successfully retrieved staff public keys", {
tenantId,
staffCount: staffPublicKeys.length,
});
return json({ staffPublicKeys });
} catch (error) {
logError(logger)("Failed to fetch staff public keys", error);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,139 @@
/**
* API Route: Get Client Tunnels
*
* Returns all client appointment tunnels for a tenant.
* Used by staff to see all client tunnels that exist in the system.
* Requires the requesting user to be authenticated and belong to the tenant.
*/
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { AppointmentService } from "$lib/server/services/appointment-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/appointments/tunnels", "GET", {
summary: "Get client tunnels",
description:
"Returns all active client appointment tunnels for a tenant. Used by staff to see all client tunnels that exist in the system. Requires authentication and tenant membership.",
tags: ["Appointments", "Tunnels"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
responses: {
"200": {
description: "Client tunnels retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
tunnels: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Tunnel ID" },
emailHash: { type: "string", description: "SHA-256 hash of client email" },
clientPublicKey: {
type: "string",
description: "Client's ML-KEM-768 public key",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp",
},
isActive: { type: "boolean", description: "Whether tunnel is active" },
},
required: ["id", "emailHash", "clientPublicKey", "isActive"],
},
},
},
required: ["tunnels"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API.ClientTunnels");
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
checkPermission(locals, tenantId, false);
try {
log.debug("Fetching client tunnels", { tenantId, requesterId: locals.user?.userId });
const appointmentService = await AppointmentService.forTenant(tenantId);
const tunnels = await appointmentService.getClientTunnels();
log.debug("Client tunnels retrieved successfully", {
tenantId,
requesterId: locals.user?.userId,
tunnelCount: tunnels.length,
});
return json({ tunnels });
} catch (error) {
logError(log)("Error fetching client tunnels", error, locals.user?.userId, tenantId);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,58 @@
import { describe, it, expect } from "vitest";
describe("Client Tunnels API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
describe("GET /api/tenants/[id]/appointments/tunnels", () => {
it("should validate tenant ID parameter", () => {
expect(mockTenantId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
it("should handle tunnel response structure", () => {
const mockTunnelResponse = {
tunnels: [
{
id: "tunnel-uuid-1",
emailHash: "sha256-hash-of-client-email",
clientPublicKey: "base64-encoded-ml-kem-768-key",
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
},
],
};
expect(Array.isArray(mockTunnelResponse.tunnels)).toBe(true);
expect(mockTunnelResponse.tunnels[0]).toHaveProperty("id");
expect(mockTunnelResponse.tunnels[0]).toHaveProperty("emailHash");
expect(mockTunnelResponse.tunnels[0]).toHaveProperty("clientPublicKey");
});
it("should validate email hash format", () => {
// SHA-256 hash should be 64 characters of hexadecimal
const mockEmailHash = "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3";
expect(mockEmailHash).toMatch(/^[a-f0-9]{64}$/i);
expect(mockEmailHash.length).toBe(64);
});
it("should handle client public key validation", () => {
const mockClientPublicKey =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==";
// Base64 validation
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
expect(mockClientPublicKey).toMatch(base64Regex);
expect(typeof mockClientPublicKey).toBe("string");
expect(mockClientPublicKey.length).toBeGreaterThan(0);
});
it("should handle empty tunnels array", () => {
const emptyResponse = { tunnels: [] };
expect(Array.isArray(emptyResponse.tunnels)).toBe(true);
expect(emptyResponse.tunnels.length).toBe(0);
});
});
});
@@ -0,0 +1,297 @@
/**
* API Route: Add Staff Key Shares to All Tunnels
*
* Adds clientTunnelStaffKeyShare entries for a new staff member to all existing tunnels.
* This is used when a new staff member joins and needs access to decrypt all existing client tunnels.
* The frontend must encrypt the tunnel keys with the new staff member's public key.
*/
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { getTenantDb, centralDb } from "$lib/server/db";
import { clientAppointmentTunnel, clientTunnelStaffKeyShare } from "$lib/server/db/tenant-schema";
import { user } from "$lib/server/db/central-schema";
import { eq } from "drizzle-orm";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments/tunnels/add-staff-key-shares", "POST", {
summary: "Add staff key shares to all tunnels",
description:
"Adds clientTunnelStaffKeyShare entries for a new staff member to a set of existing tunnels. This is used when a new staff member joins and needs access to decrypt all existing client tunnels.",
tags: ["Appointments", "Tunnels", "Staff"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Staff key shares data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
staffUserId: {
type: "string",
format: "uuid",
description: "User ID of the staff member to add key shares for",
},
keyShares: {
type: "array",
items: {
type: "object",
properties: {
tunnelId: {
type: "string",
format: "uuid",
description: "Tunnel ID to add key share for",
},
encryptedTunnelKey: {
type: "string",
description: "Tunnel key encrypted with staff member's public key",
},
},
required: ["tunnelId", "encryptedTunnelKey"],
},
description: "Array of tunnel key shares to add (minimum 1 item)",
},
},
required: ["staffUserId", "keyShares"],
},
},
},
},
responses: {
"200": {
description: "Staff key shares added successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", description: "Operation success status" },
message: { type: "string", description: "Success message" },
added: { type: "number", description: "Number of key shares added" },
skipped: {
type: "number",
description: "Number of key shares skipped (already existed)",
},
keyShares: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Key share ID" },
tunnelId: { type: "string", format: "uuid", description: "Tunnel ID" },
},
required: ["id", "tunnelId"],
},
description: "Added key shares (optional)",
},
},
required: ["success", "message", "added", "skipped"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Administrative permissions required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
const requestSchema = z.object({
staffUserId: z.string().uuid("Invalid staff user ID format"),
keyShares: z
.array(
z.object({
tunnelId: z.string().uuid("Invalid tunnel ID format"),
encryptedTunnelKey: z.string().min(1, "Encrypted tunnel key cannot be empty"),
}),
)
.min(1, "At least one key share is required"),
});
export const POST: RequestHandler = async ({ params, locals, request }) => {
const log = logger.setContext("API.AddStaffKeyShares");
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
checkPermission(locals, tenantId, false); // Administrative rights required
try {
const requestData = await request.json();
const validation = requestSchema.safeParse(requestData);
if (!validation.success) {
throw new ValidationError(
"Invalid request data: " + validation.error.errors.map((e) => e.message).join(", "),
);
}
const { staffUserId, keyShares } = validation.data;
log.debug("Adding staff key shares to tunnels", {
tenantId,
staffUserId,
keyShareCount: keyShares.length,
requesterId: locals.user?.userId,
});
const staffUser = await centralDb
.select({
id: user.id,
tenantId: user.tenantId,
isActive: user.isActive,
role: user.role,
})
.from(user)
.where(eq(user.id, staffUserId))
.limit(1);
if (staffUser.length === 0) {
throw new ValidationError("Staff user not found");
}
if (staffUser[0].tenantId !== tenantId) {
throw new ValidationError("Staff user does not belong to this tenant");
}
if (!staffUser[0].isActive) {
throw new ValidationError("Staff user is inactive");
}
const db = await getTenantDb(tenantId);
const existingTunnels = await db
.select({ id: clientAppointmentTunnel.id })
.from(clientAppointmentTunnel);
const existingTunnelIds = new Set(existingTunnels.map((t) => t.id));
const requestedTunnelIds = new Set(keyShares.map((ks) => ks.tunnelId));
const invalidTunnelIds = [...requestedTunnelIds].filter((id) => !existingTunnelIds.has(id));
if (invalidTunnelIds.length > 0) {
throw new ValidationError(`Invalid or inactive tunnel IDs: ${invalidTunnelIds.join(", ")}`);
}
const existingKeyShares = await db
.select({ tunnelId: clientTunnelStaffKeyShare.tunnelId })
.from(clientTunnelStaffKeyShare)
.where(eq(clientTunnelStaffKeyShare.userId, staffUserId));
const existingKeyShareTunnelIds = new Set(existingKeyShares.map((ks) => ks.tunnelId));
const duplicateKeyShares = keyShares.filter((ks) => existingKeyShareTunnelIds.has(ks.tunnelId));
if (duplicateKeyShares.length > 0) {
log.warn("Some key shares already exist", {
tenantId,
staffUserId,
duplicateCount: duplicateKeyShares.length,
});
}
const newKeyShares = keyShares.filter((ks) => !existingKeyShareTunnelIds.has(ks.tunnelId));
if (newKeyShares.length === 0) {
log.info("No new key shares to add - all already exist", {
tenantId,
staffUserId,
totalRequested: keyShares.length,
});
return json({
success: true,
message: "All key shares already exist",
added: 0,
skipped: keyShares.length,
});
}
const result = await db.transaction(async (tx) => {
const insertedKeyShares = await tx
.insert(clientTunnelStaffKeyShare)
.values(
newKeyShares.map((keyShare) => ({
tunnelId: keyShare.tunnelId,
userId: staffUserId,
encryptedTunnelKey: keyShare.encryptedTunnelKey,
})),
)
.returning({
id: clientTunnelStaffKeyShare.id,
tunnelId: clientTunnelStaffKeyShare.tunnelId,
});
return insertedKeyShares;
});
log.info("Staff key shares added successfully", {
tenantId,
staffUserId,
addedCount: result.length,
skippedCount: duplicateKeyShares.length,
requesterId: locals.user?.userId,
});
return json({
success: true,
message: `Successfully added ${result.length} key shares`,
added: result.length,
skipped: duplicateKeyShares.length,
keyShares: result.map((ks) => ({
id: ks.id,
tunnelId: ks.tunnelId,
})),
});
} catch (error) {
logError(log)("Error adding staff key shares", error, locals.user?.userId, tenantId);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,108 @@
import { describe, it, expect } from "vitest";
describe("Add Staff Key Shares API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockStaffUserId = "456e7890-e89b-12d3-a456-426614174001";
const mockTunnelId = "789e0123-e89b-12d3-a456-426614174002";
describe("POST /api/tenants/[id]/appointments/tunnels/add-staff-key-shares", () => {
it("should validate required parameters", () => {
expect(mockTenantId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(mockStaffUserId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(mockTunnelId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
it("should handle valid request structure", () => {
const validRequest = {
staffUserId: mockStaffUserId,
keyShares: [
{
tunnelId: mockTunnelId,
encryptedTunnelKey: "base64-encoded-encrypted-key",
},
],
};
expect(validRequest.staffUserId).toBe(mockStaffUserId);
expect(Array.isArray(validRequest.keyShares)).toBe(true);
expect(validRequest.keyShares.length).toBeGreaterThan(0);
expect(validRequest.keyShares[0]).toHaveProperty("tunnelId");
expect(validRequest.keyShares[0]).toHaveProperty("encryptedTunnelKey");
});
it("should validate key share structure", () => {
const keyShare = {
tunnelId: mockTunnelId,
encryptedTunnelKey:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
};
expect(keyShare.tunnelId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(typeof keyShare.encryptedTunnelKey).toBe("string");
expect(keyShare.encryptedTunnelKey.length).toBeGreaterThan(0);
// Validate base64 format
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
expect(keyShare.encryptedTunnelKey).toMatch(base64Regex);
});
it("should handle successful response structure", () => {
const successResponse = {
success: true,
message: "Successfully added 2 key shares",
added: 2,
skipped: 0,
keyShares: [
{
id: "keyshare-uuid-1",
tunnelId: mockTunnelId,
},
],
};
expect(successResponse.success).toBe(true);
expect(typeof successResponse.message).toBe("string");
expect(typeof successResponse.added).toBe("number");
expect(typeof successResponse.skipped).toBe("number");
expect(Array.isArray(successResponse.keyShares)).toBe(true);
});
it("should handle minimum key shares requirement", () => {
const emptyKeyShares: never[] = [];
const validKeyShares = [{ tunnelId: mockTunnelId, encryptedTunnelKey: "key" }];
expect(emptyKeyShares.length).toBe(0);
expect(validKeyShares.length).toBeGreaterThan(0);
const hasMinimumKeyShares = validKeyShares.length >= 1;
expect(hasMinimumKeyShares).toBe(true);
});
it("should validate staff user ownership", () => {
const staffUser = {
id: mockStaffUserId,
tenantId: mockTenantId,
isActive: true,
role: "STAFF" as const,
};
const requestTenantId = mockTenantId;
const belongsToTenant = staffUser.tenantId === requestTenantId;
const isActive = staffUser.isActive;
const isValidStaff = belongsToTenant && isActive;
expect(belongsToTenant).toBe(true);
expect(isActive).toBe(true);
expect(isValidStaff).toBe(true);
});
});
});
@@ -0,0 +1,221 @@
import { json, type RequestHandler } from "@sveltejs/kit";
import { z } from "zod";
import { logger } from "$lib/logger";
import { getTenantDb } from "$lib/server/db";
import { clientAppointmentTunnel } from "$lib/server/db/tenant-schema";
import type { ChallengeVerificationResponse } from "$lib/types/appointment";
import { eq } from "drizzle-orm";
import { timingSafeEqual } from "crypto";
import { challengeStore } from "$lib/server/services/challenge-store";
import {
BackendError,
InternalError,
logError,
NotFoundError,
ValidationError,
} from "$lib/server/utils/errors";
import { registerOpenAPIRoute } from "$lib/server/openapi";
const requestSchema = z.object({
challengeId: z.string(),
challengeResponse: z.string(),
});
// Register OpenAPI documentation for POST
registerOpenAPIRoute("/tenants/{id}/appointments/verify-challenge", "POST", {
summary: "Verify authentication challenge",
description:
"Verifies a client's response to a cryptographic challenge and returns access credentials for appointment data.",
tags: ["Appointments", "Authentication"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Challenge verification data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
challengeId: {
type: "string",
description: "Challenge ID received from challenge creation",
example: "a1b2c3d4e5f6789012345678",
},
challengeResponse: {
type: "string",
description: "Decrypted challenge response (base64 encoded)",
example: "SGVsbG8gV29ybGQ=",
},
},
required: ["challengeId", "challengeResponse"],
},
},
},
},
responses: {
"200": {
description: "Challenge verified successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
valid: {
type: "boolean",
description: "Whether the challenge verification was successful",
example: true,
},
encryptedTunnelKey: {
type: "string",
description: "Tunnel key encrypted with client's public key",
example: "deadbeef123456789abcdef...",
},
tunnelId: {
type: "string",
format: "uuid",
description: "Client tunnel identifier for accessing appointments",
example: "550e8400-e29b-41d4-a716-446655440000",
},
},
required: ["valid", "encryptedTunnelKey", "tunnelId"],
},
},
},
},
"400": {
description: "Invalid request data or challenge response",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Challenge not found or expired",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
/**
* POST /api/tenants/[id]/appointments/verify-challenge
*
* Verifies the challenge response of an existing client and
* returns an auth token and required keys on success.
*/
export const POST: RequestHandler = async ({ request, params }) => {
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
const body = await request.json();
const { challengeId, challengeResponse } = requestSchema.parse(body);
logger.info("Verifying challenge for existing client", {
tenantId,
challengeId,
});
// Retrieve and consume the stored challenge
const storedChallenge = await challengeStore.consume(challengeId, tenantId);
if (!storedChallenge) {
logger.warn("Challenge not found or expired", {
tenantId,
challengeId,
});
throw new NotFoundError("Challenge not found or expired");
}
// Validate challenge response using constant-time comparison
const expectedChallenge = storedChallenge.challenge;
const challengeBuffer = Buffer.from(challengeResponse, "base64");
const expectedBuffer = Buffer.from(expectedChallenge, "base64");
if (
challengeBuffer.length !== expectedBuffer.length ||
!timingSafeEqual(challengeBuffer, expectedBuffer)
) {
logger.warn("Challenge response mismatch", {
tenantId,
challengeId,
emailHashPrefix: storedChallenge.emailHash.slice(0, 8),
});
throw new ValidationError("Invalid challenge response");
}
const db = await getTenantDb(tenantId);
// Retrieve client tunnel using the emailHash from stored challenge
const tunnelResult = await db
.select({
id: clientAppointmentTunnel.id,
clientEncryptedTunnelKey: clientAppointmentTunnel.clientEncryptedTunnelKey,
})
.from(clientAppointmentTunnel)
.where(eq(clientAppointmentTunnel.emailHash, storedChallenge.emailHash))
.limit(1);
if (tunnelResult.length === 0) {
logger.warn("Client tunnel not found for verification", {
tenantId,
challengeId,
emailHashPrefix: storedChallenge.emailHash.slice(0, 8),
});
throw new NotFoundError("Client not found");
}
const tunnel = tunnelResult[0];
logger.info("Challenge response validated successfully", {
tenantId,
challengeId,
tunnelId: tunnel.id,
});
const response: ChallengeVerificationResponse = {
valid: true,
encryptedTunnelKey: tunnel.clientEncryptedTunnelKey,
tunnelId: tunnel.id,
};
logger.info("Successfully verified challenge", {
tenantId,
tunnelId: tunnel.id,
});
return json(response);
} catch (error) {
logError(logger)("Failed to verify challenge", error);
if (error instanceof BackendError) {
return error.toJson();
}
if (error instanceof z.ZodError) {
return new ValidationError("Invalid request data").toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,187 @@
import { json } from "@sveltejs/kit";
import { ScheduleService } from "$lib/server/services/schedule-service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/calendar", "GET", {
summary: "Get tenant calendar",
description:
"Retrieves the appointment calendar for a specific tenant within a date range. Shows available time slots, existing appointments, and agent availability. This is a public endpoint that doesn't require authentication.",
tags: ["Calendar", "Public"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "startDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "Start date for the calendar range (ISO 8601 format with timezone)",
},
{
name: "endDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "End date for the calendar range (ISO 8601 format with timezone)",
},
],
responses: {
"200": {
description: "Calendar retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
period: {
type: "object",
properties: {
startDate: {
type: "string",
format: "date-time",
description: "Query start date",
},
endDate: {
type: "string",
format: "date-time",
description: "Query end date",
},
},
required: ["startDate", "endDate"],
},
calendar: {
type: "array",
items: {
type: "object",
properties: {
date: {
type: "string",
format: "date",
description: "Date in YYYY-MM-DD format",
},
channels: {
type: "object",
description:
"Calendar data organized by channel ID (key-value pairs where key is channel UUID and value contains channel info, appointments, and available slots)",
},
},
},
description: "Daily calendar data",
},
},
required: ["period", "calendar"],
},
},
},
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, url }) => {
const log = logger.setContext("CalendarAPI");
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
// Parse query parameters for date range
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
if (!startDateParam || !endDateParam) {
throw new ValidationError("Both startDate and endDate query parameters are required");
}
// Validate date format (ISO 8601 with timezone)
const startDate = new Date(startDateParam);
const endDate = new Date(endDateParam);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new ValidationError(
"Invalid date format. Use ISO 8601 format with timezone (YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ss±HH:mm)",
);
}
if (startDate >= endDate) {
throw new ValidationError("Start date must be before end date");
}
// Limit the time range to prevent excessive queries (max 3 months)
const maxRangeMs = 91 * 24 * 60 * 60 * 1000; // ~3 months in milliseconds (91 days to be safe)
if (endDate.getTime() - startDate.getTime() >= maxRangeMs) {
throw new ValidationError("Date range cannot exceed 90 days");
}
log.debug("Getting calendar for tenant", {
tenantId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
const scheduleService = await ScheduleService.forTenant(tenantId);
const schedule = await scheduleService.getSchedule({
tenantId,
startDate: startDateParam,
endDate: endDateParam,
});
// Return full calendar data (including appointments and detailed agent info)
const result = {
period: schedule.period,
calendar: schedule.schedule,
};
log.debug("Calendar retrieved successfully", {
tenantId,
daysCount: schedule.schedule.length,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
return json(result);
} catch (error) {
logError(log)("Error getting calendar", error, undefined, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,295 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { GET } from "../+server";
import { ScheduleService } from "$lib/server/services/schedule-service";
// Mock the ScheduleService
vi.mock("$lib/server/services/schedule-service", () => ({
ScheduleService: {
forTenant: vi.fn(),
},
}));
// Mock logger
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
describe("Calendar API", () => {
const mockScheduleService = {
getSchedule: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(ScheduleService.forTenant).mockResolvedValue(mockScheduleService as any);
});
const createRequest = (tenantId: string, startDate?: string, endDate?: string) => {
const url = new URL("http://localhost/api/tenants/123/calendar");
if (startDate) url.searchParams.set("startDate", startDate);
if (endDate) url.searchParams.set("endDate", endDate);
return {
params: { id: tenantId },
url,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
};
const mockScheduleResponse = {
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
},
schedule: [
{
date: "2024-01-01",
channels: {
"channel-123": {
channel: {
id: "channel-123",
names: { en: "Test Channel", de: "Test Kanal" },
descriptions: { en: "Test Description", de: "Test Beschreibung" },
color: "#FF0000",
pause: false,
requiresConfirmation: false,
isPublic: true,
},
appointments: [
{
id: "appointment-123",
appointmentDate: "2024-01-01T10:00:00.000Z",
status: "CONFIRMED",
channelId: "channel-123",
},
],
availableSlots: [
{
from: "09:00",
to: "09:30",
duration: 30,
availableAgents: [
{
id: "agent-123",
name: "Test Agent",
descriptions: { en: "Test Agent Description" },
},
],
},
{
from: "11:00",
to: "11:30",
duration: 30,
availableAgents: [
{
id: "agent-123",
name: "Test Agent",
descriptions: { en: "Test Agent Description" },
},
{
id: "agent-456",
name: "Another Agent",
descriptions: { en: "Another Agent Description" },
},
],
},
],
},
},
},
],
};
describe("GET", () => {
it("should return calendar with full schedule data", async () => {
mockScheduleService.getSchedule.mockResolvedValue(mockScheduleResponse);
const request = createRequest(
"tenant-123",
"2024-01-01T00:00:00.000Z",
"2024-01-02T00:00:00.000Z",
);
const response = await GET(request);
expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData).toEqual({
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
},
calendar: mockScheduleResponse.schedule, // Full schedule data including appointments
});
expect(ScheduleService.forTenant).toHaveBeenCalledWith("tenant-123");
expect(mockScheduleService.getSchedule).toHaveBeenCalledWith({
tenantId: "tenant-123",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
});
});
it("should include full appointment and agent information in calendar response", async () => {
mockScheduleService.getSchedule.mockResolvedValue(mockScheduleResponse);
const request = createRequest(
"tenant-123",
"2024-01-01T00:00:00.000Z",
"2024-01-02T00:00:00.000Z",
);
const response = await GET(request);
expect(response.status).toBe(200);
const responseData = await response.json();
// Verify that appointments are included in the response
const channelData = responseData.calendar[0].channels["channel-123"];
expect(channelData).toHaveProperty("appointments");
expect(channelData.appointments).toHaveLength(1);
// Verify that full agent information is included
expect(channelData.availableSlots[0]).toHaveProperty("availableAgents");
expect(channelData.availableSlots[0].availableAgents).toHaveLength(1);
});
it("should return 400 for missing tenant ID", async () => {
const request = createRequest("", "2024-01-01T00:00:00.000Z", "2024-01-02T00:00:00.000Z");
const response = await GET(request);
expect(response.status).toBe(422);
});
it("should return 400 for missing startDate", async () => {
const request = createRequest("tenant-123", undefined, "2024-01-02T00:00:00.000Z");
const response = await GET(request);
expect(response.status).toBe(422);
});
it("should return 400 for missing endDate", async () => {
const request = createRequest("tenant-123", "2024-01-01T00:00:00.000Z", undefined);
const response = await GET(request);
expect(response.status).toBe(422);
});
it("should return 400 for invalid date format", async () => {
const request = createRequest("tenant-123", "invalid-date", "2024-01-02T00:00:00.000Z");
const response = await GET(request);
expect(response.status).toBe(422);
});
it("should return 400 for startDate >= endDate", async () => {
const request = createRequest(
"tenant-123",
"2024-01-02T00:00:00.000Z",
"2024-01-01T00:00:00.000Z",
);
const response = await GET(request);
expect(response.status).toBe(422);
});
it("should return 400 for date range exceeding 90 days", async () => {
const startDate = "2024-01-01T00:00:00.000Z";
const endDate = "2024-04-01T00:00:00.000Z"; // ~3 months
const request = createRequest("tenant-123", startDate, endDate);
const response = await GET(request);
expect(response.status).toBe(422);
});
it("should handle ScheduleService errors", async () => {
mockScheduleService.getSchedule.mockRejectedValue(new Error("Database error"));
const request = createRequest(
"tenant-123",
"2024-01-01T00:00:00.000Z",
"2024-01-02T00:00:00.000Z",
);
const response = await GET(request);
expect(response.status).toBe(500);
});
it("should handle empty schedule gracefully", async () => {
const emptyScheduleResponse = {
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
},
schedule: [],
};
mockScheduleService.getSchedule.mockResolvedValue(emptyScheduleResponse);
const request = createRequest(
"tenant-123",
"2024-01-01T00:00:00.000Z",
"2024-01-02T00:00:00.000Z",
);
const response = await GET(request);
expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData.calendar).toEqual([]);
});
it("should handle channels with no available slots", async () => {
const scheduleWithNoSlots = {
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-02T00:00:00.000Z",
},
schedule: [
{
date: "2024-01-01",
channels: {
"channel-123": {
channel: {
id: "channel-123",
names: { en: "Test Channel" },
descriptions: { en: "Test Description" },
color: "#FF0000",
pause: false,
requiresConfirmation: false,
isPublic: true,
},
appointments: [],
availableSlots: [],
},
},
},
],
};
mockScheduleService.getSchedule.mockResolvedValue(scheduleWithNoSlots);
const request = createRequest(
"tenant-123",
"2024-01-01T00:00:00.000Z",
"2024-01-02T00:00:00.000Z",
);
const response = await GET(request);
expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData.calendar[0].channels["channel-123"].availableSlots).toEqual([]);
});
});
});
@@ -0,0 +1,220 @@
import { json } from "@sveltejs/kit";
import { ScheduleService } from "$lib/server/services/schedule-service";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/schedule", "GET", {
summary: "Get tenant schedule (available slots only)",
description:
"Retrieves available appointment slots for a specific tenant within a date range. This endpoint is designed for client-facing booking interfaces and only shows available time slots without existing appointments. This is a public endpoint that doesn't require authentication.",
tags: ["Schedule", "Public", "Booking"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "startDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "Start date for the schedule range (ISO 8601 format with timezone)",
},
{
name: "endDate",
in: "query",
required: true,
schema: { type: "string", format: "date-time" },
description: "End date for the schedule range (ISO 8601 format with timezone)",
},
],
responses: {
"200": {
description: "Schedule retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
period: {
type: "object",
properties: {
startDate: {
type: "string",
format: "date-time",
description: "Query start date",
},
endDate: {
type: "string",
format: "date-time",
description: "Query end date",
},
},
required: ["startDate", "endDate"],
},
schedule: {
type: "array",
items: {
type: "object",
properties: {
date: {
type: "string",
format: "date",
description: "Date in YYYY-MM-DD format",
},
channels: {
type: "object",
description:
"Available slots organized by channel ID (key-value pairs where key is channel UUID and value contains channel info and available slots only)",
},
},
required: ["date", "channels"],
},
description: "Daily schedule data with available slots only",
},
},
},
},
},
},
"400": {
description: "Invalid request parameters",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Tenant not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, url }) => {
const log = logger.setContext("ScheduleAPI");
try {
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError("Tenant ID is required");
}
// Parse query parameters for date range
const startDateParam = url.searchParams.get("startDate");
const endDateParam = url.searchParams.get("endDate");
if (!startDateParam || !endDateParam) {
throw new ValidationError("Both startDate and endDate query parameters are required");
}
// Validate date format (ISO 8601 with timezone)
const startDate = new Date(startDateParam);
const endDate = new Date(endDateParam);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new ValidationError(
"Invalid date format. Use ISO 8601 format with timezone (YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ss±HH:mm)",
);
}
if (startDate >= endDate) {
throw new ValidationError("Start date must be before end date");
}
// Limit the time range to prevent excessive queries (max 3 months)
const maxRangeMs = 91 * 24 * 60 * 60 * 1000; // ~3 months in milliseconds (91 days to be safe)
if (endDate.getTime() - startDate.getTime() >= maxRangeMs) {
throw new ValidationError("Date range cannot exceed 90 days");
}
log.debug("Getting schedule for tenant", {
tenantId,
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
const scheduleService = await ScheduleService.forTenant(tenantId);
const schedule = await scheduleService.getSchedule({
tenantId,
startDate: startDateParam,
endDate: endDateParam,
});
// Transform schedule to client-friendly format (remove appointments, simplify available slots)
const clientSchedule = schedule.schedule.map((daySchedule) => ({
date: daySchedule.date,
channels: Object.fromEntries(
Object.entries(daySchedule.channels).map(([channelId, channelData]) => [
channelId,
{
channel: {
id: channelData.channel.id,
names: channelData.channel.names,
descriptions: channelData.channel.descriptions,
requiresConfirmation: channelData.channel.requiresConfirmation,
pause: channelData.channel.pause,
},
availableSlots: channelData.availableSlots.map((slot) => ({
from: slot.from,
to: slot.to,
duration: slot.duration,
availableAgentCount: slot.availableAgents.length,
})),
},
]),
),
}));
const result = {
period: schedule.period,
schedule: clientSchedule,
};
log.debug("Schedule retrieved successfully", {
tenantId,
daysCount: clientSchedule.length,
totalSlots: clientSchedule.reduce(
(total, day) =>
total +
Object.values(day.channels).reduce(
(dayTotal, channel) => dayTotal + channel.availableSlots.length,
0,
),
0,
),
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
});
return json(result);
} catch (error) {
logError(log)("Error getting schedule", error, undefined, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,311 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/server/services/schedule-service", () => ({
ScheduleService: {
forTenant: vi.fn(),
},
}));
vi.mock("$lib/logger", () => ({
default: {
setContext: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
import { ScheduleService } from "$lib/server/services/schedule-service";
describe("Schedule API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockScheduleService = {
getSchedule: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(ScheduleService.forTenant as any).mockResolvedValue(mockScheduleService);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00.000Z");
searchParams.set("endDate", "2024-01-07T23:59:59.999Z");
return {
params: { id: mockTenantId },
url: {
searchParams,
} as any,
locals: { user: null } as any, // Public endpoint, no authentication required
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/[id]/schedule", () => {
it("should return schedule for valid date range (client-friendly format)", async () => {
const mockSchedule = {
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-07T23:59:59.999Z",
},
schedule: [
{
date: "2024-01-01",
channels: {
"channel-1": {
channel: {
id: "channel-1",
names: { en: "Support", de: "Unterstützung" },
descriptions: { en: "Support Channel", de: "Support Kanal" },
pause: false,
requiresConfirmation: false,
},
appointments: [
{ id: "appointment-1", appointmentDate: "2024-01-01T10:00:00.000Z" },
],
availableSlots: [
{
from: "09:00",
to: "09:30",
duration: 30,
availableAgents: [{ id: "agent-1", name: "Agent 1" }],
},
],
},
},
},
],
};
const expectedClientResponse = {
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-07T23:59:59.999Z",
},
schedule: [
{
date: "2024-01-01",
channels: {
"channel-1": {
channel: {
id: "channel-1",
names: { en: "Support", de: "Unterstützung" },
descriptions: { en: "Support Channel", de: "Support Kanal" },
pause: false,
requiresConfirmation: false,
},
availableSlots: [
{
from: "09:00",
to: "09:30",
duration: 30,
availableAgentCount: 1,
},
],
},
},
},
],
};
mockScheduleService.getSchedule.mockResolvedValue(mockSchedule);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(expectedClientResponse);
expect(mockScheduleService.getSchedule).toHaveBeenCalledWith({
tenantId: mockTenantId,
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-07T23:59:59.999Z",
});
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Tenant ID is required");
});
it("should handle missing startDate parameter", async () => {
const searchParams = new URLSearchParams();
searchParams.set("endDate", "2024-01-07T23:59:59.999Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Both startDate and endDate query parameters are required");
});
it("should handle missing endDate parameter", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00.000Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Both startDate and endDate query parameters are required");
});
it("should handle invalid date format", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "invalid-date");
searchParams.set("endDate", "2024-01-07T23:59:59.999Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe(
"Invalid date format. Use ISO 8601 format with timezone (YYYY-MM-DDTHH:mm:ss.sssZ or YYYY-MM-DDTHH:mm:ss±HH:mm)",
);
});
it("should handle startDate after endDate", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-07T00:00:00.000Z");
searchParams.set("endDate", "2024-01-01T23:59:59.999Z");
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Start date must be before end date");
});
it("should handle date range exceeding 90 days", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00.000Z");
searchParams.set("endDate", "2024-04-01T23:59:59.999Z"); // More than 90 days
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(422);
expect(data.error).toBe("Date range cannot exceed 90 days");
});
it("should handle service errors gracefully", async () => {
mockScheduleService.getSchedule.mockRejectedValue(new Error("Database connection failed"));
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
});
it("should work without authentication (public endpoint)", async () => {
const mockSchedule = {
period: {
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-07T23:59:59.999Z",
},
schedule: [],
};
mockScheduleService.getSchedule.mockResolvedValue(mockSchedule);
const event = createMockRequestEvent({
locals: { user: null } as any,
});
const response = await GET(event);
expect(response.status).toBe(200);
expect(mockScheduleService.getSchedule).toHaveBeenCalled();
});
it("should handle different timezone formats", async () => {
const searchParams = new URLSearchParams();
searchParams.set("startDate", "2024-01-01T00:00:00+01:00");
searchParams.set("endDate", "2024-01-07T23:59:59+01:00");
const mockSchedule = {
period: {
startDate: "2024-01-01T00:00:00+01:00",
endDate: "2024-01-07T23:59:59+01:00",
},
schedule: [],
};
mockScheduleService.getSchedule.mockResolvedValue(mockSchedule);
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual(mockSchedule);
});
it("should handle maximum allowed date range (90 days)", async () => {
const startDate = "2024-01-01T00:00:00.000Z";
const endDate = "2024-03-31T23:59:59.999Z"; // Exactly 90 days
const searchParams = new URLSearchParams();
searchParams.set("startDate", startDate);
searchParams.set("endDate", endDate);
const mockSchedule = {
period: { startDate, endDate },
schedule: [],
};
mockScheduleService.getSchedule.mockResolvedValue(mockSchedule);
const event = createMockRequestEvent({
url: { searchParams } as any,
});
const response = await GET(event);
expect(response.status).toBe(200);
expect(mockScheduleService.getSchedule).toHaveBeenCalledWith({
tenantId: mockTenantId,
startDate,
endDate,
});
});
});
});
@@ -0,0 +1,312 @@
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { ERRORS } from "$lib/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { StaffService } from "$lib/server/services/staff-service";
import { z } from "zod";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/staff", "GET", {
summary: "Get tenant staff members",
description:
"Returns all staff members for a tenant. Requires authentication and tenant membership.",
tags: ["Staff", "Tenants"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
responses: {
"200": {
description: "Staff members retrieved successfully",
content: {
"application/json": {
schema: {
type: "array",
items: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "User ID" },
email: { type: "string", format: "email", description: "User email" },
name: { type: "string", description: "User name" },
role: {
type: "string",
enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"],
description: "User role",
},
isActive: { type: "boolean", description: "Whether user is active (can be null)" },
confirmationState: {
type: "string",
enum: ["INVITED", "CONFIRMED", "ACCESS_GRANTED"],
description: "User confirmation state (can be null)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (can be null)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (can be null)",
},
lastLoginAt: {
type: "string",
format: "date-time",
description: "Last login timestamp (can be null)",
},
},
required: ["id", "email", "name", "role"],
},
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// Register OpenAPI documentation for PUT
registerOpenAPIRoute("/tenants/{id}/staff", "PUT", {
summary: "Update staff member",
description:
"Updates a staff member's role, email, name, and active status. Users cannot deactivate their own account. Requires administrative permissions.",
tags: ["Staff", "Tenants"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
],
requestBody: {
description: "Staff member update data",
content: {
"application/json": {
schema: {
type: "object",
properties: {
userId: {
type: "string",
format: "uuid",
description: "User ID to update",
},
email: {
type: "string",
format: "email",
description: "New email address (optional)",
},
name: {
type: "string",
minLength: 1,
description: "New name (optional)",
},
role: {
type: "string",
enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"],
description: "New role (optional)",
},
isActive: {
type: "boolean",
description: "Active status (optional, cannot set to false for own account)",
},
},
required: ["userId"],
},
},
},
},
responses: {
"200": {
description: "Staff member updated successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "User ID" },
email: { type: "string", format: "email", description: "User email" },
name: { type: "string", description: "User name" },
role: {
type: "string",
enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"],
description: "User role",
},
isActive: { type: "boolean", description: "Whether user is active (can be null)" },
confirmationState: {
type: "string",
enum: ["INVITED", "CONFIRMED", "ACCESS_GRANTED"],
description: "User confirmation state (can be null)",
},
createdAt: {
type: "string",
format: "date-time",
description: "Creation timestamp (can be null)",
},
updatedAt: {
type: "string",
format: "date-time",
description: "Last update timestamp (can be null)",
},
lastLoginAt: {
type: "string",
format: "date-time",
description: "Last login timestamp (can be null)",
},
},
required: ["id", "email", "name", "role"],
},
},
},
},
"400": {
description: "Invalid input data or cannot deactivate own account",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Administrative permissions required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
// UserData interface is now provided by StaffService as StaffMember
const userUpdateSchema = z.object({
userId: z.string().uuid("Invalid user ID format"),
email: z.string().email("Invalid email format").optional(),
name: z.string().min(1, "Name cannot be empty").optional(),
role: z
.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"], {
errorMap: () => ({ message: "Invalid role" }),
})
.optional(),
isActive: z.boolean().optional(),
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
}
checkPermission(locals, tenantId, false);
try {
const staff = await StaffService.getStaffMembers(tenantId);
return json(staff);
} catch (error) {
logError(log)("Error fetching staff data", error, locals.user?.userId, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
export const PUT: RequestHandler = async ({ params, locals, request }) => {
const log = logger.setContext("API");
const tenantId = params.id;
if (!tenantId) {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
}
checkPermission(locals, tenantId, true);
try {
const requestData = await request.json();
const validation = userUpdateSchema.safeParse(requestData);
if (!validation.success) {
throw new ValidationError(
"Invalid user update data: " + validation.error.errors.map((e) => e.message).join(", "),
);
}
const { userId, ...updateData } = validation.data;
const updatedUser = await StaffService.updateStaffMember(
tenantId,
userId,
updateData,
locals.user?.userId,
);
return json(updatedUser);
} catch (error) {
logError(log)("Error updating staff member", error, locals.user?.userId, params.id);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,138 @@
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { ERRORS } from "$lib/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { StaffService } from "$lib/server/services/staff-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}", "DELETE", {
summary: "Delete staff member",
description:
"Permanently deletes a staff member from the tenant. This action removes the user account and all associated data including passkeys and client tunnel key shares. Users cannot delete their own account. Requires administrative permissions and operates within a database transaction for data consistency.",
tags: ["Staff", "Tenants"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "staffId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Staff member user ID to delete",
},
],
responses: {
"200": {
description: "Staff member deleted successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
success: { type: "boolean", description: "Deletion success status" },
deletedUser: {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Deleted user ID" },
email: { type: "string", format: "email", description: "Deleted user email" },
name: { type: "string", description: "Deleted user name" },
role: {
type: "string",
enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"],
description: "Deleted user role",
},
},
required: ["id", "email", "name", "role"],
},
deletedPasskeysCount: { type: "number", description: "Number of deleted passkeys" },
deletedKeySharesCount: {
type: "number",
description: "Number of deleted client tunnel key shares",
},
},
required: ["success", "deletedUser", "deletedPasskeysCount", "deletedKeySharesCount"],
},
},
},
},
"400": {
description: "Invalid input data or cannot delete own account",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Administrative permissions required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Staff member not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
const tenantId = params.id;
const staffId = params.staffId;
if (!tenantId) {
throw new ValidationError(ERRORS.TENANTS.NO_TENANT_ID);
}
if (!staffId) {
throw new ValidationError("Staff ID is required");
}
// Require administrative permissions
checkPermission(locals, tenantId, true);
try {
const result = await StaffService.deleteStaffMember(tenantId, staffId, locals.user?.userId);
return json(result);
} catch (error) {
logError(log)("Error deleting staff member", error, locals.user?.userId, tenantId);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,69 @@
import { describe, it, expect } from "vitest";
describe("Staff DELETE API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockStaffId = "456e7890-e89b-12d3-a456-426614174001";
describe("DELETE /api/tenants/[id]/staff/[staffId]", () => {
it("should validate required parameters", () => {
expect(mockTenantId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(mockStaffId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
it("should handle self-deletion prevention logic", () => {
const currentUserId = "admin123";
const targetStaffId = "admin123";
const isSelfDeletion = currentUserId === targetStaffId;
expect(isSelfDeletion).toBe(true);
const shouldPreventSelfDeletion = isSelfDeletion;
expect(shouldPreventSelfDeletion).toBe(true);
});
it("should validate successful deletion response structure", () => {
const mockSuccessResponse = {
success: true,
deletedUser: {
id: mockStaffId,
email: "deleted@example.com",
name: "Deleted User",
role: "STAFF" as const,
},
deletedPasskeysCount: 2,
deletedKeySharesCount: 5,
};
expect(mockSuccessResponse.success).toBe(true);
expect(mockSuccessResponse.deletedUser.id).toBe(mockStaffId);
expect(typeof mockSuccessResponse.deletedPasskeysCount).toBe("number");
expect(typeof mockSuccessResponse.deletedKeySharesCount).toBe("number");
});
it("should handle tenant membership validation", () => {
const staffTenantId = "123e4567-e89b-12d3-a456-426614174000";
const requestTenantId = "123e4567-e89b-12d3-a456-426614174000";
const belongsToTenant = staffTenantId === requestTenantId;
expect(belongsToTenant).toBe(true);
// Test with different tenant ID using variables
const differentTenantId = "999e4567-e89b-12d3-a456-426614174999";
const differentStaffTenantId = "999e4567-e89b-12d3-a456-426614174999";
const belongsToDifferentTenant = differentStaffTenantId === differentTenantId;
expect(belongsToDifferentTenant).toBe(true);
// Test validation function
const validateTenantMembership = (userTenantId: string, requestTenantId: string): boolean => {
return userTenantId === requestTenantId;
};
expect(validateTenantMembership(staffTenantId, requestTenantId)).toBe(true);
expect(validateTenantMembership(staffTenantId, differentTenantId)).toBe(false);
});
});
});
@@ -0,0 +1,234 @@
/**
* API Route: Get Staff Key Shard
*
* Returns the database-stored shard of a staff member's private key for key reconstruction.
* This is used by the crypto worker to reconstruct the full private key from the two shards.
*
* Security:
* - Only the staff member themselves can access their own key shard
* - The passkeyId is automatically determined from the most recently used passkey
* - Access is denied if no valid passkey or corresponding crypto data is found (no fallbacks)
* - This ensures only properly authenticated users can access their key shards
*/
import { json } from "@sveltejs/kit";
import type { RequestHandler } from "@sveltejs/kit";
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { logger } from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
import {
BackendError,
ValidationError,
AuthorizationError,
NotFoundError,
InternalError,
logError,
} from "$lib/server/utils/errors";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/key-shard", "GET", {
summary: "Get staff key shard",
description:
"Returns the database-stored shard of a staff member's private key for key reconstruction. Only the staff member themselves can access their own key shard.",
tags: ["Staff", "Cryptography"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "staffId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Staff member ID",
},
],
responses: {
"200": {
description: "Staff key shard retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
publicKey: {
type: "string",
description: "Staff member's public key",
},
privateKeyShare: {
type: "string",
description: "Database-stored shard of the private key",
},
passkeyId: {
type: "string",
description: "Passkey ID",
},
},
required: ["publicKey", "privateKeyShare", "passkeyId"],
},
},
},
},
"400": {
description: "Invalid input data",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Unauthorized - can only access own key shard",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Staff crypto data not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API.StaffKeyShard");
const { id: tenantId, staffId } = params;
try {
if (!tenantId || !staffId) {
throw new ValidationError("Tenant ID and Staff ID are required");
}
// Check basic tenant access
checkPermission(locals, tenantId, false);
// Additional security check: Only the passkey owner can access their own key shard
if (!locals.user || locals.user.userId !== staffId) {
log.warn("Unauthorized key shard access attempt", {
tenantId,
staffId,
requesterId: locals.user?.userId,
});
throw new AuthorizationError("You can only access your own key shard");
}
// Get the passkey ID from the user's most recently used passkey
// This ensures that only users who have actually authenticated with WebAuthn
// can access their key shard. No fallback to other passkeys for security.
let passkeyId: string;
try {
// Get the most recently used passkey for this authenticated user
const recentPasskey = await WebAuthnService.getMostRecentPasskey(locals.user.userId);
if (!recentPasskey) {
log.warn("No passkey found for authenticated user - security violation", {
tenantId,
staffId,
userId: locals.user.userId,
security: "User session exists but no passkey found",
});
throw new AuthorizationError("No valid passkey found for authenticated user");
}
passkeyId = recentPasskey.id;
log.debug("Using authenticated user's most recent passkey", {
tenantId,
staffId,
passkeyId,
lastUsedAt: recentPasskey.lastUsedAt,
});
} catch (error) {
if (error instanceof AuthorizationError) {
throw error; // Re-throw authorization errors
}
log.error("Failed to determine passkey ID for authenticated user", {
tenantId,
staffId,
error: String(error),
});
throw new InternalError("Failed to determine passkey ID");
}
log.debug("Fetching staff key shard", {
tenantId,
staffId,
passkeyId,
requesterId: locals.user.userId,
});
const staffCryptoService = new StaffCryptoService();
// Get the staff crypto data using the determined passkey ID
// No fallback - if the recent passkey doesn't have crypto data, access is denied
const staffCrypto = await staffCryptoService.getStaffCryptoForPasskey(
tenantId,
staffId,
passkeyId,
);
if (!staffCrypto) {
log.warn("Staff crypto data not found for authenticated passkey", {
tenantId,
staffId,
passkeyId,
security: "Access denied - no crypto data for authenticated passkey",
});
throw new NotFoundError("Staff crypto data not found for the authenticated passkey");
}
log.debug("Staff key shard retrieved successfully", {
tenantId,
staffId,
hasPublicKey: !!staffCrypto.publicKey,
hasPrivateKeyShare: !!staffCrypto.privateKeyShare,
passkeyId: staffCrypto.passkeyId,
});
return json({
publicKey: staffCrypto.publicKey,
privateKeyShare: staffCrypto.privateKeyShare,
passkeyId: staffCrypto.passkeyId,
});
} catch (error) {
// Note: passkeyId might not be available if error occurred before determination
const passkeyIdForError = locals.user?.sessionId || "unknown";
logError(log)("Failed to fetch staff key shard", error, locals.user?.userId, params.id);
log.error("Additional context", { tenantId, staffId, passkeyId: passkeyIdForError });
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,401 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { GET } from "../+server";
import type { RequestEvent } from "@sveltejs/kit";
// Mock dependencies
vi.mock("$lib/logger", () => {
const mockLogger = {
setContext: vi.fn(() => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
})),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
};
return {
default: mockLogger,
logger: mockLogger,
};
});
vi.mock("$lib/server/services/staff-crypto.service", () => ({
StaffCryptoService: vi.fn(() => ({
getStaffCryptoForPasskey: vi.fn(),
})),
}));
vi.mock("$lib/server/auth/webauthn-service", () => ({
WebAuthnService: {
getMostRecentPasskey: vi.fn(),
},
}));
vi.mock("$lib/server/utils/permissions", () => ({
checkPermission: vi.fn(),
}));
vi.mock("$lib/server/utils/errors", () => {
class MockBackendError extends Error {
constructor(
message: string,
public statusCode: number = 500,
) {
super(message);
}
toJson() {
return new Response(JSON.stringify({ error: this.message }), {
status: this.statusCode,
headers: { "Content-Type": "application/json" },
});
}
}
return {
BackendError: MockBackendError,
ValidationError: class extends MockBackendError {
constructor(message: string) {
super(message, 400);
}
},
AuthorizationError: class extends MockBackendError {
constructor(message: string) {
super(message, 403);
}
},
NotFoundError: class extends MockBackendError {
constructor(message: string) {
super(message, 404);
}
},
InternalError: class extends MockBackendError {
constructor(message: string = "Internal server error") {
super(message, 500);
}
},
logError: vi.fn(() => vi.fn()),
};
});
import { StaffCryptoService } from "$lib/server/services/staff-crypto.service";
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
import { checkPermission } from "$lib/server/utils/permissions";
describe("Staff Key Shard API Route", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockStaffId = "456e7890-e89b-12d3-a456-426614174001";
const mockPasskeyId = "passkey-abc-123";
const mockUserId = mockStaffId; // Same user accessing their own key shard
const mockStaffCryptoService = {
getStaffCryptoForPasskey: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
(StaffCryptoService as any).mockImplementation(() => mockStaffCryptoService);
(checkPermission as any).mockImplementation(() => true);
});
function createMockRequestEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
params: { id: mockTenantId, staffId: mockStaffId },
request: {} as any,
locals: {
user: {
userId: mockUserId,
sessionId: "session-123",
},
} as any,
...overrides,
} as RequestEvent;
}
describe("GET /api/tenants/[id]/staff/[staffId]/key-shard", () => {
it("should successfully return key shard for authenticated staff member", async () => {
const mockRecentPasskey = {
id: mockPasskeyId,
lastUsedAt: new Date("2024-01-15T10:00:00Z"),
};
const mockStaffCrypto = {
publicKey: "base64-encoded-ml-kem-768-public-key==",
privateKeyShare: "base64-encoded-private-key-shard==",
passkeyId: mockPasskeyId,
};
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(mockRecentPasskey);
mockStaffCryptoService.getStaffCryptoForPasskey.mockResolvedValue(mockStaffCrypto);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toEqual({
publicKey: mockStaffCrypto.publicKey,
privateKeyShare: mockStaffCrypto.privateKeyShare,
passkeyId: mockStaffCrypto.passkeyId,
});
expect(WebAuthnService.getMostRecentPasskey).toHaveBeenCalledWith(mockUserId);
expect(mockStaffCryptoService.getStaffCryptoForPasskey).toHaveBeenCalledWith(
mockTenantId,
mockStaffId,
mockPasskeyId,
);
});
it("should reject access when user tries to access another staff member's key shard", async () => {
const event = createMockRequestEvent({
locals: {
user: {
userId: "different-user-id",
sessionId: "session-123",
},
} as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("You can only access your own key shard");
expect(WebAuthnService.getMostRecentPasskey).not.toHaveBeenCalled();
expect(mockStaffCryptoService.getStaffCryptoForPasskey).not.toHaveBeenCalled();
});
it("should reject access when no user is authenticated", async () => {
const event = createMockRequestEvent({
locals: {
user: null,
} as any,
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("You can only access your own key shard");
});
it("should reject access when no recent passkey is found", async () => {
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("No valid passkey found for authenticated user");
expect(WebAuthnService.getMostRecentPasskey).toHaveBeenCalledWith(mockUserId);
expect(mockStaffCryptoService.getStaffCryptoForPasskey).not.toHaveBeenCalled();
});
it("should reject access when no staff crypto data is found for the passkey", async () => {
const mockRecentPasskey = {
id: mockPasskeyId,
lastUsedAt: new Date("2024-01-15T10:00:00Z"),
};
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(mockRecentPasskey);
mockStaffCryptoService.getStaffCryptoForPasskey.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Staff crypto data not found for the authenticated passkey");
expect(WebAuthnService.getMostRecentPasskey).toHaveBeenCalledWith(mockUserId);
expect(mockStaffCryptoService.getStaffCryptoForPasskey).toHaveBeenCalledWith(
mockTenantId,
mockStaffId,
mockPasskeyId,
);
});
it("should handle missing tenant ID", async () => {
const event = createMockRequestEvent({
params: { id: undefined, staffId: mockStaffId },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Tenant ID and Staff ID are required");
});
it("should handle missing staff ID", async () => {
const event = createMockRequestEvent({
params: { id: mockTenantId, staffId: undefined },
});
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.error).toBe("Tenant ID and Staff ID are required");
});
it("should handle WebAuthn service errors gracefully", async () => {
(WebAuthnService.getMostRecentPasskey as any).mockRejectedValue(
new Error("Database connection failed"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Failed to determine passkey ID");
expect(WebAuthnService.getMostRecentPasskey).toHaveBeenCalledWith(mockUserId);
expect(mockStaffCryptoService.getStaffCryptoForPasskey).not.toHaveBeenCalled();
});
it("should handle staff crypto service errors gracefully", async () => {
const mockRecentPasskey = {
id: mockPasskeyId,
lastUsedAt: new Date("2024-01-15T10:00:00Z"),
};
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(mockRecentPasskey);
mockStaffCryptoService.getStaffCryptoForPasskey.mockRejectedValue(
new Error("Database connection failed"),
);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(500);
expect(data.error).toBe("Internal server error");
expect(WebAuthnService.getMostRecentPasskey).toHaveBeenCalledWith(mockUserId);
expect(mockStaffCryptoService.getStaffCryptoForPasskey).toHaveBeenCalledWith(
mockTenantId,
mockStaffId,
mockPasskeyId,
);
});
it("should validate response structure", async () => {
const mockRecentPasskey = {
id: mockPasskeyId,
lastUsedAt: new Date("2024-01-15T10:00:00Z"),
};
const mockStaffCrypto = {
publicKey: "base64-encoded-ml-kem-768-public-key==",
privateKeyShare: "base64-encoded-private-key-shard==",
passkeyId: mockPasskeyId,
};
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(mockRecentPasskey);
mockStaffCryptoService.getStaffCryptoForPasskey.mockResolvedValue(mockStaffCrypto);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveProperty("publicKey");
expect(data).toHaveProperty("privateKeyShare");
expect(data).toHaveProperty("passkeyId");
expect(typeof data.publicKey).toBe("string");
expect(typeof data.privateKeyShare).toBe("string");
expect(typeof data.passkeyId).toBe("string");
expect(data.publicKey.length).toBeGreaterThan(0);
expect(data.privateKeyShare.length).toBeGreaterThan(0);
expect(data.passkeyId.length).toBeGreaterThan(0);
});
});
describe("Security Validation", () => {
it("should enforce strict user identity matching", async () => {
// Test that user can only access their own key shard
const testCases = [
{
requestingUserId: mockStaffId,
targetStaffId: mockStaffId,
shouldAllow: true,
description: "same user accessing own key shard",
},
{
requestingUserId: "different-user-id",
targetStaffId: mockStaffId,
shouldAllow: false,
description: "different user trying to access staff key shard",
},
{
requestingUserId: "admin-user-id",
targetStaffId: mockStaffId,
shouldAllow: false,
description: "admin user trying to access staff key shard",
},
];
for (const testCase of testCases) {
const event = createMockRequestEvent({
params: { id: mockTenantId, staffId: testCase.targetStaffId },
locals: {
user: {
userId: testCase.requestingUserId,
sessionId: "session-123",
},
} as any,
});
const response = await GET(event);
if (testCase.shouldAllow) {
expect(response.status).not.toBe(403);
} else {
expect(response.status).toBe(403);
const data = await response.json();
expect(data.error).toBe("You can only access your own key shard");
}
}
});
it("should require valid passkey authentication", async () => {
// Mock no recent passkey (security violation)
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(403);
expect(data.error).toBe("No valid passkey found for authenticated user");
});
it("should require matching crypto data for the authenticated passkey", async () => {
const mockRecentPasskey = {
id: mockPasskeyId,
lastUsedAt: new Date("2024-01-15T10:00:00Z"),
};
(WebAuthnService.getMostRecentPasskey as any).mockResolvedValue(mockRecentPasskey);
// Mock no crypto data for this passkey (security violation)
mockStaffCryptoService.getStaffCryptoForPasskey.mockResolvedValue(null);
const event = createMockRequestEvent();
const response = await GET(event);
const data = await response.json();
expect(response.status).toBe(404);
expect(data.error).toBe("Staff crypto data not found for the authenticated passkey");
});
});
});
@@ -0,0 +1,131 @@
/**
* API Route: Get Staff Public Key
*
* Returns the public key of a specific staff member.
* Used by other staff members to encrypt data for the target staff member.
* Requires the requesting user to be authenticated and belong to the same tenant.
*/
import type { RequestHandler } from "@sveltejs/kit";
import { json } from "@sveltejs/kit";
import { logger } from "$lib/logger";
import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
import { checkPermission } from "$lib/server/utils/permissions";
import { StaffService } from "$lib/server/services/staff-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
// Register OpenAPI documentation for GET
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/public-key", "GET", {
summary: "Get staff member's public key",
description:
"Returns the public key of a specific staff member. Used by other staff members to encrypt data for the target staff member. Requires authentication and tenant membership.",
tags: ["Staff", "Encryption"],
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Tenant ID",
},
{
name: "staffId",
in: "path",
required: true,
schema: { type: "string", format: "uuid" },
description: "Staff member's user ID",
},
],
responses: {
"200": {
description: "Staff public key retrieved successfully",
content: {
"application/json": {
schema: {
type: "object",
properties: {
userId: { type: "string", format: "uuid", description: "Staff member's user ID" },
publicKey: { type: "string", description: "Base64-encoded ML-KEM-768 public key" },
},
required: ["userId", "publicKey"],
},
},
},
},
"400": {
description: "Invalid input data or staff user not found/inactive",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"401": {
description: "Authentication required",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"403": {
description: "Insufficient permissions",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"404": {
description: "Staff user or public key not found",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
"500": {
description: "Internal server error",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
},
});
export const GET: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API.StaffPublicKey");
const tenantId = params.id;
const staffId = params.staffId;
if (!tenantId || !staffId) {
throw new ValidationError("Tenant ID and Staff ID are required");
}
checkPermission(locals, tenantId, false);
try {
log.debug("Fetching staff public key", { tenantId, staffId, requesterId: locals.user?.userId });
const result = await StaffService.getStaffPublicKey(tenantId, staffId);
log.debug("Staff public key retrieved successfully", {
tenantId,
staffId,
requesterId: locals.user?.userId,
hasPublicKey: !!result.publicKey,
});
return json(result);
} catch (error) {
logError(log)("Error fetching staff public key", error, locals.user?.userId, tenantId);
if (error instanceof BackendError) {
return error.toJson();
}
return new InternalError().toJson();
}
};
@@ -0,0 +1,62 @@
import { describe, it, expect } from "vitest";
describe("Staff Public Key API", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockStaffId = "456e7890-e89b-12d3-a456-426614174001";
describe("GET /api/tenants/[id]/staff/[staffId]/public-key", () => {
it("should validate required parameters", () => {
expect(mockTenantId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(mockStaffId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
it("should handle public key response structure", () => {
const mockPublicKeyResponse = {
userId: mockStaffId,
publicKey: "base64-encoded-ml-kem-768-public-key",
};
expect(mockPublicKeyResponse.userId).toBe(mockStaffId);
expect(typeof mockPublicKeyResponse.publicKey).toBe("string");
expect(mockPublicKeyResponse.publicKey.length).toBeGreaterThan(0);
});
it("should validate ML-KEM-768 public key format", () => {
// ML-KEM-768 public keys are typically base64 encoded and have a specific length
// ML-KEM-768 public keys are 1184 bytes, base64-encoded to ~1579 chars
const mockPublicKey = "A".repeat(1579); // Simulate a valid base64 string of correct length
// Base64 validation - should contain only valid base64 characters
const base64Regex = /^[A-Za-z0-9+/]*={0,2}$/;
expect(mockPublicKey).toMatch(base64Regex);
// Should be a substantial length for a cryptographic key
expect(mockPublicKey.length).toBeGreaterThan(50);
});
it("should handle staff user validation logic", () => {
const staffUser = {
id: mockStaffId,
tenantId: mockTenantId,
isActive: true,
};
const requestTenantId = mockTenantId;
// Check if staff belongs to requested tenant
const belongsToTenant = staffUser.tenantId === requestTenantId;
expect(belongsToTenant).toBe(true);
// Check if staff is active
expect(staffUser.isActive).toBe(true);
// Combined validation
const isValidStaffUser = belongsToTenant && staffUser.isActive;
expect(isValidStaffUser).toBe(true);
});
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect } from "vitest";
describe("Staff API Routes", () => {
const mockTenantId = "123e4567-e89b-12d3-a456-426614174000";
const mockUserId = "456e7890-e89b-12d3-a456-426614174001";
describe("GET /api/tenants/[id]/staff", () => {
it("should validate tenant ID parameter", () => {
expect(mockTenantId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
});
it("should handle staff data structure", () => {
const mockStaffData = {
id: mockUserId,
email: "user@example.com",
name: "John Doe",
role: "STAFF" as const,
isActive: true,
confirmationState: "CONFIRMED" as const,
createdAt: new Date(),
updatedAt: new Date(),
lastLoginAt: new Date(),
};
expect(mockStaffData.role).toBe("STAFF");
expect(typeof mockStaffData.isActive).toBe("boolean");
expect(mockStaffData.confirmationState).toBe("CONFIRMED");
});
it("should validate role enum values", () => {
const validRoles = ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"];
expect(validRoles).toContain("STAFF");
expect(validRoles).toContain("TENANT_ADMIN");
expect(validRoles).toContain("GLOBAL_ADMIN");
});
});
describe("PUT /api/tenants/[id]/staff", () => {
it("should validate update request structure", () => {
const validUpdateRequest = {
userId: mockUserId,
name: "Updated Name",
email: "updated@example.com",
role: "STAFF" as const,
isActive: true,
};
expect(validUpdateRequest.userId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
);
expect(validUpdateRequest.email).toContain("@");
expect(typeof validUpdateRequest.isActive).toBe("boolean");
});
it("should handle self-deactivation prevention logic", () => {
const currentUserId = "admin123";
const targetUserId = "admin123";
// Test case: not deactivating (should be allowed)
const isNotDeactivating = true;
const shouldAllowSelfUpdate = currentUserId === targetUserId && isNotDeactivating;
expect(shouldAllowSelfUpdate).toBe(true);
// Test case: deactivating own account (should be prevented)
const isDeactivating = true;
const shouldPreventSelfDeactivation =
currentUserId === targetUserId && isDeactivating === true;
expect(shouldPreventSelfDeactivation).toBe(true);
});
});
});
+19 -2
View File
@@ -23,6 +23,18 @@ const PUBLIC_PATHS = [
"/api/admin/exists",
"/api/auth/refresh", // Must be public since the access token might already be invalid when this gets called
];
// Client booking routes that need public access (no authentication required)
const CLIENT_BOOKING_PATHS = [
"/schedule", // Available time slots for clients
"/appointments/challenge", // Create authentication challenge for existing clients
"/appointments/verify-challenge", // Verify challenge response
"/appointments/create-new-client", // Create new client with first appointment
"/appointments/add-to-tunnel", // Add appointment to existing client tunnel
"/appointments/staff-public-keys", // Get staff public keys for encryption
"/appointments/", // Individual appointment details (for clients to view their own appointments)
];
const GLOBAL_ADMIN_PATHS = ["/api/admin", "/api/tenants"];
const ADMIN_PATHS = ["/api/tenant-admin"];
@@ -42,15 +54,20 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => {
return resolve(event);
}
// Check for tenant client booking routes (public for clients)
const isTenantClientBookingRoute =
path.match(/^\/api\/tenants\/[^/]+\//) &&
CLIENT_BOOKING_PATHS.some((clientPath) => path.includes(clientPath));
const isProtectedPath = PROTECTED_PATHS.some((protectedPath) => path.startsWith(protectedPath));
const isPublicPath = PUBLIC_PATHS.some((publicPath) => path.startsWith(publicPath));
const isProtectedAuthPath = PROTECTED_AUTH_PATHS.some((authPath) => path.startsWith(authPath));
const isGlobalAdminPath = GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath));
const isAdminPath = ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath));
// Allow public paths
// Allow public paths and tenant client booking routes
if (
isPublicPath &&
(isPublicPath || isTenantClientBookingRoute) &&
!isProtectedAuthPath &&
!isProtectedPath &&
!isAdminPath &&
@@ -0,0 +1,71 @@
CREATE TABLE "appointment_key_share" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"appointment_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"encrypted_key" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth_challenge" (
"id" text PRIMARY KEY NOT NULL,
"challenge" text NOT NULL,
"email_hash" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"expires_at" timestamp NOT NULL,
"consumed" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
CREATE TABLE "client_appointment_tunnel" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email_hash" text NOT NULL,
"client_public_key" text NOT NULL,
"private_key_share" text NOT NULL,
"client_key_share" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "client_appointment_tunnel_email_hash_unique" UNIQUE("email_hash")
);
--> statement-breakpoint
CREATE TABLE "client_tunnel_staff_key_share" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tunnel_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"encrypted_tunnel_key" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "staff_crypto" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"public_key" text NOT NULL,
"private_key_share" text NOT NULL,
"passkey_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
"is_active" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
ALTER TABLE "appointment" DROP CONSTRAINT "appointment_client_id_client_id_fk";
--> statement-breakpoint
ALTER TABLE "appointment" ALTER COLUMN "appointment_date" SET DATA TYPE timestamp;--> statement-breakpoint
ALTER TABLE "appointment" ALTER COLUMN "expiry_date" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "appointment" ALTER COLUMN "status" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "tunnel_id" uuid NOT NULL;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "encrypted_data" text;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "data_key" text;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "is_encrypted" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "encrypted_payload" text;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "iv" text;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "auth_tag" text;--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "created_at" timestamp DEFAULT now();--> statement-breakpoint
ALTER TABLE "appointment" ADD COLUMN "updated_at" timestamp DEFAULT now();--> statement-breakpoint
ALTER TABLE "appointment_key_share" ADD CONSTRAINT "appointment_key_share_appointment_id_appointment_id_fk" FOREIGN KEY ("appointment_id") REFERENCES "public"."appointment"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointment_key_share" ADD CONSTRAINT "appointment_key_share_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "client_tunnel_staff_key_share" ADD CONSTRAINT "client_tunnel_staff_key_share_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "staff_crypto" ADD CONSTRAINT "staff_crypto_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "client_tunnel_staff_key_unique_idx" ON "client_tunnel_staff_key_share" USING btree ("tunnel_id","user_id");--> statement-breakpoint
CREATE UNIQUE INDEX "staff_crypto_user_active_idx" ON "staff_crypto" USING btree ("user_id") WHERE "staff_crypto"."is_active" = $1;--> statement-breakpoint
ALTER TABLE "appointment" ADD CONSTRAINT "appointment_tunnel_id_client_appointment_tunnel_id_fk" FOREIGN KEY ("tunnel_id") REFERENCES "public"."client_appointment_tunnel"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "appointment" DROP COLUMN "client_id";--> statement-breakpoint
ALTER TABLE "appointment" DROP COLUMN "name";--> statement-breakpoint
ALTER TABLE "appointment" DROP COLUMN "phone";
@@ -0,0 +1 @@
ALTER TABLE "appointment" DROP COLUMN "is_encrypted";
+840
View File
@@ -0,0 +1,840 @@
{
"id": "6af592a7-2a55-4c3d-9aaa-037e933b6668",
"prevId": "71a71648-32b1-483e-b395-949a63f93f7d",
"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
}
},
"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
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"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
},
"is_encrypted": {
"name": "is_encrypted",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": 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"
}
},
"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"
},
"appointment_key_share_user_id_user_id_fk": {
"name": "appointment_key_share_user_id_user_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "user",
"columnsFrom": ["user_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
}
},
"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": {
"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": "",
"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": {
"client_tunnel_staff_key_unique_idx": {
"name": "client_tunnel_staff_key_unique_idx",
"columns": [
{
"expression": "tunnel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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"
},
"client_tunnel_staff_key_share_user_id_user_id_fk": {
"name": "client_tunnel_staff_key_share_user_id_user_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "user",
"columnsFrom": ["user_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": {
"staff_crypto_user_active_idx": {
"name": "staff_crypto_user_active_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"staff_crypto\".\"is_active\" = $1",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"staff_crypto_user_id_user_id_fk": {
"name": "staff_crypto_user_id_user_id_fk",
"tableFrom": "staff_crypto",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"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": {}
}
}
+833
View File
@@ -0,0 +1,833 @@
{
"id": "e9578637-45b2-47d6-9b42-a317b4add57a",
"prevId": "6af592a7-2a55-4c3d-9aaa-037e933b6668",
"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
}
},
"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
},
"appointment_date": {
"name": "appointment_date",
"type": "timestamp",
"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"
}
},
"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"
},
"appointment_key_share_user_id_user_id_fk": {
"name": "appointment_key_share_user_id_user_id_fk",
"tableFrom": "appointment_key_share",
"tableTo": "user",
"columnsFrom": ["user_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
}
},
"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": {
"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": "",
"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": {
"client_tunnel_staff_key_unique_idx": {
"name": "client_tunnel_staff_key_unique_idx",
"columns": [
{
"expression": "tunnel_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"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"
},
"client_tunnel_staff_key_share_user_id_user_id_fk": {
"name": "client_tunnel_staff_key_share_user_id_user_id_fk",
"tableFrom": "client_tunnel_staff_key_share",
"tableTo": "user",
"columnsFrom": ["user_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": {
"staff_crypto_user_active_idx": {
"name": "staff_crypto_user_active_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"staff_crypto\".\"is_active\" = $1",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"staff_crypto_user_id_user_id_fk": {
"name": "staff_crypto_user_id_user_id_fk",
"tableFrom": "staff_crypto",
"tableTo": "user",
"columnsFrom": ["user_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"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": {}
}
}
+14
View File
@@ -29,6 +29,20 @@
"when": 1759144219626,
"tag": "0003_big_boomerang",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1759421338309,
"tag": "0004_perfect_tomorrow_man",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1759671656380,
"tag": "0005_complex_barracuda",
"breakpoints": true
}
]
}