diff --git a/.prettierignore b/.prettierignore index 4f611ee..8151cc1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,4 +9,5 @@ bun.lock bun.lockb # Build Artifacts -src/i18n/** \ No newline at end of file +src/i18n/** +project.inlang/** \ No newline at end of file diff --git a/.prettierrc b/.prettierrc index 1e5915a..7a9b6e3 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,15 +1,15 @@ { - "useTabs": true, - "singleQuote": false, - "trailingComma": "none", - "printWidth": 100, - "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], - "overrides": [ - { - "files": "*.svelte", - "options": { - "parser": "svelte" - } - } - ] + "useTabs": false, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + "overrides": [ + { + "files": "*.svelte", + "options": { + "parser": "svelte" + } + } + ] } diff --git a/components.json b/components.json index c5d91b4..298e869 100644 --- a/components.json +++ b/components.json @@ -1,16 +1,16 @@ { - "$schema": "https://shadcn-svelte.com/schema.json", - "tailwind": { - "css": "src/app.css", - "baseColor": "slate" - }, - "aliases": { - "components": "$lib/components", - "utils": "$lib/utils", - "ui": "$lib/components/ui", - "hooks": "$lib/hooks", - "lib": "$lib" - }, - "typescript": true, - "registry": "https://shadcn-svelte.com/registry" + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "slate" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry" } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 5c5d2de..9e85a7d 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -20,7 +20,7 @@ services: test: [ "CMD-SHELL", - "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-appointment_booking}" + "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-appointment_booking}", ] interval: 10s timeout: 5s diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index ff0f96f..65cda14 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,7 +22,7 @@ services: test: [ "CMD-SHELL", - "pg_isready -U $$(cat /run/secrets/postgres_user) -d $$(cat /run/secrets/postgres_db)" + "pg_isready -U $$(cat /run/secrets/postgres_user) -d $$(cat /run/secrets/postgres_db)", ] interval: 10s timeout: 5s diff --git a/docs/email-system.md b/docs/email-system.md index 2dcd958..f95ee08 100644 --- a/docs/email-system.md +++ b/docs/email-system.md @@ -159,9 +159,9 @@ Tenants can customize three color values that are automatically applied to email ```typescript interface TenantBranding { - primaryColor: string; // Main brand color (buttons, links, logos) - secondaryColor: string; // Accent color (success messages, highlights) - backgroundColor: string; // Email background color + primaryColor: string; // Main brand color (buttons, links, logos) + secondaryColor: string; // Accent color (success messages, highlights) + backgroundColor: string; // Email background color } ``` @@ -192,9 +192,9 @@ Tenant logos are stored as binary data in the database and automatically convert ```html {{#if tenant.logo}} {{tenant.longName}} {{/if}} @@ -255,16 +255,16 @@ The email system is designed with privacy in mind: ```typescript // Client email creation (privacy-focused) const clientRecipient = { - email: user.email || "", // May be empty - name: undefined, // Never stored for clients - language: user.language || "de" + email: user.email || "", // May be empty + name: undefined, // Never stored for clients + language: user.language || "de", }; // Staff email creation const staffRecipient = { - email: user.email, // Always required - name: user.name || undefined, // Optional display name - language: user.language || "de" + email: user.email, // Always required + name: user.name || undefined, // Optional display name + language: user.language || "de", }; ``` @@ -303,10 +303,10 @@ The system automatically uses test mode in development: ```typescript // Add test case in email-system.test.ts const result = await templateEngine.renderTemplate("user-created", { - recipient: { email: "test@example.com", name: "Test User" }, - subject: "Test Subject", - language: "de", - tenant: mockTenant + recipient: { email: "test@example.com", name: "Test User" }, + subject: "Test Subject", + language: "de", + tenant: mockTenant, }); ``` diff --git a/docs/infrastructure.md b/docs/infrastructure.md index a8ab6b2..aeef6ca 100644 --- a/docs/infrastructure.md +++ b/docs/infrastructure.md @@ -170,29 +170,29 @@ import { readFileSync } from "fs"; import pg from "pg"; function getDatabaseConfig() { - if (process.env.NODE_ENV === "production") { - // Read from Docker secrets - const user = readFileSync("/run/secrets/postgres_user", "utf8").trim(); - const password = readFileSync("/run/secrets/postgres_password", "utf8").trim(); - const database = readFileSync("/run/secrets/postgres_db", "utf8").trim(); + if (process.env.NODE_ENV === "production") { + // Read from Docker secrets + const user = readFileSync("/run/secrets/postgres_user", "utf8").trim(); + const password = readFileSync("/run/secrets/postgres_password", "utf8").trim(); + const database = readFileSync("/run/secrets/postgres_db", "utf8").trim(); - return { - host: "postgres", - port: 5432, - user, - password, - database - }; - } else { - // Development configuration - return { - host: "localhost", - port: process.env.POSTGRES_PORT || 5432, - user: process.env.POSTGRES_USER || "postgres", - password: process.env.POSTGRES_PASSWORD, - database: process.env.POSTGRES_DB || "appointment_booking" - }; - } + return { + host: "postgres", + port: 5432, + user, + password, + database, + }; + } else { + // Development configuration + return { + host: "localhost", + port: process.env.POSTGRES_PORT || 5432, + user: process.env.POSTGRES_USER || "postgres", + password: process.env.POSTGRES_PASSWORD, + database: process.env.POSTGRES_DB || "appointment_booking", + }; + } } export const pool = new pg.Pool(getDatabaseConfig()); @@ -207,12 +207,12 @@ import { json } from "@sveltejs/kit"; import { pool } from "$lib/database.js"; export async function GET() { - try { - await pool.query("SELECT 1"); - return json({ status: "healthy", timestamp: new Date().toISOString() }); - } catch (error) { - return json({ status: "unhealthy", error: error.message }, { status: 500 }); - } + try { + await pool.query("SELECT 1"); + return json({ status: "healthy", timestamp: new Date().toISOString() }); + } catch (error) { + return json({ status: "unhealthy", error: error.message }, { status: 500 }); + } } ``` @@ -225,11 +225,11 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [sveltekit()], - server: { - host: "0.0.0.0", - port: 5173 - } + plugins: [sveltekit()], + server: { + host: "0.0.0.0", + port: 5173, + }, }); ``` diff --git a/docs/universal-logger.md b/docs/universal-logger.md index cb23caf..585fa75 100644 --- a/docs/universal-logger.md +++ b/docs/universal-logger.md @@ -138,23 +138,23 @@ The server-side Winston logger is configured with: import { logger } from "$lib/logger"; export const handle = async ({ event, resolve }) => { - const start = Date.now(); - const requestLogger = logger.setContext("REQUEST"); + const start = Date.now(); + const requestLogger = logger.setContext("REQUEST"); - try { - const response = await resolve(event); - const duration = Date.now() - start; + try { + const response = await resolve(event); + const duration = Date.now() - start; - requestLogger.info(`${event.request.method} ${event.url.pathname}`, { - status: response.status, - duration: `${duration}ms` - }); + requestLogger.info(`${event.request.method} ${event.url.pathname}`, { + status: response.status, + duration: `${duration}ms`, + }); - return response; - } catch (error) { - requestLogger.error("Request failed", { error }); - throw error; - } + return response; + } catch (error) { + requestLogger.error("Request failed", { error }); + throw error; + } }; ``` diff --git a/drizzle.config.ts b/drizzle.config.ts index a1427d8..4d61959 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -11,10 +11,10 @@ if (!POSTGRES_PASSWORD) throw new Error("POSTGRES_PASSWORD is not set"); const DATABASE_URL = `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}`; export default defineConfig({ - schema: "./src/lib/server/db/central-schema.ts", - dialect: "postgresql", - dbCredentials: { url: DATABASE_URL }, - out: "./migrations", - verbose: true, - strict: true + schema: "./src/lib/server/db/central-schema.ts", + dialect: "postgresql", + dbCredentials: { url: DATABASE_URL }, + out: "./migrations", + verbose: true, + strict: true, }); diff --git a/drizzle.tenant.config.ts b/drizzle.tenant.config.ts index eb81fe6..7e9120e 100644 --- a/drizzle.tenant.config.ts +++ b/drizzle.tenant.config.ts @@ -3,12 +3,12 @@ import { defineConfig } from "drizzle-kit"; // This config is used to generate migrations for tenant schemas // It uses a placeholder database URL that will be replaced at runtime export default defineConfig({ - schema: "./src/lib/server/db/tenant-schema.ts", - dialect: "postgresql", - dbCredentials: { - url: "postgresql://placeholder:placeholder@localhost:5432/placeholder" - }, - out: "./tenant-migrations", - verbose: true, - strict: true + schema: "./src/lib/server/db/tenant-schema.ts", + dialect: "postgresql", + dbCredentials: { + url: "postgresql://placeholder:placeholder@localhost:5432/placeholder", + }, + out: "./tenant-migrations", + verbose: true, + strict: true, }); diff --git a/e2e/demo.test.ts b/e2e/demo.test.ts index 1f5a374..4e8ffcc 100644 --- a/e2e/demo.test.ts +++ b/e2e/demo.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "@playwright/test"; test("home page has expected h1", async ({ page }) => { - await page.goto("/"); - await expect(page.locator("h1")).toBeVisible(); + await page.goto("/"); + await expect(page.locator("h1")).toBeVisible(); }); diff --git a/eslint.config.js b/eslint.config.js index dedef03..ec83b6b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -10,30 +10,30 @@ import svelteConfig from "./svelte.config.js"; const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url)); export default ts.config( - includeIgnoreFile(gitignorePath), - js.configs.recommended, - ...ts.configs.recommended, - ...svelte.configs.recommended, - prettier, - ...svelte.configs.prettier, - { - ignores: ["static/**"] - }, - { - languageOptions: { - globals: { ...globals.browser, ...globals.node } - }, - rules: { "no-undef": "off" } - }, - { - files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], - languageOptions: { - parserOptions: { - projectService: true, - extraFileExtensions: [".svelte"], - parser: ts.parser, - svelteConfig - } - } - } + includeIgnoreFile(gitignorePath), + js.configs.recommended, + ...ts.configs.recommended, + ...svelte.configs.recommended, + prettier, + ...svelte.configs.prettier, + { + ignores: ["static/**"], + }, + { + languageOptions: { + globals: { ...globals.browser, ...globals.node }, + }, + rules: { "no-undef": "off" }, + }, + { + files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"], + languageOptions: { + parserOptions: { + projectService: true, + extraFileExtensions: [".svelte"], + parser: ts.parser, + svelteConfig, + }, + }, + }, ); diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json index 9ec0a3d..03f5698 100644 --- a/migrations/meta/0000_snapshot.json +++ b/migrations/meta/0000_snapshot.json @@ -1,551 +1,551 @@ { - "id": "e70213b3-227a-4a40-bdbe-470310817fb3", - "prevId": "00000000-0000-0000-0000-000000000000", - "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 - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo": { - "name": "logo", - "type": "bytea", - "primaryKey": false, - "notNull": false - }, - "database_url": { - "name": "database_url", - "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_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": "'GLOBAL_ADMIN'" - }, - "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 - }, - "confirmed": { - "name": "confirmed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "token": { - "name": "token", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "token_valid_until": { - "name": "token_valid_until", - "type": "timestamp", - "primaryKey": false, - "notNull": false - } - }, - "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_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.user_role": { - "name": "user_role", - "schema": "public", - "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } + "id": "e70213b3-227a-4a40-bdbe-470310817fb3", + "prevId": "00000000-0000-0000-0000-000000000000", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "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_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": "'GLOBAL_ADMIN'" + }, + "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 + }, + "confirmed": { + "name": "confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_valid_until": { + "name": "token_valid_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "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_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.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } } diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json index cadc752..fa54d88 100644 --- a/migrations/meta/0001_snapshot.json +++ b/migrations/meta/0001_snapshot.json @@ -1,563 +1,563 @@ { - "id": "bdfafee4-65d2-4381-b4e7-df8c9337d0c1", - "prevId": "e70213b3-227a-4a40-bdbe-470310817fb3", - "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 - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo": { - "name": "logo", - "type": "bytea", - "primaryKey": false, - "notNull": false - }, - "database_url": { - "name": "database_url", - "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_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": "'GLOBAL_ADMIN'" - }, - "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 - }, - "confirmed": { - "name": "confirmed", - "type": "boolean", - "primaryKey": false, - "notNull": false, - "default": false - }, - "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 - } - }, - "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_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.user_role": { - "name": "user_role", - "schema": "public", - "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } + "id": "bdfafee4-65d2-4381-b4e7-df8c9337d0c1", + "prevId": "e70213b3-227a-4a40-bdbe-470310817fb3", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "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_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": "'GLOBAL_ADMIN'" + }, + "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 + }, + "confirmed": { + "name": "confirmed", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "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 + } + }, + "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_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.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } } diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 1d95f67..8996898 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -1,20 +1,20 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1752661377819, - "tag": "0000_big_ultimatum", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1752668982273, - "tag": "0001_equal_wonder_man", - "breakpoints": true - } - ] + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1752661377819, + "tag": "0000_big_ultimatum", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1752668982273, + "tag": "0001_equal_wonder_man", + "breakpoints": true + } + ] } diff --git a/package.json b/package.json index de8ae7a..a58d611 100644 --- a/package.json +++ b/package.json @@ -1,100 +1,100 @@ { - "name": "open-reception", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "End-to-end encrypted appointment booking platform", - "scripts": { - "dev": "vite dev", - "build": "vite build", - "preview": "vite preview", - "prepare": "svelte-kit sync && npm run i18n:compile || echo ''", - "check": "npm run prepare && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "npm run prepare && svelte-check --tsconfig ./tsconfig.json --watch", - "format": "prettier --write .", - "lint": "prettier --check . && eslint .", - "test:unit": "vitest", - "test": "npm run test:unit -- --run && npm run test:e2e", - "test:e2e": "playwright test", - "db:push": "drizzle-kit push", - "db:migrate": "drizzle-kit migrate", - "db:studio": "drizzle-kit studio", - "db:generate": "drizzle-kit generate", - "db:tenant:generate": "drizzle-kit generate --config=drizzle.tenant.config.ts", - "docker:dev:up": "docker compose -f docker-compose.dev.yml up -d", - "docker:dev:down": "docker compose -f docker-compose.dev.yml down", - "docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f", - "docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans", - "docker:build": "docker build -t openreception/open-reception:latest .", - "docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version", - "docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest", - "docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push", - "docker:prod:up": "docker compose -f docker-compose.prod.yml up -d", - "docker:prod:down": "docker compose -f docker-compose.prod.yml down", - "docker:prod:logs": "docker compose -f docker-compose.prod.yml logs -f", - "docker:prod:clean": "docker compose -f docker-compose.prod.yml down -v --remove-orphans", - "i18n:compile": "npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/i18n" - }, - "devDependencies": { - "@eslint/compat": "^1.3.0", - "@eslint/js": "^9.29.0", - "@inlang/paraglide-js": "2.2.0", - "@internationalized/date": "^3.8.2", - "@lucide/svelte": "^0.542.0", - "@playwright/test": "^1.53.1", - "@sveltejs/adapter-auto": "^6.0.1", - "@sveltejs/kit": "^2.22.0", - "@sveltejs/vite-plugin-svelte": "^5.1.0", - "@tailwindcss/typography": "^0.5.16", - "@tailwindcss/vite": "^4.1.10", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/svelte": "^5.2.8", - "@types/dotenv": "^6.1.1", - "@types/node": "^24", - "@types/nodemailer": "^6.4.17", - "bits-ui": "^2.8.13", - "clsx": "^2.1.1", - "drizzle-kit": "^0.31.1", - "eslint": "^9.29.0", - "eslint-config-prettier": "^10.1.5", - "eslint-plugin-svelte": "^3.9.3", - "formsnap": "^2.0.1", - "globals": "^16.2.0", - "jsdom": "^26.1.0", - "prettier": "^3.5.3", - "prettier-plugin-svelte": "^3.4.0", - "prettier-plugin-tailwindcss": "^0.6.13", - "svelte": "^5.34.7", - "svelte-check": "^4.2.2", - "svelte-sonner": "^1.0.5", - "sveltekit-superforms": "^2.27.1", - "tailwind-merge": "^3.3.1", - "tailwind-variants": "^1.0.0", - "tailwindcss": "^4.1.10", - "tw-animate-css": "^1.3.4", - "typescript": "^5.8.3", - "typescript-eslint": "^8.34.1", - "vite": "^6.3.5", - "vitest": "^3.2.4" - }, - "dependencies": { - "@noble/hashes": "^1.8.0", - "@noble/post-quantum": "^0.4.1", - "@sveltejs/adapter-node": "^5.2.12", - "argon2": "^0.43.0", - "argon2-browser": "^1.18.0", - "date-fns": "^4.1.0", - "dotenv": "^16.5.0", - "dotenv-expand": "^12.0.2", - "drizzle-orm": "^0.44.2", - "jose": "^6.0.11", - "mode-watcher": "^1.0.8", - "nodemailer": "^7.0.3", - "postgres": "^3.4.7", - "secrets.js-34r7h": "^2.0.2", - "uuidv7": "^1.0.2", - "winston": "^3.17.0", - "zod": "^3.25.76" - }, - "license": "AGPL-3.0" + "name": "open-reception", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "End-to-end encrypted appointment booking platform", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync && npm run i18n:compile || echo ''", + "check": "npm run prepare && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "npm run prepare && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "prettier --write .", + "lint": "prettier --check . && eslint .", + "test:unit": "vitest", + "test": "npm run test:unit -- --run && npm run test:e2e", + "test:e2e": "playwright test", + "db:push": "drizzle-kit push", + "db:migrate": "drizzle-kit migrate", + "db:studio": "drizzle-kit studio", + "db:generate": "drizzle-kit generate", + "db:tenant:generate": "drizzle-kit generate --config=drizzle.tenant.config.ts", + "docker:dev:up": "docker compose -f docker-compose.dev.yml up -d", + "docker:dev:down": "docker compose -f docker-compose.dev.yml down", + "docker:dev:logs": "docker compose -f docker-compose.dev.yml logs -f", + "docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans", + "docker:build": "docker build -t openreception/open-reception:latest .", + "docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version", + "docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest", + "docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push", + "docker:prod:up": "docker compose -f docker-compose.prod.yml up -d", + "docker:prod:down": "docker compose -f docker-compose.prod.yml down", + "docker:prod:logs": "docker compose -f docker-compose.prod.yml logs -f", + "docker:prod:clean": "docker compose -f docker-compose.prod.yml down -v --remove-orphans", + "i18n:compile": "npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/i18n" + }, + "devDependencies": { + "@eslint/compat": "^1.3.0", + "@eslint/js": "^9.29.0", + "@inlang/paraglide-js": "2.2.0", + "@internationalized/date": "^3.8.2", + "@lucide/svelte": "^0.542.0", + "@playwright/test": "^1.53.1", + "@sveltejs/adapter-auto": "^6.0.1", + "@sveltejs/kit": "^2.22.0", + "@sveltejs/vite-plugin-svelte": "^5.1.0", + "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/vite": "^4.1.10", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/svelte": "^5.2.8", + "@types/dotenv": "^6.1.1", + "@types/node": "^24", + "@types/nodemailer": "^6.4.17", + "bits-ui": "^2.8.13", + "clsx": "^2.1.1", + "drizzle-kit": "^0.31.1", + "eslint": "^9.29.0", + "eslint-config-prettier": "^10.1.5", + "eslint-plugin-svelte": "^3.9.3", + "formsnap": "^2.0.1", + "globals": "^16.2.0", + "jsdom": "^26.1.0", + "prettier": "^3.5.3", + "prettier-plugin-svelte": "^3.4.0", + "prettier-plugin-tailwindcss": "^0.6.13", + "svelte": "^5.34.7", + "svelte-check": "^4.2.2", + "svelte-sonner": "^1.0.5", + "sveltekit-superforms": "^2.27.1", + "tailwind-merge": "^3.3.1", + "tailwind-variants": "^1.0.0", + "tailwindcss": "^4.1.10", + "tw-animate-css": "^1.3.4", + "typescript": "^5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^6.3.5", + "vitest": "^3.2.4" + }, + "dependencies": { + "@noble/hashes": "^1.8.0", + "@noble/post-quantum": "^0.4.1", + "@sveltejs/adapter-node": "^5.2.12", + "argon2": "^0.43.0", + "argon2-browser": "^1.18.0", + "date-fns": "^4.1.0", + "dotenv": "^16.5.0", + "dotenv-expand": "^12.0.2", + "drizzle-orm": "^0.44.2", + "jose": "^6.0.11", + "mode-watcher": "^1.0.8", + "nodemailer": "^7.0.3", + "postgres": "^3.4.7", + "secrets.js-34r7h": "^2.0.2", + "uuidv7": "^1.0.2", + "winston": "^3.17.0", + "zod": "^3.25.76" + }, + "license": "AGPL-3.0" } diff --git a/playwright.config.ts b/playwright.config.ts index 6449fa1..1e4262e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,9 +1,9 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ - webServer: { - command: "npm run build && npm run preview", - port: 4173 - }, - testDir: "e2e" + webServer: { + command: "npm run build && npm run preview", + port: 4173, + }, + testDir: "e2e", }); diff --git a/src/app.css b/src/app.css index a856a7a..5cc759f 100644 --- a/src/app.css +++ b/src/app.css @@ -2,143 +2,143 @@ @custom-variant dark (&:where(.dark, .dark *)); :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.129 0.042 264.695); - --card: oklch(1 0 0); - --card-foreground: oklch(0.129 0.042 264.695); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.129 0.042 264.695); - --primary: oklch(0.208 0.042 265.755); - --primary-foreground: oklch(0.984 0.003 247.858); - --secondary: oklch(0.968 0.007 247.896); - --secondary-foreground: oklch(0.208 0.042 265.755); - --muted: oklch(0.968 0.007 247.896); - --muted-foreground: oklch(0.554 0.046 257.417); - --accent: oklch(0.968 0.007 247.896); - --accent-foreground: oklch(0.208 0.042 265.755); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.929 0.013 255.508); - --input: oklch(0.929 0.013 255.508); - --ring: oklch(0.704 0.04 256.788); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.984 0.003 247.858); - --sidebar-foreground: oklch(0.129 0.042 264.695); - --sidebar-primary: oklch(0.208 0.042 265.755); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.968 0.007 247.896); - --sidebar-accent-foreground: oklch(0.208 0.042 265.755); - --sidebar-border: oklch(0.929 0.013 255.508); - --sidebar-ring: oklch(0.704 0.04 256.788); + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.129 0.042 264.695); + --card: oklch(1 0 0); + --card-foreground: oklch(0.129 0.042 264.695); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.129 0.042 264.695); + --primary: oklch(0.208 0.042 265.755); + --primary-foreground: oklch(0.984 0.003 247.858); + --secondary: oklch(0.968 0.007 247.896); + --secondary-foreground: oklch(0.208 0.042 265.755); + --muted: oklch(0.968 0.007 247.896); + --muted-foreground: oklch(0.554 0.046 257.417); + --accent: oklch(0.968 0.007 247.896); + --accent-foreground: oklch(0.208 0.042 265.755); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.929 0.013 255.508); + --input: oklch(0.929 0.013 255.508); + --ring: oklch(0.704 0.04 256.788); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --sidebar: oklch(0.984 0.003 247.858); + --sidebar-foreground: oklch(0.129 0.042 264.695); + --sidebar-primary: oklch(0.208 0.042 265.755); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.968 0.007 247.896); + --sidebar-accent-foreground: oklch(0.208 0.042 265.755); + --sidebar-border: oklch(0.929 0.013 255.508); + --sidebar-ring: oklch(0.704 0.04 256.788); - /* custom colors */ - --lighter: oklch(77.983% 0.03673 254.95); - --light: oklch(69.121% 0.03859 257.443); - --medium: oklch(62.379% 0.01913 256.381); - --dark: oklch(43.309% 0.00977 254.027); - --darker: oklch(27.232% 0.00797 264.468); + /* custom colors */ + --lighter: oklch(77.983% 0.03673 254.95); + --light: oklch(69.121% 0.03859 257.443); + --medium: oklch(62.379% 0.01913 256.381); + --dark: oklch(43.309% 0.00977 254.027); + --darker: oklch(27.232% 0.00797 264.468); } .dark { - --background: oklch(0.129 0.042 264.695); - --foreground: oklch(0.984 0.003 247.858); - --card: oklch(0.208 0.042 265.755); - --card-foreground: oklch(0.984 0.003 247.858); - --popover: oklch(0.208 0.042 265.755); - --popover-foreground: oklch(0.984 0.003 247.858); - --primary: oklch(0.929 0.013 255.508); - --primary-foreground: oklch(0.208 0.042 265.755); - --secondary: oklch(0.279 0.041 260.031); - --secondary-foreground: oklch(0.984 0.003 247.858); - --muted: oklch(0.279 0.041 260.031); - --muted-foreground: oklch(0.704 0.04 256.788); - --accent: oklch(0.279 0.041 260.031); - --accent-foreground: oklch(0.984 0.003 247.858); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.551 0.027 264.364); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.208 0.042 265.755); - --sidebar-foreground: oklch(0.984 0.003 247.858); - --sidebar-primary: oklch(0.488 0.243 264.376); - --sidebar-primary-foreground: oklch(0.984 0.003 247.858); - --sidebar-accent: oklch(0.279 0.041 260.031); - --sidebar-accent-foreground: oklch(0.984 0.003 247.858); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.551 0.027 264.364); + --background: oklch(0.129 0.042 264.695); + --foreground: oklch(0.984 0.003 247.858); + --card: oklch(0.208 0.042 265.755); + --card-foreground: oklch(0.984 0.003 247.858); + --popover: oklch(0.208 0.042 265.755); + --popover-foreground: oklch(0.984 0.003 247.858); + --primary: oklch(0.929 0.013 255.508); + --primary-foreground: oklch(0.208 0.042 265.755); + --secondary: oklch(0.279 0.041 260.031); + --secondary-foreground: oklch(0.984 0.003 247.858); + --muted: oklch(0.279 0.041 260.031); + --muted-foreground: oklch(0.704 0.04 256.788); + --accent: oklch(0.279 0.041 260.031); + --accent-foreground: oklch(0.984 0.003 247.858); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.551 0.027 264.364); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.208 0.042 265.755); + --sidebar-foreground: oklch(0.984 0.003 247.858); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.984 0.003 247.858); + --sidebar-accent: oklch(0.279 0.041 260.031); + --sidebar-accent-foreground: oklch(0.984 0.003 247.858); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.551 0.027 264.364); - /* custom colors */ - --lighter: oklch(27.232% 0.00797 264.468); - --light: oklch(43.309% 0.00977 254.027); - --medium: oklch(62.379% 0.01913 256.381); - --dark: oklch(69.121% 0.03859 257.443); - --darker: oklch(77.983% 0.03673 254.95); + /* custom colors */ + --lighter: oklch(27.232% 0.00797 264.468); + --light: oklch(43.309% 0.00977 254.027); + --medium: oklch(62.379% 0.01913 256.381); + --dark: oklch(69.121% 0.03859 257.443); + --darker: oklch(77.983% 0.03673 254.95); } @theme { - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); - --color-lighter: var(--lighter); - --color-light: var(--light); - --color-medium: var(--medium); - --color-dark: var(--dark); - --color-darker: var(--darker); + --color-lighter: var(--lighter); + --color-light: var(--light); + --color-medium: var(--medium); + --color-dark: var(--dark); + --color-darker: var(--darker); - --max-w-sm: 400px; - --max-w-md: 640px; + --max-w-sm: 400px; + --max-w-md: 640px; } @layer base { - * { - border-color: var(--border); - outline-color: color-mix(in oklab, var(--ring) 50%, transparent); - } + * { + border-color: var(--border); + outline-color: color-mix(in oklab, var(--ring) 50%, transparent); + } - body { - background-color: var(--background); - color: var(--foreground); - } + body { + background-color: var(--background); + color: var(--foreground); + } } diff --git a/src/app.d.ts b/src/app.d.ts index 8876c95..d48c210 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,18 +4,18 @@ import type { JWTPayload } from "jose"; // for information about these interfaces declare global { - namespace App { - // interface Error {} - interface Locals { - user?: JWTPayload & { userId: string; sessionId: string }; - } - // interface PageData {} - // interface PageState {} - // interface Platform {} - } - interface PageState { - email?: string; - } + namespace App { + // interface Error {} + interface Locals { + user?: JWTPayload & { userId: string; sessionId: string }; + } + // interface PageData {} + // interface PageState {} + // interface Platform {} + } + interface PageState { + email?: string; + } } export {}; diff --git a/src/app.html b/src/app.html index c6a98cd..54410cc 100644 --- a/src/app.html +++ b/src/app.html @@ -1,19 +1,19 @@ - - - - - - - - - - - %sveltekit.head% - + + + + + + + + + + + %sveltekit.head% + - -
%sveltekit.body%
- + +
%sveltekit.body%
+ diff --git a/src/demo.spec.ts b/src/demo.spec.ts index 808401e..dadc7e4 100644 --- a/src/demo.spec.ts +++ b/src/demo.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; describe("sum test", () => { - it("adds 1 + 2 to equal 3", () => { - expect(1 + 2).toBe(3); - }); + it("adds 1 + 2 to equal 3", () => { + expect(1 + 2).toBe(3); + }); }); diff --git a/src/hooks.server.test.ts b/src/hooks.server.test.ts index c064c57..d0ce7b3 100644 --- a/src/hooks.server.test.ts +++ b/src/hooks.server.test.ts @@ -4,224 +4,224 @@ import { handle } from "./hooks.server"; // Mock the startup service vi.mock("$lib/server/services/startup-service", () => ({ - StartupService: { - initialize: vi.fn(() => Promise.resolve()) - } + StartupService: { + initialize: vi.fn(() => Promise.resolve()), + }, })); // Mock auth services vi.mock("$lib/server/auth/session-service", () => ({ - SessionService: { - validateSession: vi.fn() - } + SessionService: { + validateSession: vi.fn(), + }, })); vi.mock("$lib/server/auth/jwt-utils", () => ({ - verifyAccessToken: vi.fn() + verifyAccessToken: vi.fn(), })); vi.mock("$lib/server/auth/authorization-service", () => ({ - AuthorizationService: { - hasRole: vi.fn(), - hasAnyRole: vi.fn() - } + AuthorizationService: { + hasRole: vi.fn(), + hasAnyRole: vi.fn(), + }, })); // Mock the Date.now function for rate limiting tests const mockDateNow = vi.fn(); const OriginalDate = Date; vi.stubGlobal( - "Date", - class extends OriginalDate { - static now = mockDateNow; - } + "Date", + class extends OriginalDate { + static now = mockDateNow; + }, ); describe("hooks.server", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockDateNow.mockReturnValue(1000000); // Fixed timestamp for consistent testing - }); + beforeEach(() => { + vi.clearAllMocks(); + mockDateNow.mockReturnValue(1000000); // Fixed timestamp for consistent testing + }); - afterEach(() => { - // Reset any global state - vi.clearAllTimers(); - }); + afterEach(() => { + // Reset any global state + vi.clearAllTimers(); + }); - describe("rate limiting", () => { - const mockResolve = vi.fn(); - const createEvent = (ip: string = "192.168.1.1", method: string = "GET") => { - const request = new Request("http://localhost/api/health", { - method, - headers: { - "x-forwarded-for": ip - } - }); + describe("rate limiting", () => { + const mockResolve = vi.fn(); + const createEvent = (ip: string = "192.168.1.1", method: string = "GET") => { + const request = new Request("http://localhost/api/health", { + method, + headers: { + "x-forwarded-for": ip, + }, + }); - return { - url: new URL("http://localhost/api/health"), - request, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => ip, - locals: {}, - params: {}, - route: { id: null }, - setHeaders: vi.fn(), - isDataRequest: false, - isSubRequest: false, - platform: {} as any - }; - }; + return { + url: new URL("http://localhost/api/health"), + request, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => ip, + locals: {}, + params: {}, + route: { id: null }, + setHeaders: vi.fn(), + isDataRequest: false, + isSubRequest: false, + platform: {} as any, + }; + }; - beforeEach(() => { - mockResolve.mockResolvedValue(new Response("OK")); - }); + beforeEach(() => { + mockResolve.mockResolvedValue(new Response("OK")); + }); - it("should allow requests within rate limit", async () => { - const event = createEvent("192.168.1.100"); // Unique IP for this test + it("should allow requests within rate limit", async () => { + const event = createEvent("192.168.1.100"); // Unique IP for this test - const response = await handle({ event, resolve: mockResolve }); + const response = await handle({ event, resolve: mockResolve }); - expect(response.status).not.toBe(429); - expect(mockResolve).toHaveBeenCalled(); - expect(mockResolve.mock.calls[0][0]).toEqual(event); - }); + expect(response.status).not.toBe(429); + expect(mockResolve).toHaveBeenCalled(); + expect(mockResolve.mock.calls[0][0]).toEqual(event); + }); - it("should block requests when rate limit exceeded", async () => { - const event = createEvent("192.168.1.101"); // Unique IP for this test + it("should block requests when rate limit exceeded", async () => { + const event = createEvent("192.168.1.101"); // Unique IP for this test - // Make multiple requests to exceed rate limit - for (let i = 0; i < 10; i++) { - await handle({ event, resolve: mockResolve }); - } + // Make multiple requests to exceed rate limit + for (let i = 0; i < 10; i++) { + await handle({ event, resolve: mockResolve }); + } - // This request should be rate limited - const response = await handle({ event, resolve: mockResolve }); + // This request should be rate limited + const response = await handle({ event, resolve: mockResolve }); - expect(response.status).toBe(429); - expect(await response.text()).toBe("Too Many Requests"); - }); + expect(response.status).toBe(429); + expect(await response.text()).toBe("Too Many Requests"); + }); - it("should reset rate limit after window expires", async () => { - const event = createEvent("192.168.1.102"); // Unique IP for this test + it("should reset rate limit after window expires", async () => { + const event = createEvent("192.168.1.102"); // Unique IP for this test - // Exceed rate limit - for (let i = 0; i < 11; i++) { - await handle({ event, resolve: mockResolve }); - } + // Exceed rate limit + for (let i = 0; i < 11; i++) { + await handle({ event, resolve: mockResolve }); + } - // Mock time passing (would need to mock Date.now in real implementation) - // For now, we'll test that different IPs are treated separately - const differentIPEvent = createEvent("192.168.1.2"); - const response = await handle({ event: differentIPEvent, resolve: mockResolve }); + // Mock time passing (would need to mock Date.now in real implementation) + // For now, we'll test that different IPs are treated separately + const differentIPEvent = createEvent("192.168.1.2"); + const response = await handle({ event: differentIPEvent, resolve: mockResolve }); - expect(response.status).not.toBe(429); - }); - }); + expect(response.status).not.toBe(429); + }); + }); - describe("CORS handling", () => { - const mockResolve = vi.fn(); + describe("CORS handling", () => { + const mockResolve = vi.fn(); - const createCORSEvent = (method: string = "GET", path: string = "/api/health") => ({ - url: new URL(`http://localhost${path}`), - request: new Request(`http://localhost${path}`, { - method, - headers: { - "x-forwarded-for": "192.168.2.1" // Different IP for CORS tests - } - }), - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "192.168.2.1", - locals: {}, - params: {}, - route: { id: null }, - setHeaders: vi.fn(), - isDataRequest: false, - isSubRequest: false, - platform: {} as any - }); + const createCORSEvent = (method: string = "GET", path: string = "/api/health") => ({ + url: new URL(`http://localhost${path}`), + request: new Request(`http://localhost${path}`, { + method, + headers: { + "x-forwarded-for": "192.168.2.1", // Different IP for CORS tests + }, + }), + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "192.168.2.1", + locals: {}, + params: {}, + route: { id: null }, + setHeaders: vi.fn(), + isDataRequest: false, + isSubRequest: false, + platform: {} as any, + }); - beforeEach(() => { - mockResolve.mockResolvedValue(new Response("OK")); - }); + beforeEach(() => { + mockResolve.mockResolvedValue(new Response("OK")); + }); - it("should handle OPTIONS preflight requests", async () => { - const event = createCORSEvent("OPTIONS"); + it("should handle OPTIONS preflight requests", async () => { + const event = createCORSEvent("OPTIONS"); - const response = await handle({ event, resolve: mockResolve }); + const response = await handle({ event, resolve: mockResolve }); - expect(response.status).toBe(200); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET"); - expect(mockResolve).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET"); + expect(mockResolve).not.toHaveBeenCalled(); + }); - it("should add CORS headers to API routes", async () => { - const event = createCORSEvent(); + it("should add CORS headers to API routes", async () => { + const event = createCORSEvent(); - const response = await handle({ event, resolve: mockResolve }); + const response = await handle({ event, resolve: mockResolve }); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); - expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET"); - }); - }); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("GET"); + }); + }); - describe("security headers", () => { - const mockResolve = vi.fn(); + describe("security headers", () => { + const mockResolve = vi.fn(); - const createSecurityEvent = (protocol: string = "http", path: string = "/test") => ({ - url: new URL(`${protocol}://localhost${path}`), - request: new Request(`${protocol}://localhost${path}`, { - headers: { - "x-forwarded-for": "192.168.3.1" // Different IP for security tests - } - }), - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "192.168.3.1", - locals: {}, - params: {}, - route: { id: null }, - setHeaders: vi.fn(), - isDataRequest: false, - isSubRequest: false, - platform: {} as any - }); + const createSecurityEvent = (protocol: string = "http", path: string = "/test") => ({ + url: new URL(`${protocol}://localhost${path}`), + request: new Request(`${protocol}://localhost${path}`, { + headers: { + "x-forwarded-for": "192.168.3.1", // Different IP for security tests + }, + }), + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "192.168.3.1", + locals: {}, + params: {}, + route: { id: null }, + setHeaders: vi.fn(), + isDataRequest: false, + isSubRequest: false, + platform: {} as any, + }); - beforeEach(() => { - mockResolve.mockResolvedValue(new Response("OK")); - }); + beforeEach(() => { + mockResolve.mockResolvedValue(new Response("OK")); + }); - it("should add security headers to all responses", async () => { - const event = createSecurityEvent(); + it("should add security headers to all responses", async () => { + const event = createSecurityEvent(); - const response = await handle({ event, resolve: mockResolve }); + const response = await handle({ event, resolve: mockResolve }); - expect(response.headers.get("X-Frame-Options")).toBe("DENY"); - expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); - expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block"); - expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); - expect(response.headers.get("Content-Security-Policy")).toContain("default-src 'self'"); - }); + expect(response.headers.get("X-Frame-Options")).toBe("DENY"); + expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff"); + expect(response.headers.get("X-XSS-Protection")).toBe("1; mode=block"); + expect(response.headers.get("Referrer-Policy")).toBe("strict-origin-when-cross-origin"); + expect(response.headers.get("Content-Security-Policy")).toContain("default-src 'self'"); + }); - it("should add HSTS header for HTTPS requests", async () => { - const event = createSecurityEvent("https"); + it("should add HSTS header for HTTPS requests", async () => { + const event = createSecurityEvent("https"); - const response = await handle({ event, resolve: mockResolve }); + const response = await handle({ event, resolve: mockResolve }); - expect(response.headers.get("Strict-Transport-Security")).toBe( - "max-age=31536000; includeSubDomains; preload" - ); - }); + expect(response.headers.get("Strict-Transport-Security")).toBe( + "max-age=31536000; includeSubDomains; preload", + ); + }); - it("should not add HSTS header for HTTP requests", async () => { - const event = createSecurityEvent("http"); + it("should not add HSTS header for HTTP requests", async () => { + const event = createSecurityEvent("http"); - const response = await handle({ event, resolve: mockResolve }); + const response = await handle({ event, resolve: mockResolve }); - expect(response.headers.get("Strict-Transport-Security")).toBeNull(); - }); - }); + expect(response.headers.get("Strict-Transport-Security")).toBeNull(); + }); + }); }); diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8d234ca..6c35a4b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -11,36 +11,36 @@ import { i18nHandle } from "./server-hooks/i18nHandle"; import { building } from "$app/environment"; type Error = { - message?: string; - stack?: string; + message?: string; + stack?: string; }; export async function handleError({ error, event, status, message }) { - const errorLogger = logger.setContext("ERROR_HANDLER"); + const errorLogger = logger.setContext("ERROR_HANDLER"); - errorLogger.error("Unhandled error occurred", { - error: (error as Error).message, - stack: (error as Error).stack, - status, - message, - url: event.url.pathname, - method: event.request.method, - userAgent: event.request.headers.get("user-agent"), - ip: !building ? event.getClientAddress() : "server" - }); + errorLogger.error("Unhandled error occurred", { + error: (error as Error).message, + stack: (error as Error).stack, + status, + message, + url: event.url.pathname, + method: event.request.method, + userAgent: event.request.headers.get("user-agent"), + ip: !building ? event.getClientAddress() : "server", + }); - return { - message: "Internal server error occurred" - }; + return { + message: "Internal server error occurred", + }; } export const handle = sequence( - startupHandle, - loggingHandle, - i18nHandle, - rateLimitHandle, - corsHandle, - secHeaderHandle, - apiAuthHandle, - authGuard + startupHandle, + loggingHandle, + i18nHandle, + rateLimitHandle, + corsHandle, + secHeaderHandle, + apiAuthHandle, + authGuard, ); diff --git a/src/lib/components/layouts/centered-card/action-hint.svelte b/src/lib/components/layouts/centered-card/action-hint.svelte index e053c8d..afdae93 100644 --- a/src/lib/components/layouts/centered-card/action-hint.svelte +++ b/src/lib/components/layouts/centered-card/action-hint.svelte @@ -1,10 +1,10 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/layouts/centered-card/action.svelte b/src/lib/components/layouts/centered-card/action.svelte index eab531b..54003e9 100644 --- a/src/lib/components/layouts/centered-card/action.svelte +++ b/src/lib/components/layouts/centered-card/action.svelte @@ -1,9 +1,9 @@
- {@render children?.()} + {@render children?.()}
diff --git a/src/lib/components/layouts/centered-card/description.svelte b/src/lib/components/layouts/centered-card/description.svelte index df36383..13b76c0 100644 --- a/src/lib/components/layouts/centered-card/description.svelte +++ b/src/lib/components/layouts/centered-card/description.svelte @@ -1,11 +1,11 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/layouts/centered-card/header.svelte b/src/lib/components/layouts/centered-card/header.svelte index 0f8d05d..4661704 100644 --- a/src/lib/components/layouts/centered-card/header.svelte +++ b/src/lib/components/layouts/centered-card/header.svelte @@ -1,9 +1,9 @@
- {@render children?.()} + {@render children?.()}
diff --git a/src/lib/components/layouts/centered-card/main.svelte b/src/lib/components/layouts/centered-card/main.svelte index 925987c..c6a3a35 100644 --- a/src/lib/components/layouts/centered-card/main.svelte +++ b/src/lib/components/layouts/centered-card/main.svelte @@ -1,10 +1,10 @@
- {@render children?.()} + {@render children?.()}
diff --git a/src/lib/components/layouts/centered-card/root.svelte b/src/lib/components/layouts/centered-card/root.svelte index 3715f1d..805cac2 100644 --- a/src/lib/components/layouts/centered-card/root.svelte +++ b/src/lib/components/layouts/centered-card/root.svelte @@ -1,21 +1,21 @@ - - {@render children?.()} - + + {@render children?.()} + diff --git a/src/lib/components/layouts/centered-card/title.svelte b/src/lib/components/layouts/centered-card/title.svelte index 9efb262..5f72de2 100644 --- a/src/lib/components/layouts/centered-card/title.svelte +++ b/src/lib/components/layouts/centered-card/title.svelte @@ -1,11 +1,11 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/layouts/empty-layout/action.svelte b/src/lib/components/layouts/empty-layout/action.svelte index eac18d1..5a52c0b 100644 --- a/src/lib/components/layouts/empty-layout/action.svelte +++ b/src/lib/components/layouts/empty-layout/action.svelte @@ -1,7 +1,7 @@ {@render children?.()} diff --git a/src/lib/components/layouts/empty-layout/main.svelte b/src/lib/components/layouts/empty-layout/main.svelte index e8a5e3a..78aae87 100644 --- a/src/lib/components/layouts/empty-layout/main.svelte +++ b/src/lib/components/layouts/empty-layout/main.svelte @@ -1,16 +1,16 @@
- {@render children?.()} + {@render children?.()}
diff --git a/src/lib/components/layouts/empty-layout/root.svelte b/src/lib/components/layouts/empty-layout/root.svelte index c0150e2..449a868 100644 --- a/src/lib/components/layouts/empty-layout/root.svelte +++ b/src/lib/components/layouts/empty-layout/root.svelte @@ -1,18 +1,18 @@ -
- {@render children?.()} -
+
+ {@render children?.()} +
diff --git a/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte b/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte index 1fc0379..7ddc892 100644 --- a/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/app-sidebar.svelte @@ -1,27 +1,27 @@ - - - - - - - - - - - + + + + + + + + + + + diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte index 72def17..c5be0ea 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-primary.svelte @@ -1,70 +1,70 @@ - {#each items as item (item.title)} - {#if item.isTenatOnly === false || $auth.user?.tenantId} - - - {#snippet child({ props })} - - - {item.title} - - {/snippet} - - - {/if} - {/each} + {#each items as item (item.title)} + {#if item.isTenatOnly === false || $auth.user?.tenantId} + + + {#snippet child({ props })} + + + {item.title} + + {/snippet} + + + {/if} + {/each} diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte index ddfd2dc..3b4f34f 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-secondary.svelte @@ -1,57 +1,57 @@ - {#each items as item (item.title)} - {#if item.isTenatOnly === false || $auth.user?.tenantId} - - - {#snippet child({ props })} - - - {item.title} - {#if item.url.startsWith("http")} - - {/if} - - {/snippet} - - - {/if} - {/each} + {#each items as item (item.title)} + {#if item.isTenatOnly === false || $auth.user?.tenantId} + + + {#snippet child({ props })} + + + {item.title} + {#if item.url.startsWith("http")} + + {/if} + + {/snippet} + + + {/if} + {/each} diff --git a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte index b5c075c..854c49b 100644 --- a/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/nav-user.svelte @@ -1,77 +1,77 @@ - - - - {#snippet child({ props })} - - - - {nameToAvatarFallback($auth.user?.name)} - - -
- {$auth.user?.name} - {$auth.user?.email} -
- -
- {/snippet} -
- - -
- - - {nameToAvatarFallback($auth.user?.name)} - - -
- {$auth.user?.name} - {$auth.user?.email} -
-
-
- - - - goto(ROUTES.DASHBOARD.ACCOUNT)}> - - {m["nav.account"]()} - - - - goto(ROUTES.LOGOUT)}> - - {m["nav.logout"]()} - -
-
-
+ + + + {#snippet child({ props })} + + + + {nameToAvatarFallback($auth.user?.name)} + + +
+ {$auth.user?.name} + {$auth.user?.email} +
+ +
+ {/snippet} +
+ + +
+ + + {nameToAvatarFallback($auth.user?.name)} + + +
+ {$auth.user?.name} + {$auth.user?.email} +
+
+
+ + + + goto(ROUTES.DASHBOARD.ACCOUNT)}> + + {m["nav.account"]()} + + + + goto(ROUTES.LOGOUT)}> + + {m["nav.logout"]()} + +
+
+
diff --git a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte index 13be67b..b7ac05c 100644 --- a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte @@ -1,85 +1,85 @@ - - - - {#snippet child({ props })} - -
- -
-
- - {activeTenant?.name ?? m["nav.noTenantSelected.title"]()} - - - {activeTenant?.url ?? m["nav.noTenantSelected.description"]()} - -
- -
- {/snippet} -
- - {m["nav.tenants"]()} - {#each tenants as tenant (tenant.id)} - (activeTenantId = tenant.id)} class="gap-2 p-2"> -
- -
- {tenant.name} -
- {/each} - - -
- -
-
{m["nav.addTenant"]()}
-
-
-
-
+ + + + {#snippet child({ props })} + +
+ +
+
+ + {activeTenant?.name ?? m["nav.noTenantSelected.title"]()} + + + {activeTenant?.url ?? m["nav.noTenantSelected.description"]()} + +
+ +
+ {/snippet} +
+ + {m["nav.tenants"]()} + {#each tenants as tenant (tenant.id)} + (activeTenantId = tenant.id)} class="gap-2 p-2"> +
+ +
+ {tenant.name} +
+ {/each} + + +
+ +
+
{m["nav.addTenant"]()}
+
+
+
+
diff --git a/src/lib/components/layouts/sidebar-layout/root.svelte b/src/lib/components/layouts/sidebar-layout/root.svelte index e26df38..ccd4485 100644 --- a/src/lib/components/layouts/sidebar-layout/root.svelte +++ b/src/lib/components/layouts/sidebar-layout/root.svelte @@ -1,56 +1,56 @@ sidebar.setOpen(open)}> - - - - -
-
- - {#if breakcrumbs && breakcrumbs.length > 0} - - - - {#each breakcrumbs as crumb, index (`${crumb.href}-${index}`)} - {#if index === breakcrumbs.length - 1} - - - {crumb.label} - - - {:else} - - - - {/if} -
-
- {@render children?.()} -
-
-
+ + + + +
+
+ + {#if breakcrumbs && breakcrumbs.length > 0} + + + + {#each breakcrumbs as crumb, index (`${crumb.href}-${index}`)} + {#if index === breakcrumbs.length - 1} + + + {crumb.label} + + + {:else} + + + + {/if} +
+
+ {@render children?.()} +
+
+
diff --git a/src/lib/components/templates/empty-state/center-loading-state.svelte b/src/lib/components/templates/empty-state/center-loading-state.svelte index 5f4cad6..fc3b146 100644 --- a/src/lib/components/templates/empty-state/center-loading-state.svelte +++ b/src/lib/components/templates/empty-state/center-loading-state.svelte @@ -1,14 +1,14 @@
- - - - + + + +
diff --git a/src/lib/components/templates/empty-state/empty-state.svelte b/src/lib/components/templates/empty-state/empty-state.svelte index e07ec90..a476adb 100644 --- a/src/lib/components/templates/empty-state/empty-state.svelte +++ b/src/lib/components/templates/empty-state/empty-state.svelte @@ -1,18 +1,18 @@
- - {headline} - {description} + + {headline} + {description}
diff --git a/src/lib/components/templates/language-switch/language-switch.svelte b/src/lib/components/templates/language-switch/language-switch.svelte index 7c7611e..6267f9d 100644 --- a/src/lib/components/templates/language-switch/language-switch.svelte +++ b/src/lib/components/templates/language-switch/language-switch.svelte @@ -1,41 +1,41 @@
- { - setLocale(value as "de" | "en"); - }} - labels={{ - placeholder: m["i18n.label"](), - search: m["i18n.search"](), - notFound: m["i18n.notFound"]() - }} - {triggerVariant} - {triggerSize} - {triggerClass} - class={cn("w-auto")} - /> + { + setLocale(value as "de" | "en"); + }} + labels={{ + placeholder: m["i18n.label"](), + search: m["i18n.search"](), + notFound: m["i18n.notFound"](), + }} + {triggerVariant} + {triggerSize} + {triggerClass} + class={cn("w-auto")} + />
diff --git a/src/lib/components/ui/avatar/avatar-fallback.svelte b/src/lib/components/ui/avatar/avatar-fallback.svelte index 249d4a4..fee7425 100644 --- a/src/lib/components/ui/avatar/avatar-fallback.svelte +++ b/src/lib/components/ui/avatar/avatar-fallback.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/avatar/avatar-image.svelte b/src/lib/components/ui/avatar/avatar-image.svelte index 2bb9db4..8f9ceea 100644 --- a/src/lib/components/ui/avatar/avatar-image.svelte +++ b/src/lib/components/ui/avatar/avatar-image.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/avatar/avatar.svelte b/src/lib/components/ui/avatar/avatar.svelte index e37214d..0832b53 100644 --- a/src/lib/components/ui/avatar/avatar.svelte +++ b/src/lib/components/ui/avatar/avatar.svelte @@ -1,19 +1,19 @@ diff --git a/src/lib/components/ui/avatar/index.ts b/src/lib/components/ui/avatar/index.ts index b08c780..71f5b20 100644 --- a/src/lib/components/ui/avatar/index.ts +++ b/src/lib/components/ui/avatar/index.ts @@ -3,11 +3,11 @@ import Image from "./avatar-image.svelte"; import Fallback from "./avatar-fallback.svelte"; export { - Root, - Image, - Fallback, - // - Root as Avatar, - Image as AvatarImage, - Fallback as AvatarFallback + Root, + Image, + Fallback, + // + Root as Avatar, + Image as AvatarImage, + Fallback as AvatarFallback, }; diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte index a178cf5..04a9a3c 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb-ellipsis.svelte @@ -1,23 +1,23 @@ diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte index 1a84c4c..4693cdb 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb-item.svelte @@ -1,20 +1,20 @@
  • - {@render children?.()} + {@render children?.()}
  • diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte index 382b6ab..0bdfc96 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb-link.svelte @@ -1,31 +1,31 @@ {#if child} - {@render child({ props: attrs })} + {@render child({ props: attrs })} {:else} - - {@render children?.()} - + + {@render children?.()} + {/if} diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte index 1272a37..01464a0 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb-list.svelte @@ -1,23 +1,23 @@
      - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte index 5fb6979..f9041cd 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb-page.svelte @@ -1,23 +1,23 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte b/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte index 84106a1..37735d2 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb-separator.svelte @@ -1,27 +1,27 @@ diff --git a/src/lib/components/ui/breadcrumb/breadcrumb.svelte b/src/lib/components/ui/breadcrumb/breadcrumb.svelte index 8f8a3e6..b80e3f3 100644 --- a/src/lib/components/ui/breadcrumb/breadcrumb.svelte +++ b/src/lib/components/ui/breadcrumb/breadcrumb.svelte @@ -1,21 +1,21 @@ diff --git a/src/lib/components/ui/breadcrumb/index.ts b/src/lib/components/ui/breadcrumb/index.ts index 2651956..72e5847 100644 --- a/src/lib/components/ui/breadcrumb/index.ts +++ b/src/lib/components/ui/breadcrumb/index.ts @@ -7,19 +7,19 @@ import List from "./breadcrumb-list.svelte"; import Page from "./breadcrumb-page.svelte"; export { - Root, - Ellipsis, - Item, - Separator, - Link, - List, - Page, - // - Root as Breadcrumb, - Ellipsis as BreadcrumbEllipsis, - Item as BreadcrumbItem, - Separator as BreadcrumbSeparator, - Link as BreadcrumbLink, - List as BreadcrumbList, - Page as BreadcrumbPage + Root, + Ellipsis, + Item, + Separator, + Link, + List, + Page, + // + Root as Breadcrumb, + Ellipsis as BreadcrumbEllipsis, + Item as BreadcrumbItem, + Separator as BreadcrumbSeparator, + Link as BreadcrumbLink, + List as BreadcrumbList, + Page as BreadcrumbPage, }; diff --git a/src/lib/components/ui/button/button.svelte b/src/lib/components/ui/button/button.svelte index d5bf4bc..1b15395 100644 --- a/src/lib/components/ui/button/button.svelte +++ b/src/lib/components/ui/button/button.svelte @@ -1,87 +1,87 @@ {#if href} - - {@render children?.()} - + + {@render children?.()} + {:else} - + {/if} diff --git a/src/lib/components/ui/button/index.ts b/src/lib/components/ui/button/index.ts index 068bfa2..872d97c 100644 --- a/src/lib/components/ui/button/index.ts +++ b/src/lib/components/ui/button/index.ts @@ -1,17 +1,17 @@ import Root, { - type ButtonProps, - type ButtonSize, - type ButtonVariant, - buttonVariants + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, } from "./button.svelte"; export { - Root, - type ButtonProps as Props, - // - Root as Button, - buttonVariants, - type ButtonProps, - type ButtonSize, - type ButtonVariant + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, }; diff --git a/src/lib/components/ui/card/card-action.svelte b/src/lib/components/ui/card/card-action.svelte index cc36c56..aea2eb9 100644 --- a/src/lib/components/ui/card/card-action.svelte +++ b/src/lib/components/ui/card/card-action.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/card/card-content.svelte b/src/lib/components/ui/card/card-content.svelte index bc90b83..c55a8d4 100644 --- a/src/lib/components/ui/card/card-content.svelte +++ b/src/lib/components/ui/card/card-content.svelte @@ -1,15 +1,15 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/card/card-description.svelte b/src/lib/components/ui/card/card-description.svelte index 9b20ac7..4ad41e5 100644 --- a/src/lib/components/ui/card/card-description.svelte +++ b/src/lib/components/ui/card/card-description.svelte @@ -1,20 +1,20 @@

    - {@render children?.()} + {@render children?.()}

    diff --git a/src/lib/components/ui/card/card-footer.svelte b/src/lib/components/ui/card/card-footer.svelte index 2d4d0f2..65a1041 100644 --- a/src/lib/components/ui/card/card-footer.svelte +++ b/src/lib/components/ui/card/card-footer.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/card/card-header.svelte b/src/lib/components/ui/card/card-header.svelte index 2501788..843fb3b 100644 --- a/src/lib/components/ui/card/card-header.svelte +++ b/src/lib/components/ui/card/card-header.svelte @@ -1,23 +1,23 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/card/card-title.svelte b/src/lib/components/ui/card/card-title.svelte index 7447231..053db45 100644 --- a/src/lib/components/ui/card/card-title.svelte +++ b/src/lib/components/ui/card/card-title.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/card/card.svelte b/src/lib/components/ui/card/card.svelte index f6cc3e5..2a494c2 100644 --- a/src/lib/components/ui/card/card.svelte +++ b/src/lib/components/ui/card/card.svelte @@ -1,23 +1,23 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/card/index.ts b/src/lib/components/ui/card/index.ts index 10daffb..406a5ce 100644 --- a/src/lib/components/ui/card/index.ts +++ b/src/lib/components/ui/card/index.ts @@ -7,19 +7,19 @@ import Title from "./card-title.svelte"; import Action from "./card-action.svelte"; export { - Root, - Content, - Description, - Footer, - Header, - Title, - Action, - // - Root as Card, - Content as CardContent, - Description as CardDescription, - Footer as CardFooter, - Header as CardHeader, - Title as CardTitle, - Action as CardAction + Root, + Content, + Description, + Footer, + Header, + Title, + Action, + // + Root as Card, + Content as CardContent, + Description as CardDescription, + Footer as CardFooter, + Header as CardHeader, + Title as CardTitle, + Action as CardAction, }; diff --git a/src/lib/components/ui/collapsible/collapsible-content.svelte b/src/lib/components/ui/collapsible/collapsible-content.svelte index bdabb55..81a6b0f 100644 --- a/src/lib/components/ui/collapsible/collapsible-content.svelte +++ b/src/lib/components/ui/collapsible/collapsible-content.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/collapsible/collapsible-trigger.svelte b/src/lib/components/ui/collapsible/collapsible-trigger.svelte index ece7ad6..f67035d 100644 --- a/src/lib/components/ui/collapsible/collapsible-trigger.svelte +++ b/src/lib/components/ui/collapsible/collapsible-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/collapsible/collapsible.svelte b/src/lib/components/ui/collapsible/collapsible.svelte index 39cdd4e..4e6fef5 100644 --- a/src/lib/components/ui/collapsible/collapsible.svelte +++ b/src/lib/components/ui/collapsible/collapsible.svelte @@ -1,11 +1,11 @@ diff --git a/src/lib/components/ui/collapsible/index.ts b/src/lib/components/ui/collapsible/index.ts index d5db2aa..296a91b 100644 --- a/src/lib/components/ui/collapsible/index.ts +++ b/src/lib/components/ui/collapsible/index.ts @@ -3,11 +3,11 @@ import Trigger from "./collapsible-trigger.svelte"; import Content from "./collapsible-content.svelte"; export { - Root, - Content, - Trigger, - // - Root as Collapsible, - Content as CollapsibleContent, - Trigger as CollapsibleTrigger + Root, + Content, + Trigger, + // + Root as Collapsible, + Content as CollapsibleContent, + Trigger as CollapsibleTrigger, }; diff --git a/src/lib/components/ui/combobox/combobox.svelte b/src/lib/components/ui/combobox/combobox.svelte index 9353567..51cd417 100644 --- a/src/lib/components/ui/combobox/combobox.svelte +++ b/src/lib/components/ui/combobox/combobox.svelte @@ -1,89 +1,89 @@ - - {#snippet child({ props })} - - {/snippet} - - - - - - {labels.notFound} - - {#each options as option (option.value)} - { - onChange(option.value); - closeAndFocusTrigger(); - }} - > - - {option.label} - - {/each} - - - - + + {#snippet child({ props })} + + {/snippet} + + + + + + {labels.notFound} + + {#each options as option (option.value)} + { + onChange(option.value); + closeAndFocusTrigger(); + }} + > + + {option.label} + + {/each} + + + + diff --git a/src/lib/components/ui/command/command-dialog.svelte b/src/lib/components/ui/command/command-dialog.svelte index 4bdb740..5c97b28 100644 --- a/src/lib/components/ui/command/command-dialog.svelte +++ b/src/lib/components/ui/command/command-dialog.svelte @@ -1,40 +1,40 @@ - - {title} - {description} - - - - + + {title} + {description} + + + + diff --git a/src/lib/components/ui/command/command-empty.svelte b/src/lib/components/ui/command/command-empty.svelte index 6726cd8..d1c8728 100644 --- a/src/lib/components/ui/command/command-empty.svelte +++ b/src/lib/components/ui/command/command-empty.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/command/command-group.svelte b/src/lib/components/ui/command/command-group.svelte index 9e3c3be..6c30c54 100644 --- a/src/lib/components/ui/command/command-group.svelte +++ b/src/lib/components/ui/command/command-group.svelte @@ -1,30 +1,30 @@ - {#if heading} - - {heading} - - {/if} - + {#if heading} + + {heading} + + {/if} + diff --git a/src/lib/components/ui/command/command-input.svelte b/src/lib/components/ui/command/command-input.svelte index 9e5cf7a..b3fc4a3 100644 --- a/src/lib/components/ui/command/command-input.svelte +++ b/src/lib/components/ui/command/command-input.svelte @@ -1,26 +1,26 @@
    - - + +
    diff --git a/src/lib/components/ui/command/command-item.svelte b/src/lib/components/ui/command/command-item.svelte index 5833416..5f44914 100644 --- a/src/lib/components/ui/command/command-item.svelte +++ b/src/lib/components/ui/command/command-item.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/command/command-link-item.svelte b/src/lib/components/ui/command/command-link-item.svelte index ada6d2c..12dbda6 100644 --- a/src/lib/components/ui/command/command-link-item.svelte +++ b/src/lib/components/ui/command/command-link-item.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/command/command-list.svelte b/src/lib/components/ui/command/command-list.svelte index 2d3a01a..cce4d5f 100644 --- a/src/lib/components/ui/command/command-list.svelte +++ b/src/lib/components/ui/command/command-list.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/command/command-separator.svelte b/src/lib/components/ui/command/command-separator.svelte index 35c4c95..9b27005 100644 --- a/src/lib/components/ui/command/command-separator.svelte +++ b/src/lib/components/ui/command/command-separator.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/command/command-shortcut.svelte b/src/lib/components/ui/command/command-shortcut.svelte index 3d68bc5..415609e 100644 --- a/src/lib/components/ui/command/command-shortcut.svelte +++ b/src/lib/components/ui/command/command-shortcut.svelte @@ -1,20 +1,20 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/ui/command/command.svelte b/src/lib/components/ui/command/command.svelte index c64a77e..92a8fe9 100644 --- a/src/lib/components/ui/command/command.svelte +++ b/src/lib/components/ui/command/command.svelte @@ -1,22 +1,22 @@ diff --git a/src/lib/components/ui/command/index.ts b/src/lib/components/ui/command/index.ts index 14c2b32..b53a8ed 100644 --- a/src/lib/components/ui/command/index.ts +++ b/src/lib/components/ui/command/index.ts @@ -14,27 +14,27 @@ import LinkItem from "./command-link-item.svelte"; const Loading = CommandPrimitive.Loading; export { - Root, - Dialog, - Empty, - Group, - Item, - LinkItem, - Input, - List, - Separator, - Shortcut, - Loading, - // - Root as Command, - Dialog as CommandDialog, - Empty as CommandEmpty, - Group as CommandGroup, - Item as CommandItem, - LinkItem as CommandLinkItem, - Input as CommandInput, - List as CommandList, - Separator as CommandSeparator, - Shortcut as CommandShortcut, - Loading as CommandLoading + Root, + Dialog, + Empty, + Group, + Item, + LinkItem, + Input, + List, + Separator, + Shortcut, + Loading, + // + Root as Command, + Dialog as CommandDialog, + Empty as CommandEmpty, + Group as CommandGroup, + Item as CommandItem, + LinkItem as CommandLinkItem, + Input as CommandInput, + List as CommandList, + Separator as CommandSeparator, + Shortcut as CommandShortcut, + Loading as CommandLoading, }; diff --git a/src/lib/components/ui/dialog/dialog-close.svelte b/src/lib/components/ui/dialog/dialog-close.svelte index 840b2f6..7bcb438 100644 --- a/src/lib/components/ui/dialog/dialog-close.svelte +++ b/src/lib/components/ui/dialog/dialog-close.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/dialog/dialog-content.svelte b/src/lib/components/ui/dialog/dialog-content.svelte index 1610dcd..192fc2e 100644 --- a/src/lib/components/ui/dialog/dialog-content.svelte +++ b/src/lib/components/ui/dialog/dialog-content.svelte @@ -1,43 +1,43 @@ - - - {@render children?.()} - {#if showCloseButton} - - - Close - - {/if} - + + + {@render children?.()} + {#if showCloseButton} + + + Close + + {/if} + diff --git a/src/lib/components/ui/dialog/dialog-description.svelte b/src/lib/components/ui/dialog/dialog-description.svelte index 3845023..5162cd3 100644 --- a/src/lib/components/ui/dialog/dialog-description.svelte +++ b/src/lib/components/ui/dialog/dialog-description.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/dialog/dialog-footer.svelte b/src/lib/components/ui/dialog/dialog-footer.svelte index e7ff446..fdb2ebf 100644 --- a/src/lib/components/ui/dialog/dialog-footer.svelte +++ b/src/lib/components/ui/dialog/dialog-footer.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/dialog/dialog-header.svelte b/src/lib/components/ui/dialog/dialog-header.svelte index fc90cd9..be7438c 100644 --- a/src/lib/components/ui/dialog/dialog-header.svelte +++ b/src/lib/components/ui/dialog/dialog-header.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/dialog/dialog-overlay.svelte b/src/lib/components/ui/dialog/dialog-overlay.svelte index f81ad83..21730ac 100644 --- a/src/lib/components/ui/dialog/dialog-overlay.svelte +++ b/src/lib/components/ui/dialog/dialog-overlay.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/dialog/dialog-title.svelte b/src/lib/components/ui/dialog/dialog-title.svelte index e4d4b34..5fb21c0 100644 --- a/src/lib/components/ui/dialog/dialog-title.svelte +++ b/src/lib/components/ui/dialog/dialog-title.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/dialog/dialog-trigger.svelte b/src/lib/components/ui/dialog/dialog-trigger.svelte index 9d1e801..92a8e3c 100644 --- a/src/lib/components/ui/dialog/dialog-trigger.svelte +++ b/src/lib/components/ui/dialog/dialog-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/dialog/index.ts b/src/lib/components/ui/dialog/index.ts index 790315c..07515e7 100644 --- a/src/lib/components/ui/dialog/index.ts +++ b/src/lib/components/ui/dialog/index.ts @@ -13,25 +13,25 @@ const Root = DialogPrimitive.Root; const Portal = DialogPrimitive.Portal; export { - Root, - Title, - Portal, - Footer, - Header, - Trigger, - Overlay, - Content, - Description, - Close, - // - Root as Dialog, - Title as DialogTitle, - Portal as DialogPortal, - Footer as DialogFooter, - Header as DialogHeader, - Trigger as DialogTrigger, - Overlay as DialogOverlay, - Content as DialogContent, - Description as DialogDescription, - Close as DialogClose + Root, + Title, + Portal, + Footer, + Header, + Trigger, + Overlay, + Content, + Description, + Close, + // + Root as Dialog, + Title as DialogTitle, + Portal as DialogPortal, + Footer as DialogFooter, + Header as DialogHeader, + Trigger as DialogTrigger, + Overlay as DialogOverlay, + Content as DialogContent, + Description as DialogDescription, + Close as DialogClose, }; diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte index cf78621..b0825bb 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -1,41 +1,41 @@ - {#snippet children({ checked, indeterminate })} - - {#if indeterminate} - - {:else} - - {/if} - - {@render childrenProp?.()} - {/snippet} + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else} + + {/if} + + {@render childrenProp?.()} + {/snippet} diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte index f1abd5d..685809b 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -1,27 +1,27 @@ - + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte index 48d14a9..e94a853 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -1,22 +1,22 @@ diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte index aca1f7b..2172000 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte index 3f6159c..8a1a245 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -1,27 +1,27 @@ diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte index f72e477..14d11d7 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -1,24 +1,24 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte index 189aef4..d4bbe25 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -1,16 +1,16 @@ diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte index af8d5d7..9d8c9b9 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -1,31 +1,31 @@ - {#snippet children({ checked })} - - {#if checked} - - {/if} - - {@render childrenProp?.({ checked })} - {/snippet} + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte index 90f1b6f..7f2b04c 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte index 6974947..a283194 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -1,20 +1,20 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte index 7ea1437..3dc9b4c 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte index 32ccfd5..8d77bea 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -1,29 +1,29 @@ - {@render children?.()} - + {@render children?.()} + diff --git a/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte index cb05344..bb88efd 100644 --- a/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +++ b/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/dropdown-menu/index.ts b/src/lib/components/ui/dropdown-menu/index.ts index 342f314..fd34142 100644 --- a/src/lib/components/ui/dropdown-menu/index.ts +++ b/src/lib/components/ui/dropdown-menu/index.ts @@ -16,34 +16,34 @@ const Sub = DropdownMenuPrimitive.Sub; const Root = DropdownMenuPrimitive.Root; export { - CheckboxItem, - Content, - Root as DropdownMenu, - CheckboxItem as DropdownMenuCheckboxItem, - Content as DropdownMenuContent, - Group as DropdownMenuGroup, - Item as DropdownMenuItem, - Label as DropdownMenuLabel, - RadioGroup as DropdownMenuRadioGroup, - RadioItem as DropdownMenuRadioItem, - Separator as DropdownMenuSeparator, - Shortcut as DropdownMenuShortcut, - Sub as DropdownMenuSub, - SubContent as DropdownMenuSubContent, - SubTrigger as DropdownMenuSubTrigger, - Trigger as DropdownMenuTrigger, - GroupHeading as DropdownMenuGroupHeading, - Group, - GroupHeading, - Item, - Label, - RadioGroup, - RadioItem, - Root, - Separator, - Shortcut, - Sub, - SubContent, - SubTrigger, - Trigger + CheckboxItem, + Content, + Root as DropdownMenu, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, }; diff --git a/src/lib/components/ui/form/form-button.svelte b/src/lib/components/ui/form/form-button.svelte index 12c6c85..59825a4 100644 --- a/src/lib/components/ui/form/form-button.svelte +++ b/src/lib/components/ui/form/form-button.svelte @@ -1,8 +1,8 @@ - {/if} - + + {#if dev} + + Viewport: +
    xs
    + + + + + +
    + {/if} + + {m.poweredBy()} + OpenReception + + {#if dev} + + {/if} +
    diff --git a/src/lib/components/ui/passkey/state.svelte b/src/lib/components/ui/passkey/state.svelte index be07830..f00085d 100644 --- a/src/lib/components/ui/passkey/state.svelte +++ b/src/lib/components/ui/passkey/state.svelte @@ -1,55 +1,55 @@ diff --git a/src/lib/components/ui/popover/index.ts b/src/lib/components/ui/popover/index.ts index 1a8a309..5eba297 100644 --- a/src/lib/components/ui/popover/index.ts +++ b/src/lib/components/ui/popover/index.ts @@ -5,13 +5,13 @@ const Root = PopoverPrimitive.Root; const Close = PopoverPrimitive.Close; export { - Root, - Content, - Trigger, - Close, - // - Root as Popover, - Content as PopoverContent, - Trigger as PopoverTrigger, - Close as PopoverClose + Root, + Content, + Trigger, + Close, + // + Root as Popover, + Content as PopoverContent, + Trigger as PopoverTrigger, + Close as PopoverClose, }; diff --git a/src/lib/components/ui/popover/popover-content.svelte b/src/lib/components/ui/popover/popover-content.svelte index a487233..815dfd9 100644 --- a/src/lib/components/ui/popover/popover-content.svelte +++ b/src/lib/components/ui/popover/popover-content.svelte @@ -1,29 +1,29 @@ - + diff --git a/src/lib/components/ui/popover/popover-trigger.svelte b/src/lib/components/ui/popover/popover-trigger.svelte index 586323c..a58180b 100644 --- a/src/lib/components/ui/popover/popover-trigger.svelte +++ b/src/lib/components/ui/popover/popover-trigger.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts index dbfb139..d66644e 100644 --- a/src/lib/components/ui/separator/index.ts +++ b/src/lib/components/ui/separator/index.ts @@ -1,7 +1,7 @@ import Root from "./separator.svelte"; export { - Root, - // - Root as Separator + Root, + // + Root as Separator, }; diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte index 25a0662..eb0d628 100644 --- a/src/lib/components/ui/separator/separator.svelte +++ b/src/lib/components/ui/separator/separator.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/sheet/index.ts b/src/lib/components/ui/sheet/index.ts index 29e0d0c..ffa765d 100644 --- a/src/lib/components/ui/sheet/index.ts +++ b/src/lib/components/ui/sheet/index.ts @@ -12,25 +12,25 @@ const Root = SheetPrimitive.Root; const Portal = SheetPrimitive.Portal; export { - Root, - Close, - Trigger, - Portal, - Overlay, - Content, - Header, - Footer, - Title, - Description, - // - Root as Sheet, - Close as SheetClose, - Trigger as SheetTrigger, - Portal as SheetPortal, - Overlay as SheetOverlay, - Content as SheetContent, - Header as SheetHeader, - Footer as SheetFooter, - Title as SheetTitle, - Description as SheetDescription + Root, + Close, + Trigger, + Portal, + Overlay, + Content, + Header, + Footer, + Title, + Description, + // + Root as Sheet, + Close as SheetClose, + Trigger as SheetTrigger, + Portal as SheetPortal, + Overlay as SheetOverlay, + Content as SheetContent, + Header as SheetHeader, + Footer as SheetFooter, + Title as SheetTitle, + Description as SheetDescription, }; diff --git a/src/lib/components/ui/sheet/sheet-close.svelte b/src/lib/components/ui/sheet/sheet-close.svelte index ae382c1..a198386 100644 --- a/src/lib/components/ui/sheet/sheet-close.svelte +++ b/src/lib/components/ui/sheet/sheet-close.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/sheet/sheet-content.svelte b/src/lib/components/ui/sheet/sheet-content.svelte index c4ce1d8..e034221 100644 --- a/src/lib/components/ui/sheet/sheet-content.svelte +++ b/src/lib/components/ui/sheet/sheet-content.svelte @@ -1,60 +1,60 @@ - - - {@render children?.()} - - - Close - - + + + {@render children?.()} + + + Close + + diff --git a/src/lib/components/ui/sheet/sheet-description.svelte b/src/lib/components/ui/sheet/sheet-description.svelte index 333b17a..5066740 100644 --- a/src/lib/components/ui/sheet/sheet-description.svelte +++ b/src/lib/components/ui/sheet/sheet-description.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/sheet/sheet-footer.svelte b/src/lib/components/ui/sheet/sheet-footer.svelte index dd9ed84..12aba29 100644 --- a/src/lib/components/ui/sheet/sheet-footer.svelte +++ b/src/lib/components/ui/sheet/sheet-footer.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sheet/sheet-header.svelte b/src/lib/components/ui/sheet/sheet-header.svelte index 757a6a5..17e6bce 100644 --- a/src/lib/components/ui/sheet/sheet-header.svelte +++ b/src/lib/components/ui/sheet/sheet-header.svelte @@ -1,20 +1,20 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sheet/sheet-overlay.svelte b/src/lib/components/ui/sheet/sheet-overlay.svelte index 345e197..450f861 100644 --- a/src/lib/components/ui/sheet/sheet-overlay.svelte +++ b/src/lib/components/ui/sheet/sheet-overlay.svelte @@ -1,20 +1,20 @@ diff --git a/src/lib/components/ui/sheet/sheet-title.svelte b/src/lib/components/ui/sheet/sheet-title.svelte index 9fda327..daf6ec1 100644 --- a/src/lib/components/ui/sheet/sheet-title.svelte +++ b/src/lib/components/ui/sheet/sheet-title.svelte @@ -1,17 +1,17 @@ diff --git a/src/lib/components/ui/sheet/sheet-trigger.svelte b/src/lib/components/ui/sheet/sheet-trigger.svelte index e266975..dc9d59c 100644 --- a/src/lib/components/ui/sheet/sheet-trigger.svelte +++ b/src/lib/components/ui/sheet/sheet-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/sidebar/context.svelte.ts b/src/lib/components/ui/sidebar/context.svelte.ts index 6f9b4a9..7223a7a 100644 --- a/src/lib/components/ui/sidebar/context.svelte.ts +++ b/src/lib/components/ui/sidebar/context.svelte.ts @@ -5,56 +5,56 @@ import { SIDEBAR_KEYBOARD_SHORTCUT } from "./constants.js"; type Getter = () => T; export type SidebarStateProps = { - /** - * A getter function that returns the current open state of the sidebar. - * We use a getter function here to support `bind:open` on the `Sidebar.Provider` - * component. - */ - open: Getter; + /** + * A getter function that returns the current open state of the sidebar. + * We use a getter function here to support `bind:open` on the `Sidebar.Provider` + * component. + */ + open: Getter; - /** - * A function that sets the open state of the sidebar. To support `bind:open`, we need - * a source of truth for changing the open state to ensure it will be synced throughout - * the sub-components and any `bind:` references. - */ - setOpen: (open: boolean) => void; + /** + * A function that sets the open state of the sidebar. To support `bind:open`, we need + * a source of truth for changing the open state to ensure it will be synced throughout + * the sub-components and any `bind:` references. + */ + setOpen: (open: boolean) => void; }; class SidebarState { - readonly props: SidebarStateProps; - open = $derived.by(() => this.props.open()); - openMobile = $state(false); - setOpen: SidebarStateProps["setOpen"]; - #isMobile: IsMobile; - state = $derived.by(() => (this.open ? "expanded" : "collapsed")); + readonly props: SidebarStateProps; + open = $derived.by(() => this.props.open()); + openMobile = $state(false); + setOpen: SidebarStateProps["setOpen"]; + #isMobile: IsMobile; + state = $derived.by(() => (this.open ? "expanded" : "collapsed")); - constructor(props: SidebarStateProps) { - this.setOpen = props.setOpen; - this.#isMobile = new IsMobile(); - this.props = props; - } + constructor(props: SidebarStateProps) { + this.setOpen = props.setOpen; + this.#isMobile = new IsMobile(); + this.props = props; + } - // Convenience getter for checking if the sidebar is mobile - // without this, we would need to use `sidebar.isMobile.current` everywhere - get isMobile() { - return this.#isMobile.current; - } + // Convenience getter for checking if the sidebar is mobile + // without this, we would need to use `sidebar.isMobile.current` everywhere + get isMobile() { + return this.#isMobile.current; + } - // Event handler to apply to the `` - handleShortcutKeydown = (e: KeyboardEvent) => { - if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - this.toggle(); - } - }; + // Event handler to apply to the `` + handleShortcutKeydown = (e: KeyboardEvent) => { + if (e.key === SIDEBAR_KEYBOARD_SHORTCUT && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + this.toggle(); + } + }; - setOpenMobile = (value: boolean) => { - this.openMobile = value; - }; + setOpenMobile = (value: boolean) => { + this.openMobile = value; + }; - toggle = () => { - return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); - }; + toggle = () => { + return this.#isMobile.current ? (this.openMobile = !this.openMobile) : this.setOpen(!this.open); + }; } const SYMBOL_KEY = "scn-sidebar"; @@ -66,7 +66,7 @@ const SYMBOL_KEY = "scn-sidebar"; * @returns The `SidebarState` instance. */ export function setSidebar(props: SidebarStateProps): SidebarState { - return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); + return setContext(Symbol.for(SYMBOL_KEY), new SidebarState(props)); } /** @@ -75,5 +75,5 @@ export function setSidebar(props: SidebarStateProps): SidebarState { * @returns The `SidebarState` instance. */ export function useSidebar(): SidebarState { - return getContext(Symbol.for(SYMBOL_KEY)); + return getContext(Symbol.for(SYMBOL_KEY)); } diff --git a/src/lib/components/ui/sidebar/index.ts b/src/lib/components/ui/sidebar/index.ts index 5c2e6b8..42fe156 100644 --- a/src/lib/components/ui/sidebar/index.ts +++ b/src/lib/components/ui/sidebar/index.ts @@ -24,52 +24,52 @@ import Trigger from "./sidebar-trigger.svelte"; import Root from "./sidebar.svelte"; export { - Content, - Footer, - Group, - GroupAction, - GroupContent, - GroupLabel, - Header, - Input, - Inset, - Menu, - MenuAction, - MenuBadge, - MenuButton, - MenuItem, - MenuSkeleton, - MenuSub, - MenuSubButton, - MenuSubItem, - Provider, - Rail, - Root, - Separator, - // - Root as Sidebar, - Content as SidebarContent, - Footer as SidebarFooter, - Group as SidebarGroup, - GroupAction as SidebarGroupAction, - GroupContent as SidebarGroupContent, - GroupLabel as SidebarGroupLabel, - Header as SidebarHeader, - Input as SidebarInput, - Inset as SidebarInset, - Menu as SidebarMenu, - MenuAction as SidebarMenuAction, - MenuBadge as SidebarMenuBadge, - MenuButton as SidebarMenuButton, - MenuItem as SidebarMenuItem, - MenuSkeleton as SidebarMenuSkeleton, - MenuSub as SidebarMenuSub, - MenuSubButton as SidebarMenuSubButton, - MenuSubItem as SidebarMenuSubItem, - Provider as SidebarProvider, - Rail as SidebarRail, - Separator as SidebarSeparator, - Trigger as SidebarTrigger, - Trigger, - useSidebar + Content, + Footer, + Group, + GroupAction, + GroupContent, + GroupLabel, + Header, + Input, + Inset, + Menu, + MenuAction, + MenuBadge, + MenuButton, + MenuItem, + MenuSkeleton, + MenuSub, + MenuSubButton, + MenuSubItem, + Provider, + Rail, + Root, + Separator, + // + Root as Sidebar, + Content as SidebarContent, + Footer as SidebarFooter, + Group as SidebarGroup, + GroupAction as SidebarGroupAction, + GroupContent as SidebarGroupContent, + GroupLabel as SidebarGroupLabel, + Header as SidebarHeader, + Input as SidebarInput, + Inset as SidebarInset, + Menu as SidebarMenu, + MenuAction as SidebarMenuAction, + MenuBadge as SidebarMenuBadge, + MenuButton as SidebarMenuButton, + MenuItem as SidebarMenuItem, + MenuSkeleton as SidebarMenuSkeleton, + MenuSub as SidebarMenuSub, + MenuSubButton as SidebarMenuSubButton, + MenuSubItem as SidebarMenuSubItem, + Provider as SidebarProvider, + Rail as SidebarRail, + Separator as SidebarSeparator, + Trigger as SidebarTrigger, + Trigger, + useSidebar, }; diff --git a/src/lib/components/ui/sidebar/sidebar-content.svelte b/src/lib/components/ui/sidebar/sidebar-content.svelte index f121800..65c5330 100644 --- a/src/lib/components/ui/sidebar/sidebar-content.svelte +++ b/src/lib/components/ui/sidebar/sidebar-content.svelte @@ -1,24 +1,24 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-footer.svelte b/src/lib/components/ui/sidebar/sidebar-footer.svelte index 6259cb9..e528adb 100644 --- a/src/lib/components/ui/sidebar/sidebar-footer.svelte +++ b/src/lib/components/ui/sidebar/sidebar-footer.svelte @@ -1,21 +1,21 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-group-action.svelte b/src/lib/components/ui/sidebar/sidebar-group-action.svelte index d9579fd..a63d5e4 100644 --- a/src/lib/components/ui/sidebar/sidebar-group-action.svelte +++ b/src/lib/components/ui/sidebar/sidebar-group-action.svelte @@ -1,36 +1,36 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - + {/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group-content.svelte b/src/lib/components/ui/sidebar/sidebar-group-content.svelte index 415255f..504bb3e 100644 --- a/src/lib/components/ui/sidebar/sidebar-group-content.svelte +++ b/src/lib/components/ui/sidebar/sidebar-group-content.svelte @@ -1,21 +1,21 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-group-label.svelte b/src/lib/components/ui/sidebar/sidebar-group-label.svelte index 2b6287a..a028060 100644 --- a/src/lib/components/ui/sidebar/sidebar-group-label.svelte +++ b/src/lib/components/ui/sidebar/sidebar-group-label.svelte @@ -1,34 +1,34 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} -
    - {@render children?.()} -
    +
    + {@render children?.()} +
    {/if} diff --git a/src/lib/components/ui/sidebar/sidebar-group.svelte b/src/lib/components/ui/sidebar/sidebar-group.svelte index ec18a69..d858622 100644 --- a/src/lib/components/ui/sidebar/sidebar-group.svelte +++ b/src/lib/components/ui/sidebar/sidebar-group.svelte @@ -1,21 +1,21 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-header.svelte b/src/lib/components/ui/sidebar/sidebar-header.svelte index a1b2db1..cba453c 100644 --- a/src/lib/components/ui/sidebar/sidebar-header.svelte +++ b/src/lib/components/ui/sidebar/sidebar-header.svelte @@ -1,21 +1,21 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-input.svelte b/src/lib/components/ui/sidebar/sidebar-input.svelte index 19b3666..9fb07d2 100644 --- a/src/lib/components/ui/sidebar/sidebar-input.svelte +++ b/src/lib/components/ui/sidebar/sidebar-input.svelte @@ -1,21 +1,21 @@ diff --git a/src/lib/components/ui/sidebar/sidebar-inset.svelte b/src/lib/components/ui/sidebar/sidebar-inset.svelte index 140de4a..ad4740f 100644 --- a/src/lib/components/ui/sidebar/sidebar-inset.svelte +++ b/src/lib/components/ui/sidebar/sidebar-inset.svelte @@ -1,24 +1,24 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte index fb386c3..d463388 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-action.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-action.svelte @@ -1,43 +1,43 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - + {/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte index 09d4a90..339fb56 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-badge.svelte @@ -1,29 +1,29 @@
    - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte index 57e2fa2..beda3d9 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-button.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-button.svelte @@ -1,101 +1,101 @@ {#snippet Button({ props }: { props?: Record })} - {@const mergedProps = mergeProps(buttonProps, props)} - {#if child} - {@render child({ props: mergedProps })} - {:else} - - {/if} + {@const mergedProps = mergeProps(buttonProps, props)} + {#if child} + {@render child({ props: mergedProps })} + {:else} + + {/if} {/snippet} {#if !tooltipContent} - {@render Button({})} + {@render Button({})} {:else} - - - {#snippet child({ props })} - {@render Button({ props })} - {/snippet} - - - + + + {#snippet child({ props })} + {@render Button({ props })} + {/snippet} + + + {/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte index d87587c..58eeb58 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-item.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-item.svelte @@ -1,21 +1,21 @@
  • - {@render children?.()} + {@render children?.()}
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte index 68604e2..7d13a7b 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-skeleton.svelte @@ -1,36 +1,36 @@
    - {#if showIcon} - - {/if} - - {@render children?.()} + {#if showIcon} + + {/if} + + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte index d221310..c234d96 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-button.svelte @@ -1,43 +1,43 @@ {#if child} - {@render child({ props: mergedProps })} + {@render child({ props: mergedProps })} {:else} - - {@render children?.()} - + + {@render children?.()} + {/if} diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte index 681d0f1..6065e7d 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub-item.svelte @@ -1,21 +1,21 @@
  • - {@render children?.()} + {@render children?.()}
  • diff --git a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte index 8ab1111..8ea7996 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu-sub.svelte @@ -1,25 +1,25 @@
      - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-menu.svelte b/src/lib/components/ui/sidebar/sidebar-menu.svelte index 946ccce..9363848 100644 --- a/src/lib/components/ui/sidebar/sidebar-menu.svelte +++ b/src/lib/components/ui/sidebar/sidebar-menu.svelte @@ -1,21 +1,21 @@
      - {@render children?.()} + {@render children?.()}
    diff --git a/src/lib/components/ui/sidebar/sidebar-provider.svelte b/src/lib/components/ui/sidebar/sidebar-provider.svelte index b11d893..29a251d 100644 --- a/src/lib/components/ui/sidebar/sidebar-provider.svelte +++ b/src/lib/components/ui/sidebar/sidebar-provider.svelte @@ -1,45 +1,45 @@ -
    - {@render children?.()} -
    +
    + {@render children?.()} +
    diff --git a/src/lib/components/ui/sidebar/sidebar-rail.svelte b/src/lib/components/ui/sidebar/sidebar-rail.svelte index 3d58ed6..81890ad 100644 --- a/src/lib/components/ui/sidebar/sidebar-rail.svelte +++ b/src/lib/components/ui/sidebar/sidebar-rail.svelte @@ -1,36 +1,36 @@ diff --git a/src/lib/components/ui/sidebar/sidebar-separator.svelte b/src/lib/components/ui/sidebar/sidebar-separator.svelte index 5a7deda..7edb9ca 100644 --- a/src/lib/components/ui/sidebar/sidebar-separator.svelte +++ b/src/lib/components/ui/sidebar/sidebar-separator.svelte @@ -1,19 +1,19 @@ diff --git a/src/lib/components/ui/sidebar/sidebar-trigger.svelte b/src/lib/components/ui/sidebar/sidebar-trigger.svelte index 1825182..e0b02cb 100644 --- a/src/lib/components/ui/sidebar/sidebar-trigger.svelte +++ b/src/lib/components/ui/sidebar/sidebar-trigger.svelte @@ -1,35 +1,35 @@ diff --git a/src/lib/components/ui/sidebar/sidebar.svelte b/src/lib/components/ui/sidebar/sidebar.svelte index 854796e..ccdf338 100644 --- a/src/lib/components/ui/sidebar/sidebar.svelte +++ b/src/lib/components/ui/sidebar/sidebar.svelte @@ -1,101 +1,101 @@ {#if collapsible === "none"} -
    - {@render children?.()} -
    +
    + {@render children?.()} +
    {:else if sidebar.isMobile} - sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}> - - - Sidebar - Displays the mobile sidebar. - -
    - {@render children?.()} -
    -
    -
    + sidebar.openMobile, (v) => sidebar.setOpenMobile(v)} {...restProps}> + + + Sidebar + Displays the mobile sidebar. + +
    + {@render children?.()} +
    +
    +
    {:else} - + {/if} diff --git a/src/lib/components/ui/skeleton/index.ts b/src/lib/components/ui/skeleton/index.ts index 2be5c50..cb26b2c 100644 --- a/src/lib/components/ui/skeleton/index.ts +++ b/src/lib/components/ui/skeleton/index.ts @@ -1,7 +1,7 @@ import Root from "./skeleton.svelte"; export { - Root, - // - Root as Skeleton + Root, + // + Root as Skeleton, }; diff --git a/src/lib/components/ui/skeleton/skeleton.svelte b/src/lib/components/ui/skeleton/skeleton.svelte index b21d230..e0e5062 100644 --- a/src/lib/components/ui/skeleton/skeleton.svelte +++ b/src/lib/components/ui/skeleton/skeleton.svelte @@ -1,17 +1,17 @@
    diff --git a/src/lib/components/ui/sonner/sonner.svelte b/src/lib/components/ui/sonner/sonner.svelte index 1f50e1e..fea32fc 100644 --- a/src/lib/components/ui/sonner/sonner.svelte +++ b/src/lib/components/ui/sonner/sonner.svelte @@ -1,13 +1,13 @@ diff --git a/src/lib/components/ui/tooltip/index.ts b/src/lib/components/ui/tooltip/index.ts index f8dc9df..85d7744 100644 --- a/src/lib/components/ui/tooltip/index.ts +++ b/src/lib/components/ui/tooltip/index.ts @@ -7,15 +7,15 @@ const Provider = TooltipPrimitive.Provider; const Portal = TooltipPrimitive.Portal; export { - Root, - Trigger, - Content, - Provider, - Portal, - // - Root as Tooltip, - Content as TooltipContent, - Trigger as TooltipTrigger, - Provider as TooltipProvider, - Portal as TooltipPortal + Root, + Trigger, + Content, + Provider, + Portal, + // + Root as Tooltip, + Content as TooltipContent, + Trigger as TooltipTrigger, + Provider as TooltipProvider, + Portal as TooltipPortal, }; diff --git a/src/lib/components/ui/tooltip/tooltip-content.svelte b/src/lib/components/ui/tooltip/tooltip-content.svelte index b1e783e..7c61148 100644 --- a/src/lib/components/ui/tooltip/tooltip-content.svelte +++ b/src/lib/components/ui/tooltip/tooltip-content.svelte @@ -1,47 +1,47 @@ - - {@render children?.()} - - {#snippet child({ props })} -
    - {/snippet} -
    -
    + + {@render children?.()} + + {#snippet child({ props })} +
    + {/snippet} +
    +
    diff --git a/src/lib/components/ui/tooltip/tooltip-trigger.svelte b/src/lib/components/ui/tooltip/tooltip-trigger.svelte index 1acdaa4..d7b41d3 100644 --- a/src/lib/components/ui/tooltip/tooltip-trigger.svelte +++ b/src/lib/components/ui/tooltip/tooltip-trigger.svelte @@ -1,7 +1,7 @@ diff --git a/src/lib/components/ui/typography/headline.svelte b/src/lib/components/ui/typography/headline.svelte index 7ecf40c..e8a7de4 100644 --- a/src/lib/components/ui/typography/headline.svelte +++ b/src/lib/components/ui/typography/headline.svelte @@ -1,38 +1,38 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/components/ui/typography/text.svelte b/src/lib/components/ui/typography/text.svelte index 9cd3365..58da46c 100644 --- a/src/lib/components/ui/typography/text.svelte +++ b/src/lib/components/ui/typography/text.svelte @@ -1,44 +1,44 @@ - {@render children?.()} + {@render children?.()} diff --git a/src/lib/const/routes.ts b/src/lib/const/routes.ts index f82bf6f..0932258 100644 --- a/src/lib/const/routes.ts +++ b/src/lib/const/routes.ts @@ -1,21 +1,21 @@ export const ROUTES = { - SETUP: { - MAIN: "/setup", - CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", - CHECK_EMAIL: "/setup/check-email" - }, - RESEND_CONFIRMATION: "/confirm/resend", - LOGIN: "/login", - LOGOUT: "/logout", - DASHBOARD: { - MAIN: "/dashboard", - TENANTS: "/dashboard/tenants", - CALENDAR: "/dashboard/calendar", - STAFF: "/dashboard/staff", - AGENTS: "/dashboard/agents", - CHANNELS: "/dashboard/channels", - ABSENCES: "/dashboard/absences", - SETTINGS: "/dashboard/settings", - ACCOUNT: "/dashboard/account" - } + SETUP: { + MAIN: "/setup", + CREATE_ADMIN_ACCOUNT: "/setup/create-admin-account", + CHECK_EMAIL: "/setup/check-email", + }, + RESEND_CONFIRMATION: "/confirm/resend", + LOGIN: "/login", + LOGOUT: "/logout", + DASHBOARD: { + MAIN: "/dashboard", + TENANTS: "/dashboard/tenants", + CALENDAR: "/dashboard/calendar", + STAFF: "/dashboard/staff", + AGENTS: "/dashboard/agents", + CHANNELS: "/dashboard/channels", + ABSENCES: "/dashboard/absences", + SETTINGS: "/dashboard/settings", + ACCOUNT: "/dashboard/account", + }, }; diff --git a/src/lib/crypto/__tests__/hashing.test.ts b/src/lib/crypto/__tests__/hashing.test.ts index 6ada051..bb4fb43 100644 --- a/src/lib/crypto/__tests__/hashing.test.ts +++ b/src/lib/crypto/__tests__/hashing.test.ts @@ -4,292 +4,292 @@ import type { Argon2Options } from "../hashing"; // Mock the logger vi.mock("$lib/logger", () => ({ - logger: { - setContext: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - error: vi.fn() - } + logger: { + setContext: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, })); describe("OptimizedArgon2", () => { - // Test data - const testPin = "123456"; - const testClientId = "test-client-id"; - const testEmail = "test@example.com"; + // Test data + const testPin = "123456"; + const testClientId = "test-client-id"; + const testEmail = "test@example.com"; - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("deriveKeyFromPIN()", () => { - it("should derive a key with default options", async () => { - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); + describe("deriveKeyFromPIN()", () => { + it("should derive a key with default options", async () => { + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); // Default hash length - }); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); // Default hash length + }); - it("should derive a key with custom options", async () => { - const options: Argon2Options = { - memoryCost: 32768, - timeCost: 5, - parallelism: 2, - hashLength: 64 - }; + it("should derive a key with custom options", async () => { + const options: Argon2Options = { + memoryCost: 32768, + timeCost: 5, + parallelism: 2, + hashLength: 64, + }; - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(64); - }); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(64); + }); - it("should produce consistent results for same inputs", async () => { - const result1 = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); - const result2 = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); + it("should produce consistent results for same inputs", async () => { + const result1 = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); + const result2 = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); - expect(result1).toEqual(result2); - }); + expect(result1).toEqual(result2); + }); - it("should produce different results for different PINs", async () => { - const result1 = await OptimizedArgon2.deriveKeyFromPIN("123456", testClientId); - const result2 = await OptimizedArgon2.deriveKeyFromPIN("654321", testClientId); + it("should produce different results for different PINs", async () => { + const result1 = await OptimizedArgon2.deriveKeyFromPIN("123456", testClientId); + const result2 = await OptimizedArgon2.deriveKeyFromPIN("654321", testClientId); - expect(result1).not.toEqual(result2); - }); - - it("should produce different results for different client IDs", async () => { - const result1 = await OptimizedArgon2.deriveKeyFromPIN(testPin, "client1"); - const result2 = await OptimizedArgon2.deriveKeyFromPIN(testPin, "client2"); - - expect(result1).not.toEqual(result2); - }); - - it("should handle empty PIN", async () => { - const result = await OptimizedArgon2.deriveKeyFromPIN("", testClientId); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle empty client ID", async () => { - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, ""); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle unicode characters in PIN", async () => { - const unicodePin = "тест🔐äöü"; - const result = await OptimizedArgon2.deriveKeyFromPIN(unicodePin, testClientId); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle unicode characters in client ID", async () => { - const unicodeClientId = "клиент🆔äöü"; - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, unicodeClientId); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - }); - - describe("createClientIdAsync()", () => { - it("should create consistent client ID for same email", async () => { - const result1 = await OptimizedArgon2.createClientIdAsync(testEmail); - const result2 = await OptimizedArgon2.createClientIdAsync(testEmail); - - expect(result1).toBe(result2); - expect(typeof result1).toBe("string"); - expect(result1.length).toBe(64); // SHA-256 hex = 64 chars - }); - - it("should create different client IDs for different emails", async () => { - const result1 = await OptimizedArgon2.createClientIdAsync("user1@example.com"); - const result2 = await OptimizedArgon2.createClientIdAsync("user2@example.com"); - - expect(result1).not.toBe(result2); - }); + expect(result1).not.toEqual(result2); + }); + + it("should produce different results for different client IDs", async () => { + const result1 = await OptimizedArgon2.deriveKeyFromPIN(testPin, "client1"); + const result2 = await OptimizedArgon2.deriveKeyFromPIN(testPin, "client2"); + + expect(result1).not.toEqual(result2); + }); + + it("should handle empty PIN", async () => { + const result = await OptimizedArgon2.deriveKeyFromPIN("", testClientId); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle empty client ID", async () => { + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, ""); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle unicode characters in PIN", async () => { + const unicodePin = "тест🔐äöü"; + const result = await OptimizedArgon2.deriveKeyFromPIN(unicodePin, testClientId); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle unicode characters in client ID", async () => { + const unicodeClientId = "клиент🆔äöü"; + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, unicodeClientId); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + }); + + describe("createClientIdAsync()", () => { + it("should create consistent client ID for same email", async () => { + const result1 = await OptimizedArgon2.createClientIdAsync(testEmail); + const result2 = await OptimizedArgon2.createClientIdAsync(testEmail); + + expect(result1).toBe(result2); + expect(typeof result1).toBe("string"); + expect(result1.length).toBe(64); // SHA-256 hex = 64 chars + }); + + it("should create different client IDs for different emails", async () => { + const result1 = await OptimizedArgon2.createClientIdAsync("user1@example.com"); + const result2 = await OptimizedArgon2.createClientIdAsync("user2@example.com"); + + expect(result1).not.toBe(result2); + }); - it("should handle email case insensitivity", async () => { - const result1 = await OptimizedArgon2.createClientIdAsync("Test@Example.Com"); - const result2 = await OptimizedArgon2.createClientIdAsync("test@example.com"); + it("should handle email case insensitivity", async () => { + const result1 = await OptimizedArgon2.createClientIdAsync("Test@Example.Com"); + const result2 = await OptimizedArgon2.createClientIdAsync("test@example.com"); - expect(result1).toBe(result2); - }); + expect(result1).toBe(result2); + }); - it("should handle empty email", async () => { - const result = await OptimizedArgon2.createClientIdAsync(""); + it("should handle empty email", async () => { + const result = await OptimizedArgon2.createClientIdAsync(""); - expect(typeof result).toBe("string"); - expect(result.length).toBe(64); - }); - - it("should handle unicode in email", async () => { - const unicodeEmail = "тест@example.com"; - const result = await OptimizedArgon2.createClientIdAsync(unicodeEmail); + expect(typeof result).toBe("string"); + expect(result.length).toBe(64); + }); + + it("should handle unicode in email", async () => { + const unicodeEmail = "тест@example.com"; + const result = await OptimizedArgon2.createClientIdAsync(unicodeEmail); - expect(typeof result).toBe("string"); - expect(result.length).toBe(64); - }); + expect(typeof result).toBe("string"); + expect(result.length).toBe(64); + }); - it("should produce valid hex string", async () => { - const result = await OptimizedArgon2.createClientIdAsync(testEmail); + it("should produce valid hex string", async () => { + const result = await OptimizedArgon2.createClientIdAsync(testEmail); - expect(result).toMatch(/^[0-9a-f]{64}$/); - }); - }); + expect(result).toMatch(/^[0-9a-f]{64}$/); + }); + }); - describe("createClientId() - Browser only", () => { - it("should throw error in Node.js environment", async () => { - // This test runs in Node.js environment by default - await expect(OptimizedArgon2.createClientId(testEmail)).rejects.toThrow( - "createClientId should be called asynchronously in Node.js environment" - ); - }); - }); + describe("createClientId() - Browser only", () => { + it("should throw error in Node.js environment", async () => { + // This test runs in Node.js environment by default + await expect(OptimizedArgon2.createClientId(testEmail)).rejects.toThrow( + "createClientId should be called asynchronously in Node.js environment", + ); + }); + }); - describe("getImplementationInfo()", () => { - it("should return implementation information", async () => { - const info = await OptimizedArgon2.getImplementationInfo(); + describe("getImplementationInfo()", () => { + it("should return implementation information", async () => { + const info = await OptimizedArgon2.getImplementationInfo(); - expect(typeof info).toBe("string"); - expect(info.length).toBeGreaterThan(0); - // In Node.js environment, it should try native argon2 first - expect(info).toMatch(/(Native Node\.js argon2|@noble\/hashes)/); - }); - }); + expect(typeof info).toBe("string"); + expect(info.length).toBeGreaterThan(0); + // In Node.js environment, it should try native argon2 first + expect(info).toMatch(/(Native Node\.js argon2|@noble\/hashes)/); + }); + }); - describe("Edge cases and error handling", () => { - it("should handle very long PIN", async () => { - const longPin = "a".repeat(1000); - const result = await OptimizedArgon2.deriveKeyFromPIN(longPin, testClientId); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle very long client ID", async () => { - const longClientId = "b".repeat(1000); - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, longClientId); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle minimum hash length", async () => { - const options: Argon2Options = { - hashLength: 4 // Noble hashes requires minimum 4 bytes - }; - - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(4); - }); - - it("should handle large hash length", async () => { - const options: Argon2Options = { - hashLength: 128 - }; - - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(128); - }); - - it("should handle minimum memory cost", async () => { - const options: Argon2Options = { - memoryCost: 8 // 8 KB minimum - }; - - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle minimum time cost", async () => { - const options: Argon2Options = { - timeCost: 1 - }; - - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - - it("should handle different parallelism values", async () => { - const options: Argon2Options = { - parallelism: 4 - }; - - const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); - - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - }); - }); - - describe("Performance characteristics", () => { - it("should complete within reasonable time with default options", async () => { - const startTime = Date.now(); - - await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); - - const endTime = Date.now(); - const duration = endTime - startTime; - - // Should complete within 5 seconds even on slow systems - expect(duration).toBeLessThan(5000); - }); - - it("should handle concurrent derivations", async () => { - const promises = Array.from({ length: 5 }, (_, i) => - OptimizedArgon2.deriveKeyFromPIN(`pin${i}`, `client${i}`) - ); - - const results = await Promise.all(promises); - - expect(results).toHaveLength(5); - results.forEach((result, i) => { - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(32); - - // Each result should be different - results.slice(i + 1).forEach((otherResult) => { - expect(result).not.toEqual(otherResult); - }); - }); - }); - }); - - describe("Consistency across implementations", () => { - it("should produce consistent results regardless of implementation fallback", async () => { - // Test that the fallback implementation produces consistent results - const options: Argon2Options = { - memoryCost: 1024, // Low memory to ensure fallback might be used - timeCost: 1, - parallelism: 1, - hashLength: 32 - }; - - const results = await Promise.all([ - OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options), - OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options), - OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options) - ]); - - expect(results[0]).toEqual(results[1]); - expect(results[1]).toEqual(results[2]); - }); - }); + describe("Edge cases and error handling", () => { + it("should handle very long PIN", async () => { + const longPin = "a".repeat(1000); + const result = await OptimizedArgon2.deriveKeyFromPIN(longPin, testClientId); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle very long client ID", async () => { + const longClientId = "b".repeat(1000); + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, longClientId); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle minimum hash length", async () => { + const options: Argon2Options = { + hashLength: 4, // Noble hashes requires minimum 4 bytes + }; + + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(4); + }); + + it("should handle large hash length", async () => { + const options: Argon2Options = { + hashLength: 128, + }; + + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(128); + }); + + it("should handle minimum memory cost", async () => { + const options: Argon2Options = { + memoryCost: 8, // 8 KB minimum + }; + + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle minimum time cost", async () => { + const options: Argon2Options = { + timeCost: 1, + }; + + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + + it("should handle different parallelism values", async () => { + const options: Argon2Options = { + parallelism: 4, + }; + + const result = await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + }); + }); + + describe("Performance characteristics", () => { + it("should complete within reasonable time with default options", async () => { + const startTime = Date.now(); + + await OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete within 5 seconds even on slow systems + expect(duration).toBeLessThan(5000); + }); + + it("should handle concurrent derivations", async () => { + const promises = Array.from({ length: 5 }, (_, i) => + OptimizedArgon2.deriveKeyFromPIN(`pin${i}`, `client${i}`), + ); + + const results = await Promise.all(promises); + + expect(results).toHaveLength(5); + results.forEach((result, i) => { + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(32); + + // Each result should be different + results.slice(i + 1).forEach((otherResult) => { + expect(result).not.toEqual(otherResult); + }); + }); + }); + }); + + describe("Consistency across implementations", () => { + it("should produce consistent results regardless of implementation fallback", async () => { + // Test that the fallback implementation produces consistent results + const options: Argon2Options = { + memoryCost: 1024, // Low memory to ensure fallback might be used + timeCost: 1, + parallelism: 1, + hashLength: 32, + }; + + const results = await Promise.all([ + OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options), + OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options), + OptimizedArgon2.deriveKeyFromPIN(testPin, testClientId, options), + ]); + + expect(results[0]).toEqual(results[1]); + expect(results[1]).toEqual(results[2]); + }); + }); }); diff --git a/src/lib/crypto/__tests__/utils.test.ts b/src/lib/crypto/__tests__/utils.test.ts index 1e1b4a1..47e331b 100644 --- a/src/lib/crypto/__tests__/utils.test.ts +++ b/src/lib/crypto/__tests__/utils.test.ts @@ -3,422 +3,422 @@ import { BufferUtils, KyberCrypto, AESCrypto, ShamirSecretSharing } from "../uti import type { CryptoBuffer, KyberKeyPair, ShamirShare } from "../utils"; describe("BufferUtils", () => { - describe("from()", () => { - it("should convert string to CryptoBuffer with utf8 encoding", () => { - const input = "Hello World"; - const result = BufferUtils.from(input); - expect(result).toBeInstanceOf(Uint8Array); - expect(result).toEqual(new TextEncoder().encode(input)); - }); + describe("from()", () => { + it("should convert string to CryptoBuffer with utf8 encoding", () => { + const input = "Hello World"; + const result = BufferUtils.from(input); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(new TextEncoder().encode(input)); + }); - it("should convert hex string to CryptoBuffer", () => { - const input = "48656c6c6f"; - const result = BufferUtils.from(input, "hex"); - expect(result).toBeInstanceOf(Uint8Array); - expect(result).toEqual(new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])); - }); + it("should convert hex string to CryptoBuffer", () => { + const input = "48656c6c6f"; + const result = BufferUtils.from(input, "hex"); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f])); + }); - it("should convert number array to CryptoBuffer", () => { - const input = [72, 101, 108, 108, 111]; - const result = BufferUtils.from(input); - expect(result).toBeInstanceOf(Uint8Array); - expect(result).toEqual(new Uint8Array(input)); - }); + it("should convert number array to CryptoBuffer", () => { + const input = [72, 101, 108, 108, 111]; + const result = BufferUtils.from(input); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(new Uint8Array(input)); + }); - it("should convert ArrayBuffer to CryptoBuffer", () => { - const input = new ArrayBuffer(5); - const view = new Uint8Array(input); - view.set([72, 101, 108, 108, 111]); - const result = BufferUtils.from(input); - expect(result).toBeInstanceOf(Uint8Array); - expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); - }); + it("should convert ArrayBuffer to CryptoBuffer", () => { + const input = new ArrayBuffer(5); + const view = new Uint8Array(input); + view.set([72, 101, 108, 108, 111]); + const result = BufferUtils.from(input); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(new Uint8Array([72, 101, 108, 108, 111])); + }); - it("should handle empty hex string", () => { - const result = BufferUtils.from("", "hex"); - expect(result).toEqual(new Uint8Array([])); - }); + it("should handle empty hex string", () => { + const result = BufferUtils.from("", "hex"); + expect(result).toEqual(new Uint8Array([])); + }); - it("should handle invalid hex string", () => { - const result = BufferUtils.from("invalid", "hex"); - // Invalid hex characters will be parsed as NaN, resulting in non-empty array - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBeGreaterThan(0); - }); - }); + it("should handle invalid hex string", () => { + const result = BufferUtils.from("invalid", "hex"); + // Invalid hex characters will be parsed as NaN, resulting in non-empty array + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBeGreaterThan(0); + }); + }); - describe("toString()", () => { - it("should convert CryptoBuffer to utf8 string", () => { - const input = new Uint8Array([72, 101, 108, 108, 111]); - const result = BufferUtils.toString(input); - expect(result).toBe("Hello"); - }); + describe("toString()", () => { + it("should convert CryptoBuffer to utf8 string", () => { + const input = new Uint8Array([72, 101, 108, 108, 111]); + const result = BufferUtils.toString(input); + expect(result).toBe("Hello"); + }); - it("should convert CryptoBuffer to hex string", () => { - const input = new Uint8Array([72, 101, 108, 108, 111]); - const result = BufferUtils.toString(input, "hex"); - expect(result).toBe("48656c6c6f"); - }); + it("should convert CryptoBuffer to hex string", () => { + const input = new Uint8Array([72, 101, 108, 108, 111]); + const result = BufferUtils.toString(input, "hex"); + expect(result).toBe("48656c6c6f"); + }); - it("should handle empty buffer", () => { - const input = new Uint8Array([]); - expect(BufferUtils.toString(input)).toBe(""); - expect(BufferUtils.toString(input, "hex")).toBe(""); - }); + it("should handle empty buffer", () => { + const input = new Uint8Array([]); + expect(BufferUtils.toString(input)).toBe(""); + expect(BufferUtils.toString(input, "hex")).toBe(""); + }); - it("should pad single digit hex values", () => { - const input = new Uint8Array([0, 15, 255]); - const result = BufferUtils.toString(input, "hex"); - expect(result).toBe("000fff"); - }); - }); + it("should pad single digit hex values", () => { + const input = new Uint8Array([0, 15, 255]); + const result = BufferUtils.toString(input, "hex"); + expect(result).toBe("000fff"); + }); + }); - describe("concat()", () => { - it("should concatenate multiple buffers", () => { - const buf1 = new Uint8Array([1, 2, 3]); - const buf2 = new Uint8Array([4, 5]); - const buf3 = new Uint8Array([6, 7, 8, 9]); - const result = BufferUtils.concat([buf1, buf2, buf3]); - expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); - }); + describe("concat()", () => { + it("should concatenate multiple buffers", () => { + const buf1 = new Uint8Array([1, 2, 3]); + const buf2 = new Uint8Array([4, 5]); + const buf3 = new Uint8Array([6, 7, 8, 9]); + const result = BufferUtils.concat([buf1, buf2, buf3]); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9])); + }); - it("should handle empty buffers", () => { - const buf1 = new Uint8Array([1, 2]); - const buf2 = new Uint8Array([]); - const buf3 = new Uint8Array([3, 4]); - const result = BufferUtils.concat([buf1, buf2, buf3]); - expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); - }); + it("should handle empty buffers", () => { + const buf1 = new Uint8Array([1, 2]); + const buf2 = new Uint8Array([]); + const buf3 = new Uint8Array([3, 4]); + const result = BufferUtils.concat([buf1, buf2, buf3]); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4])); + }); - it("should handle single buffer", () => { - const buf = new Uint8Array([1, 2, 3]); - const result = BufferUtils.concat([buf]); - expect(result).toEqual(buf); - }); + it("should handle single buffer", () => { + const buf = new Uint8Array([1, 2, 3]); + const result = BufferUtils.concat([buf]); + expect(result).toEqual(buf); + }); - it("should handle empty array", () => { - const result = BufferUtils.concat([]); - expect(result).toEqual(new Uint8Array([])); - }); - }); + it("should handle empty array", () => { + const result = BufferUtils.concat([]); + expect(result).toEqual(new Uint8Array([])); + }); + }); - describe("randomBytes()", () => { - it("should generate random bytes of specified length", () => { - const length = 32; - const result = BufferUtils.randomBytes(length); - expect(result).toBeInstanceOf(Uint8Array); - expect(result.length).toBe(length); - }); + describe("randomBytes()", () => { + it("should generate random bytes of specified length", () => { + const length = 32; + const result = BufferUtils.randomBytes(length); + expect(result).toBeInstanceOf(Uint8Array); + expect(result.length).toBe(length); + }); - it("should generate different random bytes on each call", () => { - const result1 = BufferUtils.randomBytes(16); - const result2 = BufferUtils.randomBytes(16); - expect(result1).not.toEqual(result2); - }); + it("should generate different random bytes on each call", () => { + const result1 = BufferUtils.randomBytes(16); + const result2 = BufferUtils.randomBytes(16); + expect(result1).not.toEqual(result2); + }); - it("should handle zero length", () => { - const result = BufferUtils.randomBytes(0); - expect(result).toEqual(new Uint8Array([])); - }); - }); + it("should handle zero length", () => { + const result = BufferUtils.randomBytes(0); + expect(result).toEqual(new Uint8Array([])); + }); + }); - describe("xor()", () => { - it("should perform XOR operation on equal length buffers", () => { - const a = new Uint8Array([0xff, 0x00, 0xaa]); - const b = new Uint8Array([0x00, 0xff, 0x55]); - const result = BufferUtils.xor(a, b); - expect(result).toEqual(new Uint8Array([0xff, 0xff, 0xff])); - }); + describe("xor()", () => { + it("should perform XOR operation on equal length buffers", () => { + const a = new Uint8Array([0xff, 0x00, 0xaa]); + const b = new Uint8Array([0x00, 0xff, 0x55]); + const result = BufferUtils.xor(a, b); + expect(result).toEqual(new Uint8Array([0xff, 0xff, 0xff])); + }); - it("should handle different length buffers", () => { - const a = new Uint8Array([0xff, 0x00]); - const b = new Uint8Array([0x00, 0xff, 0x55]); - const result = BufferUtils.xor(a, b); - expect(result).toEqual(new Uint8Array([0xff, 0xff, 0x55])); - }); + it("should handle different length buffers", () => { + const a = new Uint8Array([0xff, 0x00]); + const b = new Uint8Array([0x00, 0xff, 0x55]); + const result = BufferUtils.xor(a, b); + expect(result).toEqual(new Uint8Array([0xff, 0xff, 0x55])); + }); - it("should handle empty buffers", () => { - const a = new Uint8Array([]); - const b = new Uint8Array([1, 2, 3]); - const result = BufferUtils.xor(a, b); - expect(result).toEqual(new Uint8Array([1, 2, 3])); - }); - }); + it("should handle empty buffers", () => { + const a = new Uint8Array([]); + const b = new Uint8Array([1, 2, 3]); + const result = BufferUtils.xor(a, b); + expect(result).toEqual(new Uint8Array([1, 2, 3])); + }); + }); - describe("equals()", () => { - it("should return true for equal buffers", () => { - const a = new Uint8Array([1, 2, 3, 4]); - const b = new Uint8Array([1, 2, 3, 4]); - expect(BufferUtils.equals(a, b)).toBe(true); - }); + describe("equals()", () => { + it("should return true for equal buffers", () => { + const a = new Uint8Array([1, 2, 3, 4]); + const b = new Uint8Array([1, 2, 3, 4]); + expect(BufferUtils.equals(a, b)).toBe(true); + }); - it("should return false for different buffers", () => { - const a = new Uint8Array([1, 2, 3, 4]); - const b = new Uint8Array([1, 2, 3, 5]); - expect(BufferUtils.equals(a, b)).toBe(false); - }); + it("should return false for different buffers", () => { + const a = new Uint8Array([1, 2, 3, 4]); + const b = new Uint8Array([1, 2, 3, 5]); + expect(BufferUtils.equals(a, b)).toBe(false); + }); - it("should return false for different length buffers", () => { - const a = new Uint8Array([1, 2, 3]); - const b = new Uint8Array([1, 2, 3, 4]); - expect(BufferUtils.equals(a, b)).toBe(false); - }); + it("should return false for different length buffers", () => { + const a = new Uint8Array([1, 2, 3]); + const b = new Uint8Array([1, 2, 3, 4]); + expect(BufferUtils.equals(a, b)).toBe(false); + }); - it("should return true for empty buffers", () => { - const a = new Uint8Array([]); - const b = new Uint8Array([]); - expect(BufferUtils.equals(a, b)).toBe(true); - }); - }); + it("should return true for empty buffers", () => { + const a = new Uint8Array([]); + const b = new Uint8Array([]); + expect(BufferUtils.equals(a, b)).toBe(true); + }); + }); }); describe("KyberCrypto", () => { - let keyPair: KyberKeyPair; + let keyPair: KyberKeyPair; - beforeEach(() => { - keyPair = KyberCrypto.generateKeyPair(); - }); + beforeEach(() => { + keyPair = KyberCrypto.generateKeyPair(); + }); - describe("generateKeyPair()", () => { - it("should generate a valid key pair", () => { - expect(keyPair.publicKey).toBeInstanceOf(Uint8Array); - expect(keyPair.privateKey).toBeInstanceOf(Uint8Array); - expect(keyPair.publicKey.length).toBeGreaterThan(0); - expect(keyPair.privateKey.length).toBeGreaterThan(0); - }); + describe("generateKeyPair()", () => { + it("should generate a valid key pair", () => { + expect(keyPair.publicKey).toBeInstanceOf(Uint8Array); + expect(keyPair.privateKey).toBeInstanceOf(Uint8Array); + expect(keyPair.publicKey.length).toBeGreaterThan(0); + expect(keyPair.privateKey.length).toBeGreaterThan(0); + }); - it("should generate different key pairs on each call", () => { - const keyPair2 = KyberCrypto.generateKeyPair(); - expect(keyPair.publicKey).not.toEqual(keyPair2.publicKey); - expect(keyPair.privateKey).not.toEqual(keyPair2.privateKey); - }); - }); + it("should generate different key pairs on each call", () => { + const keyPair2 = KyberCrypto.generateKeyPair(); + expect(keyPair.publicKey).not.toEqual(keyPair2.publicKey); + expect(keyPair.privateKey).not.toEqual(keyPair2.privateKey); + }); + }); - describe("encapsulate()", () => { - it("should encapsulate a shared secret", () => { - const result = KyberCrypto.encapsulate(keyPair.publicKey); - expect(result.sharedSecret).toBeInstanceOf(Uint8Array); - expect(result.encapsulatedSecret).toBeInstanceOf(Uint8Array); - expect(result.sharedSecret.length).toBeGreaterThan(0); - expect(result.encapsulatedSecret.length).toBeGreaterThan(0); - }); + describe("encapsulate()", () => { + it("should encapsulate a shared secret", () => { + const result = KyberCrypto.encapsulate(keyPair.publicKey); + expect(result.sharedSecret).toBeInstanceOf(Uint8Array); + expect(result.encapsulatedSecret).toBeInstanceOf(Uint8Array); + expect(result.sharedSecret.length).toBeGreaterThan(0); + expect(result.encapsulatedSecret.length).toBeGreaterThan(0); + }); - it("should generate different shared secrets on each call", () => { - const result1 = KyberCrypto.encapsulate(keyPair.publicKey); - const result2 = KyberCrypto.encapsulate(keyPair.publicKey); - expect(result1.sharedSecret).not.toEqual(result2.sharedSecret); - expect(result1.encapsulatedSecret).not.toEqual(result2.encapsulatedSecret); - }); - }); + it("should generate different shared secrets on each call", () => { + const result1 = KyberCrypto.encapsulate(keyPair.publicKey); + const result2 = KyberCrypto.encapsulate(keyPair.publicKey); + expect(result1.sharedSecret).not.toEqual(result2.sharedSecret); + expect(result1.encapsulatedSecret).not.toEqual(result2.encapsulatedSecret); + }); + }); - describe("decapsulate()", () => { - it("should decapsulate the shared secret correctly", () => { - const encapsulated = KyberCrypto.encapsulate(keyPair.publicKey); - const decapsulated = KyberCrypto.decapsulate( - keyPair.privateKey, - encapsulated.encapsulatedSecret - ); - expect(decapsulated).toEqual(encapsulated.sharedSecret); - }); + describe("decapsulate()", () => { + it("should decapsulate the shared secret correctly", () => { + const encapsulated = KyberCrypto.encapsulate(keyPair.publicKey); + const decapsulated = KyberCrypto.decapsulate( + keyPair.privateKey, + encapsulated.encapsulatedSecret, + ); + expect(decapsulated).toEqual(encapsulated.sharedSecret); + }); - it("should return different results for different private keys", () => { - const keyPair2 = KyberCrypto.generateKeyPair(); - const encapsulated = KyberCrypto.encapsulate(keyPair.publicKey); - const decapsulated1 = KyberCrypto.decapsulate( - keyPair.privateKey, - encapsulated.encapsulatedSecret - ); - const decapsulated2 = KyberCrypto.decapsulate( - keyPair2.privateKey, - encapsulated.encapsulatedSecret - ); - expect(decapsulated1).not.toEqual(decapsulated2); - }); - }); + it("should return different results for different private keys", () => { + const keyPair2 = KyberCrypto.generateKeyPair(); + const encapsulated = KyberCrypto.encapsulate(keyPair.publicKey); + const decapsulated1 = KyberCrypto.decapsulate( + keyPair.privateKey, + encapsulated.encapsulatedSecret, + ); + const decapsulated2 = KyberCrypto.decapsulate( + keyPair2.privateKey, + encapsulated.encapsulatedSecret, + ); + expect(decapsulated1).not.toEqual(decapsulated2); + }); + }); }); describe("AESCrypto", () => { - describe("generateSessionKey()", () => { - it("should generate a 32-byte key", () => { - const key = AESCrypto.generateSessionKey(); - expect(key).toBeInstanceOf(Uint8Array); - expect(key.length).toBe(32); - }); + describe("generateSessionKey()", () => { + it("should generate a 32-byte key", () => { + const key = AESCrypto.generateSessionKey(); + expect(key).toBeInstanceOf(Uint8Array); + expect(key.length).toBe(32); + }); - it("should generate different keys on each call", () => { - const key1 = AESCrypto.generateSessionKey(); - const key2 = AESCrypto.generateSessionKey(); - expect(key1).not.toEqual(key2); - }); - }); + it("should generate different keys on each call", () => { + const key1 = AESCrypto.generateSessionKey(); + const key2 = AESCrypto.generateSessionKey(); + expect(key1).not.toEqual(key2); + }); + }); - describe("encrypt() and decrypt()", () => { - let key: CryptoBuffer; + describe("encrypt() and decrypt()", () => { + let key: CryptoBuffer; - beforeEach(() => { - key = AESCrypto.generateSessionKey(); - }); + beforeEach(() => { + key = AESCrypto.generateSessionKey(); + }); - it("should encrypt and decrypt data correctly", async () => { - const plaintext = "Hello, World!"; - const encrypted = await AESCrypto.encrypt(plaintext, key); + it("should encrypt and decrypt data correctly", async () => { + const plaintext = "Hello, World!"; + const encrypted = await AESCrypto.encrypt(plaintext, key); - expect(encrypted.encrypted).toBeInstanceOf(Uint8Array); - expect(encrypted.iv).toBeInstanceOf(Uint8Array); - expect(encrypted.tag).toBeInstanceOf(Uint8Array); - expect(encrypted.iv.length).toBe(16); - expect(encrypted.tag.length).toBe(16); + expect(encrypted.encrypted).toBeInstanceOf(Uint8Array); + expect(encrypted.iv).toBeInstanceOf(Uint8Array); + expect(encrypted.tag).toBeInstanceOf(Uint8Array); + expect(encrypted.iv.length).toBe(16); + expect(encrypted.tag.length).toBe(16); - const decrypted = await AESCrypto.decrypt( - encrypted.encrypted, - key, - encrypted.iv, - encrypted.tag - ); - expect(decrypted).toBe(plaintext); - }); + const decrypted = await AESCrypto.decrypt( + encrypted.encrypted, + key, + encrypted.iv, + encrypted.tag, + ); + expect(decrypted).toBe(plaintext); + }); - it("should handle empty string", async () => { - const plaintext = ""; - const encrypted = await AESCrypto.encrypt(plaintext, key); - const decrypted = await AESCrypto.decrypt( - encrypted.encrypted, - key, - encrypted.iv, - encrypted.tag - ); - expect(decrypted).toBe(plaintext); - }); + it("should handle empty string", async () => { + const plaintext = ""; + const encrypted = await AESCrypto.encrypt(plaintext, key); + const decrypted = await AESCrypto.decrypt( + encrypted.encrypted, + key, + encrypted.iv, + encrypted.tag, + ); + expect(decrypted).toBe(plaintext); + }); - it("should handle unicode characters", async () => { - const plaintext = "🔐 Verschlüsselter Text mit Umlauten: äöü"; - const encrypted = await AESCrypto.encrypt(plaintext, key); - const decrypted = await AESCrypto.decrypt( - encrypted.encrypted, - key, - encrypted.iv, - encrypted.tag - ); - expect(decrypted).toBe(plaintext); - }); + it("should handle unicode characters", async () => { + const plaintext = "🔐 Verschlüsselter Text mit Umlauten: äöü"; + const encrypted = await AESCrypto.encrypt(plaintext, key); + const decrypted = await AESCrypto.decrypt( + encrypted.encrypted, + key, + encrypted.iv, + encrypted.tag, + ); + expect(decrypted).toBe(plaintext); + }); - it("should generate different IV and tag for same plaintext", async () => { - const plaintext = "Same text"; - const encrypted1 = await AESCrypto.encrypt(plaintext, key); - const encrypted2 = await AESCrypto.encrypt(plaintext, key); + it("should generate different IV and tag for same plaintext", async () => { + const plaintext = "Same text"; + const encrypted1 = await AESCrypto.encrypt(plaintext, key); + const encrypted2 = await AESCrypto.encrypt(plaintext, key); - expect(encrypted1.iv).not.toEqual(encrypted2.iv); - expect(encrypted1.encrypted).not.toEqual(encrypted2.encrypted); - expect(encrypted1.tag).not.toEqual(encrypted2.tag); - }); + expect(encrypted1.iv).not.toEqual(encrypted2.iv); + expect(encrypted1.encrypted).not.toEqual(encrypted2.encrypted); + expect(encrypted1.tag).not.toEqual(encrypted2.tag); + }); - it("should fail with wrong key", async () => { - const plaintext = "Secret message"; - const wrongKey = AESCrypto.generateSessionKey(); - const encrypted = await AESCrypto.encrypt(plaintext, key); + it("should fail with wrong key", async () => { + const plaintext = "Secret message"; + const wrongKey = AESCrypto.generateSessionKey(); + const encrypted = await AESCrypto.encrypt(plaintext, key); - await expect( - AESCrypto.decrypt(encrypted.encrypted, wrongKey, encrypted.iv, encrypted.tag) - ).rejects.toThrow(); - }); + await expect( + AESCrypto.decrypt(encrypted.encrypted, wrongKey, encrypted.iv, encrypted.tag), + ).rejects.toThrow(); + }); - it("should fail with wrong IV", async () => { - const plaintext = "Secret message"; - const wrongIV = BufferUtils.randomBytes(16); - const encrypted = await AESCrypto.encrypt(plaintext, key); + it("should fail with wrong IV", async () => { + const plaintext = "Secret message"; + const wrongIV = BufferUtils.randomBytes(16); + const encrypted = await AESCrypto.encrypt(plaintext, key); - await expect( - AESCrypto.decrypt(encrypted.encrypted, key, wrongIV, encrypted.tag) - ).rejects.toThrow(); - }); + await expect( + AESCrypto.decrypt(encrypted.encrypted, key, wrongIV, encrypted.tag), + ).rejects.toThrow(); + }); - it("should fail with wrong tag", async () => { - const plaintext = "Secret message"; - const wrongTag = BufferUtils.randomBytes(16); - const encrypted = await AESCrypto.encrypt(plaintext, key); + it("should fail with wrong tag", async () => { + const plaintext = "Secret message"; + const wrongTag = BufferUtils.randomBytes(16); + const encrypted = await AESCrypto.encrypt(plaintext, key); - await expect( - AESCrypto.decrypt(encrypted.encrypted, key, encrypted.iv, wrongTag) - ).rejects.toThrow(); - }); - }); + await expect( + AESCrypto.decrypt(encrypted.encrypted, key, encrypted.iv, wrongTag), + ).rejects.toThrow(); + }); + }); }); describe("ShamirSecretSharing", () => { - describe("splitSecret()", () => { - it("should split secret into shares", () => { - const secret = BufferUtils.from("test secret"); - const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); + describe("splitSecret()", () => { + it("should split secret into shares", () => { + const secret = BufferUtils.from("test secret"); + const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); - expect(shares).toHaveLength(3); - shares.forEach((share, index) => { - expect(share.x).toBe(index + 1); - expect(share.y).toBeInstanceOf(Uint8Array); - expect(share.y.length).toBe(4 + secret.length); // 4 bytes for length + secret - }); - }); + expect(shares).toHaveLength(3); + shares.forEach((share, index) => { + expect(share.x).toBe(index + 1); + expect(share.y).toBeInstanceOf(Uint8Array); + expect(share.y.length).toBe(4 + secret.length); // 4 bytes for length + secret + }); + }); - it("should throw error for empty secret", () => { - const secret = new Uint8Array([]); - expect(() => ShamirSecretSharing.splitSecret(secret, 2, 3)).toThrow( - "Secret cannot be empty for Shamir secret sharing" - ); - }); + it("should throw error for empty secret", () => { + const secret = new Uint8Array([]); + expect(() => ShamirSecretSharing.splitSecret(secret, 2, 3)).toThrow( + "Secret cannot be empty for Shamir secret sharing", + ); + }); - it("should handle large secrets", () => { - const secret = BufferUtils.randomBytes(1000); - const shares = ShamirSecretSharing.splitSecret(secret, 3, 5); + it("should handle large secrets", () => { + const secret = BufferUtils.randomBytes(1000); + const shares = ShamirSecretSharing.splitSecret(secret, 3, 5); - expect(shares).toHaveLength(5); - shares.forEach((share) => { - expect(share.y.length).toBe(4 + secret.length); - }); - }); - }); + expect(shares).toHaveLength(5); + shares.forEach((share) => { + expect(share.y.length).toBe(4 + secret.length); + }); + }); + }); - describe("reconstructSecret()", () => { - it("should reconstruct secret from shares", () => { - const originalSecret = BufferUtils.from("test secret for reconstruction"); - const shares = ShamirSecretSharing.splitSecret(originalSecret, 2, 5); + describe("reconstructSecret()", () => { + it("should reconstruct secret from shares", () => { + const originalSecret = BufferUtils.from("test secret for reconstruction"); + const shares = ShamirSecretSharing.splitSecret(originalSecret, 2, 5); - // Use first 2 shares (minimum threshold) - const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); - expect(reconstructed).toEqual(originalSecret); - }); + // Use first 2 shares (minimum threshold) + const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); + expect(reconstructed).toEqual(originalSecret); + }); - it("should reconstruct secret from any subset of shares", () => { - const originalSecret = BufferUtils.from("another test secret"); - const shares = ShamirSecretSharing.splitSecret(originalSecret, 3, 5); + it("should reconstruct secret from any subset of shares", () => { + const originalSecret = BufferUtils.from("another test secret"); + const shares = ShamirSecretSharing.splitSecret(originalSecret, 3, 5); - // Test different combinations - const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares.slice(0, 3)); - const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares.slice(1, 4)); - const reconstructed3 = ShamirSecretSharing.reconstructSecret(shares.slice(2, 5)); + // Test different combinations + const reconstructed1 = ShamirSecretSharing.reconstructSecret(shares.slice(0, 3)); + const reconstructed2 = ShamirSecretSharing.reconstructSecret(shares.slice(1, 4)); + const reconstructed3 = ShamirSecretSharing.reconstructSecret(shares.slice(2, 5)); - expect(reconstructed1).toEqual(originalSecret); - expect(reconstructed2).toEqual(originalSecret); - expect(reconstructed3).toEqual(originalSecret); - }); + expect(reconstructed1).toEqual(originalSecret); + expect(reconstructed2).toEqual(originalSecret); + expect(reconstructed3).toEqual(originalSecret); + }); - it("should throw error with insufficient shares", () => { - const shares: ShamirShare[] = [{ x: 1, y: new Uint8Array([1, 2, 3]) }]; - expect(() => ShamirSecretSharing.reconstructSecret(shares)).toThrow( - "Need at least 2 shares to reconstruct the secret" - ); - }); + it("should throw error with insufficient shares", () => { + const shares: ShamirShare[] = [{ x: 1, y: new Uint8Array([1, 2, 3]) }]; + expect(() => ShamirSecretSharing.reconstructSecret(shares)).toThrow( + "Need at least 2 shares to reconstruct the secret", + ); + }); - it("should handle secrets with different lengths", () => { - const secrets = [ - BufferUtils.from("short"), - BufferUtils.from("medium length secret"), - BufferUtils.randomBytes(100) - ]; + it("should handle secrets with different lengths", () => { + const secrets = [ + BufferUtils.from("short"), + BufferUtils.from("medium length secret"), + BufferUtils.randomBytes(100), + ]; - secrets.forEach((secret) => { - const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); - const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); - expect(reconstructed).toEqual(secret); - }); - }); - }); + secrets.forEach((secret) => { + const shares = ShamirSecretSharing.splitSecret(secret, 2, 3); + const reconstructed = ShamirSecretSharing.reconstructSecret(shares.slice(0, 2)); + expect(reconstructed).toEqual(secret); + }); + }); + }); }); diff --git a/src/lib/crypto/hashing.ts b/src/lib/crypto/hashing.ts index c65e1cf..3fa6777 100644 --- a/src/lib/crypto/hashing.ts +++ b/src/lib/crypto/hashing.ts @@ -14,14 +14,14 @@ import { logger } from "$lib/logger"; * Configuration options for Argon2 hashing */ export interface Argon2Options { - /** Memory cost parameter (default: 65536) */ - memoryCost?: number; - /** Time cost parameter (default: 10) */ - timeCost?: number; - /** Parallelism parameter (default: 1) */ - parallelism?: number; - /** Hash output length in bytes (default: 32) */ - hashLength?: number; + /** Memory cost parameter (default: 65536) */ + memoryCost?: number; + /** Time cost parameter (default: 10) */ + timeCost?: number; + /** Parallelism parameter (default: 1) */ + parallelism?: number; + /** Hash output length in bytes (default: 32) */ + hashLength?: number; } /** @@ -31,212 +31,212 @@ export interface Argon2Options { * Final fallback to @noble/hashes if neither is available */ export class OptimizedArgon2 { - /** - * Derives a cryptographic key from a PIN and client ID using Argon2 - * @param pin - The PIN to hash - * @param clientId - The client identifier used as salt material - * @param options - Optional Argon2 parameters - * @returns Promise resolving to the derived key - */ - static async deriveKeyFromPIN( - pin: string, - clientId: string, - options: Argon2Options = {} - ): Promise { - const defaultOptions = { - memoryCost: 65536, - timeCost: 10, - parallelism: 1, - hashLength: 32 - }; + /** + * Derives a cryptographic key from a PIN and client ID using Argon2 + * @param pin - The PIN to hash + * @param clientId - The client identifier used as salt material + * @param options - Optional Argon2 parameters + * @returns Promise resolving to the derived key + */ + static async deriveKeyFromPIN( + pin: string, + clientId: string, + options: Argon2Options = {}, + ): Promise { + const defaultOptions = { + memoryCost: 65536, + timeCost: 10, + parallelism: 1, + hashLength: 32, + }; - const opts = { ...defaultOptions, ...options }; + const opts = { ...defaultOptions, ...options }; - if (typeof window !== "undefined") { - return await this.#deriveKeyBrowser(pin, clientId, opts); - } else { - return await this.#deriveKeyNode(pin, clientId, opts); - } - } + if (typeof window !== "undefined") { + return await this.#deriveKeyBrowser(pin, clientId, opts); + } else { + return await this.#deriveKeyNode(pin, clientId, opts); + } + } - /** - * Node.js-specific key derivation using native argon2 or fallback - * @param pin - The PIN to hash - * @param clientId - The client identifier - * @param options - Complete Argon2 options - * @returns Promise resolving to the derived key - * @private - */ - static async #deriveKeyNode( - pin: string, - clientId: string, - options: Required - ): Promise { - try { - const crypto = await import("crypto"); - const salt = crypto.createHash("sha256").update(clientId).digest(); + /** + * Node.js-specific key derivation using native argon2 or fallback + * @param pin - The PIN to hash + * @param clientId - The client identifier + * @param options - Complete Argon2 options + * @returns Promise resolving to the derived key + * @private + */ + static async #deriveKeyNode( + pin: string, + clientId: string, + options: Required, + ): Promise { + try { + const crypto = await import("crypto"); + const salt = crypto.createHash("sha256").update(clientId).digest(); - try { - const argon2 = await import("argon2"); - const hash = await argon2.hash(pin, { - type: argon2.argon2id, - memoryCost: options.memoryCost, - timeCost: options.timeCost, - parallelism: options.parallelism, - salt: salt, - raw: true, - hashLength: options.hashLength - }); - return new Uint8Array(hash); - } catch { - logger.setContext("Hashing"); - logger.warn("Native argon2 unavailable, falling back to @noble/hashes"); - return await this.#deriveKeyFallback(pin, salt, options); - } - } catch (error) { - throw new Error(`Failed to derive key in Node.js: ${error}`); - } - } + try { + const argon2 = await import("argon2"); + const hash = await argon2.hash(pin, { + type: argon2.argon2id, + memoryCost: options.memoryCost, + timeCost: options.timeCost, + parallelism: options.parallelism, + salt: salt, + raw: true, + hashLength: options.hashLength, + }); + return new Uint8Array(hash); + } catch { + logger.setContext("Hashing"); + logger.warn("Native argon2 unavailable, falling back to @noble/hashes"); + return await this.#deriveKeyFallback(pin, salt, options); + } + } catch (error) { + throw new Error(`Failed to derive key in Node.js: ${error}`); + } + } - /** - * Browser-specific key derivation using argon2-browser WASM or fallback - * @param pin - The PIN to hash - * @param clientId - The client identifier - * @param options - Complete Argon2 options - * @returns Promise resolving to the derived key - * @private - */ - static async #deriveKeyBrowser( - pin: string, - clientId: string, - options: Required - ): Promise { - try { - // Create salt from client ID using Web Crypto API - const encoder = new TextEncoder(); - const saltData = await crypto.subtle.digest("SHA-256", encoder.encode(clientId)); - const salt = new Uint8Array(saltData); + /** + * Browser-specific key derivation using argon2-browser WASM or fallback + * @param pin - The PIN to hash + * @param clientId - The client identifier + * @param options - Complete Argon2 options + * @returns Promise resolving to the derived key + * @private + */ + static async #deriveKeyBrowser( + pin: string, + clientId: string, + options: Required, + ): Promise { + try { + // Create salt from client ID using Web Crypto API + const encoder = new TextEncoder(); + const saltData = await crypto.subtle.digest("SHA-256", encoder.encode(clientId)); + const salt = new Uint8Array(saltData); - // Try to use argon2-browser WASM (from static files) - try { - logger.setContext("Hashing"); - logger.debug("🔍 Checking for argon2-browser from static files..."); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const argon2 = (window as any).argon2; + // Try to use argon2-browser WASM (from static files) + try { + logger.setContext("Hashing"); + logger.debug("🔍 Checking for argon2-browser from static files..."); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const argon2 = (window as any).argon2; - if (argon2) { - logger.debug("✅ argon2-browser found from static files"); + if (argon2) { + logger.debug("✅ argon2-browser found from static files"); - const result = await argon2.hash({ - pass: pin, - salt: Array.from(salt), - type: argon2.ArgonType.Argon2id, - mem: options.memoryCost, - time: options.timeCost, - parallelism: options.parallelism, - hashLen: options.hashLength - }); + const result = await argon2.hash({ + pass: pin, + salt: Array.from(salt), + type: argon2.ArgonType.Argon2id, + mem: options.memoryCost, + time: options.timeCost, + parallelism: options.parallelism, + hashLen: options.hashLength, + }); - logger.debug("✅ Argon2 WASM hash successful"); - return new Uint8Array(result.hash); - } else { - logger.warn("❌ argon2-browser not available on window"); - throw new Error("argon2-browser not available"); - } - } catch (error) { - logger.warn( - "❌ argon2-browser static files unavailable, falling back to @noble/hashes:", - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error as any - ); - return await this.#deriveKeyFallback(pin, salt, options); - } - } catch (error) { - throw new Error(`Failed to derive key in browser: ${error}`); - } - } + logger.debug("✅ Argon2 WASM hash successful"); + return new Uint8Array(result.hash); + } else { + logger.warn("❌ argon2-browser not available on window"); + throw new Error("argon2-browser not available"); + } + } catch (error) { + logger.warn( + "❌ argon2-browser static files unavailable, falling back to @noble/hashes:", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error as any, + ); + return await this.#deriveKeyFallback(pin, salt, options); + } + } catch (error) { + throw new Error(`Failed to derive key in browser: ${error}`); + } + } - /** - * Fallback key derivation using @noble/hashes - * @param pin - The PIN to hash - * @param salt - The salt for hashing - * @param options - Complete Argon2 options - * @returns Promise resolving to the derived key - * @private - */ - static async #deriveKeyFallback( - pin: string, - salt: CryptoBuffer, - options: Required - ): Promise { - const { argon2id } = await import("@noble/hashes/argon2"); + /** + * Fallback key derivation using @noble/hashes + * @param pin - The PIN to hash + * @param salt - The salt for hashing + * @param options - Complete Argon2 options + * @returns Promise resolving to the derived key + * @private + */ + static async #deriveKeyFallback( + pin: string, + salt: CryptoBuffer, + options: Required, + ): Promise { + const { argon2id } = await import("@noble/hashes/argon2"); - return argon2id(BufferUtils.from(pin), new Uint8Array(salt), { - m: options.memoryCost, - t: options.timeCost, - p: options.parallelism, - dkLen: options.hashLength - }); - } + return argon2id(BufferUtils.from(pin), new Uint8Array(salt), { + m: options.memoryCost, + t: options.timeCost, + p: options.parallelism, + dkLen: options.hashLength, + }); + } - /** - * Creates a client ID from an email address (browser-only) - * @param email - The email address to hash - * @returns Promise resolving to hex-encoded client ID - * @throws Error in Node.js environment - */ - static async createClientId(email: string): Promise { - if (typeof window !== "undefined") { - // Browser environment - use Web Crypto API - const hash = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(email.toLowerCase()) - ); - return BufferUtils.toString(new Uint8Array(hash), "hex"); - } else { - // Node.js environment - this will be handled at runtime - throw new Error("createClientId should be called asynchronously in Node.js environment"); - } - } + /** + * Creates a client ID from an email address (browser-only) + * @param email - The email address to hash + * @returns Promise resolving to hex-encoded client ID + * @throws Error in Node.js environment + */ + static async createClientId(email: string): Promise { + if (typeof window !== "undefined") { + // Browser environment - use Web Crypto API + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(email.toLowerCase()), + ); + return BufferUtils.toString(new Uint8Array(hash), "hex"); + } else { + // Node.js environment - this will be handled at runtime + throw new Error("createClientId should be called asynchronously in Node.js environment"); + } + } - /** - * Creates a client ID from an email address (cross-platform) - * @param email - The email address to hash - * @returns Promise resolving to hex-encoded client ID - */ - static async createClientIdAsync(email: string): Promise { - if (typeof window !== "undefined") { - // Browser environment - const hash = await crypto.subtle.digest( - "SHA-256", - new TextEncoder().encode(email.toLowerCase()) - ); - return BufferUtils.toString(new Uint8Array(hash), "hex"); - } else { - // Node.js environment - const crypto = await import("crypto"); - return crypto.createHash("sha256").update(email.toLowerCase()).digest("hex"); - } - } + /** + * Creates a client ID from an email address (cross-platform) + * @param email - The email address to hash + * @returns Promise resolving to hex-encoded client ID + */ + static async createClientIdAsync(email: string): Promise { + if (typeof window !== "undefined") { + // Browser environment + const hash = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(email.toLowerCase()), + ); + return BufferUtils.toString(new Uint8Array(hash), "hex"); + } else { + // Node.js environment + const crypto = await import("crypto"); + return crypto.createHash("sha256").update(email.toLowerCase()).digest("hex"); + } + } - /** - * Gets information about the current Argon2 implementation being used - * @returns Promise resolving to implementation description - */ - static async getImplementationInfo(): Promise { - if (typeof window !== "undefined") { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((window as any).argon2) { - return "argon2-browser WASM (static files)"; - } - return "@noble/hashes (JavaScript fallback)"; - } else { - try { - await import("argon2"); - return "Native Node.js argon2 (C++ addon)"; - } catch { - return "@noble/hashes (JavaScript fallback)"; - } - } - } + /** + * Gets information about the current Argon2 implementation being used + * @returns Promise resolving to implementation description + */ + static async getImplementationInfo(): Promise { + if (typeof window !== "undefined") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((window as any).argon2) { + return "argon2-browser WASM (static files)"; + } + return "@noble/hashes (JavaScript fallback)"; + } else { + try { + await import("argon2"); + return "Native Node.js argon2 (C++ addon)"; + } catch { + return "@noble/hashes (JavaScript fallback)"; + } + } + } } diff --git a/src/lib/crypto/utils.ts b/src/lib/crypto/utils.ts index 4b2117c..c79c3e7 100644 --- a/src/lib/crypto/utils.ts +++ b/src/lib/crypto/utils.ts @@ -10,105 +10,105 @@ export type CryptoBuffer = Uint8Array; * Utility class for buffer operations and conversions */ export class BufferUtils { - /** - * Converts various data types to CryptoBuffer - * @param data - The data to convert (string, number array, Uint8Array, or ArrayBuffer) - * @param encoding - The encoding to use for string conversion ('hex' or 'utf8') - * @returns A CryptoBuffer representation of the input data - */ - static from( - data: string | number[] | Uint8Array | ArrayBuffer, - encoding?: "hex" | "utf8" - ): CryptoBuffer { - if (typeof data === "string") { - if (encoding === "hex") { - return new Uint8Array(data.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []); - } - return new TextEncoder().encode(data); - } - if (Array.isArray(data)) { - return new Uint8Array(data); - } - return new Uint8Array(data); - } + /** + * Converts various data types to CryptoBuffer + * @param data - The data to convert (string, number array, Uint8Array, or ArrayBuffer) + * @param encoding - The encoding to use for string conversion ('hex' or 'utf8') + * @returns A CryptoBuffer representation of the input data + */ + static from( + data: string | number[] | Uint8Array | ArrayBuffer, + encoding?: "hex" | "utf8", + ): CryptoBuffer { + if (typeof data === "string") { + if (encoding === "hex") { + return new Uint8Array(data.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) || []); + } + return new TextEncoder().encode(data); + } + if (Array.isArray(data)) { + return new Uint8Array(data); + } + return new Uint8Array(data); + } - /** - * Converts a CryptoBuffer to string - * @param buffer - The buffer to convert - * @param encoding - The encoding to use ('hex' or 'utf8', defaults to 'utf8') - * @returns String representation of the buffer - */ - static toString(buffer: CryptoBuffer, encoding: "hex" | "utf8" = "utf8"): string { - if (encoding === "hex") { - return Array.from(buffer) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } - return new TextDecoder().decode(buffer); - } + /** + * Converts a CryptoBuffer to string + * @param buffer - The buffer to convert + * @param encoding - The encoding to use ('hex' or 'utf8', defaults to 'utf8') + * @returns String representation of the buffer + */ + static toString(buffer: CryptoBuffer, encoding: "hex" | "utf8" = "utf8"): string { + if (encoding === "hex") { + return Array.from(buffer) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + return new TextDecoder().decode(buffer); + } - /** - * Concatenates multiple CryptoBuffers into a single buffer - * @param buffers - Array of buffers to concatenate - * @returns A single CryptoBuffer containing all input buffers - */ - static concat(buffers: CryptoBuffer[]): CryptoBuffer { - const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0); - const result = new Uint8Array(totalLength); - let offset = 0; - for (const buffer of buffers) { - result.set(buffer, offset); - offset += buffer.length; - } - return result; - } + /** + * Concatenates multiple CryptoBuffers into a single buffer + * @param buffers - Array of buffers to concatenate + * @returns A single CryptoBuffer containing all input buffers + */ + static concat(buffers: CryptoBuffer[]): CryptoBuffer { + const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0); + const result = new Uint8Array(totalLength); + let offset = 0; + for (const buffer of buffers) { + result.set(buffer, offset); + offset += buffer.length; + } + return result; + } - /** - * Generates cryptographically secure random bytes - * @param length - The number of random bytes to generate - * @returns A CryptoBuffer containing random bytes - */ - static randomBytes(length: number): CryptoBuffer { - return randomBytes(length); - } + /** + * Generates cryptographically secure random bytes + * @param length - The number of random bytes to generate + * @returns A CryptoBuffer containing random bytes + */ + static randomBytes(length: number): CryptoBuffer { + return randomBytes(length); + } - /** - * Performs XOR operation on two CryptoBuffers - * @param a - First buffer - * @param b - Second buffer - * @returns XOR result as CryptoBuffer - */ - static xor(a: CryptoBuffer, b: CryptoBuffer): CryptoBuffer { - const result = new Uint8Array(Math.max(a.length, b.length)); - for (let i = 0; i < result.length; i++) { - result[i] = (a[i] || 0) ^ (b[i] || 0); - } - return result; - } + /** + * Performs XOR operation on two CryptoBuffers + * @param a - First buffer + * @param b - Second buffer + * @returns XOR result as CryptoBuffer + */ + static xor(a: CryptoBuffer, b: CryptoBuffer): CryptoBuffer { + const result = new Uint8Array(Math.max(a.length, b.length)); + for (let i = 0; i < result.length; i++) { + result[i] = (a[i] || 0) ^ (b[i] || 0); + } + return result; + } - /** - * Compares two CryptoBuffers for equality - * @param a - First buffer - * @param b - Second buffer - * @returns True if buffers are equal, false otherwise - */ - static equals(a: CryptoBuffer, b: CryptoBuffer): boolean { - if (a.length !== b.length) return false; - for (let i = 0; i < a.length; i++) { - if (a[i] !== b[i]) return false; - } - return true; - } + /** + * Compares two CryptoBuffers for equality + * @param a - First buffer + * @param b - Second buffer + * @returns True if buffers are equal, false otherwise + */ + static equals(a: CryptoBuffer, b: CryptoBuffer): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; + } } /** * Interface for Kyber (ML-KEM-768) key pairs */ export interface KyberKeyPair { - /** The public key for encryption */ - publicKey: CryptoBuffer; - /** The private key for decryption */ - privateKey: CryptoBuffer; + /** The public key for encryption */ + publicKey: CryptoBuffer; + /** The private key for decryption */ + privateKey: CryptoBuffer; } /** @@ -116,43 +116,43 @@ export interface KyberKeyPair { * Provides key generation, encapsulation, and decapsulation */ export class KyberCrypto { - /** - * Generates a new Kyber key pair - * @returns A new KyberKeyPair containing public and private keys - */ - static generateKeyPair(): KyberKeyPair { - const keys = ml_kem768.keygen(); - return { - publicKey: new Uint8Array(keys.publicKey), - privateKey: new Uint8Array(keys.secretKey) - }; - } + /** + * Generates a new Kyber key pair + * @returns A new KyberKeyPair containing public and private keys + */ + static generateKeyPair(): KyberKeyPair { + const keys = ml_kem768.keygen(); + return { + publicKey: new Uint8Array(keys.publicKey), + privateKey: new Uint8Array(keys.secretKey), + }; + } - /** - * Encapsulates a shared secret using the recipient's public key - * @param publicKey - The recipient's public key - * @returns Object containing the shared secret and encapsulated secret - */ - static encapsulate(publicKey: CryptoBuffer): { - sharedSecret: CryptoBuffer; - encapsulatedSecret: CryptoBuffer; - } { - const result = ml_kem768.encapsulate(publicKey); - return { - sharedSecret: new Uint8Array(result.sharedSecret), - encapsulatedSecret: new Uint8Array(result.cipherText) - }; - } + /** + * Encapsulates a shared secret using the recipient's public key + * @param publicKey - The recipient's public key + * @returns Object containing the shared secret and encapsulated secret + */ + static encapsulate(publicKey: CryptoBuffer): { + sharedSecret: CryptoBuffer; + encapsulatedSecret: CryptoBuffer; + } { + const result = ml_kem768.encapsulate(publicKey); + return { + sharedSecret: new Uint8Array(result.sharedSecret), + encapsulatedSecret: new Uint8Array(result.cipherText), + }; + } - /** - * Decapsulates a shared secret using the private key - * @param privateKey - The private key for decapsulation - * @param encapsulatedSecret - The encapsulated secret from the sender - * @returns The shared secret - */ - static decapsulate(privateKey: CryptoBuffer, encapsulatedSecret: CryptoBuffer): CryptoBuffer { - return new Uint8Array(ml_kem768.decapsulate(encapsulatedSecret, privateKey)); - } + /** + * Decapsulates a shared secret using the private key + * @param privateKey - The private key for decapsulation + * @param encapsulatedSecret - The encapsulated secret from the sender + * @returns The shared secret + */ + static decapsulate(privateKey: CryptoBuffer, encapsulatedSecret: CryptoBuffer): CryptoBuffer { + return new Uint8Array(ml_kem768.decapsulate(encapsulatedSecret, privateKey)); + } } /** @@ -160,123 +160,123 @@ export class KyberCrypto { * Uses Web Crypto API in browsers and Node.js crypto in server environments */ export class AESCrypto { - /** - * Generates a random 256-bit AES session key - * @returns A 32-byte CryptoBuffer for use as AES-256 key - */ - static generateSessionKey(): CryptoBuffer { - return BufferUtils.randomBytes(32); - } + /** + * Generates a random 256-bit AES session key + * @returns A 32-byte CryptoBuffer for use as AES-256 key + */ + static generateSessionKey(): CryptoBuffer { + return BufferUtils.randomBytes(32); + } - /** - * Encrypts data using AES-256-GCM - * @param data - The plaintext string to encrypt - * @param key - The 32-byte AES key - * @returns Promise resolving to encrypted data, IV, and authentication tag - */ - static async encrypt( - data: string, - key: CryptoBuffer - ): Promise<{ encrypted: CryptoBuffer; iv: CryptoBuffer; tag: CryptoBuffer }> { - const iv = BufferUtils.randomBytes(16); + /** + * Encrypts data using AES-256-GCM + * @param data - The plaintext string to encrypt + * @param key - The 32-byte AES key + * @returns Promise resolving to encrypted data, IV, and authentication tag + */ + static async encrypt( + data: string, + key: CryptoBuffer, + ): Promise<{ encrypted: CryptoBuffer; iv: CryptoBuffer; tag: CryptoBuffer }> { + const iv = BufferUtils.randomBytes(16); - // Use Web Crypto API if available (browser), otherwise fall back to Node.js - if (typeof crypto !== "undefined" && crypto.subtle) { - // Browser implementation using Web Crypto API - const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ - "encrypt" - ]); + // Use Web Crypto API if available (browser), otherwise fall back to Node.js + if (typeof crypto !== "undefined" && crypto.subtle) { + // Browser implementation using Web Crypto API + const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ + "encrypt", + ]); - const additionalData = BufferUtils.from("appointment-data"); - const encrypted = await crypto.subtle.encrypt( - { name: "AES-GCM", iv, additionalData }, - cryptoKey, - BufferUtils.from(data) - ); + const additionalData = BufferUtils.from("appointment-data"); + const encrypted = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, additionalData }, + cryptoKey, + BufferUtils.from(data), + ); - // Extract tag from encrypted data (last 16 bytes) - const encryptedArray = new Uint8Array(encrypted); - const tag = encryptedArray.slice(-16); - const ciphertext = encryptedArray.slice(0, -16); + // Extract tag from encrypted data (last 16 bytes) + const encryptedArray = new Uint8Array(encrypted); + const tag = encryptedArray.slice(-16); + const ciphertext = encryptedArray.slice(0, -16); - return { - encrypted: ciphertext, - iv, - tag - }; - } else { - // Node.js fallback using built-in crypto - const nodeCrypto = await import("crypto"); - const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, iv); - cipher.setAAD(BufferUtils.from("appointment-data")); + return { + encrypted: ciphertext, + iv, + tag, + }; + } else { + // Node.js fallback using built-in crypto + const nodeCrypto = await import("crypto"); + const cipher = nodeCrypto.createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(BufferUtils.from("appointment-data")); - let encrypted = cipher.update(data, "utf8"); - encrypted = Buffer.concat([encrypted, cipher.final()]); - const tag = cipher.getAuthTag(); + let encrypted = cipher.update(data, "utf8"); + encrypted = Buffer.concat([encrypted, cipher.final()]); + const tag = cipher.getAuthTag(); - return { - encrypted: new Uint8Array(encrypted), - iv, - tag: new Uint8Array(tag) - }; - } - } + return { + encrypted: new Uint8Array(encrypted), + iv, + tag: new Uint8Array(tag), + }; + } + } - /** - * Decrypts AES-256-GCM encrypted data - * @param encrypted - The encrypted data - * @param key - The 32-byte AES key - * @param iv - The initialization vector - * @param tag - The authentication tag - * @returns Promise resolving to the decrypted plaintext string - */ - static async decrypt( - encrypted: CryptoBuffer, - key: CryptoBuffer, - iv: CryptoBuffer, - tag: CryptoBuffer - ): Promise { - if (typeof crypto !== "undefined" && crypto.subtle) { - // Browser implementation using Web Crypto API - const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ - "decrypt" - ]); + /** + * Decrypts AES-256-GCM encrypted data + * @param encrypted - The encrypted data + * @param key - The 32-byte AES key + * @param iv - The initialization vector + * @param tag - The authentication tag + * @returns Promise resolving to the decrypted plaintext string + */ + static async decrypt( + encrypted: CryptoBuffer, + key: CryptoBuffer, + iv: CryptoBuffer, + tag: CryptoBuffer, + ): Promise { + if (typeof crypto !== "undefined" && crypto.subtle) { + // Browser implementation using Web Crypto API + const cryptoKey = await crypto.subtle.importKey("raw", key, { name: "AES-GCM" }, false, [ + "decrypt", + ]); - const additionalData = BufferUtils.from("appointment-data"); + const additionalData = BufferUtils.from("appointment-data"); - // Combine encrypted data and tag for Web Crypto API - const encryptedWithTag = BufferUtils.concat([encrypted, tag]); + // Combine encrypted data and tag for Web Crypto API + const encryptedWithTag = BufferUtils.concat([encrypted, tag]); - const decrypted = await crypto.subtle.decrypt( - { name: "AES-GCM", iv, additionalData }, - cryptoKey, - encryptedWithTag - ); + const decrypted = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, additionalData }, + cryptoKey, + encryptedWithTag, + ); - return BufferUtils.toString(new Uint8Array(decrypted)); - } else { - // Node.js fallback using built-in crypto - const nodeCrypto = await import("crypto"); - const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, iv); - decipher.setAAD(BufferUtils.from("appointment-data")); - decipher.setAuthTag(tag); + return BufferUtils.toString(new Uint8Array(decrypted)); + } else { + // Node.js fallback using built-in crypto + const nodeCrypto = await import("crypto"); + const decipher = nodeCrypto.createDecipheriv("aes-256-gcm", key, iv); + decipher.setAAD(BufferUtils.from("appointment-data")); + decipher.setAuthTag(tag); - let decrypted = decipher.update(encrypted); - decrypted = Buffer.concat([decrypted, decipher.final()]); + let decrypted = decipher.update(encrypted); + decrypted = Buffer.concat([decrypted, decipher.final()]); - return decrypted.toString("utf8"); - } - } + return decrypted.toString("utf8"); + } + } } /** * Interface for Shamir secret sharing shares */ export interface ShamirShare { - /** The x-coordinate of the share */ - x: number; - /** The y-coordinate containing the share data */ - y: CryptoBuffer; + /** The x-coordinate of the share */ + x: number; + /** The y-coordinate containing the share data */ + y: CryptoBuffer; } /** @@ -284,55 +284,55 @@ export interface ShamirShare { * Note: This is a basic implementation for demonstration purposes */ export class ShamirSecretSharing { - /** - * Splits a secret into multiple shares - * @param secret - The secret to split - * @param threshold - Minimum number of shares needed to reconstruct - * @param totalShares - Total number of shares to create - * @returns Array of ShamirShare objects - */ - static splitSecret(secret: CryptoBuffer, threshold: number, totalShares: number): ShamirShare[] { - if (!secret || secret.length === 0) { - throw new Error("Secret cannot be empty for Shamir secret sharing"); - } + /** + * Splits a secret into multiple shares + * @param secret - The secret to split + * @param threshold - Minimum number of shares needed to reconstruct + * @param totalShares - Total number of shares to create + * @returns Array of ShamirShare objects + */ + static splitSecret(secret: CryptoBuffer, threshold: number, totalShares: number): ShamirShare[] { + if (!secret || secret.length === 0) { + throw new Error("Secret cannot be empty for Shamir secret sharing"); + } - const originalLength = secret.length; - const lengthBytes = new Uint8Array(4); - new DataView(lengthBytes.buffer).setUint32(0, originalLength, true); + const originalLength = secret.length; + const lengthBytes = new Uint8Array(4); + new DataView(lengthBytes.buffer).setUint32(0, originalLength, true); - const shares: ShamirShare[] = []; + const shares: ShamirShare[] = []; - for (let i = 0; i < totalShares; i++) { - const shareData = new Uint8Array(4 + secret.length); - shareData.set(lengthBytes, 0); - shareData.set(secret, 4); + for (let i = 0; i < totalShares; i++) { + const shareData = new Uint8Array(4 + secret.length); + shareData.set(lengthBytes, 0); + shareData.set(secret, 4); - shares.push({ - x: i + 1, - y: shareData - }); - } + shares.push({ + x: i + 1, + y: shareData, + }); + } - return shares; - } + return shares; + } - /** - * Reconstructs a secret from shares - * @param shareArray - Array of shares to reconstruct from - * @returns The reconstructed secret - */ - static reconstructSecret(shareArray: ShamirShare[]): CryptoBuffer { - if (shareArray.length < 2) { - throw new Error("Need at least 2 shares to reconstruct the secret"); - } + /** + * Reconstructs a secret from shares + * @param shareArray - Array of shares to reconstruct from + * @returns The reconstructed secret + */ + static reconstructSecret(shareArray: ShamirShare[]): CryptoBuffer { + if (shareArray.length < 2) { + throw new Error("Need at least 2 shares to reconstruct the secret"); + } - const firstShare = shareArray[0]; + const firstShare = shareArray[0]; - const lengthBytes = firstShare.y.slice(0, 4); - const originalLength = new DataView(lengthBytes.buffer).getUint32(0, true); + const lengthBytes = firstShare.y.slice(0, 4); + const originalLength = new DataView(lengthBytes.buffer).getUint32(0, true); - const secret = firstShare.y.slice(4, 4 + originalLength); + const secret = firstShare.y.slice(4, 4 + originalLength); - return secret; - } + return secret; + } } diff --git a/src/lib/hooks/is-mobile.svelte.ts b/src/lib/hooks/is-mobile.svelte.ts index 4829c00..ef34be7 100644 --- a/src/lib/hooks/is-mobile.svelte.ts +++ b/src/lib/hooks/is-mobile.svelte.ts @@ -3,7 +3,7 @@ import { MediaQuery } from "svelte/reactivity"; const DEFAULT_MOBILE_BREAKPOINT = 768; export class IsMobile extends MediaQuery { - constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { - super(`max-width: ${breakpoint - 1}px`); - } + constructor(breakpoint: number = DEFAULT_MOBILE_BREAKPOINT) { + super(`max-width: ${breakpoint - 1}px`); + } } diff --git a/src/lib/logger/__tests__/api-endpoint.test.ts b/src/lib/logger/__tests__/api-endpoint.test.ts index 598d8ab..2c7db25 100644 --- a/src/lib/logger/__tests__/api-endpoint.test.ts +++ b/src/lib/logger/__tests__/api-endpoint.test.ts @@ -4,68 +4,68 @@ import { describe, it, expect } from "vitest"; // The actual server endpoint at /routes/api/log/+server.ts is tested via integration describe("API Log Endpoint Logic", () => { - describe("Request Processing", () => { - it("should handle different log levels correctly", () => { - // Test that our endpoint logic handles all log levels - const logLevels = ["debug", "info", "warn", "error", "unknown"]; + describe("Request Processing", () => { + it("should handle different log levels correctly", () => { + // Test that our endpoint logic handles all log levels + const logLevels = ["debug", "info", "warn", "error", "unknown"]; - logLevels.forEach((level) => { - expect(level).toBeTruthy(); // Basic test that levels exist - }); - }); + logLevels.forEach((level) => { + expect(level).toBeTruthy(); // Basic test that levels exist + }); + }); - it("should validate request structure", () => { - // Test basic request validation concepts - const validRequest = { - level: "info", - message: "test message", - meta: { key: "value" } - }; + it("should validate request structure", () => { + // Test basic request validation concepts + const validRequest = { + level: "info", + message: "test message", + meta: { key: "value" }, + }; - expect(validRequest.level).toBe("info"); - expect(validRequest.message).toBe("test message"); - expect(validRequest.meta).toEqual({ key: "value" }); - }); + expect(validRequest.level).toBe("info"); + expect(validRequest.message).toBe("test message"); + expect(validRequest.meta).toEqual({ key: "value" }); + }); - it("should handle missing or malformed data", () => { - // Test edge cases - const edgeCases = [ - { level: null, message: "test" }, - { level: "info", message: null }, - { level: "info", message: "test", meta: null }, - {} - ]; + it("should handle missing or malformed data", () => { + // Test edge cases + const edgeCases = [ + { level: null, message: "test" }, + { level: "info", message: null }, + { level: "info", message: "test", meta: null }, + {}, + ]; - edgeCases.forEach((testCase) => { - // Basic validation that we can handle these cases - expect(typeof testCase).toBe("object"); - }); - }); - }); + edgeCases.forEach((testCase) => { + // Basic validation that we can handle these cases + expect(typeof testCase).toBe("object"); + }); + }); + }); - describe("Response Format", () => { - it("should return success response format", () => { - const successResponse = { success: true }; - expect(successResponse.success).toBe(true); - }); + describe("Response Format", () => { + it("should return success response format", () => { + const successResponse = { success: true }; + expect(successResponse.success).toBe(true); + }); - it("should return error response format", () => { - const errorResponse = { success: false }; - expect(errorResponse.success).toBe(false); - }); - }); + it("should return error response format", () => { + const errorResponse = { success: false }; + expect(errorResponse.success).toBe(false); + }); + }); - describe("Error Handling", () => { - it("should handle JSON parsing errors", () => { - const error = new Error("Invalid JSON"); - expect(error.message).toBe("Invalid JSON"); - }); + describe("Error Handling", () => { + it("should handle JSON parsing errors", () => { + const error = new Error("Invalid JSON"); + expect(error.message).toBe("Invalid JSON"); + }); - it("should handle unknown errors", () => { - const unknownError = "string error"; - expect(typeof unknownError).toBe("string"); - }); - }); + it("should handle unknown errors", () => { + const unknownError = "string error"; + expect(typeof unknownError).toBe("string"); + }); + }); }); // Note: The actual API endpoint is tested through integration tests diff --git a/src/lib/logger/__tests__/index.server.test.ts b/src/lib/logger/__tests__/index.server.test.ts index d8cc2ff..6af9513 100644 --- a/src/lib/logger/__tests__/index.server.test.ts +++ b/src/lib/logger/__tests__/index.server.test.ts @@ -3,389 +3,389 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock $app/environment for server environment vi.mock("$app/environment", () => ({ - browser: false, - dev: true + browser: false, + dev: true, })); // Mock winston logger const mockWinstonLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), }; // Mock winston module vi.mock("../winston", () => ({ - default: mockWinstonLogger + default: mockWinstonLogger, })); describe("UniversalLogger - Server Side", () => { - let logger: any; - let createLogger: any; + let logger: any; + let createLogger: any; - beforeEach(async () => { - // Clear all mocks - vi.clearAllMocks(); + beforeEach(async () => { + // Clear all mocks + vi.clearAllMocks(); - // Dynamic import after mocks are set up - const loggerModule = await import("../index"); - logger = loggerModule.logger; - createLogger = loggerModule.createLogger; - }); + // Dynamic import after mocks are set up + const loggerModule = await import("../index"); + logger = loggerModule.logger; + createLogger = loggerModule.createLogger; + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("Context Management", () => { - it("should set and use context correctly", () => { - const contextLogger = createLogger("ServerContext"); - contextLogger.info("Server message", { data: "test" }); + describe("Context Management", () => { + it("should set and use context correctly", () => { + const contextLogger = createLogger("ServerContext"); + contextLogger.info("Server message", { data: "test" }); - expect(mockWinstonLogger.info).toHaveBeenCalledWith({ - message: "[ServerContext] Server message", - data: "test", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith({ + message: "[ServerContext] Server message", + data: "test", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should work without context", () => { - logger.info("Server message without context"); + it("should work without context", () => { + logger.info("Server message without context"); - expect(mockWinstonLogger.info).toHaveBeenCalledWith({ - message: "Server message without context", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith({ + message: "Server message without context", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should return logger instance when setting context", () => { - const result = logger.setContext("TestContext"); - expect(result).toBe(logger); - }); - }); + it("should return logger instance when setting context", () => { + const result = logger.setContext("TestContext"); + expect(result).toBe(logger); + }); + }); - describe("Message Formatting", () => { - it("should format messages with context prefix", () => { - const testLogger = createLogger("API"); - testLogger.debug("Processing request", { requestId: "123" }); + describe("Message Formatting", () => { + it("should format messages with context prefix", () => { + const testLogger = createLogger("API"); + testLogger.debug("Processing request", { requestId: "123" }); - expect(mockWinstonLogger.debug).toHaveBeenCalledWith({ - message: "[API] Processing request", - requestId: "123", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.debug).toHaveBeenCalledWith({ + message: "[API] Processing request", + requestId: "123", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should format messages without context prefix when no context set", async () => { - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.debug("Processing request", { requestId: "456" }); + it("should format messages without context prefix when no context set", async () => { + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.debug("Processing request", { requestId: "456" }); - expect(mockWinstonLogger.debug).toHaveBeenCalledWith({ - message: "Processing request", - requestId: "456", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.debug).toHaveBeenCalledWith({ + message: "Processing request", + requestId: "456", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should include source as server", () => { - logger.info("Test message"); + it("should include source as server", () => { + logger.info("Test message"); - expect(mockWinstonLogger.info).toHaveBeenCalledWith( - expect.objectContaining({ - source: "server" - }) - ); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + source: "server", + }), + ); + }); - it("should set userAgent as undefined for server", () => { - logger.info("Test message"); + it("should set userAgent as undefined for server", () => { + logger.info("Test message"); - expect(mockWinstonLogger.info).toHaveBeenCalledWith( - expect.objectContaining({ - userAgent: undefined - }) - ); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + userAgent: undefined, + }), + ); + }); - it("should add timestamp to all messages", () => { - const beforeTime = Date.now(); - logger.info("Test message"); - const afterTime = Date.now(); + it("should add timestamp to all messages", () => { + const beforeTime = Date.now(); + logger.info("Test message"); + const afterTime = Date.now(); - expect(mockWinstonLogger.info).toHaveBeenCalledWith( - expect.objectContaining({ - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }) - ); + expect(mockWinstonLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }), + ); - const loggedTimestamp = new Date(mockWinstonLogger.info.mock.calls[0][0].timestamp).getTime(); - expect(loggedTimestamp).toBeGreaterThanOrEqual(beforeTime); - expect(loggedTimestamp).toBeLessThanOrEqual(afterTime); - }); - }); + const loggedTimestamp = new Date(mockWinstonLogger.info.mock.calls[0][0].timestamp).getTime(); + expect(loggedTimestamp).toBeGreaterThanOrEqual(beforeTime); + expect(loggedTimestamp).toBeLessThanOrEqual(afterTime); + }); + }); - describe("Logging Methods", () => { - it("should call winston debug with formatted message", () => { - const testLogger = createLogger("Debug"); - const meta = { debug: true, level: 1 }; + describe("Logging Methods", () => { + it("should call winston debug with formatted message", () => { + const testLogger = createLogger("Debug"); + const meta = { debug: true, level: 1 }; - testLogger.debug("Debug message", meta); + testLogger.debug("Debug message", meta); - expect(mockWinstonLogger.debug).toHaveBeenCalledWith({ - message: "[Debug] Debug message", - debug: true, - level: 1, - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.debug).toHaveBeenCalledWith({ + message: "[Debug] Debug message", + debug: true, + level: 1, + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should call winston info with formatted message", () => { - const testLogger = createLogger("Info"); - const meta = { userId: 123, action: "login" }; + it("should call winston info with formatted message", () => { + const testLogger = createLogger("Info"); + const meta = { userId: 123, action: "login" }; - testLogger.info("User logged in", meta); + testLogger.info("User logged in", meta); - expect(mockWinstonLogger.info).toHaveBeenCalledWith({ - message: "[Info] User logged in", - userId: 123, - action: "login", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith({ + message: "[Info] User logged in", + userId: 123, + action: "login", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should call winston warn with formatted message", () => { - const testLogger = createLogger("Warn"); - const meta = { warning: "deprecated", api: "v1" }; + it("should call winston warn with formatted message", () => { + const testLogger = createLogger("Warn"); + const meta = { warning: "deprecated", api: "v1" }; - testLogger.warn("API deprecated", meta); + testLogger.warn("API deprecated", meta); - expect(mockWinstonLogger.warn).toHaveBeenCalledWith({ - message: "[Warn] API deprecated", - warning: "deprecated", - api: "v1", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.warn).toHaveBeenCalledWith({ + message: "[Warn] API deprecated", + warning: "deprecated", + api: "v1", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should call winston error with formatted message", () => { - const testLogger = createLogger("Error"); - const meta = { error: "database_connection", code: 500 }; + it("should call winston error with formatted message", () => { + const testLogger = createLogger("Error"); + const meta = { error: "database_connection", code: 500 }; - testLogger.error("Database connection failed", meta); + testLogger.error("Database connection failed", meta); - expect(mockWinstonLogger.error).toHaveBeenCalledWith({ - message: "[Error] Database connection failed", - error: "database_connection", - code: 500, - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.error).toHaveBeenCalledWith({ + message: "[Error] Database connection failed", + error: "database_connection", + code: 500, + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should handle empty meta objects", async () => { - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.info("Message without meta"); + it("should handle empty meta objects", async () => { + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.info("Message without meta"); - expect(mockWinstonLogger.info).toHaveBeenCalledWith({ - message: "Message without meta", - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith({ + message: "Message without meta", + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should preserve existing meta properties", async () => { - const meta = { - source: "override-attempt", - timestamp: "override-attempt", - userAgent: "override-attempt", - customProp: "should-be-preserved" - }; + it("should preserve existing meta properties", async () => { + const meta = { + source: "override-attempt", + timestamp: "override-attempt", + userAgent: "override-attempt", + customProp: "should-be-preserved", + }; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.info("Test message", meta); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.info("Test message", meta); - expect(mockWinstonLogger.info).toHaveBeenCalledWith({ - message: "Test message", - source: "server", // Should override - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), // Should override - userAgent: undefined, // Should override - customProp: "should-be-preserved" // Should preserve - }); - }); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith({ + message: "Test message", + source: "server", // Should override + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), // Should override + userAgent: undefined, // Should override + customProp: "should-be-preserved", // Should preserve + }); + }); + }); - describe("Request Logging", () => { - it("should log successful requests as info", async () => { - const mockRequest = { - method: "GET", - url: "https://api.example.com/users" - } as Request; + describe("Request Logging", () => { + it("should log successful requests as info", async () => { + const mockRequest = { + method: "GET", + url: "https://api.example.com/users", + } as Request; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.logRequest(mockRequest, 150, 200); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.logRequest(mockRequest, 150, 200); - expect(mockWinstonLogger.info).toHaveBeenCalledWith({ - message: "GET https://api.example.com/users - 200 (150ms)", - method: "GET", - url: "https://api.example.com/users", - statusCode: 200, - responseTime: 150, - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.info).toHaveBeenCalledWith({ + message: "GET https://api.example.com/users - 200 (150ms)", + method: "GET", + url: "https://api.example.com/users", + statusCode: 200, + responseTime: 150, + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should log client errors (4xx) as errors", async () => { - const mockRequest = { - method: "POST", - url: "https://api.example.com/users" - } as Request; + it("should log client errors (4xx) as errors", async () => { + const mockRequest = { + method: "POST", + url: "https://api.example.com/users", + } as Request; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.logRequest(mockRequest, 200, 404); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.logRequest(mockRequest, 200, 404); - expect(mockWinstonLogger.error).toHaveBeenCalledWith({ - message: "POST https://api.example.com/users - 404 (200ms)", - method: "POST", - url: "https://api.example.com/users", - statusCode: 404, - responseTime: 200, - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.error).toHaveBeenCalledWith({ + message: "POST https://api.example.com/users - 404 (200ms)", + method: "POST", + url: "https://api.example.com/users", + statusCode: 404, + responseTime: 200, + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should log server errors (5xx) as errors", async () => { - const mockRequest = { - method: "PUT", - url: "https://api.example.com/data/123" - } as Request; + it("should log server errors (5xx) as errors", async () => { + const mockRequest = { + method: "PUT", + url: "https://api.example.com/data/123", + } as Request; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.logRequest(mockRequest, 5000, 500); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.logRequest(mockRequest, 5000, 500); - expect(mockWinstonLogger.error).toHaveBeenCalledWith({ - message: "PUT https://api.example.com/data/123 - 500 (5000ms)", - method: "PUT", - url: "https://api.example.com/data/123", - statusCode: 500, - responseTime: 5000, - source: "server", - userAgent: undefined, - timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/) - }); - }); + expect(mockWinstonLogger.error).toHaveBeenCalledWith({ + message: "PUT https://api.example.com/data/123 - 500 (5000ms)", + method: "PUT", + url: "https://api.example.com/data/123", + statusCode: 500, + responseTime: 5000, + source: "server", + userAgent: undefined, + timestamp: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + }); + }); - it("should handle edge case status codes correctly", async () => { - const mockRequest = { - method: "GET", - url: "https://api.example.com/test" - } as Request; + it("should handle edge case status codes correctly", async () => { + const mockRequest = { + method: "GET", + url: "https://api.example.com/test", + } as Request; - // Create fresh loggers without context - const { UniversalLogger } = await import("../index"); - const freshLogger1 = new UniversalLogger(); - const freshLogger2 = new UniversalLogger(); + // Create fresh loggers without context + const { UniversalLogger } = await import("../index"); + const freshLogger1 = new UniversalLogger(); + const freshLogger2 = new UniversalLogger(); - // Test boundary cases - freshLogger1.logRequest(mockRequest, 100, 399); // Should be info - expect(mockWinstonLogger.info).toHaveBeenCalled(); + // Test boundary cases + freshLogger1.logRequest(mockRequest, 100, 399); // Should be info + expect(mockWinstonLogger.info).toHaveBeenCalled(); - vi.clearAllMocks(); + vi.clearAllMocks(); - freshLogger2.logRequest(mockRequest, 100, 400); // Should be error - expect(mockWinstonLogger.error).toHaveBeenCalled(); - }); - }); + freshLogger2.logRequest(mockRequest, 100, 400); // Should be error + expect(mockWinstonLogger.error).toHaveBeenCalled(); + }); + }); - describe("Factory Function", () => { - it("should create new logger instances with different contexts", () => { - const logger1 = createLogger("Service1"); - const logger2 = createLogger("Service2"); + describe("Factory Function", () => { + it("should create new logger instances with different contexts", () => { + const logger1 = createLogger("Service1"); + const logger2 = createLogger("Service2"); - logger1.info("Message from service 1"); - logger2.info("Message from service 2"); + logger1.info("Message from service 1"); + logger2.info("Message from service 2"); - expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - message: "[Service1] Message from service 1" - }) - ); - expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - message: "[Service2] Message from service 2" - }) - ); - }); + expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: "[Service1] Message from service 1", + }), + ); + expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "[Service2] Message from service 2", + }), + ); + }); - it("should create independent logger instances", () => { - const logger1 = createLogger("Original"); - const logger2 = createLogger("Independent"); + it("should create independent logger instances", () => { + const logger1 = createLogger("Original"); + const logger2 = createLogger("Independent"); - // Modify one logger's context - logger1.setContext("Modified"); + // Modify one logger's context + logger1.setContext("Modified"); - logger1.info("Message 1"); - logger2.info("Message 2"); + logger1.info("Message 1"); + logger2.info("Message 2"); - expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - message: "[Modified] Message 1" - }) - ); - expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - message: "[Independent] Message 2" - }) - ); - }); - }); + expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + message: "[Modified] Message 1", + }), + ); + expect(mockWinstonLogger.info).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + message: "[Independent] Message 2", + }), + ); + }); + }); - describe("No Client Error Forwarding on Server", () => { - it("should not attempt to send error to server when in server environment", () => { - // Mock fetch to verify it's not called - const mockFetch = vi.fn(); - global.fetch = mockFetch; + describe("No Client Error Forwarding on Server", () => { + it("should not attempt to send error to server when in server environment", () => { + // Mock fetch to verify it's not called + const mockFetch = vi.fn(); + global.fetch = mockFetch; - logger.error("Server error", { code: 500 }); + logger.error("Server error", { code: 500 }); - expect(mockWinstonLogger.error).toHaveBeenCalled(); - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); + expect(mockWinstonLogger.error).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/lib/logger/__tests__/index.test.ts b/src/lib/logger/__tests__/index.test.ts index 811f931..d2fa188 100644 --- a/src/lib/logger/__tests__/index.test.ts +++ b/src/lib/logger/__tests__/index.test.ts @@ -3,32 +3,32 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock $app/environment before importing the logger vi.mock("$app/environment", () => ({ - browser: true + browser: true, })); // Mock global objects that exist in browser Object.defineProperty(global, "navigator", { - value: { - userAgent: "Test Browser/1.0" - }, - writable: true + value: { + userAgent: "Test Browser/1.0", + }, + writable: true, }); Object.defineProperty(global, "window", { - value: { - location: { - href: "https://test.example.com/page" - } - }, - writable: true + value: { + location: { + href: "https://test.example.com/page", + }, + }, + writable: true, }); // Mock console methods const mockConsole = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), }; // Mock fetch @@ -36,264 +36,264 @@ const mockFetch = vi.fn(); global.fetch = mockFetch; describe("UniversalLogger - Client Side", () => { - let logger: any; - let createLogger: any; + let logger: any; + let createLogger: any; - beforeEach(async () => { - // Clear all mocks - vi.clearAllMocks(); + beforeEach(async () => { + // Clear all mocks + vi.clearAllMocks(); - // Mock console methods - vi.spyOn(console, "debug").mockImplementation(mockConsole.debug); - vi.spyOn(console, "info").mockImplementation(mockConsole.info); - vi.spyOn(console, "warn").mockImplementation(mockConsole.warn); - vi.spyOn(console, "error").mockImplementation(mockConsole.error); + // Mock console methods + vi.spyOn(console, "debug").mockImplementation(mockConsole.debug); + vi.spyOn(console, "info").mockImplementation(mockConsole.info); + vi.spyOn(console, "warn").mockImplementation(mockConsole.warn); + vi.spyOn(console, "error").mockImplementation(mockConsole.error); - // Dynamic import after mocks are set up - const loggerModule = await import("../index"); - logger = loggerModule.logger; - createLogger = loggerModule.createLogger; - }); + // Dynamic import after mocks are set up + const loggerModule = await import("../index"); + logger = loggerModule.logger; + createLogger = loggerModule.createLogger; + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("Context Management", () => { - it("should set and use context correctly", () => { - const contextLogger = createLogger("TestContext"); - contextLogger.info("Test message"); + describe("Context Management", () => { + it("should set and use context correctly", () => { + const contextLogger = createLogger("TestContext"); + contextLogger.info("Test message"); - expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ [TestContext] Test message", {}); - }); + expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ [TestContext] Test message", {}); + }); - it("should work without context", () => { - logger.info("Test message"); + it("should work without context", () => { + logger.info("Test message"); - expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ Test message", {}); - }); + expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ Test message", {}); + }); - it("should return logger instance when setting context", () => { - const result = logger.setContext("TestContext"); - expect(result).toBe(logger); - }); - }); + it("should return logger instance when setting context", () => { + const result = logger.setContext("TestContext"); + expect(result).toBe(logger); + }); + }); - describe("Logging Methods", () => { - it("should log debug messages with correct format", () => { - const testLogger = createLogger("Debug"); - const meta = { extra: "data" }; + describe("Logging Methods", () => { + it("should log debug messages with correct format", () => { + const testLogger = createLogger("Debug"); + const meta = { extra: "data" }; - testLogger.debug("Debug message", meta); + testLogger.debug("Debug message", meta); - expect(mockConsole.debug).toHaveBeenCalledWith("🐛 [Debug] Debug message", meta); - }); + expect(mockConsole.debug).toHaveBeenCalledWith("🐛 [Debug] Debug message", meta); + }); - it("should log info messages with correct format", () => { - const testLogger = createLogger("Info"); - const meta = { userId: 123 }; + it("should log info messages with correct format", () => { + const testLogger = createLogger("Info"); + const meta = { userId: 123 }; - testLogger.info("Info message", meta); + testLogger.info("Info message", meta); - expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ [Info] Info message", meta); - }); + expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ [Info] Info message", meta); + }); - it("should log warn messages with correct format", () => { - const testLogger = createLogger("Warn"); - const meta = { warning: "deprecated" }; + it("should log warn messages with correct format", () => { + const testLogger = createLogger("Warn"); + const meta = { warning: "deprecated" }; - testLogger.warn("Warning message", meta); + testLogger.warn("Warning message", meta); - expect(mockConsole.warn).toHaveBeenCalledWith("⚠️ [Warn] Warning message", meta); - }); + expect(mockConsole.warn).toHaveBeenCalledWith("⚠️ [Warn] Warning message", meta); + }); - it("should log error messages with correct format", () => { - const testLogger = createLogger("Error"); - const meta = { error: "failed" }; + it("should log error messages with correct format", () => { + const testLogger = createLogger("Error"); + const meta = { error: "failed" }; - testLogger.error("Error message", meta); + testLogger.error("Error message", meta); - expect(mockConsole.error).toHaveBeenCalledWith("❌ [Error] Error message", meta); - }); + expect(mockConsole.error).toHaveBeenCalledWith("❌ [Error] Error message", meta); + }); - it("should handle empty meta objects", async () => { - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.info("Message without meta"); + it("should handle empty meta objects", async () => { + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.info("Message without meta"); - expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ Message without meta", {}); - }); - }); + expect(mockConsole.info).toHaveBeenCalledWith("ℹ️ Message without meta", {}); + }); + }); - describe("Client Error Forwarding", () => { - it("should send error to server when logging error", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + describe("Client Error Forwarding", () => { + it("should send error to server when logging error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - const testLogger = createLogger("ErrorTest"); - const meta = { errorCode: 500 }; + const testLogger = createLogger("ErrorTest"); + const meta = { errorCode: 500 }; - testLogger.error("Server error", meta); + testLogger.error("Server error", meta); - // Wait for async operation - await new Promise((resolve) => setTimeout(resolve, 0)); + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockFetch).toHaveBeenCalledWith( - "/api/log", - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - body: expect.stringContaining('"level":"error"') - }) - ); + expect(mockFetch).toHaveBeenCalledWith( + "/api/log", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: expect.stringContaining('"level":"error"'), + }), + ); - // Verify the sent data contains expected properties - const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(sentData.level).toBe("error"); - expect(sentData.message).toBe("Server error"); - expect(sentData.meta.context).toBe("ErrorTest"); - expect(sentData.meta.url).toBe("https://test.example.com/page"); - expect(sentData.meta.userAgent).toBe("Test Browser/1.0"); - expect(sentData.meta.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - }); + // Verify the sent data contains expected properties + const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(sentData.level).toBe("error"); + expect(sentData.message).toBe("Server error"); + expect(sentData.meta.context).toBe("ErrorTest"); + expect(sentData.meta.url).toBe("https://test.example.com/page"); + expect(sentData.meta.userAgent).toBe("Test Browser/1.0"); + expect(sentData.meta.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); - it("should handle fetch errors gracefully", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + it("should handle fetch errors gracefully", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); - const testLogger = createLogger("ErrorTest"); - testLogger.error("Test error"); + const testLogger = createLogger("ErrorTest"); + testLogger.error("Test error"); - // Wait for async operation - await new Promise((resolve) => setTimeout(resolve, 0)); + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockConsole.error).toHaveBeenCalledWith( - "Failed to send error to server:", - expect.any(Error) - ); - }); + expect(mockConsole.error).toHaveBeenCalledWith( + "Failed to send error to server:", + expect.any(Error), + ); + }); - it("should not send non-error logs to server", () => { - logger.info("Info message"); - logger.warn("Warning message"); - logger.debug("Debug message"); + it("should not send non-error logs to server", () => { + logger.info("Info message"); + logger.warn("Warning message"); + logger.debug("Debug message"); - expect(mockFetch).not.toHaveBeenCalled(); - }); - }); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); - describe("Request Logging", () => { - it("should log successful requests as info", async () => { - const mockRequest = { - method: "GET", - url: "https://api.example.com/users" - } as Request; + describe("Request Logging", () => { + it("should log successful requests as info", async () => { + const mockRequest = { + method: "GET", + url: "https://api.example.com/users", + } as Request; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.logRequest(mockRequest, 150, 200); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.logRequest(mockRequest, 150, 200); - expect(mockConsole.info).toHaveBeenCalledWith( - "ℹ️ GET https://api.example.com/users - 200 (150ms)", - { - method: "GET", - url: "https://api.example.com/users", - statusCode: 200, - responseTime: 150 - } - ); - }); + expect(mockConsole.info).toHaveBeenCalledWith( + "ℹ️ GET https://api.example.com/users - 200 (150ms)", + { + method: "GET", + url: "https://api.example.com/users", + statusCode: 200, + responseTime: 150, + }, + ); + }); - it("should log client errors (4xx) as errors", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + it("should log client errors (4xx) as errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - const mockRequest = { - method: "POST", - url: "https://api.example.com/users" - } as Request; + const mockRequest = { + method: "POST", + url: "https://api.example.com/users", + } as Request; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.logRequest(mockRequest, 200, 404); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.logRequest(mockRequest, 200, 404); - expect(mockConsole.error).toHaveBeenCalledWith( - "❌ POST https://api.example.com/users - 404 (200ms)", - { - method: "POST", - url: "https://api.example.com/users", - statusCode: 404, - responseTime: 200 - } - ); + expect(mockConsole.error).toHaveBeenCalledWith( + "❌ POST https://api.example.com/users - 404 (200ms)", + { + method: "POST", + url: "https://api.example.com/users", + statusCode: 404, + responseTime: 200, + }, + ); - // Should also send to server since it's an error - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockFetch).toHaveBeenCalled(); - }); + // Should also send to server since it's an error + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockFetch).toHaveBeenCalled(); + }); - it("should log server errors (5xx) as errors", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + it("should log server errors (5xx) as errors", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - const mockRequest = { - method: "GET", - url: "https://api.example.com/data" - } as Request; + const mockRequest = { + method: "GET", + url: "https://api.example.com/data", + } as Request; - // Create a fresh logger without context - const { UniversalLogger } = await import("../index"); - const freshLogger = new UniversalLogger(); - freshLogger.logRequest(mockRequest, 1000, 500); + // Create a fresh logger without context + const { UniversalLogger } = await import("../index"); + const freshLogger = new UniversalLogger(); + freshLogger.logRequest(mockRequest, 1000, 500); - expect(mockConsole.error).toHaveBeenCalledWith( - "❌ GET https://api.example.com/data - 500 (1000ms)", - { - method: "GET", - url: "https://api.example.com/data", - statusCode: 500, - responseTime: 1000 - } - ); + expect(mockConsole.error).toHaveBeenCalledWith( + "❌ GET https://api.example.com/data - 500 (1000ms)", + { + method: "GET", + url: "https://api.example.com/data", + statusCode: 500, + responseTime: 1000, + }, + ); - // Should also send to server since it's an error - await new Promise((resolve) => setTimeout(resolve, 0)); - expect(mockFetch).toHaveBeenCalled(); - }); - }); + // Should also send to server since it's an error + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mockFetch).toHaveBeenCalled(); + }); + }); - describe("Factory Function", () => { - it("should create new logger instances with context", () => { - const logger1 = createLogger("Context1"); - const logger2 = createLogger("Context2"); + describe("Factory Function", () => { + it("should create new logger instances with context", () => { + const logger1 = createLogger("Context1"); + const logger2 = createLogger("Context2"); - logger1.info("Message 1"); - logger2.info("Message 2"); + logger1.info("Message 1"); + logger2.info("Message 2"); - expect(mockConsole.info).toHaveBeenNthCalledWith(1, "ℹ️ [Context1] Message 1", {}); - expect(mockConsole.info).toHaveBeenNthCalledWith(2, "ℹ️ [Context2] Message 2", {}); - }); + expect(mockConsole.info).toHaveBeenNthCalledWith(1, "ℹ️ [Context1] Message 1", {}); + expect(mockConsole.info).toHaveBeenNthCalledWith(2, "ℹ️ [Context2] Message 2", {}); + }); - it("should create independent logger instances", () => { - const logger1 = createLogger("Context1"); - const logger2 = createLogger("Context2"); + it("should create independent logger instances", () => { + const logger1 = createLogger("Context1"); + const logger2 = createLogger("Context2"); - // Modify one logger's context - logger1.setContext("ModifiedContext"); + // Modify one logger's context + logger1.setContext("ModifiedContext"); - logger1.info("Message 1"); - logger2.info("Message 2"); + logger1.info("Message 1"); + logger2.info("Message 2"); - expect(mockConsole.info).toHaveBeenNthCalledWith(1, "ℹ️ [ModifiedContext] Message 1", {}); - expect(mockConsole.info).toHaveBeenNthCalledWith(2, "ℹ️ [Context2] Message 2", {}); - }); - }); + expect(mockConsole.info).toHaveBeenNthCalledWith(1, "ℹ️ [ModifiedContext] Message 1", {}); + expect(mockConsole.info).toHaveBeenNthCalledWith(2, "ℹ️ [Context2] Message 2", {}); + }); + }); }); diff --git a/src/lib/logger/__tests__/integration.test.ts b/src/lib/logger/__tests__/integration.test.ts index 5f82d06..73e823f 100644 --- a/src/lib/logger/__tests__/integration.test.ts +++ b/src/lib/logger/__tests__/integration.test.ts @@ -3,300 +3,300 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Integration test for the complete client error forwarding flow describe("UniversalLogger Integration - Client Error Forwarding", () => { - let mockFetch: any; - let mockConsole: any; - let originalFetch: any; + let mockFetch: any; + let mockConsole: any; + let originalFetch: any; - beforeEach(() => { - // Store original fetch - originalFetch = global.fetch; + beforeEach(() => { + // Store original fetch + originalFetch = global.fetch; - // Setup fetch mock - mockFetch = vi.fn(); - global.fetch = mockFetch; + // Setup fetch mock + mockFetch = vi.fn(); + global.fetch = mockFetch; - // Setup console mocks - mockConsole = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - }; + // Setup console mocks + mockConsole = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; - vi.spyOn(console, "debug").mockImplementation(mockConsole.debug); - vi.spyOn(console, "info").mockImplementation(mockConsole.info); - vi.spyOn(console, "warn").mockImplementation(mockConsole.warn); - vi.spyOn(console, "error").mockImplementation(mockConsole.error); + vi.spyOn(console, "debug").mockImplementation(mockConsole.debug); + vi.spyOn(console, "info").mockImplementation(mockConsole.info); + vi.spyOn(console, "warn").mockImplementation(mockConsole.warn); + vi.spyOn(console, "error").mockImplementation(mockConsole.error); - // Mock browser environment - vi.doMock("$app/environment", () => ({ - browser: true - })); + // Mock browser environment + vi.doMock("$app/environment", () => ({ + browser: true, + })); - // Mock browser globals - Object.defineProperty(global, "navigator", { - value: { userAgent: "Test Browser/1.0" }, - writable: true - }); + // Mock browser globals + Object.defineProperty(global, "navigator", { + value: { userAgent: "Test Browser/1.0" }, + writable: true, + }); - Object.defineProperty(global, "window", { - value: { location: { href: "https://test.example.com/page" } }, - writable: true - }); - }); + Object.defineProperty(global, "window", { + value: { location: { href: "https://test.example.com/page" } }, + writable: true, + }); + }); - afterEach(() => { - // Restore original fetch - global.fetch = originalFetch; - vi.restoreAllMocks(); - vi.resetModules(); - }); + afterEach(() => { + // Restore original fetch + global.fetch = originalFetch; + vi.restoreAllMocks(); + vi.resetModules(); + }); - describe("End-to-End Error Forwarding", () => { - it("should forward client error to server and handle successful response", async () => { - // Mock successful server response - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + describe("End-to-End Error Forwarding", () => { + it("should forward client error to server and handle successful response", async () => { + // Mock successful server response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - // Import logger after mocks are set up - const { createLogger } = await import("../index"); - const logger = createLogger("IntegrationTest"); + // Import logger after mocks are set up + const { createLogger } = await import("../index"); + const logger = createLogger("IntegrationTest"); - // Trigger client error - const errorMessage = "Integration test error"; - const errorMeta = { errorCode: "INT001", component: "TestComponent" }; + // Trigger client error + const errorMessage = "Integration test error"; + const errorMeta = { errorCode: "INT001", component: "TestComponent" }; - logger.error(errorMessage, errorMeta); + logger.error(errorMessage, errorMeta); - // Wait for async operation - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 10)); - // Verify client-side logging - expect(mockConsole.error).toHaveBeenCalledWith( - "❌ [IntegrationTest] Integration test error", - errorMeta - ); + // Verify client-side logging + expect(mockConsole.error).toHaveBeenCalledWith( + "❌ [IntegrationTest] Integration test error", + errorMeta, + ); - // Verify server request - expect(mockFetch).toHaveBeenCalledWith( - "/api/log", - expect.objectContaining({ - method: "POST", - headers: { "Content-Type": "application/json" }, - body: expect.stringContaining('"level":"error"') - }) - ); + // Verify server request + expect(mockFetch).toHaveBeenCalledWith( + "/api/log", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: expect.stringContaining('"level":"error"'), + }), + ); - // Verify the sent data contains expected properties - const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(sentData.level).toBe("error"); - expect(sentData.message).toBe(errorMessage); - expect(sentData.meta.context).toBe("IntegrationTest"); - expect(sentData.meta.url).toBe("https://test.example.com/page"); - expect(sentData.meta.userAgent).toBe("Test Browser/1.0"); - expect(sentData.meta.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); - }); + // Verify the sent data contains expected properties + const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(sentData.level).toBe("error"); + expect(sentData.message).toBe(errorMessage); + expect(sentData.meta.context).toBe("IntegrationTest"); + expect(sentData.meta.url).toBe("https://test.example.com/page"); + expect(sentData.meta.userAgent).toBe("Test Browser/1.0"); + expect(sentData.meta.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/); + }); - it("should handle server API error gracefully", async () => { - // Mock server error response - mockFetch.mockRejectedValueOnce(new Error("Network error")); + it("should handle server API error gracefully", async () => { + // Mock server error response + mockFetch.mockRejectedValueOnce(new Error("Network error")); - const { createLogger } = await import("../index"); - const logger = createLogger("ErrorHandling"); + const { createLogger } = await import("../index"); + const logger = createLogger("ErrorHandling"); - logger.error("Test error"); + logger.error("Test error"); - // Wait for async operation - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 10)); - // Verify client error is logged locally - expect(mockConsole.error).toHaveBeenCalledWith("❌ [ErrorHandling] Test error", {}); + // Verify client error is logged locally + expect(mockConsole.error).toHaveBeenCalledWith("❌ [ErrorHandling] Test error", {}); - // Verify fallback error logging - expect(mockConsole.error).toHaveBeenCalledWith( - "Failed to send error to server:", - expect.any(Error) - ); - }); + // Verify fallback error logging + expect(mockConsole.error).toHaveBeenCalledWith( + "Failed to send error to server:", + expect.any(Error), + ); + }); - it("should handle server returning error status", async () => { - // Mock server error status - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error" - }); + it("should handle server returning error status", async () => { + // Mock server error status + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); - const { createLogger } = await import("../index"); - const logger = createLogger("ServerError"); + const { createLogger } = await import("../index"); + const logger = createLogger("ServerError"); - logger.error("Server unavailable"); + logger.error("Server unavailable"); - // Wait for async operation - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for async operation + await new Promise((resolve) => setTimeout(resolve, 10)); - // Should still attempt to send to server - expect(mockFetch).toHaveBeenCalled(); + // Should still attempt to send to server + expect(mockFetch).toHaveBeenCalled(); - // Local error should still be logged - expect(mockConsole.error).toHaveBeenCalledWith("❌ [ServerError] Server unavailable", {}); - }); - }); + // Local error should still be logged + expect(mockConsole.error).toHaveBeenCalledWith("❌ [ServerError] Server unavailable", {}); + }); + }); - describe("Server-side API Integration", () => { - it("should process forwarded client errors correctly", async () => { - // Mock server environment - vi.doMock("$app/environment", () => ({ - browser: false - })); + describe("Server-side API Integration", () => { + it("should process forwarded client errors correctly", async () => { + // Mock server environment + vi.doMock("$app/environment", () => ({ + browser: false, + })); - // Mock winston logger - const mockWinstonLogger = { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn() - }; + // Mock winston logger + const mockWinstonLogger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; - vi.doMock("./winston", () => ({ - default: mockWinstonLogger - })); + vi.doMock("./winston", () => ({ + default: mockWinstonLogger, + })); - // Mock SvelteKit json function - const mockJson = vi.fn().mockImplementation((data, options) => ({ data, options })); - vi.doMock("@sveltejs/kit", () => ({ - json: mockJson - })); + // Mock SvelteKit json function + const mockJson = vi.fn().mockImplementation((data, options) => ({ data, options })); + vi.doMock("@sveltejs/kit", () => ({ + json: mockJson, + })); - // This test is simplified since we can't easily test the actual server endpoint - // in this integration test due to mocking complexities. The server endpoint - // is tested separately in its own test file. + // This test is simplified since we can't easily test the actual server endpoint + // in this integration test due to mocking complexities. The server endpoint + // is tested separately in its own test file. - // Just verify that the mock setup would work - expect(mockWinstonLogger.error).toBeDefined(); - expect(mockJson).toBeDefined(); + // Just verify that the mock setup would work + expect(mockWinstonLogger.error).toBeDefined(); + expect(mockJson).toBeDefined(); - // The actual server endpoint integration is tested in the dedicated server.test.ts file - }); - }); + // The actual server endpoint integration is tested in the dedicated server.test.ts file + }); + }); - describe("Cross-Environment Behavior", () => { - it("should not forward non-error logs from client", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + describe("Cross-Environment Behavior", () => { + it("should not forward non-error logs from client", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - const { createLogger } = await import("../index"); - const logger = createLogger("NoForwarding"); + const { createLogger } = await import("../index"); + const logger = createLogger("NoForwarding"); - // Log different levels - logger.debug("Debug message"); - logger.info("Info message"); - logger.warn("Warning message"); + // Log different levels + logger.debug("Debug message"); + logger.info("Info message"); + logger.warn("Warning message"); - // Wait for any potential async operations - await new Promise((resolve) => setTimeout(resolve, 10)); + // Wait for any potential async operations + await new Promise((resolve) => setTimeout(resolve, 10)); - // Verify only console methods were called, no fetch - expect(mockConsole.debug).toHaveBeenCalled(); - expect(mockConsole.info).toHaveBeenCalled(); - expect(mockConsole.warn).toHaveBeenCalled(); - expect(mockFetch).not.toHaveBeenCalled(); - }); + // Verify only console methods were called, no fetch + expect(mockConsole.debug).toHaveBeenCalled(); + expect(mockConsole.info).toHaveBeenCalled(); + expect(mockConsole.warn).toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); - it("should handle multiple concurrent error forwarding requests", async () => { - // Mock successful responses for all requests - mockFetch.mockResolvedValue({ - ok: true, - json: async () => ({ success: true }) - }); + it("should handle multiple concurrent error forwarding requests", async () => { + // Mock successful responses for all requests + mockFetch.mockResolvedValue({ + ok: true, + json: async () => ({ success: true }), + }); - const { createLogger } = await import("../index"); - const logger1 = createLogger("Concurrent1"); - const logger2 = createLogger("Concurrent2"); + const { createLogger } = await import("../index"); + const logger1 = createLogger("Concurrent1"); + const logger2 = createLogger("Concurrent2"); - // Trigger multiple errors simultaneously - const promises = [ - logger1.error("Error 1", { id: 1 }), - logger2.error("Error 2", { id: 2 }), - logger1.error("Error 3", { id: 3 }) - ]; + // Trigger multiple errors simultaneously + const promises = [ + logger1.error("Error 1", { id: 1 }), + logger2.error("Error 2", { id: 2 }), + logger1.error("Error 3", { id: 3 }), + ]; - await Promise.all(promises); + await Promise.all(promises); - // Wait for all async operations - await new Promise((resolve) => setTimeout(resolve, 20)); + // Wait for all async operations + await new Promise((resolve) => setTimeout(resolve, 20)); - // Verify all requests were made - expect(mockFetch).toHaveBeenCalledTimes(3); + // Verify all requests were made + expect(mockFetch).toHaveBeenCalledTimes(3); - // Verify each request has correct context - const calls = mockFetch.mock.calls; - expect(calls[0][1].body).toContain('"context":"Concurrent1"'); - expect(calls[1][1].body).toContain('"context":"Concurrent2"'); - expect(calls[2][1].body).toContain('"context":"Concurrent1"'); - }); - }); + // Verify each request has correct context + const calls = mockFetch.mock.calls; + expect(calls[0][1].body).toContain('"context":"Concurrent1"'); + expect(calls[1][1].body).toContain('"context":"Concurrent2"'); + expect(calls[2][1].body).toContain('"context":"Concurrent1"'); + }); + }); - describe("Data Integrity", () => { - it("should preserve complex metadata through forwarding", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + describe("Data Integrity", () => { + it("should preserve complex metadata through forwarding", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - const { createLogger } = await import("../index"); - const logger = createLogger("DataIntegrity"); + const { createLogger } = await import("../index"); + const logger = createLogger("DataIntegrity"); - const complexMeta = { - user: { id: 123, name: "Test User" }, - error: { stack: "Error stack trace...", code: 500 }, - array: [1, 2, { nested: true }], - boolean: true, - null: null, - timestamp: new Date().toISOString() - }; + const complexMeta = { + user: { id: 123, name: "Test User" }, + error: { stack: "Error stack trace...", code: 500 }, + array: [1, 2, { nested: true }], + boolean: true, + null: null, + timestamp: new Date().toISOString(), + }; - logger.error("Complex error", complexMeta); + logger.error("Complex error", complexMeta); - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); - const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); + const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); - // Verify complex meta is preserved - expect(sentData.meta.user).toEqual({ id: 123, name: "Test User" }); - expect(sentData.meta.error).toEqual({ stack: "Error stack trace...", code: 500 }); - expect(sentData.meta.array).toEqual([1, 2, { nested: true }]); - expect(sentData.meta.boolean).toBe(true); - expect(sentData.meta.null).toBe(null); + // Verify complex meta is preserved + expect(sentData.meta.user).toEqual({ id: 123, name: "Test User" }); + expect(sentData.meta.error).toEqual({ stack: "Error stack trace...", code: 500 }); + expect(sentData.meta.array).toEqual([1, 2, { nested: true }]); + expect(sentData.meta.boolean).toBe(true); + expect(sentData.meta.null).toBe(null); - // Verify additional context is added - expect(sentData.meta.context).toBe("DataIntegrity"); - expect(sentData.meta.url).toBe("https://test.example.com/page"); - expect(sentData.meta.userAgent).toBe("Test Browser/1.0"); - }); + // Verify additional context is added + expect(sentData.meta.context).toBe("DataIntegrity"); + expect(sentData.meta.url).toBe("https://test.example.com/page"); + expect(sentData.meta.userAgent).toBe("Test Browser/1.0"); + }); - it("should maintain message and level integrity", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ success: true }) - }); + it("should maintain message and level integrity", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true }), + }); - const { createLogger } = await import("../index"); - const logger = createLogger("MessageIntegrity"); + const { createLogger } = await import("../index"); + const logger = createLogger("MessageIntegrity"); - const originalMessage = "Original error message with special chars: áéíóú 中文 🚀"; - logger.error(originalMessage); + const originalMessage = "Original error message with special chars: áéíóú 中文 🚀"; + logger.error(originalMessage); - await new Promise((resolve) => setTimeout(resolve, 10)); + await new Promise((resolve) => setTimeout(resolve, 10)); - const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); + const sentData = JSON.parse(mockFetch.mock.calls[0][1].body); - expect(sentData.level).toBe("error"); - expect(sentData.message).toBe(originalMessage); - }); - }); + expect(sentData.level).toBe("error"); + expect(sentData.message).toBe(originalMessage); + }); + }); }); diff --git a/src/lib/logger/index.ts b/src/lib/logger/index.ts index d45d6b7..e4efbd6 100644 --- a/src/lib/logger/index.ts +++ b/src/lib/logger/index.ts @@ -3,110 +3,110 @@ import { browser } from "$app/environment"; // eslint-disable-next-line @typescript-eslint/no-explicit-any let winstonLogger: any = undefined; if (!browser) { - const { default: winston } = await import("./winston"); - winstonLogger = winston; + const { default: winston } = await import("./winston"); + winstonLogger = winston; } class UniversalLogger { - #context: string = ""; + #context: string = ""; - setContext(context: string) { - this.#context = context; - return this; - } + setContext(context: string) { + this.#context = context; + return this; + } - #formatMessage(message: string, meta = {}) { - const contextPrefix = this.#context ? `[${this.#context}] ` : ""; - return { - message: `${contextPrefix}${message}`, - ...meta, - source: browser ? "client" : "server", - userAgent: browser ? navigator.userAgent : undefined, - timestamp: new Date().toISOString() - }; - } + #formatMessage(message: string, meta = {}) { + const contextPrefix = this.#context ? `[${this.#context}] ` : ""; + return { + message: `${contextPrefix}${message}`, + ...meta, + source: browser ? "client" : "server", + userAgent: browser ? navigator.userAgent : undefined, + timestamp: new Date().toISOString(), + }; + } - debug(message: string, meta = {}) { - if (browser) { - console.debug(`🐛 ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); - } else { - winstonLogger.debug(this.#formatMessage(message, meta)); - } - } + debug(message: string, meta = {}) { + if (browser) { + console.debug(`🐛 ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); + } else { + winstonLogger.debug(this.#formatMessage(message, meta)); + } + } - info(message: string, meta = {}) { - if (browser) { - console.info(`ℹ️ ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); - } else { - winstonLogger.info(this.#formatMessage(message, meta)); - } - } + info(message: string, meta = {}) { + if (browser) { + console.info(`ℹ️ ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); + } else { + winstonLogger.info(this.#formatMessage(message, meta)); + } + } - warn(message: string, meta = {}) { - if (browser) { - console.warn(`⚠️ ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); - } else { - winstonLogger.warn(this.#formatMessage(message, meta)); - } - } + warn(message: string, meta = {}) { + if (browser) { + console.warn(`⚠️ ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); + } else { + winstonLogger.warn(this.#formatMessage(message, meta)); + } + } - error(message: string, meta = {}) { - if (browser) { - console.error(`❌ ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); + error(message: string, meta = {}) { + if (browser) { + console.error(`❌ ${this.#context ? `[${this.#context}] ` : ""}${message}`, meta); - this.#sendErrorToServer(message, meta); - } else { - winstonLogger.error(this.#formatMessage(message, meta)); - } - } + this.#sendErrorToServer(message, meta); + } else { + winstonLogger.error(this.#formatMessage(message, meta)); + } + } - async #sendErrorToServer(message: string, meta = {}) { - try { - await fetch("/api/log", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - level: "error", - message, - meta: { - ...meta, - context: this.#context, - url: window.location.href, - userAgent: navigator.userAgent, - timestamp: new Date().toISOString() - } - }) - }); - } catch (err) { - console.error("Failed to send error to server:", err); - } - } + async #sendErrorToServer(message: string, meta = {}) { + try { + await fetch("/api/log", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + level: "error", + message, + meta: { + ...meta, + context: this.#context, + url: window.location.href, + userAgent: navigator.userAgent, + timestamp: new Date().toISOString(), + }, + }), + }); + } catch (err) { + console.error("Failed to send error to server:", err); + } + } - logRequest(request: Request, responseTime: number, statusCode: number) { - const message = `${request.method} ${request.url} - ${statusCode} (${responseTime}ms)`; + logRequest(request: Request, responseTime: number, statusCode: number) { + const message = `${request.method} ${request.url} - ${statusCode} (${responseTime}ms)`; - if (statusCode >= 400) { - this.error(message, { - method: request.method, - url: request.url, - statusCode, - responseTime - }); - } else { - this.info(message, { - method: request.method, - url: request.url, - statusCode, - responseTime - }); - } - } + if (statusCode >= 400) { + this.error(message, { + method: request.method, + url: request.url, + statusCode, + responseTime, + }); + } else { + this.info(message, { + method: request.method, + url: request.url, + statusCode, + responseTime, + }); + } + } } export const logger = new UniversalLogger(); export const createLogger = (context: string) => { - return new UniversalLogger().setContext(context); + return new UniversalLogger().setContext(context); }; export { UniversalLogger }; diff --git a/src/lib/logger/winston.ts b/src/lib/logger/winston.ts index 948e206..4d16b86 100644 --- a/src/lib/logger/winston.ts +++ b/src/lib/logger/winston.ts @@ -2,31 +2,31 @@ import winston from "winston"; import { dev } from "$app/environment"; const customFormat = winston.format.combine( - winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), - winston.format.errors({ stack: true }), - winston.format.colorize(), - winston.format.printf(({ timestamp, level, message, stack, ...meta }) => { - let log = `${timestamp} [${level}]: ${message}`; - if (stack) { - log += `\n${stack}`; - } + winston.format.timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), + winston.format.errors({ stack: true }), + winston.format.colorize(), + winston.format.printf(({ timestamp, level, message, stack, ...meta }) => { + let log = `${timestamp} [${level}]: ${message}`; + if (stack) { + log += `\n${stack}`; + } - if (Object.keys(meta).length > 0) { - log += `\n${JSON.stringify(meta, null, 2)}`; - } + if (Object.keys(meta).length > 0) { + log += `\n${JSON.stringify(meta, null, 2)}`; + } - return log; - }) + return log; + }), ); const logger = winston.createLogger({ - level: dev ? "debug" : "info", - format: customFormat, - transports: [ - new winston.transports.Console({ - format: winston.format.combine(customFormat) - }) - ] + level: dev ? "debug" : "info", + format: customFormat, + transports: [ + new winston.transports.Console({ + format: winston.format.combine(customFormat), + }), + ], }); export default logger; diff --git a/src/lib/server/auth/__tests__/authorization.test.ts b/src/lib/server/auth/__tests__/authorization.test.ts index bc81d06..cf68011 100644 --- a/src/lib/server/auth/__tests__/authorization.test.ts +++ b/src/lib/server/auth/__tests__/authorization.test.ts @@ -5,212 +5,212 @@ import { AuthenticationError } from "$lib/server/utils/errors"; import type { JWTPayload } from "jose"; const mockGlobalAdmin: JWTPayload = { - userId: "global-admin-id", - email: "global@example.com", - name: "Global Admin", - role: "GLOBAL_ADMIN", - sessionId: "session-1", - iat: Date.now(), - exp: Date.now() + 3600 + userId: "global-admin-id", + email: "global@example.com", + name: "Global Admin", + role: "GLOBAL_ADMIN", + sessionId: "session-1", + iat: Date.now(), + exp: Date.now() + 3600, }; const mockTenantAdmin: JWTPayload = { - userId: "tenant-admin-id", - email: "tenant@example.com", - name: "Tenant Admin", - role: "TENANT_ADMIN", - tenantId: "tenant-123", - sessionId: "session-2", - iat: Date.now(), - exp: Date.now() + 3600 + userId: "tenant-admin-id", + email: "tenant@example.com", + name: "Tenant Admin", + role: "TENANT_ADMIN", + tenantId: "tenant-123", + sessionId: "session-2", + iat: Date.now(), + exp: Date.now() + 3600, }; const mockStaff: JWTPayload = { - userId: "staff-id", - email: "staff@example.com", - name: "Staff Member", - role: "STAFF", - tenantId: "tenant-123", - sessionId: "session-3", - iat: Date.now(), - exp: Date.now() + 3600 + userId: "staff-id", + email: "staff@example.com", + name: "Staff Member", + role: "STAFF", + tenantId: "tenant-123", + sessionId: "session-3", + iat: Date.now(), + exp: Date.now() + 3600, }; describe("AuthorizationService", () => { - describe("requireRole", () => { - it("should allow access for correct role", () => { - expect(() => { - AuthorizationService.requireRole(mockGlobalAdmin, "GLOBAL_ADMIN"); - }).not.toThrow(); - }); + describe("requireRole", () => { + it("should allow access for correct role", () => { + expect(() => { + AuthorizationService.requireRole(mockGlobalAdmin, "GLOBAL_ADMIN"); + }).not.toThrow(); + }); - it("should deny access for incorrect role", () => { - expect(() => { - AuthorizationService.requireRole(mockTenantAdmin, "GLOBAL_ADMIN"); - }).toThrow(AuthenticationError); - }); + it("should deny access for incorrect role", () => { + expect(() => { + AuthorizationService.requireRole(mockTenantAdmin, "GLOBAL_ADMIN"); + }).toThrow(AuthenticationError); + }); - it("should deny access for null user", () => { - expect(() => { - AuthorizationService.requireRole(null as any, "GLOBAL_ADMIN"); - }).toThrow(AuthenticationError); - }); - }); + it("should deny access for null user", () => { + expect(() => { + AuthorizationService.requireRole(null as any, "GLOBAL_ADMIN"); + }).toThrow(AuthenticationError); + }); + }); - describe("requireAnyRole", () => { - it("should allow access for any allowed role", () => { - expect(() => { - AuthorizationService.requireAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); - }).not.toThrow(); + describe("requireAnyRole", () => { + it("should allow access for any allowed role", () => { + expect(() => { + AuthorizationService.requireAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); + }).not.toThrow(); - expect(() => { - AuthorizationService.requireAnyRole(mockStaff, ["TENANT_ADMIN", "STAFF"]); - }).not.toThrow(); - }); + expect(() => { + AuthorizationService.requireAnyRole(mockStaff, ["TENANT_ADMIN", "STAFF"]); + }).not.toThrow(); + }); - it("should deny access for disallowed role", () => { - expect(() => { - AuthorizationService.requireAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); - }).toThrow(AuthenticationError); - }); - }); + it("should deny access for disallowed role", () => { + expect(() => { + AuthorizationService.requireAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); + }).toThrow(AuthenticationError); + }); + }); - describe("requireTenantAccess", () => { - it("should allow global admin access to any tenant", () => { - expect(() => { - AuthorizationService.requireTenantAccess(mockGlobalAdmin, "any-tenant-id"); - }).not.toThrow(); - }); + describe("requireTenantAccess", () => { + it("should allow global admin access to any tenant", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockGlobalAdmin, "any-tenant-id"); + }).not.toThrow(); + }); - it("should allow tenant admin access to their own tenant", () => { - expect(() => { - AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-123"); - }).not.toThrow(); - }); + it("should allow tenant admin access to their own tenant", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-123"); + }).not.toThrow(); + }); - it("should deny tenant admin access to other tenants", () => { - expect(() => { - AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-456"); - }).toThrow(AuthenticationError); - }); + it("should deny tenant admin access to other tenants", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockTenantAdmin, "tenant-456"); + }).toThrow(AuthenticationError); + }); - it("should allow staff access to their own tenant", () => { - expect(() => { - AuthorizationService.requireTenantAccess(mockStaff, "tenant-123"); - }).not.toThrow(); - }); + it("should allow staff access to their own tenant", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockStaff, "tenant-123"); + }).not.toThrow(); + }); - it("should deny staff access to other tenants", () => { - expect(() => { - AuthorizationService.requireTenantAccess(mockStaff, "tenant-456"); - }).toThrow(AuthenticationError); - }); - }); + it("should deny staff access to other tenants", () => { + expect(() => { + AuthorizationService.requireTenantAccess(mockStaff, "tenant-456"); + }).toThrow(AuthenticationError); + }); + }); - describe("requireGlobalAdmin", () => { - it("should allow global admin access", () => { - expect(() => { - AuthorizationService.requireGlobalAdmin(mockGlobalAdmin); - }).not.toThrow(); - }); + describe("requireGlobalAdmin", () => { + it("should allow global admin access", () => { + expect(() => { + AuthorizationService.requireGlobalAdmin(mockGlobalAdmin); + }).not.toThrow(); + }); - it("should deny non-global admin access", () => { - expect(() => { - AuthorizationService.requireGlobalAdmin(mockTenantAdmin); - }).toThrow(AuthenticationError); - }); - }); + it("should deny non-global admin access", () => { + expect(() => { + AuthorizationService.requireGlobalAdmin(mockTenantAdmin); + }).toThrow(AuthenticationError); + }); + }); - describe("requireTenantAdmin", () => { - it("should allow global admin access", () => { - expect(() => { - AuthorizationService.requireTenantAdmin(mockGlobalAdmin); - }).not.toThrow(); - }); + describe("requireTenantAdmin", () => { + it("should allow global admin access", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockGlobalAdmin); + }).not.toThrow(); + }); - it("should allow tenant admin access", () => { - expect(() => { - AuthorizationService.requireTenantAdmin(mockTenantAdmin); - }).not.toThrow(); - }); + it("should allow tenant admin access", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockTenantAdmin); + }).not.toThrow(); + }); - it("should deny staff access", () => { - expect(() => { - AuthorizationService.requireTenantAdmin(mockStaff); - }).toThrow(AuthenticationError); - }); + it("should deny staff access", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockStaff); + }).toThrow(AuthenticationError); + }); - it("should check tenant access for tenant admin", () => { - expect(() => { - AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-123"); - }).not.toThrow(); + it("should check tenant access for tenant admin", () => { + expect(() => { + AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-123"); + }).not.toThrow(); - expect(() => { - AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-456"); - }).toThrow(AuthenticationError); - }); - }); + expect(() => { + AuthorizationService.requireTenantAdmin(mockTenantAdmin, "tenant-456"); + }).toThrow(AuthenticationError); + }); + }); - describe("requireStaffOrAbove", () => { - it("should allow all roles access", () => { - expect(() => { - AuthorizationService.requireStaffOrAbove(mockGlobalAdmin); - }).not.toThrow(); + describe("requireStaffOrAbove", () => { + it("should allow all roles access", () => { + expect(() => { + AuthorizationService.requireStaffOrAbove(mockGlobalAdmin); + }).not.toThrow(); - expect(() => { - AuthorizationService.requireStaffOrAbove(mockTenantAdmin); - }).not.toThrow(); + expect(() => { + AuthorizationService.requireStaffOrAbove(mockTenantAdmin); + }).not.toThrow(); - expect(() => { - AuthorizationService.requireStaffOrAbove(mockStaff); - }).not.toThrow(); - }); + expect(() => { + AuthorizationService.requireStaffOrAbove(mockStaff); + }).not.toThrow(); + }); - it("should check tenant access for tenant-specific roles", () => { - expect(() => { - AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-123"); - }).not.toThrow(); + it("should check tenant access for tenant-specific roles", () => { + expect(() => { + AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-123"); + }).not.toThrow(); - expect(() => { - AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-456"); - }).toThrow(AuthenticationError); - }); - }); + expect(() => { + AuthorizationService.requireStaffOrAbove(mockStaff, "tenant-456"); + }).toThrow(AuthenticationError); + }); + }); - describe("utility functions", () => { - it("should correctly identify user roles", () => { - expect(AuthorizationService.isGlobalAdmin(mockGlobalAdmin)).toBe(true); - expect(AuthorizationService.isGlobalAdmin(mockTenantAdmin)).toBe(false); + describe("utility functions", () => { + it("should correctly identify user roles", () => { + expect(AuthorizationService.isGlobalAdmin(mockGlobalAdmin)).toBe(true); + expect(AuthorizationService.isGlobalAdmin(mockTenantAdmin)).toBe(false); - expect(AuthorizationService.isTenantAdmin(mockTenantAdmin)).toBe(true); - expect(AuthorizationService.isTenantAdmin(mockStaff)).toBe(false); + expect(AuthorizationService.isTenantAdmin(mockTenantAdmin)).toBe(true); + expect(AuthorizationService.isTenantAdmin(mockStaff)).toBe(false); - expect(AuthorizationService.isStaff(mockStaff)).toBe(true); - expect(AuthorizationService.isStaff(mockTenantAdmin)).toBe(false); - }); + expect(AuthorizationService.isStaff(mockStaff)).toBe(true); + expect(AuthorizationService.isStaff(mockTenantAdmin)).toBe(false); + }); - it("should correctly check role membership", () => { - expect(AuthorizationService.hasRole(mockGlobalAdmin, "GLOBAL_ADMIN")).toBe(true); - expect(AuthorizationService.hasRole(mockGlobalAdmin, "TENANT_ADMIN")).toBe(false); + it("should correctly check role membership", () => { + expect(AuthorizationService.hasRole(mockGlobalAdmin, "GLOBAL_ADMIN")).toBe(true); + expect(AuthorizationService.hasRole(mockGlobalAdmin, "TENANT_ADMIN")).toBe(false); - expect( - AuthorizationService.hasAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]) - ).toBe(true); - expect(AuthorizationService.hasAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"])).toBe( - false - ); - }); + expect( + AuthorizationService.hasAnyRole(mockTenantAdmin, ["GLOBAL_ADMIN", "TENANT_ADMIN"]), + ).toBe(true); + expect(AuthorizationService.hasAnyRole(mockStaff, ["GLOBAL_ADMIN", "TENANT_ADMIN"])).toBe( + false, + ); + }); - it("should correctly check tenant access", () => { - expect(AuthorizationService.canAccessTenant(mockGlobalAdmin, "any-tenant")).toBe(true); - expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-123")).toBe(true); - expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-456")).toBe(false); - }); + it("should correctly check tenant access", () => { + expect(AuthorizationService.canAccessTenant(mockGlobalAdmin, "any-tenant")).toBe(true); + expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-123")).toBe(true); + expect(AuthorizationService.canAccessTenant(mockTenantAdmin, "tenant-456")).toBe(false); + }); - it("should correctly get user tenant ID", () => { - expect(AuthorizationService.getUserTenantId(mockGlobalAdmin)).toBe(null); - expect(AuthorizationService.getUserTenantId(mockTenantAdmin)).toBe("tenant-123"); - expect(AuthorizationService.getUserTenantId(mockStaff)).toBe("tenant-123"); - }); - }); + it("should correctly get user tenant ID", () => { + expect(AuthorizationService.getUserTenantId(mockGlobalAdmin)).toBe(null); + expect(AuthorizationService.getUserTenantId(mockTenantAdmin)).toBe("tenant-123"); + expect(AuthorizationService.getUserTenantId(mockStaff)).toBe("tenant-123"); + }); + }); }); diff --git a/src/lib/server/auth/__tests__/jwt-utils.test.ts b/src/lib/server/auth/__tests__/jwt-utils.test.ts index 580b912..48c4bb9 100644 --- a/src/lib/server/auth/__tests__/jwt-utils.test.ts +++ b/src/lib/server/auth/__tests__/jwt-utils.test.ts @@ -1,163 +1,163 @@ import { describe, it, expect } from "vitest"; import { - generateAccessToken, - generateRefreshToken, - verifyAccessToken, - verifyRefreshToken, - generateTokens, - isTokenExpired + generateAccessToken, + generateRefreshToken, + verifyAccessToken, + verifyRefreshToken, + generateTokens, + isTokenExpired, } from "../jwt-utils"; import type { SelectUser } from "$lib/server/db/central-schema"; const mockUser: SelectUser = { - id: "test-user-id", - email: "test@example.com", - name: "Test User", - role: "GLOBAL_ADMIN", - tenantId: null, - createdAt: new Date(), - updatedAt: new Date(), - lastLoginAt: new Date(), - isActive: true, - confirmed: true, - token: null, - tokenValidUntil: null, - passphraseHash: null, - recoveryPassphrase: null, - language: "de" + id: "test-user-id", + email: "test@example.com", + name: "Test User", + role: "GLOBAL_ADMIN", + tenantId: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: new Date(), + isActive: true, + confirmed: true, + token: null, + tokenValidUntil: null, + passphraseHash: null, + recoveryPassphrase: null, + language: "de", }; describe("JWT Utils", () => { - const sessionId = "test-session-id"; + const sessionId = "test-session-id"; - describe("generateAccessToken", () => { - it("should generate a valid access token", async () => { - const token = await generateAccessToken(mockUser, sessionId); - expect(token).toBeDefined(); - expect(typeof token).toBe("string"); - expect(token.split(".")).toHaveLength(3); - }); + describe("generateAccessToken", () => { + it("should generate a valid access token", async () => { + const token = await generateAccessToken(mockUser, sessionId); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); - it("should include user data in token payload", async () => { - const token = await generateAccessToken(mockUser, sessionId); - const payload = await verifyAccessToken(token); + it("should include user data in token payload", async () => { + const token = await generateAccessToken(mockUser, sessionId); + const payload = await verifyAccessToken(token); - expect(payload).toBeDefined(); - expect(payload!.userId).toBe(mockUser.id); - expect(payload!.email).toBe(mockUser.email); - expect(payload!.name).toBe(mockUser.name); - expect(payload!.role).toBe(mockUser.role); - expect(payload!.sessionId).toBe(sessionId); - }); - }); + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + expect(payload!.email).toBe(mockUser.email); + expect(payload!.name).toBe(mockUser.name); + expect(payload!.role).toBe(mockUser.role); + expect(payload!.sessionId).toBe(sessionId); + }); + }); - describe("generateRefreshToken", () => { - it("should generate a valid refresh token", async () => { - const token = await generateRefreshToken(mockUser.id, sessionId); - expect(token).toBeDefined(); - expect(typeof token).toBe("string"); - expect(token.split(".")).toHaveLength(3); - }); + describe("generateRefreshToken", () => { + it("should generate a valid refresh token", async () => { + const token = await generateRefreshToken(mockUser.id, sessionId); + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".")).toHaveLength(3); + }); - it("should include user and session data in token payload", async () => { - const token = await generateRefreshToken(mockUser.id, sessionId); - const payload = await verifyRefreshToken(token); + it("should include user and session data in token payload", async () => { + const token = await generateRefreshToken(mockUser.id, sessionId); + const payload = await verifyRefreshToken(token); - expect(payload).toBeDefined(); - expect(payload!.userId).toBe(mockUser.id); - expect(payload!.sessionId).toBe(sessionId); - }); - }); + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + expect(payload!.sessionId).toBe(sessionId); + }); + }); - describe("verifyAccessToken", () => { - it("should verify a valid access token", async () => { - const token = await generateAccessToken(mockUser, sessionId); - const payload = await verifyAccessToken(token); + describe("verifyAccessToken", () => { + it("should verify a valid access token", async () => { + const token = await generateAccessToken(mockUser, sessionId); + const payload = await verifyAccessToken(token); - expect(payload).toBeDefined(); - expect(payload!.userId).toBe(mockUser.id); - }); + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + }); - it("should reject invalid tokens", async () => { - const payload = await verifyAccessToken("invalid.token.here"); - expect(payload).toBeNull(); - }); + it("should reject invalid tokens", async () => { + const payload = await verifyAccessToken("invalid.token.here"); + expect(payload).toBeNull(); + }); - it("should reject malformed tokens", async () => { - const payload = await verifyAccessToken("invalid-token"); - expect(payload).toBeNull(); - }); - }); + it("should reject malformed tokens", async () => { + const payload = await verifyAccessToken("invalid-token"); + expect(payload).toBeNull(); + }); + }); - describe("verifyRefreshToken", () => { - it("should verify a valid refresh token", async () => { - const token = await generateRefreshToken(mockUser.id, sessionId); - const payload = await verifyRefreshToken(token); + describe("verifyRefreshToken", () => { + it("should verify a valid refresh token", async () => { + const token = await generateRefreshToken(mockUser.id, sessionId); + const payload = await verifyRefreshToken(token); - expect(payload).toBeDefined(); - expect(payload!.userId).toBe(mockUser.id); - expect(payload!.sessionId).toBe(sessionId); - }); + expect(payload).toBeDefined(); + expect(payload!.userId).toBe(mockUser.id); + expect(payload!.sessionId).toBe(sessionId); + }); - it("should reject invalid tokens", async () => { - const payload = await verifyRefreshToken("invalid.token.here"); - expect(payload).toBeNull(); - }); + it("should reject invalid tokens", async () => { + const payload = await verifyRefreshToken("invalid.token.here"); + expect(payload).toBeNull(); + }); - it("should reject access tokens as refresh tokens", async () => { - const accessToken = await generateAccessToken(mockUser, sessionId); - const payload = await verifyRefreshToken(accessToken); - expect(payload).toBeNull(); - }); - }); + it("should reject access tokens as refresh tokens", async () => { + const accessToken = await generateAccessToken(mockUser, sessionId); + const payload = await verifyRefreshToken(accessToken); + expect(payload).toBeNull(); + }); + }); - describe("generateTokens", () => { - it("should generate both access and refresh tokens", async () => { - const tokens = await generateTokens(mockUser, sessionId); + describe("generateTokens", () => { + it("should generate both access and refresh tokens", async () => { + const tokens = await generateTokens(mockUser, sessionId); - expect(tokens.accessToken).toBeDefined(); - expect(tokens.refreshToken).toBeDefined(); - expect(typeof tokens.accessToken).toBe("string"); - expect(typeof tokens.refreshToken).toBe("string"); - }); + expect(tokens.accessToken).toBeDefined(); + expect(tokens.refreshToken).toBeDefined(); + expect(typeof tokens.accessToken).toBe("string"); + expect(typeof tokens.refreshToken).toBe("string"); + }); - it("should generate verifiable tokens", async () => { - const tokens = await generateTokens(mockUser, sessionId); + it("should generate verifiable tokens", async () => { + const tokens = await generateTokens(mockUser, sessionId); - const accessPayload = await verifyAccessToken(tokens.accessToken); - const refreshPayload = await verifyRefreshToken(tokens.refreshToken); + const accessPayload = await verifyAccessToken(tokens.accessToken); + const refreshPayload = await verifyRefreshToken(tokens.refreshToken); - expect(accessPayload).toBeDefined(); - expect(refreshPayload).toBeDefined(); - expect(accessPayload!.userId).toBe(mockUser.id); - expect(refreshPayload!.userId).toBe(mockUser.id); - }); - }); + expect(accessPayload).toBeDefined(); + expect(refreshPayload).toBeDefined(); + expect(accessPayload!.userId).toBe(mockUser.id); + expect(refreshPayload!.userId).toBe(mockUser.id); + }); + }); - describe("isTokenExpired", () => { - it("should return false for valid tokens", async () => { - const token = await generateAccessToken(mockUser, sessionId); - expect(await isTokenExpired(token)).toBe(false); - }); + describe("isTokenExpired", () => { + it("should return false for valid tokens", async () => { + const token = await generateAccessToken(mockUser, sessionId); + expect(await isTokenExpired(token)).toBe(false); + }); - it("should return true for malformed tokens", async () => { - expect(await isTokenExpired("invalid-token")).toBe(true); - }); + it("should return true for malformed tokens", async () => { + expect(await isTokenExpired("invalid-token")).toBe(true); + }); - it("should return true for empty tokens", async () => { - expect(await isTokenExpired("")).toBe(true); - }); - }); + it("should return true for empty tokens", async () => { + expect(await isTokenExpired("")).toBe(true); + }); + }); - describe("Token expiration", () => { - it("should respect token expiration", async () => { - // Test with an obviously expired token (JWT with past exp claim) - // This is a manually crafted expired JWT for testing purposes - const expiredToken = - "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJuYW1lIjoiVGVzdCBVc2VyIiwicm9sZSI6IkdMT0JBTF9BRE1JTiIsInNlc3Npb25JZCI6InRlc3Qtc2Vzc2lvbi1pZCIsImlhdCI6MTYwMDAwMDAwMCwiZXhwIjoxNjAwMDAwOTAwfQ."; + describe("Token expiration", () => { + it("should respect token expiration", async () => { + // Test with an obviously expired token (JWT with past exp claim) + // This is a manually crafted expired JWT for testing purposes + const expiredToken = + "eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiJ0ZXN0LXVzZXItaWQiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJuYW1lIjoiVGVzdCBVc2VyIiwicm9sZSI6IkdMT0JBTF9BRE1JTiIsInNlc3Npb25JZCI6InRlc3Qtc2Vzc2lvbi1pZCIsImlhdCI6MTYwMDAwMDAwMCwiZXhwIjoxNjAwMDAwOTAwfQ."; - const payload = await verifyAccessToken(expiredToken); - expect(payload).toBeNull(); - }); - }); + const payload = await verifyAccessToken(expiredToken); + expect(payload).toBeNull(); + }); + }); }); diff --git a/src/lib/server/auth/__tests__/session-service.test.ts b/src/lib/server/auth/__tests__/session-service.test.ts index c843f36..3dd44bf 100644 --- a/src/lib/server/auth/__tests__/session-service.test.ts +++ b/src/lib/server/auth/__tests__/session-service.test.ts @@ -5,205 +5,205 @@ import * as jwtUtils from "../jwt-utils"; // Mock dependencies vi.mock("$lib/server/db", () => ({ - db: { - select: vi.fn(), - update: vi.fn() - } + db: { + select: vi.fn(), + update: vi.fn(), + }, })); vi.mock("../jwt-utils"); const mockUser = { - id: "test-user-id", - email: "test@example.com", - name: "Test User", - role: "STAFF" as const, - tenantId: "tenant-123", - isActive: true, - confirmed: true, - createdAt: new Date(), - updatedAt: new Date(), - lastLoginAt: null + id: "test-user-id", + email: "test@example.com", + name: "Test User", + role: "STAFF" as const, + tenantId: "tenant-123", + isActive: true, + confirmed: true, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, }; const mockSession = { - id: "session-id", - userId: "test-user-id", - sessionToken: "session-token", - accessToken: "access-token", - refreshToken: "refresh-token", - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now - lastUsedAt: new Date(), - ipAddress: "127.0.0.1", - userAgent: "test-agent", - createdAt: new Date(), - updatedAt: new Date() + id: "session-id", + userId: "test-user-id", + sessionToken: "session-token", + accessToken: "access-token", + refreshToken: "refresh-token", + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now + lastUsedAt: new Date(), + ipAddress: "127.0.0.1", + userAgent: "test-agent", + createdAt: new Date(), + updatedAt: new Date(), }; describe("SessionService.validateTokenWithDB", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("should return user, sessionId and exp for valid token", async () => { - const { db } = await import("$lib/server/db"); + it("should return user, sessionId and exp for valid token", async () => { + const { db } = await import("$lib/server/db"); - // Mock JWT verification - const mockTokenData = { - sessionId: "session-id", - userId: "test-user-id", - exp: Math.floor(Date.now() / 1000) + 3600 - }; - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); + // Mock JWT verification + const mockTokenData = { + sessionId: "session-id", + userId: "test-user-id", + exp: Math.floor(Date.now() / 1000) + 3600, + }; + vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); - // Mock successful database query - const mockQuery = vi.fn().mockResolvedValue([ - { - user: mockUser, - user_session: mockSession - } - ]); + // Mock successful database query + const mockQuery = vi.fn().mockResolvedValue([ + { + user: mockUser, + user_session: mockSession, + }, + ]); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockQuery - }) - }) - }) - } as any); + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: mockQuery, + }), + }), + }), + } as any); - vi.mocked(db.update).mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]) - }) - } as any); + vi.mocked(db.update).mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue([]), + }), + } as any); - const result = await SessionService.validateTokenWithDB("valid-token"); + const result = await SessionService.validateTokenWithDB("valid-token"); - expect(result).toEqual({ - user: mockUser, - sessionId: "session-id", - exp: mockSession.expiresAt - }); - expect(jwtUtils.verifyAccessToken).toHaveBeenCalledWith("valid-token"); - }); + expect(result).toEqual({ + user: mockUser, + sessionId: "session-id", + exp: mockSession.expiresAt, + }); + expect(jwtUtils.verifyAccessToken).toHaveBeenCalledWith("valid-token"); + }); - it("should return null for invalid JWT token", async () => { - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(null); + it("should return null for invalid JWT token", async () => { + vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(null); - const result = await SessionService.validateTokenWithDB("invalid-token"); + const result = await SessionService.validateTokenWithDB("invalid-token"); - expect(result).toBeNull(); - expect(jwtUtils.verifyAccessToken).toHaveBeenCalledWith("invalid-token"); - }); + expect(result).toBeNull(); + expect(jwtUtils.verifyAccessToken).toHaveBeenCalledWith("invalid-token"); + }); - it("should return null for non-existent session", async () => { - const { db } = await import("$lib/server/db"); + it("should return null for non-existent session", async () => { + const { db } = await import("$lib/server/db"); - const mockTokenData = { - sessionId: "non-existent-session", - userId: "test-user-id", - exp: Math.floor(Date.now() / 1000) + 3600 - }; - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); + const mockTokenData = { + sessionId: "non-existent-session", + userId: "test-user-id", + exp: Math.floor(Date.now() / 1000) + 3600, + }; + vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); - // Mock empty database result - const mockQuery = vi.fn().mockResolvedValue([]); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockQuery - }) - }) - }) - } as any); + // Mock empty database result + const mockQuery = vi.fn().mockResolvedValue([]); + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: mockQuery, + }), + }), + }), + } as any); - const result = await SessionService.validateTokenWithDB("valid-token"); + const result = await SessionService.validateTokenWithDB("valid-token"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("should return null for inactive user", async () => { - const { db } = await import("$lib/server/db"); + it("should return null for inactive user", async () => { + const { db } = await import("$lib/server/db"); - const mockTokenData = { - sessionId: "session-id", - userId: "test-user-id", - exp: Math.floor(Date.now() / 1000) + 3600 - }; - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); + const mockTokenData = { + sessionId: "session-id", + userId: "test-user-id", + exp: Math.floor(Date.now() / 1000) + 3600, + }; + vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); - const inactiveUser = { - ...mockUser, - isActive: false - }; + const inactiveUser = { + ...mockUser, + isActive: false, + }; - const mockQuery = vi.fn().mockResolvedValue([ - { - user: inactiveUser, - user_session: mockSession - } - ]); + const mockQuery = vi.fn().mockResolvedValue([ + { + user: inactiveUser, + user_session: mockSession, + }, + ]); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockQuery - }) - }) - }) - } as any); + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: mockQuery, + }), + }), + }), + } as any); - const result = await SessionService.validateTokenWithDB("valid-token"); + const result = await SessionService.validateTokenWithDB("valid-token"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("should return null for unconfirmed user", async () => { - const { db } = await import("$lib/server/db"); + it("should return null for unconfirmed user", async () => { + const { db } = await import("$lib/server/db"); - const mockTokenData = { - sessionId: "session-id", - userId: "test-user-id", - exp: Math.floor(Date.now() / 1000) + 3600 - }; - vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); + const mockTokenData = { + sessionId: "session-id", + userId: "test-user-id", + exp: Math.floor(Date.now() / 1000) + 3600, + }; + vi.mocked(jwtUtils.verifyAccessToken).mockResolvedValue(mockTokenData); - const unconfirmedUser = { - ...mockUser, - confirmed: false - }; + const unconfirmedUser = { + ...mockUser, + confirmed: false, + }; - const mockQuery = vi.fn().mockResolvedValue([ - { - user: unconfirmedUser, - user_session: mockSession - } - ]); + const mockQuery = vi.fn().mockResolvedValue([ + { + user: unconfirmedUser, + user_session: mockSession, + }, + ]); - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - innerJoin: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - limit: mockQuery - }) - }) - }) - } as any); + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + innerJoin: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: mockQuery, + }), + }), + }), + } as any); - const result = await SessionService.validateTokenWithDB("valid-token"); + const result = await SessionService.validateTokenWithDB("valid-token"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("should return null and handle errors gracefully", async () => { - vi.mocked(jwtUtils.verifyAccessToken).mockRejectedValue(new Error("JWT error")); + it("should return null and handle errors gracefully", async () => { + vi.mocked(jwtUtils.verifyAccessToken).mockRejectedValue(new Error("JWT error")); - const result = await SessionService.validateTokenWithDB("token"); + const result = await SessionService.validateTokenWithDB("token"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); }); diff --git a/src/lib/server/auth/authorization-service.ts b/src/lib/server/auth/authorization-service.ts index f0a5468..0cad355 100644 --- a/src/lib/server/auth/authorization-service.ts +++ b/src/lib/server/auth/authorization-service.ts @@ -7,137 +7,137 @@ const logger = new UniversalLogger().setContext("Authorization"); export type UserRole = "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF"; export class AuthorizationService { - static requireRole(user: JWTPayload, requiredRole: UserRole): void { - if (!user) { - throw new AuthenticationError("Authentication required"); - } + static requireRole(user: JWTPayload, requiredRole: UserRole): void { + if (!user) { + throw new AuthenticationError("Authentication required"); + } - if (user.role !== requiredRole) { - logger.warn( - `Access denied: User ${user.email} has role ${user.role}, required ${requiredRole}` - ); - throw new AuthenticationError("Insufficient permissions"); - } + if (user.role !== requiredRole) { + logger.warn( + `Access denied: User ${user.email} has role ${user.role}, required ${requiredRole}`, + ); + throw new AuthenticationError("Insufficient permissions"); + } - logger.debug(`Authorization granted: User ${user.email} has required role ${requiredRole}`); - } + logger.debug(`Authorization granted: User ${user.email} has required role ${requiredRole}`); + } - static requireAnyRole(user: JWTPayload, allowedRoles: UserRole[]): void { - if (!user) { - throw new AuthenticationError("Authentication required"); - } + static requireAnyRole(user: JWTPayload, allowedRoles: UserRole[]): void { + if (!user) { + throw new AuthenticationError("Authentication required"); + } - if (!allowedRoles.includes(user.role as UserRole)) { - logger.warn( - `Access denied: User ${user.email} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}` - ); - throw new AuthenticationError("Insufficient permissions"); - } + if (!allowedRoles.includes(user.role as UserRole)) { + logger.warn( + `Access denied: User ${user.email} has role ${user.role}, allowed roles: ${allowedRoles.join(", ")}`, + ); + throw new AuthenticationError("Insufficient permissions"); + } - logger.debug(`Authorization granted: User ${user.email} has allowed role ${user.role}`); - } + logger.debug(`Authorization granted: User ${user.email} has allowed role ${user.role}`); + } - static requireTenantAccess(user: JWTPayload, tenantId: string): void { - if (!user) { - throw new AuthenticationError("Authentication required"); - } + static requireTenantAccess(user: JWTPayload, tenantId: string): void { + if (!user) { + throw new AuthenticationError("Authentication required"); + } - if (user.role === "GLOBAL_ADMIN") { - logger.debug( - `Authorization granted: Global admin ${user.email} accessing tenant ${tenantId}` - ); - return; - } + if (user.role === "GLOBAL_ADMIN") { + logger.debug( + `Authorization granted: Global admin ${user.email} accessing tenant ${tenantId}`, + ); + return; + } - if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { - if (!user.tenantId) { - logger.warn(`Access denied: User ${user.email} has no tenant assigned`); - throw new AuthenticationError("No tenant access"); - } + if (user.role === "TENANT_ADMIN" || user.role === "STAFF") { + if (!user.tenantId) { + logger.warn(`Access denied: User ${user.email} has no tenant assigned`); + throw new AuthenticationError("No tenant access"); + } - if (user.tenantId !== tenantId) { - logger.warn( - `Access denied: User ${user.email} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}` - ); - throw new AuthenticationError("Tenant access denied"); - } + if (user.tenantId !== tenantId) { + logger.warn( + `Access denied: User ${user.email} trying to access tenant ${tenantId}, but belongs to ${user.tenantId}`, + ); + throw new AuthenticationError("Tenant access denied"); + } - logger.debug(`Authorization granted: User ${user.email} accessing own tenant ${tenantId}`); - return; - } + logger.debug(`Authorization granted: User ${user.email} accessing own tenant ${tenantId}`); + return; + } - logger.warn(`Access denied: User ${user.email} has invalid role ${user.role}`); - throw new AuthenticationError("Invalid role"); - } + logger.warn(`Access denied: User ${user.email} has invalid role ${user.role}`); + throw new AuthenticationError("Invalid role"); + } - static requireGlobalAdmin(user: JWTPayload): void { - this.requireRole(user, "GLOBAL_ADMIN"); - } + static requireGlobalAdmin(user: JWTPayload): void { + this.requireRole(user, "GLOBAL_ADMIN"); + } - static requireTenantAdmin(user: JWTPayload, tenantId?: string): void { - this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); + static requireTenantAdmin(user: JWTPayload, tenantId?: string): void { + this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]); - if (tenantId && user.role === "TENANT_ADMIN") { - this.requireTenantAccess(user, tenantId); - } - } + if (tenantId && user.role === "TENANT_ADMIN") { + this.requireTenantAccess(user, tenantId); + } + } - static requireStaffOrAbove(user: JWTPayload, tenantId?: string): void { - this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]); + static requireStaffOrAbove(user: JWTPayload, tenantId?: string): void { + this.requireAnyRole(user, ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]); - if (tenantId && (user.role === "TENANT_ADMIN" || user.role === "STAFF")) { - this.requireTenantAccess(user, tenantId); - } - } + if (tenantId && (user.role === "TENANT_ADMIN" || user.role === "STAFF")) { + this.requireTenantAccess(user, tenantId); + } + } - static canAccessTenant(user: JWTPayload, tenantId: string): boolean { - try { - this.requireTenantAccess(user, tenantId); - return true; - } catch { - return false; - } - } + static canAccessTenant(user: JWTPayload, tenantId: string): boolean { + try { + this.requireTenantAccess(user, tenantId); + return true; + } catch { + return false; + } + } - static isGlobalAdmin(user: JWTPayload): boolean { - return user.role === "GLOBAL_ADMIN"; - } + static isGlobalAdmin(user: JWTPayload): boolean { + return user.role === "GLOBAL_ADMIN"; + } - static isTenantAdmin(user: JWTPayload): boolean { - return user.role === "TENANT_ADMIN"; - } + static isTenantAdmin(user: JWTPayload): boolean { + return user.role === "TENANT_ADMIN"; + } - static isStaff(user: JWTPayload): boolean { - return user.role === "STAFF"; - } + static isStaff(user: JWTPayload): boolean { + return user.role === "STAFF"; + } - static hasRole(user: JWTPayload, role: UserRole): boolean { - return user.role === role; - } + static hasRole(user: JWTPayload, role: UserRole): boolean { + return user.role === role; + } - static hasAnyRole(user: JWTPayload, roles: UserRole[]): boolean { - return roles.includes(user.role as UserRole); - } + static hasAnyRole(user: JWTPayload, roles: UserRole[]): boolean { + return roles.includes(user.role as UserRole); + } - static getUserTenantId(user: JWTPayload): string | null { - return (user.tenantId as string) || null; - } + static getUserTenantId(user: JWTPayload): string | null { + return (user.tenantId as string) || null; + } } export function withAuthorization( - user: JWTPayload, - requiredRole?: UserRole, - allowedRoles?: UserRole[] + user: JWTPayload, + requiredRole?: UserRole, + allowedRoles?: UserRole[], ) { - if (!user) { - throw new AuthenticationError("Authentication required"); - } + if (!user) { + throw new AuthenticationError("Authentication required"); + } - if (requiredRole) { - AuthorizationService.requireRole(user, requiredRole); - } + if (requiredRole) { + AuthorizationService.requireRole(user, requiredRole); + } - if (allowedRoles) { - AuthorizationService.requireAnyRole(user, allowedRoles); - } + if (allowedRoles) { + AuthorizationService.requireAnyRole(user, allowedRoles); + } } diff --git a/src/lib/server/auth/jwt-utils.ts b/src/lib/server/auth/jwt-utils.ts index 12870bf..156984c 100644 --- a/src/lib/server/auth/jwt-utils.ts +++ b/src/lib/server/auth/jwt-utils.ts @@ -6,12 +6,12 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("JWT"); export interface JWTTokens { - accessToken: string; - refreshToken: string; + accessToken: string; + refreshToken: string; } if (!env.JWT_SECRET) { - throw Error("Mandatory ENV variable JWT_SECRET is missing!"); + throw Error("Mandatory ENV variable JWT_SECRET is missing!"); } const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); @@ -19,108 +19,108 @@ const ACCESS_TOKEN_EXPIRES = "15m"; // 15 minutes const REFRESH_TOKEN_EXPIRES = "7d"; // 7 days export async function generateAccessToken(user: SelectUser, sessionId: string): Promise { - const now = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000); - const payload: Omit = { - userId: user.id, - email: user.email, - name: user.name, - role: user.role, - tenantId: user.tenantId || undefined, - sessionId - }; + const payload: Omit = { + userId: user.id, + email: user.email, + name: user.name, + role: user.role, + tenantId: user.tenantId || undefined, + sessionId, + }; - const jwt = await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt(now) - .setExpirationTime(ACCESS_TOKEN_EXPIRES) - .sign(JWT_SECRET); + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(ACCESS_TOKEN_EXPIRES) + .sign(JWT_SECRET); - return jwt; + return jwt; } export async function generateRefreshToken(userId: string, sessionId: string): Promise { - const now = Math.floor(Date.now() / 1000); + const now = Math.floor(Date.now() / 1000); - const payload = { - userId, - sessionId, - type: "refresh" - }; + const payload = { + userId, + sessionId, + type: "refresh", + }; - const jwt = await new SignJWT(payload) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt(now) - .setExpirationTime(REFRESH_TOKEN_EXPIRES) - .sign(JWT_SECRET); + const jwt = await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(REFRESH_TOKEN_EXPIRES) + .sign(JWT_SECRET); - return jwt; + return jwt; } export async function verifyAccessToken( - token: string + token: string, ): Promise<(JWTPayload & { userId: string; sessionId: string }) | null> { - try { - const { payload } = await jwtVerify(token, JWT_SECRET); + try { + const { payload } = await jwtVerify(token, JWT_SECRET); - return { - userId: payload.userId as string, - email: payload.email, - name: payload.name, - role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", - tenantId: payload.tenantId as string | undefined, - sessionId: payload.sessionId as string, - iat: payload.iat, - exp: payload.exp - }; - } catch (error) { - logger.warn("JWT verification failed:", { error: String(error) }); - return null; - } + return { + userId: payload.userId as string, + email: payload.email, + name: payload.name, + role: payload.role as "GLOBAL_ADMIN" | "TENANT_ADMIN" | "STAFF", + tenantId: payload.tenantId as string | undefined, + sessionId: payload.sessionId as string, + iat: payload.iat, + exp: payload.exp, + }; + } catch (error) { + logger.warn("JWT verification failed:", { error: String(error) }); + return null; + } } export async function verifyRefreshToken( - token: string + token: string, ): Promise<{ userId: string; sessionId: string } | null> { - try { - const { payload } = await jwtVerify(token, JWT_SECRET); + try { + const { payload } = await jwtVerify(token, JWT_SECRET); - if ( - typeof payload.userId === "string" && - typeof payload.sessionId === "string" && - payload.type === "refresh" - ) { - return { - userId: payload.userId, - sessionId: payload.sessionId - }; - } + if ( + typeof payload.userId === "string" && + typeof payload.sessionId === "string" && + payload.type === "refresh" + ) { + return { + userId: payload.userId, + sessionId: payload.sessionId, + }; + } - logger.warn("Invalid refresh token payload"); - return null; - } catch (error) { - logger.warn("Refresh token verification failed:", { error: String(error) }); - return null; - } + logger.warn("Invalid refresh token payload"); + return null; + } catch (error) { + logger.warn("Refresh token verification failed:", { error: String(error) }); + return null; + } } export async function generateTokens(user: SelectUser, sessionId: string): Promise { - const [accessToken, refreshToken] = await Promise.all([ - generateAccessToken(user, sessionId), - generateRefreshToken(user.id, sessionId) - ]); + const [accessToken, refreshToken] = await Promise.all([ + generateAccessToken(user, sessionId), + generateRefreshToken(user.id, sessionId), + ]); - return { - accessToken, - refreshToken - }; + return { + accessToken, + refreshToken, + }; } export async function isTokenExpired(token: string): Promise { - try { - await jwtVerify(token, JWT_SECRET); - return false; - } catch { - return true; - } + try { + await jwtVerify(token, JWT_SECRET); + return false; + } catch { + return true; + } } diff --git a/src/lib/server/auth/session-service.ts b/src/lib/server/auth/session-service.ts index cb90a61..7233b50 100644 --- a/src/lib/server/auth/session-service.ts +++ b/src/lib/server/auth/session-service.ts @@ -2,9 +2,9 @@ import { eq, and, gt, lt } from "drizzle-orm"; import { db } from "$lib/server/db"; import { user, userSession } from "$lib/server/db/central-schema"; import type { - SelectUser, - InsertUserSession, - SelectUserSession + SelectUser, + InsertUserSession, + SelectUserSession, } from "$lib/server/db/central-schema"; import { generateTokens, verifyRefreshToken, verifyAccessToken } from "./jwt-utils"; import { UniversalLogger } from "$lib/logger"; @@ -13,300 +13,300 @@ import { ValidationError, NotFoundError } from "$lib/server/utils/errors"; const logger = new UniversalLogger().setContext("AuthService"); export interface SessionData { - sessionToken: string; - accessToken: string; - refreshToken: string; - user: SelectUser; - expiresAt: Date; + sessionToken: string; + accessToken: string; + refreshToken: string; + user: SelectUser; + expiresAt: Date; } export interface LoginResult { - sessionToken: string; - accessToken: string; - refreshToken: string; - user: SelectUser; - expiresAt: Date; + sessionToken: string; + accessToken: string; + refreshToken: string; + user: SelectUser; + expiresAt: Date; } export interface RefreshResult { - accessToken: string; - refreshToken: string; - expiresAt: Date; + accessToken: string; + refreshToken: string; + expiresAt: Date; } export class SessionService { - private static readonly SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds + private static readonly SESSION_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days in milliseconds - static async createSession( - userId: string, - ipAddress?: string, - userAgent?: string - ): Promise { - logger.info(`Creating session for user: ${userId}`); + static async createSession( + userId: string, + ipAddress?: string, + userAgent?: string, + ): Promise { + logger.info(`Creating session for user: ${userId}`); - const existingUser = await db.select().from(user).where(eq(user.id, userId)).limit(1); + const existingUser = await db.select().from(user).where(eq(user.id, userId)).limit(1); - if (existingUser.length === 0) { - throw new NotFoundError(`User with ID ${userId} not found`); - } + if (existingUser.length === 0) { + throw new NotFoundError(`User with ID ${userId} not found`); + } - const userData = existingUser[0]; + const userData = existingUser[0]; - if (!userData.isActive) { - throw new ValidationError("User account is inactive"); - } + if (!userData.isActive) { + throw new ValidationError("User account is inactive"); + } - if (!userData.confirmed) { - throw new ValidationError("User account is not confirmed"); - } + if (!userData.confirmed) { + throw new ValidationError("User account is not confirmed"); + } - // Create session entry first to get the ID - const sessionData: InsertUserSession = { - userId: userData.id, - sessionToken: "", // Will be updated with actual token - accessToken: "", // Will be updated with actual token - refreshToken: "", // Will be updated with actual token - ipAddress, - userAgent, - expiresAt: new Date(Date.now() + this.SESSION_DURATION), - lastUsedAt: new Date() - }; + // Create session entry first to get the ID + const sessionData: InsertUserSession = { + userId: userData.id, + sessionToken: "", // Will be updated with actual token + accessToken: "", // Will be updated with actual token + refreshToken: "", // Will be updated with actual token + ipAddress, + userAgent, + expiresAt: new Date(Date.now() + this.SESSION_DURATION), + lastUsedAt: new Date(), + }; - const [createdSession] = await db.insert(userSession).values(sessionData).returning(); + const [createdSession] = await db.insert(userSession).values(sessionData).returning(); - // Generate tokens with the actual session ID - const tokens = await generateTokens(userData, createdSession.id); + // Generate tokens with the actual session ID + const tokens = await generateTokens(userData, createdSession.id); - // Update session with actual tokens - const [updatedSession] = await db - .update(userSession) - .set({ - sessionToken: tokens.accessToken, // Use access token as session token - accessToken: tokens.accessToken, - refreshToken: tokens.refreshToken - }) - .where(eq(userSession.id, createdSession.id)) - .returning(); + // Update session with actual tokens + const [updatedSession] = await db + .update(userSession) + .set({ + sessionToken: tokens.accessToken, // Use access token as session token + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + }) + .where(eq(userSession.id, createdSession.id)) + .returning(); - await db.update(user).set({ lastLoginAt: new Date() }).where(eq(user.id, userId)); + await db.update(user).set({ lastLoginAt: new Date() }).where(eq(user.id, userId)); - logger.info(`Session created successfully for user: ${userId}`); + logger.info(`Session created successfully for user: ${userId}`); - return { - sessionToken: updatedSession.sessionToken, - accessToken: updatedSession.accessToken, - refreshToken: updatedSession.refreshToken, - user: userData, - expiresAt: updatedSession.expiresAt - }; - } + return { + sessionToken: updatedSession.sessionToken, + accessToken: updatedSession.accessToken, + refreshToken: updatedSession.refreshToken, + user: userData, + expiresAt: updatedSession.expiresAt, + }; + } - /** - * Validates an access token by checking if it exists in the database and is not expired - */ - static async validateTokenWithDB( - accessToken: string - ): Promise<{ user: SelectUser; sessionId: string; exp: Date } | null> { - logger.debug("Validating access token with database"); + /** + * Validates an access token by checking if it exists in the database and is not expired + */ + static async validateTokenWithDB( + accessToken: string, + ): Promise<{ user: SelectUser; sessionId: string; exp: Date } | null> { + logger.debug("Validating access token with database"); - try { - // First verify the JWT token structure - const tokenData = await verifyAccessToken(accessToken); - if (!tokenData) { - logger.debug("Access token is invalid or expired"); - return null; - } + try { + // First verify the JWT token structure + const tokenData = await verifyAccessToken(accessToken); + if (!tokenData) { + logger.debug("Access token is invalid or expired"); + return null; + } - // Check if session exists in database and is not expired - const sessions = await db - .select() - .from(userSession) - .innerJoin(user, eq(userSession.userId, user.id)) - .where( - and( - eq(userSession.id, tokenData.sessionId), - eq(userSession.accessToken, accessToken), - gt(userSession.expiresAt, new Date()) - ) - ) - .limit(1); + // Check if session exists in database and is not expired + const sessions = await db + .select() + .from(userSession) + .innerJoin(user, eq(userSession.userId, user.id)) + .where( + and( + eq(userSession.id, tokenData.sessionId), + eq(userSession.accessToken, accessToken), + gt(userSession.expiresAt, new Date()), + ), + ) + .limit(1); - if (sessions.length === 0) { - logger.debug("Session not found in database or expired"); - return null; - } + if (sessions.length === 0) { + logger.debug("Session not found in database or expired"); + return null; + } - const session = sessions[0]; + const session = sessions[0]; - if (!session.user.isActive) { - logger.debug("User account is inactive"); - return null; - } + if (!session.user.isActive) { + logger.debug("User account is inactive"); + return null; + } - if (!session.user.confirmed) { - logger.debug("User account is not confirmed"); - return null; - } + if (!session.user.confirmed) { + logger.debug("User account is not confirmed"); + return null; + } - // Update last used time - await db - .update(userSession) - .set({ lastUsedAt: new Date() }) - .where(eq(userSession.id, session.user_session.id)); + // Update last used time + await db + .update(userSession) + .set({ lastUsedAt: new Date() }) + .where(eq(userSession.id, session.user_session.id)); - logger.debug("Access token validated successfully"); + logger.debug("Access token validated successfully"); - return { - user: session.user, - exp: session.user_session.expiresAt, - sessionId: session.user_session.id - }; - } catch (error) { - logger.error("Error validating token with database:", { error: String(error) }); - return null; - } - } + return { + user: session.user, + exp: session.user_session.expiresAt, + sessionId: session.user_session.id, + }; + } catch (error) { + logger.error("Error validating token with database:", { error: String(error) }); + return null; + } + } - static async validateSession(sessionToken: string): Promise { - logger.debug(`Validating session: ${sessionToken}`); + static async validateSession(sessionToken: string): Promise { + logger.debug(`Validating session: ${sessionToken}`); - const sessions = await db - .select() - .from(userSession) - .innerJoin(user, eq(userSession.userId, user.id)) - .where(and(eq(userSession.sessionToken, sessionToken), gt(userSession.expiresAt, new Date()))) - .limit(1); + const sessions = await db + .select() + .from(userSession) + .innerJoin(user, eq(userSession.userId, user.id)) + .where(and(eq(userSession.sessionToken, sessionToken), gt(userSession.expiresAt, new Date()))) + .limit(1); - if (sessions.length === 0) { - logger.warn(`Session not found or expired: ${sessionToken}`); - return null; - } + if (sessions.length === 0) { + logger.warn(`Session not found or expired: ${sessionToken}`); + return null; + } - const session = sessions[0]; + const session = sessions[0]; - if (!session.user.isActive) { - logger.warn(`User account inactive for session: ${sessionToken}`); - return null; - } + if (!session.user.isActive) { + logger.warn(`User account inactive for session: ${sessionToken}`); + return null; + } - await db - .update(userSession) - .set({ lastUsedAt: new Date() }) - .where(eq(userSession.id, session.user_session.id)); + await db + .update(userSession) + .set({ lastUsedAt: new Date() }) + .where(eq(userSession.id, session.user_session.id)); - logger.debug(`Session validated successfully: ${sessionToken}`); + logger.debug(`Session validated successfully: ${sessionToken}`); - return { - sessionToken: session.user_session.sessionToken, - accessToken: session.user_session.accessToken, - refreshToken: session.user_session.refreshToken, - user: session.user, - expiresAt: session.user_session.expiresAt - }; - } + return { + sessionToken: session.user_session.sessionToken, + accessToken: session.user_session.accessToken, + refreshToken: session.user_session.refreshToken, + user: session.user, + expiresAt: session.user_session.expiresAt, + }; + } - static async refreshSession(refreshToken: string): Promise { - logger.debug("Refreshing session tokens"); + static async refreshSession(refreshToken: string): Promise { + logger.debug("Refreshing session tokens"); - const tokenData = await verifyRefreshToken(refreshToken); - if (!tokenData) { - logger.warn("Invalid refresh token"); - return null; - } + const tokenData = await verifyRefreshToken(refreshToken); + if (!tokenData) { + logger.warn("Invalid refresh token"); + return null; + } - const sessions = await db - .select() - .from(userSession) - .innerJoin(user, eq(userSession.userId, user.id)) - .where( - and( - eq(userSession.id, tokenData.sessionId), - eq(userSession.refreshToken, refreshToken), - gt(userSession.expiresAt, new Date()) - ) - ) - .limit(1); + const sessions = await db + .select() + .from(userSession) + .innerJoin(user, eq(userSession.userId, user.id)) + .where( + and( + eq(userSession.id, tokenData.sessionId), + eq(userSession.refreshToken, refreshToken), + gt(userSession.expiresAt, new Date()), + ), + ) + .limit(1); - if (sessions.length === 0) { - logger.warn("Session not found for refresh token"); - return null; - } + if (sessions.length === 0) { + logger.warn("Session not found for refresh token"); + return null; + } - const session = sessions[0]; + const session = sessions[0]; - if (!session.user.isActive) { - logger.warn("User account inactive for refresh"); - return null; - } + if (!session.user.isActive) { + logger.warn("User account inactive for refresh"); + return null; + } - const newTokens = await generateTokens(session.user, session.user_session.id); - const newExpiresAt = new Date(Date.now() + this.SESSION_DURATION); + const newTokens = await generateTokens(session.user, session.user_session.id); + const newExpiresAt = new Date(Date.now() + this.SESSION_DURATION); - await db - .update(userSession) - .set({ - accessToken: newTokens.accessToken, - refreshToken: newTokens.refreshToken, - expiresAt: newExpiresAt, - lastUsedAt: new Date() - }) - .where(eq(userSession.id, session.user_session.id)); + await db + .update(userSession) + .set({ + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + expiresAt: newExpiresAt, + lastUsedAt: new Date(), + }) + .where(eq(userSession.id, session.user_session.id)); - logger.info("Session tokens refreshed successfully"); + logger.info("Session tokens refreshed successfully"); - return { - accessToken: newTokens.accessToken, - refreshToken: newTokens.refreshToken, - expiresAt: newExpiresAt - }; - } + return { + accessToken: newTokens.accessToken, + refreshToken: newTokens.refreshToken, + expiresAt: newExpiresAt, + }; + } - static async logout(sessionToken: string): Promise { - logger.info(`Logging out session: ${sessionToken}`); + static async logout(sessionToken: string): Promise { + logger.info(`Logging out session: ${sessionToken}`); - await db.delete(userSession).where(eq(userSession.sessionToken, sessionToken)); + await db.delete(userSession).where(eq(userSession.sessionToken, sessionToken)); - logger.info(`Session logged out successfully: ${sessionToken}`); - } + logger.info(`Session logged out successfully: ${sessionToken}`); + } - static async logoutAllSessions(userId: string): Promise { - logger.info(`Logging out all sessions for user: ${userId}`); + static async logoutAllSessions(userId: string): Promise { + logger.info(`Logging out all sessions for user: ${userId}`); - await db.delete(userSession).where(eq(userSession.userId, userId)); + await db.delete(userSession).where(eq(userSession.userId, userId)); - logger.info(`All sessions logged out for user: ${userId}`); - } + logger.info(`All sessions logged out for user: ${userId}`); + } - static async cleanupExpiredSessions(): Promise { - logger.info("Cleaning up expired sessions"); + static async cleanupExpiredSessions(): Promise { + logger.info("Cleaning up expired sessions"); - await db.delete(userSession).where(lt(userSession.expiresAt, new Date())); + await db.delete(userSession).where(lt(userSession.expiresAt, new Date())); - logger.info("Expired sessions cleaned up"); - } + logger.info("Expired sessions cleaned up"); + } - static async getActiveSessions(userId: string): Promise { - logger.debug(`Getting active sessions for user: ${userId}`); + static async getActiveSessions(userId: string): Promise { + logger.debug(`Getting active sessions for user: ${userId}`); - const sessions = await db - .select() - .from(userSession) - .where(and(eq(userSession.userId, userId), gt(userSession.expiresAt, new Date()))) - .orderBy(userSession.lastUsedAt); + const sessions = await db + .select() + .from(userSession) + .where(and(eq(userSession.userId, userId), gt(userSession.expiresAt, new Date()))) + .orderBy(userSession.lastUsedAt); - return sessions; - } + return sessions; + } - static async getUserFromSession(sessionToken: string): Promise { - const sessionData = await this.validateSession(sessionToken); - return sessionData ? sessionData.user : null; - } + static async getUserFromSession(sessionToken: string): Promise { + const sessionData = await this.validateSession(sessionToken); + return sessionData ? sessionData.user : null; + } - static async revokeSession(sessionId: string): Promise { - logger.info(`Revoking session: ${sessionId}`); + static async revokeSession(sessionId: string): Promise { + logger.info(`Revoking session: ${sessionId}`); - await db.delete(userSession).where(eq(userSession.id, sessionId)); + await db.delete(userSession).where(eq(userSession.id, sessionId)); - logger.info(`Session revoked: ${sessionId}`); - } + logger.info(`Session revoked: ${sessionId}`); + } } diff --git a/src/lib/server/auth/webauthn-service.ts b/src/lib/server/auth/webauthn-service.ts index 97aa2a8..020173d 100644 --- a/src/lib/server/auth/webauthn-service.ts +++ b/src/lib/server/auth/webauthn-service.ts @@ -7,392 +7,392 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("WebAuthnService"); export interface WebAuthnCredential { - id: string; - response: { - authenticatorData: string; - signature: string; - userHandle?: string; - clientDataJSON: string; - }; + id: string; + response: { + authenticatorData: string; + signature: string; + userHandle?: string; + clientDataJSON: string; + }; } export interface WebAuthnVerificationResult { - verified: boolean; - userId?: string; - newCounter?: number; - passkeyId?: string; + verified: boolean; + userId?: string; + newCounter?: number; + passkeyId?: string; } export class WebAuthnService { - /** - * Verify a WebAuthn authentication assertion - * @param credential - The WebAuthn credential from the client - * @param challengeFromSession - The challenge that was sent to the client (should be stored in session) - * @returns Verification result with user ID if successful - */ - static async verifyAuthentication( - credential: WebAuthnCredential, - challengeFromSession: string - ): Promise { - try { - logger.debug("Verifying WebAuthn authentication", { - credentialId: credential.id, - hasChallenge: !!challengeFromSession - }); + /** + * Verify a WebAuthn authentication assertion + * @param credential - The WebAuthn credential from the client + * @param challengeFromSession - The challenge that was sent to the client (should be stored in session) + * @returns Verification result with user ID if successful + */ + static async verifyAuthentication( + credential: WebAuthnCredential, + challengeFromSession: string, + ): Promise { + try { + logger.debug("Verifying WebAuthn authentication", { + credentialId: credential.id, + hasChallenge: !!challengeFromSession, + }); - // Get the passkey from database - const passkeyResults = await db - .select() - .from(userPasskey) - .where(eq(userPasskey.id, credential.id)) - .limit(1); + // Get the passkey from database + const passkeyResults = await db + .select() + .from(userPasskey) + .where(eq(userPasskey.id, credential.id)) + .limit(1); - if (passkeyResults.length === 0) { - logger.warn("Passkey not found", { credentialId: credential.id }); - return { verified: false }; - } + if (passkeyResults.length === 0) { + logger.warn("Passkey not found", { credentialId: credential.id }); + return { verified: false }; + } - const passkey = passkeyResults[0]; + const passkey = passkeyResults[0]; - // Parse client data JSON - const clientDataJSON = JSON.parse( - Buffer.from(credential.response.clientDataJSON, "base64").toString() - ); + // Parse client data JSON + const clientDataJSON = JSON.parse( + Buffer.from(credential.response.clientDataJSON, "base64").toString(), + ); - // Verify the challenge (convert base64url to base64 for comparison) - const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/"); - const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/"); + // Verify the challenge (convert base64url to base64 for comparison) + const expectedChallenge = challengeFromSession.replace(/-/g, "+").replace(/_/g, "/"); + const receivedChallenge = clientDataJSON.challenge.replace(/-/g, "+").replace(/_/g, "/"); - if (expectedChallenge !== receivedChallenge) { - logger.warn("Challenge mismatch", { - credentialId: credential.id, - expectedChallenge, - receivedChallenge - }); - return { verified: false }; - } + if (expectedChallenge !== receivedChallenge) { + logger.warn("Challenge mismatch", { + credentialId: credential.id, + expectedChallenge, + receivedChallenge, + }); + return { verified: false }; + } - // Verify the origin matches expected origin (critical for security) - const allowedOrigins = WebAuthnService.getAllowedOrigins(); - if (!allowedOrigins.includes(clientDataJSON.origin)) { - logger.warn("Origin verification failed", { - credentialId: credential.id, - receivedOrigin: clientDataJSON.origin, - allowedOrigins - }); - return { verified: false }; - } + // Verify the origin matches expected origin (critical for security) + const allowedOrigins = WebAuthnService.getAllowedOrigins(); + if (!allowedOrigins.includes(clientDataJSON.origin)) { + logger.warn("Origin verification failed", { + credentialId: credential.id, + receivedOrigin: clientDataJSON.origin, + allowedOrigins, + }); + return { verified: false }; + } - logger.debug("Origin verification successful", { - origin: clientDataJSON.origin - }); + logger.debug("Origin verification successful", { + origin: clientDataJSON.origin, + }); - // Parse authenticator data - const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); + // Parse authenticator data + const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); - // Parse authenticator data for detailed debugging - const parsedAuthData = WebAuthnService.parseAuthenticatorData(authenticatorDataBuffer); + // Parse authenticator data for detailed debugging + const parsedAuthData = WebAuthnService.parseAuthenticatorData(authenticatorDataBuffer); - logger.debug("Authenticator data analysis", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length, - bufferHex: authenticatorDataBuffer.toString("hex"), - parsed: parsedAuthData - }); + logger.debug("Authenticator data analysis", { + credentialId: credential.id, + bufferLength: authenticatorDataBuffer.length, + bufferHex: authenticatorDataBuffer.toString("hex"), + parsed: parsedAuthData, + }); - // Extract counter from authenticator data - // Format: rpIdHash(32) + flags(1) + counter(4) + attestedCredentialData(variable) - // Counter is at bytes 33-36 (0-indexed) - if (authenticatorDataBuffer.length < 37) { - logger.warn("Authenticator data too short for counter extraction", { - credentialId: credential.id, - bufferLength: authenticatorDataBuffer.length - }); - return { verified: false }; - } + // Extract counter from authenticator data + // Format: rpIdHash(32) + flags(1) + counter(4) + attestedCredentialData(variable) + // Counter is at bytes 33-36 (0-indexed) + if (authenticatorDataBuffer.length < 37) { + logger.warn("Authenticator data too short for counter extraction", { + credentialId: credential.id, + bufferLength: authenticatorDataBuffer.length, + }); + return { verified: false }; + } - const newCounter = authenticatorDataBuffer.readUInt32BE(33); + const newCounter = authenticatorDataBuffer.readUInt32BE(33); - logger.debug("Counter extraction", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter, - counterBytes: authenticatorDataBuffer.subarray(33, 37).toString("hex") - }); + logger.debug("Counter extraction", { + credentialId: credential.id, + storedCounter: passkey.counter, + newCounter, + counterBytes: authenticatorDataBuffer.subarray(33, 37).toString("hex"), + }); - // Some authenticators (especially software-based ones) always return 0 - // In that case, we skip counter verification but log it - if (newCounter === 0 && passkey.counter === 0) { - logger.info("Authenticator uses zero counter - skipping counter verification", { - credentialId: credential.id - }); - } else if (newCounter <= passkey.counter) { - logger.warn("Counter verification failed - possible replay attack", { - credentialId: credential.id, - storedCounter: passkey.counter, - newCounter - }); - return { verified: false }; - } + // Some authenticators (especially software-based ones) always return 0 + // In that case, we skip counter verification but log it + if (newCounter === 0 && passkey.counter === 0) { + logger.info("Authenticator uses zero counter - skipping counter verification", { + credentialId: credential.id, + }); + } else if (newCounter <= passkey.counter) { + logger.warn("Counter verification failed - possible replay attack", { + credentialId: credential.id, + storedCounter: passkey.counter, + newCounter, + }); + return { verified: false }; + } - // Create the data to be signed - const clientDataHash = createHash("sha256") - .update(Buffer.from(credential.response.clientDataJSON, "base64")) - .digest(); + // Create the data to be signed + const clientDataHash = createHash("sha256") + .update(Buffer.from(credential.response.clientDataJSON, "base64")) + .digest(); - const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]); + const signedData = Buffer.concat([authenticatorDataBuffer, clientDataHash]); - // Verify the signature - const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64"); - const signatureBuffer = Buffer.from(credential.response.signature, "base64"); + // Verify the signature + const publicKeyBuffer = Buffer.from(passkey.publicKey, "base64"); + const signatureBuffer = Buffer.from(credential.response.signature, "base64"); - const isSignatureValid = await this.verifySignature( - publicKeyBuffer, - signedData, - signatureBuffer - ); + const isSignatureValid = await this.verifySignature( + publicKeyBuffer, + signedData, + signatureBuffer, + ); - if (!isSignatureValid) { - logger.warn("Signature verification failed", { credentialId: credential.id }); - return { verified: false }; - } + if (!isSignatureValid) { + logger.warn("Signature verification failed", { credentialId: credential.id }); + return { verified: false }; + } - // Update the counter in the database to prevent replay attacks - // Only update if the authenticator provides a non-zero counter - if (newCounter > 0) { - await db - .update(userPasskey) - .set({ counter: newCounter }) - .where(eq(userPasskey.id, passkey.id)); + // Update the counter in the database to prevent replay attacks + // Only update if the authenticator provides a non-zero counter + if (newCounter > 0) { + await db + .update(userPasskey) + .set({ counter: newCounter }) + .where(eq(userPasskey.id, passkey.id)); - logger.debug("Counter updated in database", { - credentialId: credential.id, - newCounter - }); - } else { - logger.debug("Counter update skipped (zero counter authenticator)", { - credentialId: credential.id - }); - } + logger.debug("Counter updated in database", { + credentialId: credential.id, + newCounter, + }); + } else { + logger.debug("Counter update skipped (zero counter authenticator)", { + credentialId: credential.id, + }); + } - logger.debug("WebAuthn authentication successful", { - credentialId: credential.id, - userId: passkey.userId, - newCounter, - counterUpdated: true - }); + logger.debug("WebAuthn authentication successful", { + credentialId: credential.id, + userId: passkey.userId, + newCounter, + counterUpdated: true, + }); - return { - verified: true, - userId: passkey.userId, - newCounter, - passkeyId: passkey.id - }; - } catch (error) { - logger.error("WebAuthn verification error", { - error: String(error), - credentialId: credential.id - }); - return { verified: false }; - } - } + return { + verified: true, + userId: passkey.userId, + newCounter, + passkeyId: passkey.id, + }; + } catch (error) { + logger.error("WebAuthn verification error", { + error: String(error), + credentialId: credential.id, + }); + return { verified: false }; + } + } - /** - * Verify a signature using the stored public key - * This is a simplified implementation - in production, you should use a proper WebAuthn library - * like @simplewebauthn/server for complete verification - */ - private static async verifySignature( - publicKey: Buffer, - signedData: Buffer, - signature: Buffer - ): Promise { - try { - const crypto = await import("node:crypto"); + /** + * Verify a signature using the stored public key + * This is a simplified implementation - in production, you should use a proper WebAuthn library + * like @simplewebauthn/server for complete verification + */ + private static async verifySignature( + publicKey: Buffer, + signedData: Buffer, + signature: Buffer, + ): Promise { + try { + const crypto = await import("node:crypto"); - // This is a simplified implementation - // In production, you should use a proper WebAuthn library that handles: - // - Different key formats (COSE, etc.) - // - Different signature algorithms - // - Proper ASN.1 parsing - // - Certificate chain validation + // This is a simplified implementation + // In production, you should use a proper WebAuthn library that handles: + // - Different key formats (COSE, etc.) + // - Different signature algorithms + // - Proper ASN.1 parsing + // - Certificate chain validation - // For now, we'll assume ES256 (ECDSA P-256 with SHA-256) - // and that the public key is in the correct format + // For now, we'll assume ES256 (ECDSA P-256 with SHA-256) + // and that the public key is in the correct format - const verify = crypto.createVerify("SHA256"); - verify.update(signedData); - verify.end(); + const verify = crypto.createVerify("SHA256"); + verify.update(signedData); + verify.end(); - // This is a placeholder - proper implementation would: - // 1. Parse the COSE key format - // 2. Convert to the correct format for Node.js crypto - // 3. Handle different algorithms properly + // This is a placeholder - proper implementation would: + // 1. Parse the COSE key format + // 2. Convert to the correct format for Node.js crypto + // 3. Handle different algorithms properly - logger.debug("Signature verification (simplified implementation)", { - publicKeyLength: publicKey.length, - signatureLength: signature.length, - signedDataLength: signedData.length - }); + logger.debug("Signature verification (simplified implementation)", { + publicKeyLength: publicKey.length, + signatureLength: signature.length, + signedDataLength: signedData.length, + }); - // For development, we'll return true if all data is present - // TODO: Replace with proper signature verification - return publicKey.length > 0 && signature.length > 0 && signedData.length > 0; - } catch (error) { - logger.error("Signature verification error", { error: String(error) }); - return false; - } - } + // For development, we'll return true if all data is present + // TODO: Replace with proper signature verification + return publicKey.length > 0 && signature.length > 0 && signedData.length > 0; + } catch (error) { + logger.error("Signature verification error", { error: String(error) }); + return false; + } + } - /** - * Generate a random challenge for WebAuthn authentication - * This should be stored in the session and used for verification - */ - static generateChallenge(): string { - return randomBytes(32).toString("base64url"); - } + /** + * Generate a random challenge for WebAuthn authentication + * This should be stored in the session and used for verification + */ + static generateChallenge(): string { + return randomBytes(32).toString("base64url"); + } - /** - * Update the counter for a passkey after successful authentication - */ - static async updatePasskeyCounter(passkeyId: string, newCounter: number): Promise { - await db - .update(userPasskey) - .set({ - counter: newCounter, - lastUsedAt: new Date(), - updatedAt: new Date() - }) - .where(eq(userPasskey.id, passkeyId)); + /** + * Update the counter for a passkey after successful authentication + */ + static async updatePasskeyCounter(passkeyId: string, newCounter: number): Promise { + await db + .update(userPasskey) + .set({ + counter: newCounter, + lastUsedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(userPasskey.id, passkeyId)); - logger.debug("Passkey counter updated", { passkeyId, newCounter }); - } + logger.debug("Passkey counter updated", { passkeyId, newCounter }); + } - /** - * Get all passkeys for a user - */ - static async getUserPasskeys(userId: string) { - return await db.select().from(userPasskey).where(eq(userPasskey.userId, userId)); - } + /** + * Get all passkeys for a user + */ + static async getUserPasskeys(userId: string) { + return await db.select().from(userPasskey).where(eq(userPasskey.userId, userId)); + } - /** - * Get allowed origins for WebAuthn verification - * Uses SERVER_DOMAIN in production, localhost variants in development - */ - private static getAllowedOrigins(): string[] { - const origins: string[] = []; + /** + * Get allowed origins for WebAuthn verification + * Uses SERVER_DOMAIN in production, localhost variants in development + */ + private static getAllowedOrigins(): string[] { + const origins: string[] = []; - // Check if we're in development (NODE_ENV or presence of dev indicators) - const isDevelopment = - process.env.NODE_ENV === "development" || - process.env.NODE_ENV === "dev" || - !process.env.SERVER_DOMAIN; + // Check if we're in development (NODE_ENV or presence of dev indicators) + const isDevelopment = + process.env.NODE_ENV === "development" || + process.env.NODE_ENV === "dev" || + !process.env.SERVER_DOMAIN; - if (isDevelopment) { - // Development origins - origins.push( - "http://localhost:5173", - "http://127.0.0.1:5173", - "http://localhost:4173", - "http://127.0.0.1:4173" - ); - } else { - // Production: Use SERVER_DOMAIN - const serverDomain = process.env.SERVER_DOMAIN; - if (serverDomain) { - // Add main domain with HTTPS - origins.push(`https://${serverDomain}`); + if (isDevelopment) { + // Development origins + origins.push( + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173", + ); + } else { + // Production: Use SERVER_DOMAIN + const serverDomain = process.env.SERVER_DOMAIN; + if (serverDomain) { + // Add main domain with HTTPS + origins.push(`https://${serverDomain}`); - // Add www variant if it doesn't already start with www - if (!serverDomain.startsWith("www.")) { - origins.push(`https://www.${serverDomain}`); - } + // Add www variant if it doesn't already start with www + if (!serverDomain.startsWith("www.")) { + origins.push(`https://www.${serverDomain}`); + } - // TODO: Webauthn does not support wildcard domains. We'll need another solution for that. - } - } + // TODO: Webauthn does not support wildcard domains. We'll need another solution for that. + } + } - // Allow manual override via WEBAUTHN_ALLOWED_ORIGINS - const envOrigins = process.env.WEBAUTHN_ALLOWED_ORIGINS; - if (envOrigins) { - origins.push(...envOrigins.split(",").map((o) => o.trim())); - } + // Allow manual override via WEBAUTHN_ALLOWED_ORIGINS + const envOrigins = process.env.WEBAUTHN_ALLOWED_ORIGINS; + if (envOrigins) { + origins.push(...envOrigins.split(",").map((o) => o.trim())); + } - // Ensure we have at least one allowed origin - if (origins.length === 0) { - logger.error("No WebAuthn allowed origins configured - this is a security risk!", { - NODE_ENV: process.env.NODE_ENV, - SERVER_DOMAIN: process.env.SERVER_DOMAIN, - WEBAUTHN_ALLOWED_ORIGINS: process.env.WEBAUTHN_ALLOWED_ORIGINS - }); - // Fallback to localhost in development only - if (isDevelopment) { - origins.push("http://localhost:5173"); - } - } + // Ensure we have at least one allowed origin + if (origins.length === 0) { + logger.error("No WebAuthn allowed origins configured - this is a security risk!", { + NODE_ENV: process.env.NODE_ENV, + SERVER_DOMAIN: process.env.SERVER_DOMAIN, + WEBAUTHN_ALLOWED_ORIGINS: process.env.WEBAUTHN_ALLOWED_ORIGINS, + }); + // Fallback to localhost in development only + if (isDevelopment) { + origins.push("http://localhost:5173"); + } + } - logger.debug("WebAuthn allowed origins configured", { - origins, - isDevelopment, - serverDomain: process.env.SERVER_DOMAIN - }); + logger.debug("WebAuthn allowed origins configured", { + origins, + isDevelopment, + serverDomain: process.env.SERVER_DOMAIN, + }); - return origins; - } + return origins; + } - /** - * Parse authenticator data structure for debugging - */ - private static parseAuthenticatorData(authenticatorDataBuffer: Buffer) { - if (authenticatorDataBuffer.length < 37) { - return { error: "Buffer too short" }; - } + /** + * Parse authenticator data structure for debugging + */ + private static parseAuthenticatorData(authenticatorDataBuffer: Buffer) { + if (authenticatorDataBuffer.length < 37) { + return { error: "Buffer too short" }; + } - const rpIdHash = authenticatorDataBuffer.subarray(0, 32); - const flags = authenticatorDataBuffer.readUInt8(32); - const counter = authenticatorDataBuffer.readUInt32BE(33); + const rpIdHash = authenticatorDataBuffer.subarray(0, 32); + const flags = authenticatorDataBuffer.readUInt8(32); + const counter = authenticatorDataBuffer.readUInt32BE(33); - // Parse flags - const userPresent = !!(flags & 0x01); - const userVerified = !!(flags & 0x04); - const attestedCredentialDataIncluded = !!(flags & 0x40); - const extensionDataIncluded = !!(flags & 0x80); + // Parse flags + const userPresent = !!(flags & 0x01); + const userVerified = !!(flags & 0x04); + const attestedCredentialDataIncluded = !!(flags & 0x40); + const extensionDataIncluded = !!(flags & 0x80); - return { - rpIdHash: rpIdHash.toString("hex"), - flags: { - raw: flags, - userPresent, - userVerified, - attestedCredentialDataIncluded, - extensionDataIncluded - }, - counter, - totalLength: authenticatorDataBuffer.length - }; - } + return { + rpIdHash: rpIdHash.toString("hex"), + flags: { + raw: flags, + userPresent, + userVerified, + attestedCredentialDataIncluded, + extensionDataIncluded, + }, + counter, + totalLength: authenticatorDataBuffer.length, + }; + } - /** - * Extract counter from WebAuthn credential response - * Used during registration to get the initial counter value - */ - static extractCounterFromCredential(credential: { - response: { - authenticatorData: string; - }; - }): number { - try { - // Parse authenticator data - const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); + /** + * Extract counter from WebAuthn credential response + * Used during registration to get the initial counter value + */ + static extractCounterFromCredential(credential: { + response: { + authenticatorData: string; + }; + }): number { + try { + // Parse authenticator data + const authenticatorDataBuffer = Buffer.from(credential.response.authenticatorData, "base64"); - // Extract counter from authenticator data (bytes 33-36) - const counter = authenticatorDataBuffer.readUInt32BE(33); + // Extract counter from authenticator data (bytes 33-36) + const counter = authenticatorDataBuffer.readUInt32BE(33); - logger.debug("Counter extracted from credential", { counter }); - return counter; - } catch (error) { - logger.error("Failed to extract counter from credential", { error: String(error) }); - return 0; // Fallback to 0 if extraction fails - } - } + logger.debug("Counter extracted from credential", { counter }); + return counter; + } catch (error) { + logger.error("Failed to extract counter from credential", { error: String(error) }); + return 0; // Fallback to 0 if extraction fails + } + } } diff --git a/src/lib/server/db/base.ts b/src/lib/server/db/base.ts index 739579f..4e1e551 100644 --- a/src/lib/server/db/base.ts +++ b/src/lib/server/db/base.ts @@ -5,7 +5,7 @@ import { customType } from "drizzle-orm/pg-core"; * Used for storing encrypted data, images, and other binary content */ export const bytea = customType<{ data: Buffer; driverData: Buffer }>({ - dataType() { - return "bytea"; - } + dataType() { + return "bytea"; + }, }); diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index 8d3b050..8c531c7 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -1,14 +1,14 @@ import type { InferInsertModel, InferSelectModel } from "drizzle-orm"; import { - pgTable, - uuid, - text, - pgEnum, - uniqueIndex, - boolean, - timestamp, - integer, - index + pgTable, + uuid, + text, + pgEnum, + uniqueIndex, + boolean, + timestamp, + integer, + index, } from "drizzle-orm/pg-core"; import { bytea } from "./base"; @@ -23,10 +23,10 @@ export const configTypeEnum = pgEnum("config_type", ["BOOLEAN", "NUMBER", "STRIN export const userRoleEnum = pgEnum("user_role", ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]); export const tenantSetupState = pgEnum("setup_state", [ - "NEW", // newly created - "SETTINGS_CREATED", // settings were reviewed - "AGENTS_SET_UP", // agents set up was triggered or skipped - "FIRST_CHANNEL_CREATED" // the first channel was set up + "NEW", // newly created + "SETTINGS_CREATED", // settings were reviewed + "AGENTS_SET_UP", // agents set up was triggered or skipped + "FIRST_CHANNEL_CREATED", // the first channel was set up ]); /** @@ -36,28 +36,28 @@ export const tenantSetupState = pgEnum("setup_state", [ * @table tenant */ export const tenant = pgTable( - "tenant", - { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Short name used as subdomain (e.g., 'acme' for acme.example.com) */ - shortName: text("short_name").notNull().unique(), - /** Full organization name displayed to users */ - longName: text("long_name").notNull(), - /** Optional description of the organization */ - description: text("description"), - /** Organization logo as binary data (PNG, JPEG, GIF, or WEBP) */ - logo: bytea("logo"), - /** Database connection string for this tenant's isolated database */ - databaseUrl: text("database_url").notNull(), - /** STate of tenant setup */ - setupState: tenantSetupState("setup_state").notNull().default("NEW"), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow() - }, - (table) => ({ - tenantDbUrlUnique: uniqueIndex("tenant_database_url_idx").on(table.databaseUrl) - }) + "tenant", + { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Short name used as subdomain (e.g., 'acme' for acme.example.com) */ + shortName: text("short_name").notNull().unique(), + /** Full organization name displayed to users */ + longName: text("long_name").notNull(), + /** Optional description of the organization */ + description: text("description"), + /** Organization logo as binary data (PNG, JPEG, GIF, or WEBP) */ + logo: bytea("logo"), + /** Database connection string for this tenant's isolated database */ + databaseUrl: text("database_url").notNull(), + /** STate of tenant setup */ + setupState: tenantSetupState("setup_state").notNull().default("NEW"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), + }, + (table) => ({ + tenantDbUrlUnique: uniqueIndex("tenant_database_url_idx").on(table.databaseUrl), + }), ); /** @@ -66,72 +66,72 @@ export const tenant = pgTable( * @table tenant_config */ export const tenantConfig = pgTable( - "tenant_config", - { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Foreign key to tenant */ - tenantId: uuid("tenant_id") - .notNull() - .references(() => tenant.id), - /** Configuration entry name/key */ - name: text("name").notNull(), - /** Data type of the configuration value */ - type: configTypeEnum("type").notNull(), - /** Configuration value stored as text (parsed based on type) */ - value: text("value").notNull(), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow() - }, - (table) => ({ - tenantConfigUnique: uniqueIndex("tenant_config_tenant_name_idx").on(table.tenantId, table.name) - }) + "tenant_config", + { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Foreign key to tenant */ + tenantId: uuid("tenant_id") + .notNull() + .references(() => tenant.id), + /** Configuration entry name/key */ + name: text("name").notNull(), + /** Data type of the configuration value */ + type: configTypeEnum("type").notNull(), + /** Configuration value stored as text (parsed based on type) */ + value: text("value").notNull(), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), + }, + (table) => ({ + tenantConfigUnique: uniqueIndex("tenant_config_tenant_name_idx").on(table.tenantId, table.name), + }), ); export const user = pgTable( - "user", - { - id: uuid("id").primaryKey().defaultRandom(), - email: text("email").notNull().unique(), - name: text("name").notNull(), - role: userRoleEnum("role").notNull().default("GLOBAL_ADMIN"), - tenantId: uuid("tenant_id").references(() => tenant.id), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), - lastLoginAt: timestamp("last_login_at"), - isActive: boolean("is_active").default(true), - confirmed: boolean("confirmed").default(false), - token: text("token"), - tokenValidUntil: timestamp("token_valid_until"), - /** Hashed passphrase for password authentication (optional, alternative to WebAuthn) */ - passphraseHash: text("passphrase_hash"), - /** Recovery passphrase for WebAuthn-only users (stored in plain text, shown only once) */ - recoveryPassphrase: text("recovery_passphrase"), - /** User's preferred language for emails and interface */ - language: text("language").notNull().default("de") - }, - (table) => ({ - emailUnique: uniqueIndex("user_email_idx").on(table.email) - }) + "user", + { + id: uuid("id").primaryKey().defaultRandom(), + email: text("email").notNull().unique(), + name: text("name").notNull(), + role: userRoleEnum("role").notNull().default("GLOBAL_ADMIN"), + tenantId: uuid("tenant_id").references(() => tenant.id), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), + lastLoginAt: timestamp("last_login_at"), + isActive: boolean("is_active").default(true), + confirmed: boolean("confirmed").default(false), + token: text("token"), + tokenValidUntil: timestamp("token_valid_until"), + /** Hashed passphrase for password authentication (optional, alternative to WebAuthn) */ + passphraseHash: text("passphrase_hash"), + /** Recovery passphrase for WebAuthn-only users (stored in plain text, shown only once) */ + recoveryPassphrase: text("recovery_passphrase"), + /** User's preferred language for emails and interface */ + language: text("language").notNull().default("de"), + }, + (table) => ({ + emailUnique: uniqueIndex("user_email_idx").on(table.email), + }), ); export const userPasskey = pgTable( - "user_passkey", - { - id: text("id").primaryKey(), // Credential ID as provided by WebAuthn - userId: uuid("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - publicKey: text("public_key").notNull(), // Base64 encoded - counter: integer("counter").notNull().default(0), - deviceName: text("device_name"), // "MacBook Pro", "YubiKey 5", etc. - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), - lastUsedAt: timestamp("last_used_at") - }, - (table) => ({ - userPasskeyIdx: index("user_passkey_user_idx").on(table.userId) - }) + "user_passkey", + { + id: text("id").primaryKey(), // Credential ID as provided by WebAuthn + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + publicKey: text("public_key").notNull(), // Base64 encoded + counter: integer("counter").notNull().default(0), + deviceName: text("device_name"), // "MacBook Pro", "YubiKey 5", etc. + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), + lastUsedAt: timestamp("last_used_at"), + }, + (table) => ({ + userPasskeyIdx: index("user_passkey_user_idx").on(table.userId), + }), ); /** @@ -141,26 +141,26 @@ export const userPasskey = pgTable( * @table user_session */ export const userSession = pgTable( - "user_session", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: uuid("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - sessionToken: text("session_token").notNull().unique(), - accessToken: text("access_token").notNull(), - refreshToken: text("refresh_token").notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), - expiresAt: timestamp("expires_at").notNull(), - lastUsedAt: timestamp("last_used_at").defaultNow() - }, - (table) => ({ - userSessionIdx: index("user_session_user_idx").on(table.userId), - sessionTokenIdx: uniqueIndex("user_session_token_idx").on(table.sessionToken) - }) + "user_session", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + sessionToken: text("session_token").notNull().unique(), + accessToken: text("access_token").notNull(), + refreshToken: text("refresh_token").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), + expiresAt: timestamp("expires_at").notNull(), + lastUsedAt: timestamp("last_used_at").defaultNow(), + }, + (table) => ({ + userSessionIdx: index("user_session_user_idx").on(table.userId), + sessionTokenIdx: uniqueIndex("user_session_token_idx").on(table.sessionToken), + }), ); /** @@ -170,44 +170,44 @@ export const userSession = pgTable( * @table user_invite */ export const userInvite = pgTable( - "user_invite", - { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Secure invite code sent to user (UUID v4) */ - inviteCode: uuid("invite_code").notNull().unique().defaultRandom(), - /** Email address of invited user */ - email: text("email").notNull(), - /** Name of invited user */ - name: text("name").notNull(), - /** Role to assign to user when they register */ - role: userRoleEnum("role").notNull(), - /** Tenant the user is being invited to */ - tenantId: uuid("tenant_id") - .notNull() - .references(() => tenant.id, { onDelete: "cascade" }), - /** User who sent the invitation */ - invitedBy: uuid("invited_by") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - /** Language preference for the invitation */ - language: text("language").notNull().default("de"), - /** Whether the invitation has been used */ - used: boolean("used").notNull().default(false), - /** When the invitation was used (if applicable) */ - usedAt: timestamp("used_at"), - /** User ID that was created from this invitation (if used) */ - createdUserId: uuid("created_user_id").references(() => user.id), - createdAt: timestamp("created_at").defaultNow(), - updatedAt: timestamp("updated_at").defaultNow(), - /** Invitation expires after 7 days */ - expiresAt: timestamp("expires_at").notNull() - }, - (table) => ({ - inviteCodeIdx: uniqueIndex("user_invite_code_idx").on(table.inviteCode), - inviteEmailIdx: index("user_invite_email_idx").on(table.email), - inviteTenantIdx: index("user_invite_tenant_idx").on(table.tenantId) - }) + "user_invite", + { + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Secure invite code sent to user (UUID v4) */ + inviteCode: uuid("invite_code").notNull().unique().defaultRandom(), + /** Email address of invited user */ + email: text("email").notNull(), + /** Name of invited user */ + name: text("name").notNull(), + /** Role to assign to user when they register */ + role: userRoleEnum("role").notNull(), + /** Tenant the user is being invited to */ + tenantId: uuid("tenant_id") + .notNull() + .references(() => tenant.id, { onDelete: "cascade" }), + /** User who sent the invitation */ + invitedBy: uuid("invited_by") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + /** Language preference for the invitation */ + language: text("language").notNull().default("de"), + /** Whether the invitation has been used */ + used: boolean("used").notNull().default(false), + /** When the invitation was used (if applicable) */ + usedAt: timestamp("used_at"), + /** User ID that was created from this invitation (if used) */ + createdUserId: uuid("created_user_id").references(() => user.id), + createdAt: timestamp("created_at").defaultNow(), + updatedAt: timestamp("updated_at").defaultNow(), + /** Invitation expires after 7 days */ + expiresAt: timestamp("expires_at").notNull(), + }, + (table) => ({ + inviteCodeIdx: uniqueIndex("user_invite_code_idx").on(table.inviteCode), + inviteEmailIdx: index("user_invite_email_idx").on(table.email), + inviteTenantIdx: index("user_invite_tenant_idx").on(table.tenantId), + }), ); /** diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index bfc931e..a05d5e1 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -21,39 +21,39 @@ const tenantDbCache = new Map>() * @returns Promise - The tenant's database connection */ export async function getTenantDb( - tenantId: string + tenantId: string, ): Promise> { - // Check cache first - if (tenantDbCache.has(tenantId)) { - return tenantDbCache.get(tenantId)!; - } + // Check cache first + if (tenantDbCache.has(tenantId)) { + return tenantDbCache.get(tenantId)!; + } - // Get tenant configuration from central database - const tenant = await centralDb - .select() - .from(centralSchema.tenant) - .where(eq(centralSchema.tenant.id, tenantId)) - .limit(1); + // Get tenant configuration from central database + 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`); - } + if (tenant.length === 0) { + throw new Error(`Tenant with ID ${tenantId} not found`); + } - // Create tenant-specific database connection - const tenantClient = postgres(tenant[0].databaseUrl); - const tenantDb = drizzle(tenantClient, { schema: tenantSchema }); + // Create tenant-specific database connection + const tenantClient = postgres(tenant[0].databaseUrl); + const tenantDb = drizzle(tenantClient, { schema: tenantSchema }); - // Cache the connection - tenantDbCache.set(tenantId, tenantDb); + // Cache the connection + tenantDbCache.set(tenantId, tenantDb); - return tenantDb; + return tenantDb; } /** * Clear cached database connections (useful for testing or tenant updates) */ export function clearTenantDbCache(): void { - tenantDbCache.clear(); + tenantDbCache.clear(); } // Legacy export for backward compatibility (points to central DB) diff --git a/src/lib/server/db/tenant-config.ts b/src/lib/server/db/tenant-config.ts index 5d73b7b..21cde50 100644 --- a/src/lib/server/db/tenant-config.ts +++ b/src/lib/server/db/tenant-config.ts @@ -3,73 +3,73 @@ import * as centralSchema from "./central-schema"; import { eq, sql } from "drizzle-orm"; export class TenantConfig { - private constructor(public readonly tenantId: string) {} - #configuration: Record = {}; + private constructor(public readonly tenantId: string) {} + #configuration: Record = {}; - public get configuration(): Record { - return this.#configuration; - } + public get configuration(): Record { + return this.#configuration; + } - public static async create(tenantId: string) { - const tenant = new TenantConfig(tenantId); - const configEntries = await tenant.#getTenantConfig(); - tenant.#configuration = configEntries.reduce( - (acc: Record, entry) => { - switch (entry.type) { - case "BOOLEAN": - acc[entry.name] = entry.value == "true"; - break; - case "NUMBER": - acc[entry.name] = parseFloat(entry.value); - break; - case "STRING": - acc[entry.name] = entry.value; - } - return acc; - }, - {} as Record - ); - return tenant; - } + public static async create(tenantId: string) { + const tenant = new TenantConfig(tenantId); + const configEntries = await tenant.#getTenantConfig(); + tenant.#configuration = configEntries.reduce( + (acc: Record, entry) => { + switch (entry.type) { + case "BOOLEAN": + acc[entry.name] = entry.value == "true"; + break; + case "NUMBER": + acc[entry.name] = parseFloat(entry.value); + break; + case "STRING": + acc[entry.name] = entry.value; + } + return acc; + }, + {} as Record, + ); + return tenant; + } - public async setConfig(key: string, value: boolean | number | string): Promise { - await centralDb - .insert(centralSchema.tenantConfig) - .values({ - tenantId: this.tenantId, - type: this.#getType(value), - name: key, - value: value.toString() - }) - .onConflictDoUpdate({ - target: [centralSchema.tenantConfig.tenantId, centralSchema.tenantConfig.name], - set: { - type: sql`excluded.type`, - value: sql`excluded.value` - } - }); - this.#configuration[key] = value; - } + public async setConfig(key: string, value: boolean | number | string): Promise { + await centralDb + .insert(centralSchema.tenantConfig) + .values({ + tenantId: this.tenantId, + type: this.#getType(value), + name: key, + value: value.toString(), + }) + .onConflictDoUpdate({ + target: [centralSchema.tenantConfig.tenantId, centralSchema.tenantConfig.name], + set: { + type: sql`excluded.type`, + value: sql`excluded.value`, + }, + }); + this.#configuration[key] = value; + } - #getType(value: boolean | number | string): "BOOLEAN" | "NUMBER" | "STRING" { - if (typeof value == "boolean") { - return "BOOLEAN"; - } - if (typeof value == "number") { - return "NUMBER"; - } - return "STRING"; - } + #getType(value: boolean | number | string): "BOOLEAN" | "NUMBER" | "STRING" { + if (typeof value == "boolean") { + return "BOOLEAN"; + } + if (typeof value == "number") { + return "NUMBER"; + } + return "STRING"; + } - /** - * Get tenant configuration entries by tenant ID - * @param tenantId - The tenant's UUID - * @returns Promise - Array of config entries - */ - async #getTenantConfig(): Promise { - return await centralDb - .select() - .from(centralSchema.tenantConfig) - .where(eq(centralSchema.tenantConfig.tenantId, this.tenantId)); - } + /** + * Get tenant configuration entries by tenant ID + * @param tenantId - The tenant's UUID + * @returns Promise - Array of config entries + */ + async #getTenantConfig(): Promise { + return await centralDb + .select() + .from(centralSchema.tenantConfig) + .where(eq(centralSchema.tenantConfig.tenantId, this.tenantId)); + } } diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index a45ae21..748b751 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -1,14 +1,14 @@ import type { InferSelectModel } from "drizzle-orm"; import { - pgTable, - boolean, - uuid, - text, - date, - pgEnum, - time, - integer, - json + pgTable, + boolean, + uuid, + text, + date, + pgEnum, + time, + integer, + json, } from "drizzle-orm/pg-core"; import { bytea } from "./base"; @@ -21,11 +21,11 @@ export const channelTypeEnum = pgEnum("channel_type", ["ROOM", "MACHINE", "PERSO /** Appointment status enumeration - tracks the lifecycle of appointments */ export const appointmentStatusEnum = pgEnum("appointment_status", [ - "NEW", - "CONFIRMED", - "HELD", - "REJECTED", - "NO_SHOW" + "NEW", + "CONFIRMED", + "HELD", + "REJECTED", + "NO_SHOW", ]); /** @@ -35,14 +35,14 @@ export const appointmentStatusEnum = pgEnum("appointment_status", [ * @table agent */ export const agent = pgTable("agent", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Agent's display name */ - name: text("name").notNull(), - /** Optional description of the agent's role or specialties */ - description: text("description"), - /** Optional logo/profile image for the agent (PNG, JPEG, GIF, or WEBP) */ - logo: bytea("logo") + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Agent's display name */ + name: text("name").notNull(), + /** Optional description of the agent's role or specialties */ + description: text("description"), + /** Optional logo/profile image for the agent (PNG, JPEG, GIF, or WEBP) */ + logo: bytea("logo"), }); /** @@ -53,22 +53,22 @@ export const agent = pgTable("agent", { * @table channel */ export const channel = pgTable("channel", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Channel display names in multiple languages (array of strings in same order as languages) */ - names: json("names").$type().notNull(), - /** Optional color for UI display (hex code) */ - color: text("color"), - /** Whether the channel is paused and does not offer nor accept new appointments */ - pause: boolean("paused").notNull().default(false), - /** Optional descriptions in multiple languages (array of strings in same order as languages) */ - descriptions: json("descriptions").$type(), - /** Active languages for this channel (array of language codes) */ - languages: json("languages").$type().notNull(), - /** Whether channel is publicly bookable or requires internal access */ - isPublic: boolean("is_public"), - /** Whether appointments must be explicitly confirmed by staff */ - requiresConfirmation: boolean("requires_confirmation") + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Channel display names in multiple languages (array of strings in same order as languages) */ + names: json("names").$type().notNull(), + /** Optional color for UI display (hex code) */ + color: text("color"), + /** Whether the channel is paused and does not offer nor accept new appointments */ + pause: boolean("paused").notNull().default(false), + /** Optional descriptions in multiple languages (array of strings in same order as languages) */ + descriptions: json("descriptions").$type(), + /** Active languages for this channel (array of language codes) */ + languages: json("languages").$type().notNull(), + /** Whether channel is publicly bookable or requires internal access */ + isPublic: boolean("is_public"), + /** Whether appointments must be explicitly confirmed by staff */ + requiresConfirmation: boolean("requires_confirmation"), }); /** @@ -79,16 +79,16 @@ export const channel = pgTable("channel", { * @table slotTemplate */ export const slotTemplate = pgTable("slotTemplate", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Bitmask for weekdays (1=Monday, 2=Tuesday, 4=Wednesday, etc.) */ - weekdays: integer("weekdays"), - /** Start time for the slot template */ - from: time("from").notNull(), - /** End time for the slot template */ - to: time("to").notNull(), - /** Duration of individual appointment slots in minutes */ - duration: integer("duration").notNull() + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Bitmask for weekdays (1=Monday, 2=Tuesday, 4=Wednesday, etc.) */ + weekdays: integer("weekdays"), + /** Start time for the slot template */ + from: time("from").notNull(), + /** End time for the slot template */ + to: time("to").notNull(), + /** Duration of individual appointment slots in minutes */ + duration: integer("duration").notNull(), }); /** @@ -98,14 +98,14 @@ export const slotTemplate = pgTable("slotTemplate", { * @table channelAgent */ export const channelAgent = pgTable("channel_agent", { - /** Foreign key to channel */ - channelId: uuid("channel_id") - .notNull() - .references(() => channel.id), - /** Foreign key to agent */ - agentId: uuid("agent_id") - .notNull() - .references(() => agent.id) + /** Foreign key to channel */ + channelId: uuid("channel_id") + .notNull() + .references(() => channel.id), + /** Foreign key to agent */ + agentId: uuid("agent_id") + .notNull() + .references(() => agent.id), }); /** @@ -115,14 +115,14 @@ export const channelAgent = pgTable("channel_agent", { * @table channelSlotTemplate */ export const channelSlotTemplate = pgTable("channel_slot_template", { - /** Foreign key to channel */ - channelId: uuid("channel_id") - .notNull() - .references(() => channel.id), - /** Foreign key to slot template */ - slotTemplateId: uuid("slot_template_id") - .notNull() - .references(() => slotTemplate.id) + /** Foreign key to channel */ + channelId: uuid("channel_id") + .notNull() + .references(() => channel.id), + /** Foreign key to slot template */ + slotTemplateId: uuid("slot_template_id") + .notNull() + .references(() => slotTemplate.id), }); /** @@ -132,18 +132,18 @@ export const channelSlotTemplate = pgTable("channel_slot_template", { * @table client */ export const client = pgTable("client", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Hash of client email for identification without storing plaintext */ - hashKey: text("hash_key").notNull().unique(), - /** Client's public key for end-to-end encryption */ - publicKey: text("public_key").notNull(), - /** Server-side share of client's private key for recovery */ - privateKeyShare: text("private_key_share").notNull(), - /** Client email address (optional for privacy) */ - email: text("email"), - /** Preferred language for communications (de/en) */ - language: text("language") + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Hash of client email for identification without storing plaintext */ + hashKey: text("hash_key").notNull().unique(), + /** Client's public key for end-to-end encryption */ + publicKey: text("public_key").notNull(), + /** Server-side share of client's private key for recovery */ + privateKeyShare: text("private_key_share").notNull(), + /** Client email address (optional for privacy) */ + email: text("email"), + /** Preferred language for communications (de/en) */ + language: text("language"), }); /** @@ -153,20 +153,20 @@ export const client = pgTable("client", { * @table staff */ export const staff = pgTable("staff", { - /** Primary key - unique identifier */ - id: uuid("id").primaryKey().defaultRandom(), - /** Hash of staff login name for identification */ - hashKey: text("hash_key").notNull().unique(), - /** Staff member's public key for end-to-end encryption */ - publicKey: text("public_key").notNull(), - /** Staff member's display name */ - name: text("name"), - /** Job title or position within the organization */ - position: text("position"), - /** Staff email address (required for notifications) */ - email: text("email").notNull(), - /** Preferred language for communications (de/en) */ - language: text("language") + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Hash of staff login name for identification */ + hashKey: text("hash_key").notNull().unique(), + /** Staff member's public key for end-to-end encryption */ + publicKey: text("public_key").notNull(), + /** Staff member's display name */ + name: text("name"), + /** Job title or position within the organization */ + position: text("position"), + /** Staff email address (required for notifications) */ + email: text("email").notNull(), + /** Preferred language for communications (de/en) */ + language: text("language"), }); /** @@ -176,26 +176,26 @@ export const staff = pgTable("staff", { * @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") - .notNull() - .references(() => client.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(), - /** When appointment data expires and can be auto-deleted */ - expiryDate: date("expiry_date").notNull(), - /** Appointment title/subject */ - title: text("title").notNull(), - /** Optional detailed description of the appointment */ - description: text("description"), - /** Current status of the appointment */ - status: appointmentStatusEnum("status").notNull().default("NEW") + /** Primary key - unique identifier */ + id: uuid("id").primaryKey().defaultRandom(), + /** Foreign key to client who booked the appointment */ + clientId: uuid("client_id") + .notNull() + .references(() => client.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(), + /** When appointment data expires and can be auto-deleted */ + expiryDate: date("expiry_date").notNull(), + /** Appointment title/subject */ + title: text("title").notNull(), + /** Optional detailed description of the appointment */ + description: text("description"), + /** Current status of the appointment */ + status: appointmentStatusEnum("status").notNull().default("NEW"), }); /** diff --git a/src/lib/server/db/tenant-service.ts b/src/lib/server/db/tenant-service.ts index c67a534..df7ad20 100644 --- a/src/lib/server/db/tenant-service.ts +++ b/src/lib/server/db/tenant-service.ts @@ -7,62 +7,62 @@ import * as tenantSchema from "./tenant-schema"; * Provides a clean interface for working with tenant data */ export class TenantService { - #db: Awaited> | null = null; - #config?: TenantConfig; + #db: Awaited> | null = null; + #config?: TenantConfig; - constructor(public readonly tenantId: string) { - this.tenantId = tenantId; - } + constructor(public readonly tenantId: string) { + this.tenantId = tenantId; + } - /** - * Get the tenant's database connection (cached) - */ - async getDb() { - if (!this.#db) { - this.#db = await getTenantDb(this.tenantId); - } - return this.#db; - } + /** + * Get the tenant's database connection (cached) + */ + async getDb() { + if (!this.#db) { + this.#db = await getTenantDb(this.tenantId); + } + return this.#db; + } - /** - * Get all clients for this tenant - */ - async getClients() { - const db = await this.getDb(); - return await db.select().from(tenantSchema.client); - } + /** + * Get all clients for this tenant + */ + async getClients() { + const db = await this.getDb(); + return await db.select().from(tenantSchema.client); + } - /** - * Get all staff for this tenant - */ - async getStaff() { - const db = await this.getDb(); - return await db.select().from(tenantSchema.staff); - } + /** + * Get all staff for this tenant + */ + async getStaff() { + const db = await this.getDb(); + return await db.select().from(tenantSchema.staff); + } - /** - * Get all channels for this tenant - */ - async getChannels() { - const db = await this.getDb(); - return await db.select().from(tenantSchema.channel); - } + /** + * Get all channels for this tenant + */ + async getChannels() { + const db = await this.getDb(); + return await db.select().from(tenantSchema.channel); + } - /** - * Get all appointments for this tenant - */ - async getAppointments() { - const db = await this.getDb(); - return await db.select().from(tenantSchema.appointment); - } + /** + * Get all appointments for this tenant + */ + async getAppointments() { + const db = await this.getDb(); + return await db.select().from(tenantSchema.appointment); + } - /** - * Get tenant configuration - */ - async getConfig() { - if (!this.#config) { - this.#config = await TenantConfig.create(this.tenantId); - } - return this.#config.configuration; - } + /** + * Get tenant configuration + */ + async getConfig() { + if (!this.#config) { + this.#config = await TenantConfig.create(this.tenantId); + } + return this.#config.configuration; + } } diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index a54b4d3..e9f1a14 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -2,34 +2,34 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; // Mock environment variables vi.mock("$env/dynamic/private", () => ({ - env: { - SMTP_HOST: "smtp.test.com", - SMTP_PORT: "587", - SMTP_SECURE: "false", - SMTP_USER: "test@example.com", - SMTP_PASS: "test-password", - SMTP_FROM_NAME: "Test App", - SMTP_FROM_EMAIL: "noreply@test.com" - } + env: { + SMTP_HOST: "smtp.test.com", + SMTP_PORT: "587", + SMTP_SECURE: "false", + SMTP_USER: "test@example.com", + SMTP_PASS: "test-password", + SMTP_FROM_NAME: "Test App", + SMTP_FROM_EMAIL: "noreply@test.com", + }, })); // Mock nodemailer const mockSendMail = vi.fn(); const mockVerify = vi.fn(); const mockTransporter = { - sendMail: mockSendMail, - verify: mockVerify + sendMail: mockSendMail, + verify: mockVerify, }; vi.mock("nodemailer", () => ({ - default: { - createTransport: vi.fn(() => mockTransporter) - } + default: { + createTransport: vi.fn(() => mockTransporter), + }, })); // Mock fs/promises vi.mock("fs/promises", () => ({ - readFile: vi.fn() + readFile: vi.fn(), })); // Import modules after mocking @@ -43,476 +43,476 @@ const mockReadFile = vi.mocked(readFile); const mockCreateTransporter = vi.mocked(nodemailer.createTransport); describe("Email System", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); - mockVerify.mockResolvedValue(true); - }); + beforeEach(() => { + vi.clearAllMocks(); + mockSendMail.mockResolvedValue({ messageId: "test-message-id" }); + mockVerify.mockResolvedValue(true); + }); - describe("Mailer", () => { - it("should send email successfully", async () => { - const recipient = { email: "test@example.com", name: "Test User" }; - const subject = "Test Subject"; - const htmlContent = "

    Test HTML

    "; - const textContent = "Test Text"; + describe("Mailer", () => { + it("should send email successfully", async () => { + const recipient = { email: "test@example.com", name: "Test User" }; + const subject = "Test Subject"; + const htmlContent = "

    Test HTML

    "; + const textContent = "Test Text"; - await sendEmail(recipient, subject, htmlContent, textContent); + await sendEmail(recipient, subject, htmlContent, textContent); - expect(mockCreateTransporter).toHaveBeenCalledWith({ - host: "smtp.test.com", - port: 587, - secure: false, - auth: { - user: "test@example.com", - pass: "test-password" - } - }); + expect(mockCreateTransporter).toHaveBeenCalledWith({ + host: "smtp.test.com", + port: 587, + secure: false, + auth: { + user: "test@example.com", + pass: "test-password", + }, + }); - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com" - }, - to: { - name: "Test User", - address: "test@example.com" - }, - subject: "Test Subject", - html: "

    Test HTML

    ", - text: "Test Text" - }); - }); + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com", + }, + to: { + name: "Test User", + address: "test@example.com", + }, + subject: "Test Subject", + html: "

    Test HTML

    ", + text: "Test Text", + }); + }); - it("should test email connection", async () => { - const result = await testEmailConnection(); - expect(result).toBe(true); - expect(mockVerify).toHaveBeenCalled(); - }); - }); + it("should test email connection", async () => { + const result = await testEmailConnection(); + expect(result).toBe(true); + expect(mockVerify).toHaveBeenCalled(); + }); + }); - describe("Template Engine", () => { - let templateEngine: EmailTemplateEngine; + describe("Template Engine", () => { + let templateEngine: EmailTemplateEngine; - beforeEach(() => { - templateEngine = new EmailTemplateEngine("test/templates"); - }); + beforeEach(() => { + templateEngine = new EmailTemplateEngine("test/templates"); + }); - it("should render template with variables", async () => { - const mockHtml = "

    Hello {{recipient.name}}

    "; - const mockText = "Hello {{recipient.name}}"; + it("should render template with variables", async () => { + const mockHtml = "

    Hello {{recipient.name}}

    "; + const mockText = "Hello {{recipient.name}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = await templateEngine.renderTemplate("user-created", { - recipient: { email: "test@example.com", name: "Test User" }, - subject: "Test Subject", - language: "de", - tenant: mockTenant - }); + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com", name: "Test User" }, + subject: "Test Subject", + language: "de", + tenant: mockTenant, + }); - expect(result.html).toBe("

    Hello Test User

    "); - expect(result.text).toBe("Hello Test User"); - expect(result.subject).toBe("Test Subject"); - }); + expect(result.html).toBe("

    Hello Test User

    "); + expect(result.text).toBe("Hello Test User"); + expect(result.subject).toBe("Test Subject"); + }); - it("should handle conditional blocks", async () => { - const mockHtml = "{{#if showButton}}{{/if}}"; - const mockText = "{{#if showButton}}Button available{{/if}}"; + it("should handle conditional blocks", async () => { + const mockHtml = "{{#if showButton}}{{/if}}"; + const mockText = "{{#if showButton}}Button available{{/if}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = await templateEngine.renderTemplate("user-created", { - recipient: { email: "test@example.com" }, - subject: "Test", - language: "de", - tenant: mockTenant, - showButton: true - }); + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com" }, + subject: "Test", + language: "de", + tenant: mockTenant, + showButton: true, + }); - expect(result.html).toBe(""); - expect(result.text).toBe("Button available"); - }); + expect(result.html).toBe(""); + expect(result.text).toBe("Button available"); + }); - it("should handle missing conditionals", async () => { - const mockHtml = "{{#if showButton}}{{/if}}"; - const mockText = "{{#if showButton}}Button available{{/if}}"; + it("should handle missing conditionals", async () => { + const mockHtml = "{{#if showButton}}{{/if}}"; + const mockText = "{{#if showButton}}Button available{{/if}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = await templateEngine.renderTemplate("user-created", { - recipient: { email: "test@example.com" }, - subject: "Test", - language: "de", - tenant: mockTenant, - showButton: false - }); + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com" }, + subject: "Test", + language: "de", + tenant: mockTenant, + showButton: false, + }); - expect(result.html).toBe(""); - expect(result.text).toBe(""); - }); + expect(result.html).toBe(""); + expect(result.text).toBe(""); + }); - it("should handle English language", async () => { - const mockHtml = "

    Welcome {{recipient.name}}

    "; - const mockText = "Welcome {{recipient.name}}"; + it("should handle English language", async () => { + const mockHtml = "

    Welcome {{recipient.name}}

    "; + const mockText = "Welcome {{recipient.name}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = await templateEngine.renderTemplate("user-created", { - recipient: { email: "test@example.com", name: "John Doe" }, - subject: "Welcome", - language: "en", - tenant: mockTenant - }); + const result = await templateEngine.renderTemplate("user-created", { + recipient: { email: "test@example.com", name: "John Doe" }, + subject: "Welcome", + language: "en", + tenant: mockTenant, + }); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("user-created.en.html"), - "utf-8" - ); - expect(result.html).toBe("

    Welcome John Doe

    "); - }); - }); + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("user-created.en.html"), + "utf-8", + ); + expect(result.html).toBe("

    Welcome John Doe

    "); + }); + }); - describe("Email Service", () => { - it("should send user created email in German", async () => { - const mockHtml = "

    Willkommen {{recipient.name}}

    "; - const mockText = "Willkommen {{recipient.name}}"; + describe("Email Service", () => { + it("should send user created email in German", async () => { + const mockHtml = "

    Willkommen {{recipient.name}}

    "; + const mockText = "Willkommen {{recipient.name}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - // Create a mock staff user with German language - const staffUser = { - id: "test-id", - hashKey: "test-hash", - publicKey: "test-key", - name: "Max Mustermann", - position: "Arzt", - email: "test@example.com", - language: "de" - }; - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; - const loginUrl = "https://app.example.com/login"; + // Create a mock staff user with German language + const staffUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + name: "Max Mustermann", + position: "Arzt", + email: "test@example.com", + language: "de", + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; + const loginUrl = "https://app.example.com/login"; - await sendUserCreatedEmail(staffUser, mockTenant, loginUrl); + await sendUserCreatedEmail(staffUser, mockTenant, loginUrl); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("user-created.de.html"), - "utf-8" - ); + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("user-created.de.html"), + "utf-8", + ); - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com" - }, - to: { - name: "Max Mustermann", - address: "test@example.com" - }, - subject: "Willkommen bei Open Reception", - html: "

    Willkommen Max Mustermann

    ", - text: "Willkommen Max Mustermann" - }); - }); + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com", + }, + to: { + name: "Max Mustermann", + address: "test@example.com", + }, + subject: "Willkommen bei Open Reception", + html: "

    Willkommen Max Mustermann

    ", + text: "Willkommen Max Mustermann", + }); + }); - it("should send user created email in English", async () => { - const mockHtml = "

    Welcome {{recipient.name}}

    "; - const mockText = "Welcome {{recipient.name}}"; + it("should send user created email in English", async () => { + const mockHtml = "

    Welcome {{recipient.name}}

    "; + const mockText = "Welcome {{recipient.name}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - // Create a mock staff user with English language - const staffUser = { - id: "test-id", - hashKey: "test-hash", - publicKey: "test-key", - name: "John Doe", - position: "Doctor", - email: "test@example.com", - language: "en" - }; - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; - const loginUrl = "https://app.example.com/login"; + // Create a mock staff user with English language + const staffUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + name: "John Doe", + position: "Doctor", + email: "test@example.com", + language: "en", + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; + const loginUrl = "https://app.example.com/login"; - await sendUserCreatedEmail(staffUser, mockTenant, loginUrl); + await sendUserCreatedEmail(staffUser, mockTenant, loginUrl); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("user-created.en.html"), - "utf-8" - ); + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("user-created.en.html"), + "utf-8", + ); - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com" - }, - to: { - name: "John Doe", - address: "test@example.com" - }, - subject: "Welcome to Open Reception", - html: "

    Welcome John Doe

    ", - text: "Welcome John Doe" - }); - }); + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com", + }, + to: { + name: "John Doe", + address: "test@example.com", + }, + subject: "Welcome to Open Reception", + html: "

    Welcome John Doe

    ", + text: "Welcome John Doe", + }); + }); - it("should handle template rendering errors", async () => { - mockReadFile.mockRejectedValue(new Error("Template not found")); + it("should handle template rendering errors", async () => { + mockReadFile.mockRejectedValue(new Error("Template not found")); - const recipient = { email: "test@example.com" }; - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const recipient = { email: "test@example.com" }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - await expect( - sendTemplatedEmail("user-created", recipient, "Test", "de", mockTenant, {}) - ).rejects.toThrow("Template not found"); - }); + await expect( + sendTemplatedEmail("user-created", recipient, "Test", "de", mockTenant, {}), + ).rejects.toThrow("Template not found"); + }); - it("should handle SMTP errors", async () => { - const mockHtml = "

    Test

    "; - const mockText = "Test"; + it("should handle SMTP errors", async () => { + const mockHtml = "

    Test

    "; + const mockText = "Test"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - mockSendMail.mockRejectedValueOnce(new Error("SMTP Error")); + mockSendMail.mockRejectedValueOnce(new Error("SMTP Error")); - const clientUser = { - id: "test-id", - hashKey: "test-hash", - publicKey: "test-key", - privateKeyShare: "test-share", - email: "test@example.com", - language: "de" - }; - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const clientUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + privateKeyShare: "test-share", + email: "test@example.com", + language: "de", + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - await expect( - sendUserCreatedEmail(clientUser, mockTenant, "https://example.com/login") - ).rejects.toThrow("Failed to send email to test@example.com"); - }); + await expect( + sendUserCreatedEmail(clientUser, mockTenant, "https://example.com/login"), + ).rejects.toThrow("Failed to send email to test@example.com"); + }); - it("should send confirmation email in German", async () => { - const mockHtml = - "

    Bestätigungscode: {{confirmationCode}}

    Gültig für {{expirationMinutes}} Minuten

    "; - const mockText = - "Bestätigungscode: {{confirmationCode}} - Gültig für {{expirationMinutes}} Minuten"; + it("should send confirmation email in German", async () => { + const mockHtml = + "

    Bestätigungscode: {{confirmationCode}}

    Gültig für {{expirationMinutes}} Minuten

    "; + const mockText = + "Bestätigungscode: {{confirmationCode}} - Gültig für {{expirationMinutes}} Minuten"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const staffUser = { - id: "test-id", - hashKey: "test-hash", - publicKey: "test-key", - name: "Max Mustermann", - position: "Arzt", - email: "test@example.com", - language: "de" - }; - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; - const confirmationCode = "ABC123"; - const expirationMinutes = 15; + const staffUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + name: "Max Mustermann", + position: "Arzt", + email: "test@example.com", + language: "de", + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; + const confirmationCode = "ABC123"; + const expirationMinutes = 15; - await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes); + await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("confirmation.de.html"), - "utf-8" - ); + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("confirmation.de.html"), + "utf-8", + ); - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com" - }, - to: { - name: "Max Mustermann", - address: "test@example.com" - }, - subject: "Registrierung bestätigen", - html: "

    Bestätigungscode: ABC123

    Gültig für 15 Minuten

    ", - text: "Bestätigungscode: ABC123 - Gültig für 15 Minuten" - }); - }); + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com", + }, + to: { + name: "Max Mustermann", + address: "test@example.com", + }, + subject: "Registrierung bestätigen", + html: "

    Bestätigungscode: ABC123

    Gültig für 15 Minuten

    ", + text: "Bestätigungscode: ABC123 - Gültig für 15 Minuten", + }); + }); - it("should send confirmation email in English", async () => { - const mockHtml = - "

    Confirmation code: {{confirmationCode}}

    Valid for {{expirationMinutes}} minutes

    "; - const mockText = - "Confirmation code: {{confirmationCode}} - Valid for {{expirationMinutes}} minutes"; + it("should send confirmation email in English", async () => { + const mockHtml = + "

    Confirmation code: {{confirmationCode}}

    Valid for {{expirationMinutes}} minutes

    "; + const mockText = + "Confirmation code: {{confirmationCode}} - Valid for {{expirationMinutes}} minutes"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const staffUser = { - id: "test-id", - hashKey: "test-hash", - publicKey: "test-key", - name: "John Doe", - position: "Doctor", - email: "test@example.com", - language: "en" - }; - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; - const confirmationCode = "XYZ789"; - const expirationMinutes = 10; + const staffUser = { + id: "test-id", + hashKey: "test-hash", + publicKey: "test-key", + name: "John Doe", + position: "Doctor", + email: "test@example.com", + language: "en", + }; + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; + const confirmationCode = "XYZ789"; + const expirationMinutes = 10; - await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes); + await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes); - expect(mockReadFile).toHaveBeenCalledWith( - expect.stringContaining("confirmation.en.html"), - "utf-8" - ); + expect(mockReadFile).toHaveBeenCalledWith( + expect.stringContaining("confirmation.en.html"), + "utf-8", + ); - expect(mockSendMail).toHaveBeenCalledWith({ - from: { - name: "Test App", - address: "noreply@test.com" - }, - to: { - name: "John Doe", - address: "test@example.com" - }, - subject: "Confirm Your Registration", - html: "

    Confirmation code: XYZ789

    Valid for 10 minutes

    ", - text: "Confirmation code: XYZ789 - Valid for 10 minutes" - }); - }); + expect(mockSendMail).toHaveBeenCalledWith({ + from: { + name: "Test App", + address: "noreply@test.com", + }, + to: { + name: "John Doe", + address: "test@example.com", + }, + subject: "Confirm Your Registration", + html: "

    Confirmation code: XYZ789

    Valid for 10 minutes

    ", + text: "Confirmation code: XYZ789 - Valid for 10 minutes", + }); + }); - it("should handle confirmation template rendering", async () => { - const mockHtml = "Code: {{confirmationCode}}"; - const mockText = "Code: {{confirmationCode}}"; + it("should handle confirmation template rendering", async () => { + const mockHtml = "Code: {{confirmationCode}}"; + const mockText = "Code: {{confirmationCode}}"; - mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); + mockReadFile.mockResolvedValueOnce(mockHtml).mockResolvedValueOnce(mockText); - const templateEngine = new EmailTemplateEngine("test/templates"); - const mockTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Organization", - description: null, - logo: null, - databaseUrl: "postgresql://test", - setupState: "FIRST_CHANNEL_CREATED" as const, - createdAt: new Date(), - updatedAt: new Date() - }; + const templateEngine = new EmailTemplateEngine("test/templates"); + const mockTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Organization", + description: null, + logo: null, + databaseUrl: "postgresql://test", + setupState: "FIRST_CHANNEL_CREATED" as const, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = await templateEngine.renderTemplate("confirmation", { - recipient: { email: "test@example.com", name: "Test User" }, - subject: "Test Confirmation", - language: "de", - tenant: mockTenant, - confirmationCode: "TEST123", - expirationMinutes: 15 - }); + const result = await templateEngine.renderTemplate("confirmation", { + recipient: { email: "test@example.com", name: "Test User" }, + subject: "Test Confirmation", + language: "de", + tenant: mockTenant, + confirmationCode: "TEST123", + expirationMinutes: 15, + }); - expect(result.html).toBe("Code: TEST123"); - expect(result.text).toBe("Code: TEST123"); - }); - }); + expect(result.html).toBe("Code: TEST123"); + expect(result.text).toBe("Code: TEST123"); + }); + }); }); diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index d76f5ad..03dd3c0 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -6,322 +6,322 @@ import type { SelectTenant } from "$lib/server/db/central-schema"; const mockEnv = vi.hoisted(() => ({ NODE_ENV: "development" })); vi.mock("$env/dynamic/private", () => ({ - env: mockEnv + env: mockEnv, })); describe("generateBaseUrl", () => { - beforeEach(() => { - // Reset NODE_ENV to development for each test - mockEnv.NODE_ENV = "development"; - }); + beforeEach(() => { + // Reset NODE_ENV to development for each test + mockEnv.NODE_ENV = "development"; + }); - afterEach(() => { - vi.clearAllMocks(); - }); + afterEach(() => { + vi.clearAllMocks(); + }); - describe("Development/Local Environment", () => { - it("should return localhost URL regardless of tenant", () => { - const requestUrl = new URL("http://localhost:5173"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - logo: null, - createdAt: new Date(), - updatedAt: new Date(), - description: "", - databaseUrl: "", - setupState: "NEW" - }; + describe("Development/Local Environment", () => { + it("should return localhost URL regardless of tenant", () => { + const requestUrl = new URL("http://localhost:5173"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + description: "", + databaseUrl: "", + setupState: "NEW", + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("http://localhost:5173"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://localhost:5173"); + }); - it("should return localhost URL for null tenant", () => { - const requestUrl = new URL("http://localhost:3000"); + it("should return localhost URL for null tenant", () => { + const requestUrl = new URL("http://localhost:3000"); - const result = generateBaseUrl(requestUrl, null); - expect(result).toBe("http://localhost:3000"); - }); + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("http://localhost:3000"); + }); - it("should preserve port for localhost", () => { - const requestUrl = new URL("http://localhost:8080"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "test", - longName: "Test Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should preserve port for localhost", () => { + const requestUrl = new URL("http://localhost:8080"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "test", + longName: "Test Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("http://localhost:8080"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://localhost:8080"); + }); - it("should handle 127.x.x.x addresses", () => { - const requestUrl = new URL("http://127.0.0.1:3000"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should handle 127.x.x.x addresses", () => { + const requestUrl = new URL("http://127.0.0.1:3000"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("http://127.0.0.1:3000"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://127.0.0.1:3000"); + }); - it("should handle 192.168.x.x addresses", () => { - const requestUrl = new URL("http://192.168.1.100:8080"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should handle 192.168.x.x addresses", () => { + const requestUrl = new URL("http://192.168.1.100:8080"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("http://192.168.1.100:8080"); - }); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://192.168.1.100:8080"); + }); + }); - describe("Production Environment", () => { - beforeEach(() => { - mockEnv.NODE_ENV = "production"; - }); + describe("Production Environment", () => { + beforeEach(() => { + mockEnv.NODE_ENV = "production"; + }); - it("should return main domain for null tenant", () => { - const requestUrl = new URL("https://example.com"); + it("should return main domain for null tenant", () => { + const requestUrl = new URL("https://example.com"); - const result = generateBaseUrl(requestUrl, null); - expect(result).toBe("https://example.com"); - }); + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("https://example.com"); + }); - it("should return main domain with port for null tenant", () => { - const requestUrl = new URL("https://example.com:8443"); + it("should return main domain with port for null tenant", () => { + const requestUrl = new URL("https://example.com:8443"); - const result = generateBaseUrl(requestUrl, null); - expect(result).toBe("https://example.com:8443"); - }); + const result = generateBaseUrl(requestUrl, null); + expect(result).toBe("https://example.com:8443"); + }); - it("should create subdomain URL for tenant on main domain", () => { - const requestUrl = new URL("https://example.com"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should create subdomain URL for tenant on main domain", () => { + const requestUrl = new URL("https://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://acme.example.com"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.example.com"); + }); - it("should create subdomain URL with port", () => { - const requestUrl = new URL("https://example.com:8443"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should create subdomain URL with port", () => { + const requestUrl = new URL("https://example.com:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://acme.example.com:8443"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.example.com:8443"); + }); - it("should replace existing subdomain with tenant shortName", () => { - const requestUrl = new URL("https://old-tenant.example.com"); - const tenant: SelectTenant = { - id: "tenant-2", - shortName: "new-tenant", - longName: "New Tenant Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should replace existing subdomain with tenant shortName", () => { + const requestUrl = new URL("https://old-tenant.example.com"); + const tenant: SelectTenant = { + id: "tenant-2", + shortName: "new-tenant", + longName: "New Tenant Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://new-tenant.example.com"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://new-tenant.example.com"); + }); - it("should replace existing subdomain with port", () => { - const requestUrl = new URL("https://old-tenant.example.com:8443"); - const tenant: SelectTenant = { - id: "tenant-2", - shortName: "new-tenant", - longName: "New Tenant Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should replace existing subdomain with port", () => { + const requestUrl = new URL("https://old-tenant.example.com:8443"); + const tenant: SelectTenant = { + id: "tenant-2", + shortName: "new-tenant", + longName: "New Tenant Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://new-tenant.example.com:8443"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://new-tenant.example.com:8443"); + }); - it("should handle complex subdomains (keep last two parts)", () => { - const requestUrl = new URL("https://admin.api.example.com"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "tenant", - longName: "Tenant Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should handle complex subdomains (keep last two parts)", () => { + const requestUrl = new URL("https://admin.api.example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "tenant", + longName: "Tenant Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://tenant.example.com"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://tenant.example.com"); + }); - it("should handle http protocol", () => { - const requestUrl = new URL("http://example.com"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should handle http protocol", () => { + const requestUrl = new URL("http://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("http://acme.example.com"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("http://acme.example.com"); + }); - it("should return main domain when tenant has no shortName", () => { - const requestUrl = new URL("https://example.com"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "", // Empty shortName - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should return main domain when tenant has no shortName", () => { + const requestUrl = new URL("https://example.com"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "", // Empty shortName + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://example.com"); - }); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://example.com"); + }); + }); - describe("Edge Cases", () => { - it("should handle single domain names in production", () => { - mockEnv.NODE_ENV = "production"; - const requestUrl = new URL("https://app"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + describe("Edge Cases", () => { + it("should handle single domain names in production", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://app"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://acme.app"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://acme.app"); + }); - it("should handle localhost in production (still treated as development)", () => { - mockEnv.NODE_ENV = "production"; - const requestUrl = new URL("https://localhost:8443"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should handle localhost in production (still treated as development)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://localhost:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://localhost:8443"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://localhost:8443"); + }); - it("should handle IP addresses as development", () => { - mockEnv.NODE_ENV = "production"; - const requestUrl = new URL("https://192.168.1.100:8443"); - const tenant: SelectTenant = { - id: "tenant-1", - shortName: "acme", - longName: "ACME Corp", - description: "", - databaseUrl: "", - setupState: "NEW", - logo: null, - createdAt: new Date(), - updatedAt: new Date() - }; + it("should handle IP addresses as development", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://192.168.1.100:8443"); + const tenant: SelectTenant = { + id: "tenant-1", + shortName: "acme", + longName: "ACME Corp", + description: "", + databaseUrl: "", + setupState: "NEW", + logo: null, + createdAt: new Date(), + updatedAt: new Date(), + }; - const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://192.168.1.100:8443"); - }); + const result = generateBaseUrl(requestUrl, tenant); + expect(result).toBe("https://192.168.1.100:8443"); + }); - it("should handle default HTTP port (80)", () => { - mockEnv.NODE_ENV = "production"; - const requestUrl = new URL("http://example.com:80"); + it("should handle default HTTP port (80)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("http://example.com:80"); - // URL constructor should handle port 80 correctly - const result = generateBaseUrl(requestUrl, null); - // Default HTTP port shouldn't be included in URL - expect(result).toBe("http://example.com"); - }); + // URL constructor should handle port 80 correctly + const result = generateBaseUrl(requestUrl, null); + // Default HTTP port shouldn't be included in URL + expect(result).toBe("http://example.com"); + }); - it("should handle default HTTPS port (443)", () => { - mockEnv.NODE_ENV = "production"; - const requestUrl = new URL("https://example.com:443"); + it("should handle default HTTPS port (443)", () => { + mockEnv.NODE_ENV = "production"; + const requestUrl = new URL("https://example.com:443"); - // URL constructor should handle port 443 correctly - const result = generateBaseUrl(requestUrl, null); - // Default HTTPS port shouldn't be included in URL - expect(result).toBe("https://example.com"); - }); - }); + // URL constructor should handle port 443 correctly + const result = generateBaseUrl(requestUrl, null); + // Default HTTPS port shouldn't be included in URL + expect(result).toBe("https://example.com"); + }); + }); }); diff --git a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts b/src/lib/server/email/__tests__/tenant-admin-invite.test.ts index bf40d38..0c9d601 100644 --- a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts +++ b/src/lib/server/email/__tests__/tenant-admin-invite.test.ts @@ -2,47 +2,47 @@ import { describe, it, expect } from "vitest"; import { sendTenantAdminInviteEmail } from "../email-service"; describe("sendTenantAdminInviteEmail", () => { - const mockTenant = { - id: "test-tenant-id", - shortName: "testcorp", - longName: "Test Corporation GmbH", - description: "A test corporation", - databaseUrl: "postgresql://test", - setupState: "NEW" as const, - createdAt: new Date(), - updatedAt: new Date(), - logo: null - }; + const mockTenant = { + id: "test-tenant-id", + shortName: "testcorp", + longName: "Test Corporation GmbH", + description: "A test corporation", + databaseUrl: "postgresql://test", + setupState: "NEW" as const, + createdAt: new Date(), + updatedAt: new Date(), + logo: null, + }; - it("should accept correct parameters and not throw for German language", () => { - const adminEmail = "admin@testcorp.com"; - const adminName = "Test Admin"; - const registrationUrl = "http://localhost:5173/register?tenant=test"; + it("should accept correct parameters and not throw for German language", () => { + const adminEmail = "admin@testcorp.com"; + const adminName = "Test Admin"; + const registrationUrl = "http://localhost:5173/register?tenant=test"; - // This test just ensures the function accepts the right parameters - // and doesn't throw on setup (actual email sending will fail without SMTP config) - expect(() => { - sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "de"); - }).not.toThrow(); - }); + // This test just ensures the function accepts the right parameters + // and doesn't throw on setup (actual email sending will fail without SMTP config) + expect(() => { + sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "de"); + }).not.toThrow(); + }); - it("should accept correct parameters and not throw for English language", () => { - const adminEmail = "admin@testcorp.com"; - const adminName = "Test Admin"; - const registrationUrl = "http://localhost:5173/register?tenant=test"; + it("should accept correct parameters and not throw for English language", () => { + const adminEmail = "admin@testcorp.com"; + const adminName = "Test Admin"; + const registrationUrl = "http://localhost:5173/register?tenant=test"; - expect(() => { - sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "en"); - }).not.toThrow(); - }); + expect(() => { + sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl, "en"); + }).not.toThrow(); + }); - it("should accept correct parameters and not throw with default language", () => { - const adminEmail = "admin@testcorp.com"; - const adminName = "Test Admin"; - const registrationUrl = "http://localhost:5173/register?tenant=test"; + it("should accept correct parameters and not throw with default language", () => { + const adminEmail = "admin@testcorp.com"; + const adminName = "Test Admin"; + const registrationUrl = "http://localhost:5173/register?tenant=test"; - expect(() => { - sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl); - }).not.toThrow(); - }); + expect(() => { + sendTenantAdminInviteEmail(adminEmail, adminName, mockTenant, registrationUrl); + }).not.toThrow(); + }); }); diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 643492f..593772b 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -1,9 +1,9 @@ import { sendEmail, type EmailRecipient, createEmailRecipient } from "./mailer"; import { - templateEngine, - type EmailTemplateType, - type TemplateData, - type Language + templateEngine, + type EmailTemplateType, + type TemplateData, + type Language, } from "./template-engine"; import type { SelectClient, SelectStaff, SelectAppointment } from "$lib/server/db/tenant-schema"; import type { SelectTenant } from "$lib/server/db/central-schema"; @@ -20,28 +20,28 @@ import type { SelectTenant } from "$lib/server/db/central-schema"; * @returns {Promise} */ export async function sendTemplatedEmail( - templateType: EmailTemplateType, - recipient: EmailRecipient, - subject: string, - language: Language = "en", - tenant: SelectTenant, - templateData: Record = {} + templateType: EmailTemplateType, + recipient: EmailRecipient, + subject: string, + language: Language = "en", + tenant: SelectTenant, + templateData: Record = {}, ): Promise { - const data: TemplateData = { - recipient, - subject, - language, - tenant, - ...templateData - }; + const data: TemplateData = { + recipient, + subject, + language, + tenant, + ...templateData, + }; - try { - const rendered = await templateEngine.renderTemplate(templateType, data); - await sendEmail(recipient, rendered.subject, rendered.html, rendered.text); - } catch (error) { - console.error(`Failed to send templated email (${templateType}):`, error); - throw error; - } + try { + const rendered = await templateEngine.renderTemplate(templateType, data); + await sendEmail(recipient, rendered.subject, rendered.html, rendered.text); + } catch (error) { + console.error(`Failed to send templated email (${templateType}):`, error); + throw error; + } } /** @@ -53,17 +53,17 @@ export async function sendTemplatedEmail( * @returns {Promise} */ export async function sendUserCreatedEmail( - user: SelectClient | SelectStaff, - tenant: SelectTenant, - loginUrl: string + user: SelectClient | SelectStaff, + tenant: SelectTenant, + loginUrl: string, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Welcome to Open Reception" : "Willkommen bei Open Reception"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Welcome to Open Reception" : "Willkommen bei Open Reception"; - await sendTemplatedEmail("user-created", recipient, subject, language, tenant, { - loginUrl - }); + await sendTemplatedEmail("user-created", recipient, subject, language, tenant, { + loginUrl, + }); } /** @@ -74,14 +74,14 @@ export async function sendUserCreatedEmail( * @returns {Promise} */ export async function sendPinResetEmail( - user: SelectClient | SelectStaff, - tenant: SelectTenant + user: SelectClient | SelectStaff, + tenant: SelectTenant, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "PIN Reset Information" : "PIN zurückgesetzt"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "PIN Reset Information" : "PIN zurückgesetzt"; - await sendTemplatedEmail("pin-reset", recipient, subject, language, tenant, {}); + await sendTemplatedEmail("pin-reset", recipient, subject, language, tenant, {}); } /** @@ -92,14 +92,14 @@ export async function sendPinResetEmail( * @returns {Promise} */ export async function sendKeyResetEmail( - user: SelectClient | SelectStaff, - tenant: SelectTenant + user: SelectClient | SelectStaff, + tenant: SelectTenant, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Key Reset Information" : "Schlüssel zurückgesetzt"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Key Reset Information" : "Schlüssel zurückgesetzt"; - await sendTemplatedEmail("key-reset", recipient, subject, language, tenant, {}); + await sendTemplatedEmail("key-reset", recipient, subject, language, tenant, {}); } /** @@ -112,23 +112,23 @@ export async function sendKeyResetEmail( * @returns {Promise} */ export async function sendAppointmentReminderEmail( - user: SelectClient | SelectStaff, - tenant: SelectTenant, - appointment: SelectAppointment, - cancelUrl?: string + user: SelectClient | SelectStaff, + tenant: SelectTenant, + appointment: SelectAppointment, + cancelUrl?: string, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Appointment Reminder" : "Terminerinnerung"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Appointment Reminder" : "Terminerinnerung"; - await sendTemplatedEmail("appointment-reminder", recipient, subject, language, tenant, { - appointment, - appointmentDate: appointment.appointmentDate, - appointmentTime: appointment.appointmentDate, // You might want to add a separate time field - title: appointment.title, - description: appointment.description, - cancelUrl - }); + await sendTemplatedEmail("appointment-reminder", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + appointmentTime: appointment.appointmentDate, // You might want to add a separate time field + title: appointment.title, + description: appointment.description, + cancelUrl, + }); } /** @@ -141,22 +141,22 @@ export async function sendAppointmentReminderEmail( * @returns {Promise} */ export async function sendAppointmentCreatedEmail( - user: SelectClient | SelectStaff, - tenant: SelectTenant, - appointment: SelectAppointment, - cancelUrl?: string + user: SelectClient | SelectStaff, + tenant: SelectTenant, + appointment: SelectAppointment, + cancelUrl?: string, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Appointment Confirmed" : "Termin bestätigt"; - await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, { - appointment, - appointmentDate: appointment.appointmentDate, - title: appointment.title, - description: appointment.description, - cancelUrl - }); + await sendTemplatedEmail("appointment-created", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + title: appointment.title, + description: appointment.description, + cancelUrl, + }); } /** @@ -169,22 +169,22 @@ export async function sendAppointmentCreatedEmail( * @returns {Promise} */ export async function sendAppointmentUpdatedEmail( - user: SelectClient | SelectStaff, - tenant: SelectTenant, - appointment: SelectAppointment, - cancelUrl?: string + user: SelectClient | SelectStaff, + tenant: SelectTenant, + appointment: SelectAppointment, + cancelUrl?: string, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Appointment Updated" : "Termin aktualisiert"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Appointment Updated" : "Termin aktualisiert"; - await sendTemplatedEmail("appointment-updated", recipient, subject, language, tenant, { - appointment, - appointmentDate: appointment.appointmentDate, - title: appointment.title, - description: appointment.description, - cancelUrl - }); + await sendTemplatedEmail("appointment-updated", recipient, subject, language, tenant, { + appointment, + appointmentDate: appointment.appointmentDate, + title: appointment.title, + description: appointment.description, + cancelUrl, + }); } /** @@ -194,31 +194,31 @@ export async function sendAppointmentUpdatedEmail( * @returns {string} The appropriate base URL */ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string { - const protocol = requestUrl.protocol; - const port = requestUrl.port ? `:${requestUrl.port}` : ""; - const hostname = requestUrl.hostname; + const protocol = requestUrl.protocol; + const port = requestUrl.port ? `:${requestUrl.port}` : ""; + const hostname = requestUrl.hostname; - // In development, always use the original hostname regardless of tenant - if (hostname === "localhost" || hostname.startsWith("127.") || hostname.startsWith("192.168.")) { - return `${protocol}//${hostname}${port}`; - } + // In development, always use the original hostname regardless of tenant + if (hostname === "localhost" || hostname.startsWith("127.") || hostname.startsWith("192.168.")) { + return `${protocol}//${hostname}${port}`; + } - // In production, handle tenant subdomains - if (tenant?.shortName) { - const parts = hostname.split("."); + // In production, handle tenant subdomains + if (tenant?.shortName) { + const parts = hostname.split("."); - if (parts.length > 2) { - // Complex subdomain - use only the last two parts (domain.tld) and add tenant - const domain = parts.slice(-2).join("."); - return `${protocol}//${tenant.shortName}.${domain}${port}`; - } else { - // Main domain, prepend tenant subdomain - return `${protocol}//${tenant.shortName}.${hostname}${port}`; - } - } + if (parts.length > 2) { + // Complex subdomain - use only the last two parts (domain.tld) and add tenant + const domain = parts.slice(-2).join("."); + return `${protocol}//${tenant.shortName}.${domain}${port}`; + } else { + // Main domain, prepend tenant subdomain + return `${protocol}//${tenant.shortName}.${hostname}${port}`; + } + } - // For global admin or no tenant, use main domain - return `${protocol}//${hostname}${port}`; + // For global admin or no tenant, use main domain + return `${protocol}//${hostname}${port}`; } /** @@ -232,29 +232,29 @@ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): s * @returns {Promise} */ export async function sendConfirmationEmail( - user: { id: string; email: string | null; name: string | null; language?: string | null }, - tenant: SelectTenant, - confirmationCode: string, - expirationMinutes: number = 15, - requestUrl?: URL + user: { id: string; email: string | null; name: string | null; language?: string | null }, + tenant: SelectTenant, + confirmationCode: string, + expirationMinutes: number = 15, + requestUrl?: URL, ): Promise { - const recipient = createEmailRecipient(user); - const language = (recipient.language as Language) || "en"; - const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; + const recipient = createEmailRecipient(user); + const language = (recipient.language as Language) || "en"; + const subject = language === "en" ? "Confirm Your Registration" : "Registrierung bestätigen"; - // Generate appropriate base URL if request URL is provided - const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173"; + // Generate appropriate base URL if request URL is provided + const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173"; - // Create enhanced tenant object with baseUrl - const tenantWithBaseUrl = { - ...tenant, - baseUrl - }; + // Create enhanced tenant object with baseUrl + const tenantWithBaseUrl = { + ...tenant, + baseUrl, + }; - await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, { - confirmationCode, - expirationMinutes - }); + await sendTemplatedEmail("confirmation", recipient, subject, language, tenantWithBaseUrl, { + confirmationCode, + expirationMinutes, + }); } /** @@ -268,24 +268,24 @@ export async function sendConfirmationEmail( * @returns {Promise} */ export async function sendTenantAdminInviteEmail( - adminEmail: string, - adminName: string, - tenant: SelectTenant, - registrationUrl: string, - language: Language = "en" + adminEmail: string, + adminName: string, + tenant: SelectTenant, + registrationUrl: string, + language: Language = "en", ): Promise { - const recipient: EmailRecipient = { - email: adminEmail, - name: adminName, - language - }; + const recipient: EmailRecipient = { + email: adminEmail, + name: adminName, + language, + }; - const subject = - language === "en" ? "Invitation as Tenant Administrator" : "Einladung als Tenant-Administrator"; + const subject = + language === "en" ? "Invitation as Tenant Administrator" : "Einladung als Tenant-Administrator"; - await sendTemplatedEmail("tenant-admin-invite", recipient, subject, language, tenant, { - registrationUrl - }); + await sendTemplatedEmail("tenant-admin-invite", recipient, subject, language, tenant, { + registrationUrl, + }); } /** @@ -300,25 +300,25 @@ export async function sendTenantAdminInviteEmail( * @returns {Promise} */ export async function sendUserInviteEmail( - userEmail: string, - userName: string, - tenant: SelectTenant, - role: "TENANT_ADMIN" | "STAFF", - registrationUrl: string, - language: Language = "en" + userEmail: string, + userName: string, + tenant: SelectTenant, + role: "TENANT_ADMIN" | "STAFF", + registrationUrl: string, + language: Language = "en", ): Promise { - const recipient: EmailRecipient = { - email: userEmail, - name: userName, - language - }; + const recipient: EmailRecipient = { + email: userEmail, + name: userName, + language, + }; - const subject = - language === "en" - ? `Invitation to ${tenant.longName || tenant.shortName}` - : `Einladung zu ${tenant.longName || tenant.shortName}`; + const subject = + language === "en" + ? `Invitation to ${tenant.longName || tenant.shortName}` + : `Einladung zu ${tenant.longName || tenant.shortName}`; - await sendTemplatedEmail("user-invite", recipient, subject, language, tenant, { - registrationUrl - }); + await sendTemplatedEmail("user-invite", recipient, subject, language, tenant, { + registrationUrl, + }); } diff --git a/src/lib/server/email/mailer.ts b/src/lib/server/email/mailer.ts index 0099554..db9fc8e 100644 --- a/src/lib/server/email/mailer.ts +++ b/src/lib/server/email/mailer.ts @@ -11,9 +11,9 @@ import type Mail from "nodemailer/lib/mailer"; * @property {string} [language] - Optional language preference (de/en) */ export interface EmailRecipient { - email: string; - name?: string; - language?: string; + email: string; + name?: string; + language?: string; } /** @@ -23,35 +23,35 @@ export interface EmailRecipient { * @throws {Error} When user has no email address */ export function createEmailRecipient( - user: - | SelectClient - | SelectStaff - | { id: string; email: string | null; name: string | null; language?: string | null } + user: + | SelectClient + | SelectStaff + | { id: string; email: string | null; name: string | null; language?: string | null }, ): EmailRecipient { - // Handle SelectUser type (from central schema) - if ("email" in user && !("publicKey" in user)) { - return { - email: user.email || "", - name: user.name || undefined, - language: user.language || "de" // Use user's language preference - }; - } - // Handle SelectStaff type (has name property) - else if ("name" in user) { - return { - email: user.email, - name: user.name || undefined, - language: user.language || "de" - }; - } - // Handle SelectClient type (no name property) - else { - return { - email: user.email || "", // Client email is optional - name: undefined, // Clients don't have names for privacy - language: user.language || "de" - }; - } + // Handle SelectUser type (from central schema) + if ("email" in user && !("publicKey" in user)) { + return { + email: user.email || "", + name: user.name || undefined, + language: user.language || "de", // Use user's language preference + }; + } + // Handle SelectStaff type (has name property) + else if ("name" in user) { + return { + email: user.email, + name: user.name || undefined, + language: user.language || "de", + }; + } + // Handle SelectClient type (no name property) + else { + return { + email: user.email || "", // Client email is optional + name: undefined, // Clients don't have names for privacy + language: user.language || "de", + }; + } } /** @@ -61,19 +61,19 @@ export function createEmailRecipient( * @private */ function createTransporter() { - if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASS) { - throw new Error("SMTP configuration is incomplete. Please check your environment variables."); - } - const config = { - host: env.SMTP_HOST, - port: parseInt(env.SMTP_PORT), - secure: env.SMTP_SECURE === "true", - auth: { - user: env.SMTP_USER, - pass: env.SMTP_PASS - } - }; - return nodemailer.createTransport(config); + if (!env.SMTP_HOST || !env.SMTP_PORT || !env.SMTP_USER || !env.SMTP_PASS) { + throw new Error("SMTP configuration is incomplete. Please check your environment variables."); + } + const config = { + host: env.SMTP_HOST, + port: parseInt(env.SMTP_PORT), + secure: env.SMTP_SECURE === "true", + auth: { + user: env.SMTP_USER, + pass: env.SMTP_PASS, + }, + }; + return nodemailer.createTransport(config); } /** @@ -86,46 +86,46 @@ function createTransporter() { * @returns {Promise} */ export async function sendEmail( - recipient: EmailRecipient, - subject: string, - htmlContent: string, - textContent: string + recipient: EmailRecipient, + subject: string, + htmlContent: string, + textContent: string, ): Promise { - const transporter = createTransporter(); + const transporter = createTransporter(); - if (!recipient.email) { - // Recipient has not stored an email address, so no mail will be send - // TODO: Log this event - return; - } + if (!recipient.email) { + // Recipient has not stored an email address, so no mail will be send + // TODO: Log this event + return; + } - const from_address = env.SMTP_FROM_EMAIL || env.SMTP_USER; + const from_address = env.SMTP_FROM_EMAIL || env.SMTP_USER; - if (!from_address) { - throw new Error("From address is required"); - } + if (!from_address) { + throw new Error("From address is required"); + } - const mailOptions: Mail.Options = { - from: { - name: env.SMTP_FROM_NAME || "Open Reception", - address: from_address - }, - to: { - name: recipient.name || "", - address: recipient.email - }, - subject, - html: htmlContent, - text: textContent - }; + const mailOptions: Mail.Options = { + from: { + name: env.SMTP_FROM_NAME || "Open Reception", + address: from_address, + }, + to: { + name: recipient.name || "", + address: recipient.email, + }, + subject, + html: htmlContent, + text: textContent, + }; - try { - await transporter.sendMail(mailOptions); - console.log(`Email sent successfully to ${recipient.email}`); - } catch (error) { - console.error("Failed to send email:", error); - throw new Error(`Failed to send email to ${recipient.email}`); - } + try { + await transporter.sendMail(mailOptions); + console.log(`Email sent successfully to ${recipient.email}`); + } catch (error) { + console.error("Failed to send email:", error); + throw new Error(`Failed to send email to ${recipient.email}`); + } } /** @@ -133,12 +133,12 @@ export async function sendEmail( * @returns {Promise} True if connection successful, false otherwise */ export async function testEmailConnection(): Promise { - try { - const transporter = createTransporter(); - await transporter.verify(); - return true; - } catch (error) { - console.error("SMTP connection test failed:", error); - return false; - } + try { + const transporter = createTransporter(); + await transporter.verify(); + return true; + } catch (error) { + console.error("SMTP connection test failed:", error); + return false; + } } diff --git a/src/lib/server/email/template-engine.ts b/src/lib/server/email/template-engine.ts index 8d3c40c..a9843d2 100644 --- a/src/lib/server/email/template-engine.ts +++ b/src/lib/server/email/template-engine.ts @@ -11,15 +11,15 @@ export type Language = "de" | "en"; * @typedef {'user-created' | 'pin-reset' | 'key-reset' | 'appointment-reminder' | 'appointment-created' | 'appointment-updated' | 'confirmation'} EmailTemplateType */ export type EmailTemplateType = - | "user-created" - | "pin-reset" // Info that PIN was reset (no code) - | "key-reset" - | "appointment-reminder" - | "appointment-created" - | "appointment-updated" - | "confirmation" // Registration confirmation with one-time code - | "tenant-admin-invite" // Invitation for tenant administrator - | "user-invite"; // Invitation for new users to existing tenant + | "user-created" + | "pin-reset" // Info that PIN was reset (no code) + | "key-reset" + | "appointment-reminder" + | "appointment-created" + | "appointment-updated" + | "confirmation" // Registration confirmation with one-time code + | "tenant-admin-invite" // Invitation for tenant administrator + | "user-invite"; // Invitation for new users to existing tenant /** * Template data interface containing all variables available in templates @@ -31,11 +31,11 @@ export type EmailTemplateType = * @property {unknown} [key] - Additional template variables */ export interface TemplateData { - recipient: EmailRecipient; - subject: string; - language: Language; - tenant: SelectTenant; - [key: string]: unknown; + recipient: EmailRecipient; + subject: string; + language: Language; + tenant: SelectTenant; + [key: string]: unknown; } /** @@ -46,9 +46,9 @@ export interface TemplateData { * @property {string} subject - Rendered subject line */ export interface RenderedTemplate { - html: string; - text: string; - subject: string; + html: string; + text: string; + subject: string; } /** @@ -56,128 +56,128 @@ export interface RenderedTemplate { * Supports variable substitution, conditionals, loops, and multilingual templates */ export class EmailTemplateEngine { - private templatePath: string; + private templatePath: string; - /** - * Create a new template engine instance - * @param {string} templatePath - Path to template directory - */ - constructor(templatePath: string = "src/lib/server/email/templates") { - this.templatePath = templatePath; - } + /** + * Create a new template engine instance + * @param {string} templatePath - Path to template directory + */ + constructor(templatePath: string = "src/lib/server/email/templates") { + this.templatePath = templatePath; + } - /** - * Render a template with the provided data - * @param {EmailTemplateType} templateType - Type of template to render - * @param {TemplateData} data - Template data and variables - * @returns {Promise} Rendered template with HTML, text, and subject - * @throws {Error} When template files are not found - */ - async renderTemplate( - templateType: EmailTemplateType, - data: TemplateData - ): Promise { - const language = data.language || "de"; - const [htmlContent, textContent] = await Promise.all([ - this.loadTemplate(templateType, "html", language), - this.loadTemplate(templateType, "txt", language) - ]); + /** + * Render a template with the provided data + * @param {EmailTemplateType} templateType - Type of template to render + * @param {TemplateData} data - Template data and variables + * @returns {Promise} Rendered template with HTML, text, and subject + * @throws {Error} When template files are not found + */ + async renderTemplate( + templateType: EmailTemplateType, + data: TemplateData, + ): Promise { + const language = data.language || "de"; + const [htmlContent, textContent] = await Promise.all([ + this.loadTemplate(templateType, "html", language), + this.loadTemplate(templateType, "txt", language), + ]); - const renderedHtml = this.replaceVariables(htmlContent, data); - const renderedText = this.replaceVariables(textContent, data); - const renderedSubject = this.replaceVariables(data.subject, data); + const renderedHtml = this.replaceVariables(htmlContent, data); + const renderedText = this.replaceVariables(textContent, data); + const renderedSubject = this.replaceVariables(data.subject, data); - return { - html: renderedHtml, - text: renderedText, - subject: renderedSubject - }; - } + return { + html: renderedHtml, + text: renderedText, + subject: renderedSubject, + }; + } - /** - * Load template file with language fallback support - * @param {EmailTemplateType} templateType - Template type - * @param {'html' | 'txt'} fileType - File type to load - * @param {Language} language - Language preference (defaults to 'de') - * @returns {Promise} Template file content - * @throws {Error} When template file is not found - * @private - */ - private async loadTemplate( - templateType: EmailTemplateType, - fileType: "html" | "txt", - language: Language = "de" - ): Promise { - const fileName = `${templateType}.${language}.${fileType}`; - const filePath = join(process.cwd(), this.templatePath, fileName); + /** + * Load template file with language fallback support + * @param {EmailTemplateType} templateType - Template type + * @param {'html' | 'txt'} fileType - File type to load + * @param {Language} language - Language preference (defaults to 'de') + * @returns {Promise} Template file content + * @throws {Error} When template file is not found + * @private + */ + private async loadTemplate( + templateType: EmailTemplateType, + fileType: "html" | "txt", + language: Language = "de", + ): Promise { + const fileName = `${templateType}.${language}.${fileType}`; + const filePath = join(process.cwd(), this.templatePath, fileName); - try { - return await readFile(filePath, "utf-8"); - } catch (error) { - // Fallback to German if language-specific template doesn't exist - if (language !== "de") { - console.warn(`Template ${fileName} not found, falling back to German`); - return this.loadTemplate(templateType, fileType, "de"); - } - throw new Error(`Template file not found: ${fileName}. Error: ${error}`); - } - } + try { + return await readFile(filePath, "utf-8"); + } catch (error) { + // Fallback to German if language-specific template doesn't exist + if (language !== "de") { + console.warn(`Template ${fileName} not found, falling back to German`); + return this.loadTemplate(templateType, fileType, "de"); + } + throw new Error(`Template file not found: ${fileName}. Error: ${error}`); + } + } - /** - * Replace variables in template content using Handlebars-like syntax - * Supports: {{variable}}, {{#if condition}}...{{/if}}, {{#each items}}...{{/each}} - * @param {string} content - Template content with variables - * @param {TemplateData} data - Data for variable substitution - * @returns {string} Content with variables replaced - * @private - */ - private replaceVariables(content: string, data: TemplateData): string { - let result = content; + /** + * Replace variables in template content using Handlebars-like syntax + * Supports: {{variable}}, {{#if condition}}...{{/if}}, {{#each items}}...{{/each}} + * @param {string} content - Template content with variables + * @param {TemplateData} data - Data for variable substitution + * @returns {string} Content with variables replaced + * @private + */ + private replaceVariables(content: string, data: TemplateData): string { + let result = content; - // Replace simple variables like {{variable}} - result = result.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, path) => { - const value = this.getNestedValue(data, path); - return value !== undefined ? String(value) : match; - }); + // Replace simple variables like {{variable}} + result = result.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, path) => { + const value = this.getNestedValue(data, path); + return value !== undefined ? String(value) : match; + }); - // Replace conditional blocks like {{#if condition}}...{{/if}} - result = result.replace( - /\{\{#if\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/if\}\}/g, - (match, path, block) => { - const value = this.getNestedValue(data, path); - return value ? block : ""; - } - ); + // Replace conditional blocks like {{#if condition}}...{{/if}} + result = result.replace( + /\{\{#if\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/if\}\}/g, + (match, path, block) => { + const value = this.getNestedValue(data, path); + return value ? block : ""; + }, + ); - // Replace loops like {{#each items}}...{{/each}} - result = result.replace( - /\{\{#each\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/each\}\}/g, - (match, path, block) => { - const items = this.getNestedValue(data, path); - if (Array.isArray(items)) { - return items.map((item) => this.replaceVariables(block, { ...data, item })).join(""); - } - return ""; - } - ); + // Replace loops like {{#each items}}...{{/each}} + result = result.replace( + /\{\{#each\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/each\}\}/g, + (match, path, block) => { + const items = this.getNestedValue(data, path); + if (Array.isArray(items)) { + return items.map((item) => this.replaceVariables(block, { ...data, item })).join(""); + } + return ""; + }, + ); - return result; - } + return result; + } - /** - * Get nested value from object using dot notation (e.g., 'tenant.primaryColor') - * @param {TemplateData} obj - Object to search in - * @param {string} path - Dot-separated path to value - * @returns {unknown} Value at path or undefined if not found - * @private - */ - private getNestedValue(obj: TemplateData, path: string): unknown { - return path.split(".").reduce((current: unknown, key: string) => { - return current && typeof current === "object" && key in current - ? (current as Record)[key] - : undefined; - }, obj); - } + /** + * Get nested value from object using dot notation (e.g., 'tenant.primaryColor') + * @param {TemplateData} obj - Object to search in + * @param {string} path - Dot-separated path to value + * @returns {unknown} Value at path or undefined if not found + * @private + */ + private getNestedValue(obj: TemplateData, path: string): unknown { + return path.split(".").reduce((current: unknown, key: string) => { + return current && typeof current === "object" && key in current + ? (current as Record)[key] + : undefined; + }, obj); + } } /** Global template engine instance */ diff --git a/src/lib/server/email/templates/appointment-reminder.de.html b/src/lib/server/email/templates/appointment-reminder.de.html index 8605990..44b6a65 100644 --- a/src/lib/server/email/templates/appointment-reminder.de.html +++ b/src/lib/server/email/templates/appointment-reminder.de.html @@ -1,121 +1,121 @@ - - - - Terminerinnerung - - - - + diff --git a/src/lib/server/email/templates/appointment-reminder.en.html b/src/lib/server/email/templates/appointment-reminder.en.html index 012a9cb..a75af40 100644 --- a/src/lib/server/email/templates/appointment-reminder.en.html +++ b/src/lib/server/email/templates/appointment-reminder.en.html @@ -1,121 +1,121 @@ - - - - Appointment Reminder - - - - + diff --git a/src/lib/server/email/templates/confirmation.de.html b/src/lib/server/email/templates/confirmation.de.html index 7a15d11..28a444e 100644 --- a/src/lib/server/email/templates/confirmation.de.html +++ b/src/lib/server/email/templates/confirmation.de.html @@ -1,174 +1,174 @@ - - - - Registrierung bestätigen - - + .warning { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + color: #856404; + } + + - -
    -
    - {{#if tenant.logo}} - {{tenant.longName}} - {{/if}} - -

    Registrierung bestätigen

    -
    + +
    +
    + {{#if tenant.logo}} + {{tenant.longName}} + {{/if}} + +

    Registrierung bestätigen

    +
    -
    -

    Hallo,

    +
    +

    Hallo,

    -

    - vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre - Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode: -

    +

    + vielen Dank für Ihre Registrierung bei {{tenant.longName}}. Um Ihre + Registrierung abzuschließen, verwenden Sie bitte den folgenden Bestätigungscode: +

    -
    -
    Ihr Bestätigungscode:
    -
    {{confirmationCode}}
    -
    - Geben Sie diesen Code in das Bestätigungsfeld ein -
    - Oder hier klicken. -
    +
    +
    Ihr Bestätigungscode:
    +
    {{confirmationCode}}
    +
    + Geben Sie diesen Code in das Bestätigungsfeld ein +
    + Oder hier klicken. +
    -
    - Wichtiger Hinweis: Dieser Code ist nur für - {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet - werden. -
    +
    + Wichtiger Hinweis: Dieser Code ist nur für + {{expirationMinutes}} Minuten gültig und kann nur einmal verwendet + werden. +
    -

    - Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail - ignorieren. -

    +

    + Falls Sie sich nicht bei {{tenant.longName}} registriert haben, können Sie diese E-Mail + ignorieren. +

    -

    Bei Fragen wenden Sie sich gerne an unser Support-Team.

    +

    Bei Fragen wenden Sie sich gerne an unser Support-Team.

    -

    - Mit freundlichen Grüßen
    - Das {{tenant.longName}} Team -

    -
    +

    + Mit freundlichen Grüßen
    + Das {{tenant.longName}} Team +

    +
    - -
    - + +
    + diff --git a/src/lib/server/email/templates/confirmation.en.html b/src/lib/server/email/templates/confirmation.en.html index 10cd866..8bca515 100644 --- a/src/lib/server/email/templates/confirmation.en.html +++ b/src/lib/server/email/templates/confirmation.en.html @@ -1,127 +1,127 @@ - - - - Confirm Registration - - - -
    -
    - {{#if tenant.logo}} - {{tenant.longName}} - {{/if}} - -

    Confirm Registration

    -
    + + + + Confirm Registration + + + +
    +
    + {{#if tenant.logo}} + {{tenant.longName}} + {{/if}} + +

    Confirm Registration

    +
    -
    -

    Hello,

    +
    +

    Hello,

    -

    - Thank you for registering with {{tenant.longName}}. To complete your - registration, please use the following confirmation code: -

    +

    + Thank you for registering with {{tenant.longName}}. To complete your + registration, please use the following confirmation code: +

    -
    -
    Your confirmation code:
    -
    {{confirmationCode}}
    -
    - Enter this code in the confirmation field -
    - Or simply click here. -
    +
    +
    Your confirmation code:
    +
    {{confirmationCode}}
    +
    + Enter this code in the confirmation field +
    + Or simply click here. +
    -
    - Important Notice: This code is valid for - {{expirationMinutes}} minutes only and can be used only once. -
    +
    + Important Notice: This code is valid for + {{expirationMinutes}} minutes only and can be used only once. +
    -

    If you did not register with {{tenant.longName}}, you can safely ignore this email.

    +

    If you did not register with {{tenant.longName}}, you can safely ignore this email.

    -

    If you have any questions, please contact our support team.

    +

    If you have any questions, please contact our support team.

    -

    - Best regards
    - The {{tenant.longName}} Team -

    -
    +

    + Best regards
    + The {{tenant.longName}} Team +

    +
    - -
    - + +
    + diff --git a/src/lib/server/email/templates/pin-reset.de.html b/src/lib/server/email/templates/pin-reset.de.html index 28fa9bd..a608855 100644 --- a/src/lib/server/email/templates/pin-reset.de.html +++ b/src/lib/server/email/templates/pin-reset.de.html @@ -1,86 +1,86 @@ - - - - PIN zurückgesetzt - - - - + diff --git a/src/lib/server/email/templates/pin-reset.en.html b/src/lib/server/email/templates/pin-reset.en.html index 3eabb3b..29a3f95 100644 --- a/src/lib/server/email/templates/pin-reset.en.html +++ b/src/lib/server/email/templates/pin-reset.en.html @@ -1,83 +1,83 @@ - - - - PIN Reset Successful - - - - + diff --git a/src/lib/server/email/templates/tenant-admin-invite.de.html b/src/lib/server/email/templates/tenant-admin-invite.de.html index d74df1d..8b7b2e8 100644 --- a/src/lib/server/email/templates/tenant-admin-invite.de.html +++ b/src/lib/server/email/templates/tenant-admin-invite.de.html @@ -1,113 +1,113 @@ - - - - Einladung als Tenant-Administrator - - - - + diff --git a/src/lib/server/email/templates/tenant-admin-invite.en.html b/src/lib/server/email/templates/tenant-admin-invite.en.html index 48e8b61..77ba9d9 100644 --- a/src/lib/server/email/templates/tenant-admin-invite.en.html +++ b/src/lib/server/email/templates/tenant-admin-invite.en.html @@ -1,112 +1,112 @@ - - - - Invitation as Tenant Administrator - - - - + diff --git a/src/lib/server/email/templates/user-created.de.html b/src/lib/server/email/templates/user-created.de.html index 1fe8bd5..c7afd17 100644 --- a/src/lib/server/email/templates/user-created.de.html +++ b/src/lib/server/email/templates/user-created.de.html @@ -1,99 +1,99 @@ - - - - Willkommen bei Open Reception - - - - + diff --git a/src/lib/server/email/templates/user-created.en.html b/src/lib/server/email/templates/user-created.en.html index 42ebc06..3c2b99b 100644 --- a/src/lib/server/email/templates/user-created.en.html +++ b/src/lib/server/email/templates/user-created.en.html @@ -1,99 +1,99 @@ - - - - Welcome to Open Reception - - - - + diff --git a/src/lib/server/email/templates/user-invite.de.html b/src/lib/server/email/templates/user-invite.de.html index 196184a..761bb59 100644 --- a/src/lib/server/email/templates/user-invite.de.html +++ b/src/lib/server/email/templates/user-invite.de.html @@ -1,117 +1,117 @@ - - - - Einladung zu {{tenant.longName}} - - - - + diff --git a/src/lib/server/email/templates/user-invite.en.html b/src/lib/server/email/templates/user-invite.en.html index 64705b0..9498372 100644 --- a/src/lib/server/email/templates/user-invite.en.html +++ b/src/lib/server/email/templates/user-invite.en.html @@ -1,114 +1,114 @@ - - - - Invitation to {{tenant.longName}} - - - - + diff --git a/src/lib/server/openapi.ts b/src/lib/server/openapi.ts index 8d9dc39..a4568c5 100644 --- a/src/lib/server/openapi.ts +++ b/src/lib/server/openapi.ts @@ -1,157 +1,157 @@ export type JsonSchema = { - type?: "string" | "number" | "integer" | "boolean" | "array" | "object"; - properties?: Record; - items?: JsonSchema; - required?: string[]; - format?: string; - description?: string; - default?: unknown; - example?: unknown; - enum?: unknown[]; - minLength?: number; - maxLength?: number; - additionalProperties?: boolean; - $ref?: string; + type?: "string" | "number" | "integer" | "boolean" | "array" | "object"; + properties?: Record; + items?: JsonSchema; + required?: string[]; + format?: string; + description?: string; + default?: unknown; + example?: unknown; + enum?: unknown[]; + minLength?: number; + maxLength?: number; + additionalProperties?: boolean; + $ref?: string; }; export interface OpenApiResponse { - description: string; - content?: Record< - string, - { - schema: JsonSchema; - example?: unknown; - } - >; + description: string; + content?: Record< + string, + { + schema: JsonSchema; + example?: unknown; + } + >; } export interface OpenApiParameter { - name: string; - in: "query" | "path" | "header"; - description?: string; - required?: boolean; - schema: JsonSchema; + name: string; + in: "query" | "path" | "header"; + description?: string; + required?: boolean; + schema: JsonSchema; } export interface OpenApiRequestBody { - description?: string; - content: Record< - string, - { - schema: JsonSchema; - example?: unknown; - } - >; + description?: string; + content: Record< + string, + { + schema: JsonSchema; + example?: unknown; + } + >; } export interface OpenApiOperation { - summary?: string; - description?: string; - tags?: string[]; - responses?: Record; - parameters?: OpenApiParameter[]; - requestBody?: OpenApiRequestBody; + summary?: string; + description?: string; + tags?: string[]; + responses?: Record; + parameters?: OpenApiParameter[]; + requestBody?: OpenApiRequestBody; } const apiOperationsRegistry = new Map(); export function registerOpenAPIRoute(path: string, method: string, operation: OpenApiOperation) { - // Skip registration during testing completely - try { - // Check if we're in a testing environment - if ( - typeof globalThis !== "undefined" && - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (globalThis as any).__vitest_worker__ !== undefined - ) { - return; - } + // Skip registration during testing completely + try { + // Check if we're in a testing environment + if ( + typeof globalThis !== "undefined" && + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).__vitest_worker__ !== undefined + ) { + return; + } - if ( - typeof process !== "undefined" && - (process.env.NODE_ENV === "test" || - process.env.VITEST === "true" || - // eslint-disable-next-line @typescript-eslint/no-explicit-any - typeof (process as any).__vitest_worker__ !== "undefined") - ) { - return; - } + if ( + typeof process !== "undefined" && + (process.env.NODE_ENV === "test" || + process.env.VITEST === "true" || + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeof (process as any).__vitest_worker__ !== "undefined") + ) { + return; + } - const key = `${method.toUpperCase()} ${path}`; - apiOperationsRegistry.set(key, operation); - } catch { - // Silently ignore errors during testing - return; - } + const key = `${method.toUpperCase()} ${path}`; + apiOperationsRegistry.set(key, operation); + } catch { + // Silently ignore errors during testing + return; + } } export function getApiOperations(): Map { - return apiOperationsRegistry; + return apiOperationsRegistry; } export function generateOpenApiSpec() { - const paths: Record> = {}; + const paths: Record> = {}; - // Convert registered operations to OpenAPI paths - for (const [key, operation] of apiOperationsRegistry.entries()) { - const [method, path] = key.split(" ", 2); - if (!paths[path]) { - paths[path] = {}; - } - paths[path][method.toLowerCase()] = operation; - } + // Convert registered operations to OpenAPI paths + for (const [key, operation] of apiOperationsRegistry.entries()) { + const [method, path] = key.split(" ", 2); + if (!paths[path]) { + paths[path] = {}; + } + paths[path][method.toLowerCase()] = operation; + } - return { - openapi: "3.0.0", - info: { - title: "Open Reception API", - description: "End-to-end encrypted appointment booking platform", - version: "0.0.1", - license: { - name: "AGPL-3.0", - url: "https://www.gnu.org/licenses/agpl-3.0.html" - } - }, - servers: [ - { - url: "/api", - description: "API Server" - } - ], - paths, - components: { - schemas: { - Error: { - type: "object", - properties: { - error: { type: "string", description: "Error message" }, - code: { type: "string", description: "Error code" } - }, - required: ["error"] - }, - HealthStatus: { - type: "object", - properties: { - core: { type: "boolean", description: "Core service status" }, - database: { type: "boolean", description: "Database connectivity status" }, - memory: { type: "integer", description: "Free memory in megabytes" }, - load: { type: "number", format: "float", description: "Average CPU load" } - }, - required: ["core", "database", "memory", "load"] - } - } - }, - tags: [ - { name: "Health", description: "Health check and monitoring endpoints" }, - { - name: "Authentication", - description: "User authentication and session management endpoints" - }, - { name: "Admin", description: "Admin account management endpoints" }, - { name: "Appointments", description: "Appointment management endpoints" }, - { name: "Clients", description: "Client management endpoints" }, - { name: "Channels", description: "Channel management endpoints" }, - { name: "Questionnaires", description: "Questionnaire management endpoints" } - ] - }; + return { + openapi: "3.0.0", + info: { + title: "Open Reception API", + description: "End-to-end encrypted appointment booking platform", + version: "0.0.1", + license: { + name: "AGPL-3.0", + url: "https://www.gnu.org/licenses/agpl-3.0.html", + }, + }, + servers: [ + { + url: "/api", + description: "API Server", + }, + ], + paths, + components: { + schemas: { + Error: { + type: "object", + properties: { + error: { type: "string", description: "Error message" }, + code: { type: "string", description: "Error code" }, + }, + required: ["error"], + }, + HealthStatus: { + type: "object", + properties: { + core: { type: "boolean", description: "Core service status" }, + database: { type: "boolean", description: "Database connectivity status" }, + memory: { type: "integer", description: "Free memory in megabytes" }, + load: { type: "number", format: "float", description: "Average CPU load" }, + }, + required: ["core", "database", "memory", "load"], + }, + }, + }, + tags: [ + { name: "Health", description: "Health check and monitoring endpoints" }, + { + name: "Authentication", + description: "User authentication and session management endpoints", + }, + { name: "Admin", description: "Admin account management endpoints" }, + { name: "Appointments", description: "Appointment management endpoints" }, + { name: "Clients", description: "Client management endpoints" }, + { name: "Channels", description: "Channel management endpoints" }, + { name: "Questionnaires", description: "Questionnaire management endpoints" }, + ], + }; } diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 423c07e..9809f69 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -4,17 +4,17 @@ import { ValidationError, NotFoundError } from "../../utils/errors"; // Mock dependencies before imports vi.mock("../../db", () => ({ - getTenantDb: vi.fn() + getTenantDb: vi.fn(), })); vi.mock("$lib/logger", () => ({ - default: { - setContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn(), - warn: vi.fn() - })) - } + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + })), + }, })); // Import after mocking @@ -23,469 +23,469 @@ import { getTenantDb } from "../../db"; // 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() - })), - orderBy: vi.fn(), - innerJoin: vi.fn(() => ({ - where: 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() - })) - })) + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + returning: vi.fn(), + })), + })), + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn(), + })), + orderBy: vi.fn(), + innerJoin: vi.fn(() => ({ + where: 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 mockAgent = { - id: "agent-123", - name: "Test Agent", - description: "Test description", - logo: Buffer.from("test") + id: "agent-123", + name: "Test Agent", + description: "Test description", + logo: Buffer.from("test"), }; describe("AgentService", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); - }); - - describe("forTenant", () => { - it("should create agent service for tenant", async () => { - const service = await AgentService.forTenant("tenant-123"); - - expect(service.tenantId).toBe("tenant-123"); - expect(getTenantDb).toHaveBeenCalledWith("tenant-123"); - }); - - it("should handle database connection error", async () => { - vi.mocked(getTenantDb).mockRejectedValue(new Error("DB connection failed")); - - await expect(AgentService.forTenant("tenant-123")).rejects.toThrow("DB connection failed"); - }); - }); - - describe("createAgent", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should create agent successfully", async () => { - const insertChain = { - values: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([mockAgent]) - })) - }; - mockDb.insert.mockReturnValue(insertChain); - - const request = { - name: "Test Agent", - description: "Test description", - logo: Buffer.from("test") - }; - - const result = await service.createAgent(request); - - expect(result).toEqual(mockAgent); - expect(mockDb.insert).toHaveBeenCalled(); - expect(insertChain.values).toHaveBeenCalledWith({ - name: "Test Agent", - description: "Test description", - logo: Buffer.from("test") - }); - }); - - it("should handle validation error for invalid name", async () => { - const request = { - name: "", - description: "Test description" - }; - - await expect(service.createAgent(request)).rejects.toThrow(ValidationError); - }); - - it("should handle database error during creation", async () => { - const insertChain = { - values: vi.fn(() => ({ - returning: vi.fn().mockRejectedValue(new Error("DB error")) - })) - }; - mockDb.insert.mockReturnValue(insertChain); - - const request = { - name: "Test Agent" - }; - - await expect(service.createAgent(request)).rejects.toThrow("DB error"); - }); - }); - - describe("getAgentById", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should return agent when found", async () => { - const selectChain = { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([mockAgent]) - })) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - const result = await service.getAgentById("agent-123"); - - expect(result).toEqual(mockAgent); - expect(mockDb.select).toHaveBeenCalled(); - }); - - it("should return null when agent not found", async () => { - const selectChain = { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn().mockResolvedValue([]) - })) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - const result = await service.getAgentById("nonexistent-agent"); - - expect(result).toBeNull(); - }); - - it("should handle database error", async () => { - const selectChain = { - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: vi.fn().mockRejectedValue(new Error("DB error")) - })) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - await expect(service.getAgentById("agent-123")).rejects.toThrow("DB error"); - }); - }); - - describe("getAllAgents", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should return all agents", async () => { - const agents = [mockAgent, { ...mockAgent, id: "agent-456", name: "Agent 2" }]; - const selectChain = { - from: vi.fn(() => ({ - orderBy: vi.fn().mockResolvedValue(agents) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - const result = await service.getAllAgents(); - - expect(result).toEqual(agents); - expect(result).toHaveLength(2); - }); - - it("should return empty array when no agents exist", async () => { - const selectChain = { - from: vi.fn(() => ({ - orderBy: vi.fn().mockResolvedValue([]) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - const result = await service.getAllAgents(); - - expect(result).toEqual([]); - expect(result).toHaveLength(0); - }); - - it("should handle database error", async () => { - const selectChain = { - from: vi.fn(() => ({ - orderBy: vi.fn().mockRejectedValue(new Error("DB error")) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - await expect(service.getAllAgents()).rejects.toThrow("DB error"); - }); - }); - - describe("updateAgent", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should update agent successfully", async () => { - const updatedAgent = { ...mockAgent, name: "Updated Agent" }; - const updateChain = { - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([updatedAgent]) - })) - })) - }; - mockDb.update.mockReturnValue(updateChain); - - const updateData = { name: "Updated Agent" }; - const result = await service.updateAgent("agent-123", updateData); - - expect(result).toEqual(updatedAgent); - expect(updateChain.set).toHaveBeenCalledWith(updateData); - }); - - it("should throw NotFoundError when agent not found", async () => { - const updateChain = { - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([]) - })) - })) - }; - mockDb.update.mockReturnValue(updateChain); - - const updateData = { name: "Updated Agent" }; - - await expect(service.updateAgent("nonexistent-agent", updateData)).rejects.toThrow( - NotFoundError - ); - }); - - it("should handle validation error", async () => { - const updateData = { name: "" }; - - await expect(service.updateAgent("agent-123", updateData)).rejects.toThrow(ValidationError); - }); - - it("should handle database error", async () => { - const updateChain = { - set: vi.fn(() => ({ - where: vi.fn(() => ({ - returning: vi.fn().mockRejectedValue(new Error("DB error")) - })) - })) - }; - mockDb.update.mockReturnValue(updateChain); - - const updateData = { name: "Updated Agent" }; - - await expect(service.updateAgent("agent-123", updateData)).rejects.toThrow("DB error"); - }); - }); - - describe("deleteAgent", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should delete agent successfully", async () => { - // Mock channel-agent deletion - const channelAgentDeleteChain = { - where: vi.fn().mockResolvedValue([]) - }; - mockDb.delete.mockReturnValueOnce(channelAgentDeleteChain); - - // Mock agent deletion - const agentDeleteChain = { - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([mockAgent]) - })) - }; - mockDb.delete.mockReturnValueOnce(agentDeleteChain); - - const result = await service.deleteAgent("agent-123"); - - expect(result).toBe(true); - expect(mockDb.delete).toHaveBeenCalledTimes(2); - }); - - it("should return false when agent not found", async () => { - // Mock channel-agent deletion - const channelAgentDeleteChain = { - where: vi.fn().mockResolvedValue([]) - }; - mockDb.delete.mockReturnValueOnce(channelAgentDeleteChain); - - // Mock agent deletion with no results - const agentDeleteChain = { - where: vi.fn(() => ({ - returning: vi.fn().mockResolvedValue([]) - })) - }; - mockDb.delete.mockReturnValueOnce(agentDeleteChain); - - const result = await service.deleteAgent("nonexistent-agent"); - - expect(result).toBe(false); - }); - - it("should handle database error", async () => { - const deleteChain = { - where: vi.fn().mockRejectedValue(new Error("DB error")) - }; - mockDb.delete.mockReturnValue(deleteChain); - - await expect(service.deleteAgent("agent-123")).rejects.toThrow("DB error"); - }); - }); - - describe("getAgentsByChannel", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should return agents for channel", async () => { - const agents = [mockAgent]; - const selectChain = { - from: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn().mockResolvedValue(agents) - })) - })) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - const result = await service.getAgentsByChannel("channel-123"); - - expect(result).toEqual(agents); - }); - - it("should return empty array when no agents assigned", async () => { - const selectChain = { - from: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn().mockResolvedValue([]) - })) - })) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - const result = await service.getAgentsByChannel("channel-123"); - - expect(result).toEqual([]); - }); - - it("should handle database error", async () => { - const selectChain = { - from: vi.fn(() => ({ - innerJoin: vi.fn(() => ({ - where: vi.fn(() => ({ - orderBy: vi.fn().mockRejectedValue(new Error("DB error")) - })) - })) - })) - }; - mockDb.select.mockReturnValue(selectChain); - - await expect(service.getAgentsByChannel("channel-123")).rejects.toThrow("DB error"); - }); - }); - - describe("assignAgentToChannel", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should assign agent to channel successfully", async () => { - const insertChain = { - values: vi.fn(() => ({ - onConflictDoNothing: vi.fn().mockResolvedValue([]), - returning: vi.fn().mockResolvedValue([]) - })) - }; - mockDb.insert.mockReturnValue(insertChain); - - await service.assignAgentToChannel("agent-123", "channel-123"); - - expect(insertChain.values).toHaveBeenCalledWith({ - agentId: "agent-123", - channelId: "channel-123" - }); - }); - - it("should handle database error", async () => { - const insertChain = { - values: vi.fn(() => ({ - onConflictDoNothing: vi.fn().mockRejectedValue(new Error("DB error")), - returning: vi.fn().mockResolvedValue([]) - })) - }; - mockDb.insert.mockReturnValue(insertChain); - - await expect(service.assignAgentToChannel("agent-123", "channel-123")).rejects.toThrow( - "DB error" - ); - }); - }); - - describe("removeAgentFromChannel", () => { - let service: AgentService; - - beforeEach(async () => { - service = await AgentService.forTenant("tenant-123"); - }); - - it("should remove agent from channel successfully", async () => { - const deleteChain = { - where: vi.fn().mockResolvedValue([]) - }; - mockDb.delete.mockReturnValue(deleteChain); - - await service.removeAgentFromChannel("agent-123", "channel-123"); - - expect(mockDb.delete).toHaveBeenCalled(); - }); - - it("should handle database error", async () => { - const deleteChain = { - where: vi.fn().mockRejectedValue(new Error("DB error")) - }; - mockDb.delete.mockReturnValue(deleteChain); - - await expect(service.removeAgentFromChannel("agent-123", "channel-123")).rejects.toThrow( - "DB error" - ); - }); - }); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + }); + + describe("forTenant", () => { + it("should create agent service for tenant", async () => { + const service = await AgentService.forTenant("tenant-123"); + + expect(service.tenantId).toBe("tenant-123"); + expect(getTenantDb).toHaveBeenCalledWith("tenant-123"); + }); + + it("should handle database connection error", async () => { + vi.mocked(getTenantDb).mockRejectedValue(new Error("DB connection failed")); + + await expect(AgentService.forTenant("tenant-123")).rejects.toThrow("DB connection failed"); + }); + }); + + describe("createAgent", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should create agent successfully", async () => { + const insertChain = { + values: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([mockAgent]), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + const request = { + name: "Test Agent", + description: "Test description", + logo: Buffer.from("test"), + }; + + const result = await service.createAgent(request); + + expect(result).toEqual(mockAgent); + expect(mockDb.insert).toHaveBeenCalled(); + expect(insertChain.values).toHaveBeenCalledWith({ + name: "Test Agent", + description: "Test description", + logo: Buffer.from("test"), + }); + }); + + it("should handle validation error for invalid name", async () => { + const request = { + name: "", + description: "Test description", + }; + + await expect(service.createAgent(request)).rejects.toThrow(ValidationError); + }); + + it("should handle database error during creation", async () => { + const insertChain = { + values: vi.fn(() => ({ + returning: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + const request = { + name: "Test Agent", + }; + + await expect(service.createAgent(request)).rejects.toThrow("DB error"); + }); + }); + + describe("getAgentById", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should return agent when found", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([mockAgent]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getAgentById("agent-123"); + + expect(result).toEqual(mockAgent); + expect(mockDb.select).toHaveBeenCalled(); + }); + + it("should return null when agent not found", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockResolvedValue([]), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getAgentById("nonexistent-agent"); + + expect(result).toBeNull(); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.getAgentById("agent-123")).rejects.toThrow("DB error"); + }); + }); + + describe("getAllAgents", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should return all agents", async () => { + const agents = [mockAgent, { ...mockAgent, id: "agent-456", name: "Agent 2" }]; + const selectChain = { + from: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue(agents), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getAllAgents(); + + expect(result).toEqual(agents); + expect(result).toHaveLength(2); + }); + + it("should return empty array when no agents exist", async () => { + const selectChain = { + from: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getAllAgents(); + + expect(result).toEqual([]); + expect(result).toHaveLength(0); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + orderBy: vi.fn().mockRejectedValue(new Error("DB error")), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.getAllAgents()).rejects.toThrow("DB error"); + }); + }); + + describe("updateAgent", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should update agent successfully", async () => { + const updatedAgent = { ...mockAgent, name: "Updated Agent" }; + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([updatedAgent]), + })), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + const updateData = { name: "Updated Agent" }; + const result = await service.updateAgent("agent-123", updateData); + + expect(result).toEqual(updatedAgent); + expect(updateChain.set).toHaveBeenCalledWith(updateData); + }); + + it("should throw NotFoundError when agent not found", async () => { + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + const updateData = { name: "Updated Agent" }; + + await expect(service.updateAgent("nonexistent-agent", updateData)).rejects.toThrow( + NotFoundError, + ); + }); + + it("should handle validation error", async () => { + const updateData = { name: "" }; + + await expect(service.updateAgent("agent-123", updateData)).rejects.toThrow(ValidationError); + }); + + it("should handle database error", async () => { + const updateChain = { + set: vi.fn(() => ({ + where: vi.fn(() => ({ + returning: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + }; + mockDb.update.mockReturnValue(updateChain); + + const updateData = { name: "Updated Agent" }; + + await expect(service.updateAgent("agent-123", updateData)).rejects.toThrow("DB error"); + }); + }); + + describe("deleteAgent", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should delete agent successfully", async () => { + // Mock channel-agent deletion + const channelAgentDeleteChain = { + where: vi.fn().mockResolvedValue([]), + }; + mockDb.delete.mockReturnValueOnce(channelAgentDeleteChain); + + // Mock agent deletion + const agentDeleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([mockAgent]), + })), + }; + mockDb.delete.mockReturnValueOnce(agentDeleteChain); + + const result = await service.deleteAgent("agent-123"); + + expect(result).toBe(true); + expect(mockDb.delete).toHaveBeenCalledTimes(2); + }); + + it("should return false when agent not found", async () => { + // Mock channel-agent deletion + const channelAgentDeleteChain = { + where: vi.fn().mockResolvedValue([]), + }; + mockDb.delete.mockReturnValueOnce(channelAgentDeleteChain); + + // Mock agent deletion with no results + const agentDeleteChain = { + where: vi.fn(() => ({ + returning: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.delete.mockReturnValueOnce(agentDeleteChain); + + const result = await service.deleteAgent("nonexistent-agent"); + + expect(result).toBe(false); + }); + + it("should handle database error", async () => { + const deleteChain = { + where: vi.fn().mockRejectedValue(new Error("DB error")), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await expect(service.deleteAgent("agent-123")).rejects.toThrow("DB error"); + }); + }); + + describe("getAgentsByChannel", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should return agents for channel", async () => { + const agents = [mockAgent]; + const selectChain = { + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue(agents), + })), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getAgentsByChannel("channel-123"); + + expect(result).toEqual(agents); + }); + + it("should return empty array when no agents assigned", async () => { + const selectChain = { + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockResolvedValue([]), + })), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + const result = await service.getAgentsByChannel("channel-123"); + + expect(result).toEqual([]); + }); + + it("should handle database error", async () => { + const selectChain = { + from: vi.fn(() => ({ + innerJoin: vi.fn(() => ({ + where: vi.fn(() => ({ + orderBy: vi.fn().mockRejectedValue(new Error("DB error")), + })), + })), + })), + }; + mockDb.select.mockReturnValue(selectChain); + + await expect(service.getAgentsByChannel("channel-123")).rejects.toThrow("DB error"); + }); + }); + + describe("assignAgentToChannel", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should assign agent to channel successfully", async () => { + const insertChain = { + values: vi.fn(() => ({ + onConflictDoNothing: vi.fn().mockResolvedValue([]), + returning: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + await service.assignAgentToChannel("agent-123", "channel-123"); + + expect(insertChain.values).toHaveBeenCalledWith({ + agentId: "agent-123", + channelId: "channel-123", + }); + }); + + it("should handle database error", async () => { + const insertChain = { + values: vi.fn(() => ({ + onConflictDoNothing: vi.fn().mockRejectedValue(new Error("DB error")), + returning: vi.fn().mockResolvedValue([]), + })), + }; + mockDb.insert.mockReturnValue(insertChain); + + await expect(service.assignAgentToChannel("agent-123", "channel-123")).rejects.toThrow( + "DB error", + ); + }); + }); + + describe("removeAgentFromChannel", () => { + let service: AgentService; + + beforeEach(async () => { + service = await AgentService.forTenant("tenant-123"); + }); + + it("should remove agent from channel successfully", async () => { + const deleteChain = { + where: vi.fn().mockResolvedValue([]), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await service.removeAgentFromChannel("agent-123", "channel-123"); + + expect(mockDb.delete).toHaveBeenCalled(); + }); + + it("should handle database error", async () => { + const deleteChain = { + where: vi.fn().mockRejectedValue(new Error("DB error")), + }; + mockDb.delete.mockReturnValue(deleteChain); + + await expect(service.removeAgentFromChannel("agent-123", "channel-123")).rejects.toThrow( + "DB error", + ); + }); + }); }); diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index 6c76df8..6557db2 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -4,38 +4,38 @@ import { ValidationError } from "../../utils/errors"; // Mock dependencies before imports vi.mock("../../db", () => ({ - getTenantDb: vi.fn(), - centralDb: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => Promise.resolve([])) - })) - })), - insert: vi.fn(), - update: vi.fn(), - delete: vi.fn() - } + getTenantDb: vi.fn(), + centralDb: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve([])), + })), + })), + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, })); vi.mock("$lib/logger", () => ({ - default: { - setContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn(), - warn: vi.fn() - })) - } + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + })), + }, })); vi.mock("../../db/tenant-config", () => ({ - TenantConfig: { - create: vi.fn(() => - Promise.resolve({ - configuration: { nextChannelColor: 0 }, - setConfig: vi.fn() - }) - ) - } + TenantConfig: { + create: vi.fn(() => + Promise.resolve({ + configuration: { nextChannelColor: 0 }, + setConfig: vi.fn(), + }), + ), + }, })); // Import after mocking @@ -44,324 +44,324 @@ import { getTenantDb } from "../../db"; // Mock data with valid UUIDs const mockChannel = { - id: "550e8400-e29b-41d4-a716-446655440000", - names: ["Test Channel"], - color: "#FF0000", - descriptions: ["Test description"], - languages: ["de"], - isPublic: true, - requiresConfirmation: false, - pause: false + id: "550e8400-e29b-41d4-a716-446655440000", + names: ["Test Channel"], + color: "#FF0000", + descriptions: ["Test description"], + languages: ["de"], + isPublic: true, + requiresConfirmation: false, + pause: false, }; const mockAgent = { - id: "550e8400-e29b-41d4-a716-446655440001", - name: "Test Agent", - description: "Test description", - logo: null + id: "550e8400-e29b-41d4-a716-446655440001", + name: "Test Agent", + description: "Test description", + logo: null, }; const mockSlotTemplate = { - id: "550e8400-e29b-41d4-a716-446655440002", - name: "Test Slot", - weekdays: 31, - from: "09:00", - to: "17:00", - duration: 30 + id: "550e8400-e29b-41d4-a716-446655440002", + name: "Test Slot", + weekdays: 31, + from: "09:00", + to: "17:00", + duration: 30, }; // Simple mock database const mockDb = { - transaction: vi.fn(), - insert: vi.fn(), - select: vi.fn(), - update: vi.fn(), - delete: vi.fn() + transaction: vi.fn(), + insert: vi.fn(), + select: vi.fn(), + update: vi.fn(), + delete: vi.fn(), }; describe("ChannelService", () => { - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); - }); - - describe("forTenant", () => { - it("should create channel service for tenant", async () => { - const service = await ChannelService.forTenant("tenant-123"); - - expect(service.tenantId).toBe("tenant-123"); - expect(getTenantDb).toHaveBeenCalledWith("tenant-123"); - }); - - it("should handle database connection error", async () => { - vi.mocked(getTenantDb).mockRejectedValue(new Error("DB connection failed")); - - await expect(ChannelService.forTenant("tenant-123")).rejects.toThrow("DB connection failed"); - }); - }); - - describe("createChannel", () => { - let service: ChannelService; - - beforeEach(async () => { - service = await ChannelService.forTenant("tenant-123"); - }); - - it("should create channel successfully", async () => { - const expectedResult = { - ...mockChannel, - agents: [mockAgent], - slotTemplates: [mockSlotTemplate] - }; - - mockDb.transaction.mockResolvedValue(expectedResult); - - // Use minimal valid request that matches schema exactly - const request = { - names: ["Test Channel"], - languages: ["de"] - }; - - const result = await service.createChannel(request as any); - - expect(result).toEqual(expectedResult); - expect(mockDb.transaction).toHaveBeenCalled(); - }); - - it("should create channel with slot templates and agents", async () => { - const expectedResult = { - ...mockChannel, - agents: [mockAgent], - slotTemplates: [mockSlotTemplate] - }; - - mockDb.transaction.mockResolvedValue(expectedResult); - - const request = { - names: ["Test Channel"], - languages: ["de"], - agentIds: ["550e8400-e29b-41d4-a716-446655440001"], - slotTemplates: [ - { - name: "Test Slot", - from: "09:00", - to: "17:00", - duration: 30 - } - ] - }; - - const result = await service.createChannel(request); - - expect(result).toEqual(expectedResult); - expect(mockDb.transaction).toHaveBeenCalled(); - }); - - it("should handle validation error for invalid name", async () => { - const request = { - names: [""], - languages: ["de"], - agentIds: [], - slotTemplates: [] - }; - - await expect(service.createChannel(request)).rejects.toThrow(ValidationError); - }); - - it("should handle validation error for invalid time format", async () => { - const request = { - names: ["Test Channel"], - languages: ["de"], - agentIds: [], - slotTemplates: [ - { - name: "Test Slot", - from: "invalid-time", - to: "17:00", - duration: 30 - } - ] - }; - - await expect(service.createChannel(request)).rejects.toThrow(ValidationError); - }); - - it("should handle database transaction error", async () => { - mockDb.transaction.mockRejectedValue(new Error("Transaction failed")); - - const request = { - names: ["Test Channel"], - languages: ["de"], - agentIds: [], - slotTemplates: [] - }; - - await expect(service.createChannel(request)).rejects.toThrow("Transaction failed"); - }); - }); - - describe("updateChannel", () => { - let service: ChannelService; - - beforeEach(async () => { - service = await ChannelService.forTenant("tenant-123"); - }); - - it("should update channel successfully", async () => { - const expectedResult = { - ...mockChannel, - names: ["Updated Channel"], - agents: [], - slotTemplates: [] - }; - - mockDb.transaction.mockResolvedValue(expectedResult); - - const updateData = { - names: ["Updated Channel"] - }; - - const result = await service.updateChannel( - "550e8400-e29b-41d4-a716-446655440000", - updateData - ); - - expect(result).toEqual(expectedResult); - expect(mockDb.transaction).toHaveBeenCalled(); - }); - - it("should handle validation error for invalid name", async () => { - const updateData = { names: [""], languages: ["de"] }; - - await expect( - service.updateChannel("550e8400-e29b-41d4-a716-446655440000", updateData) - ).rejects.toThrow(ValidationError); - }); - - it("should handle transaction error", async () => { - mockDb.transaction.mockRejectedValue(new Error("Transaction failed")); - - const updateData = { names: ["Updated Channel"], languages: ["de"] }; - - await expect( - service.updateChannel("550e8400-e29b-41d4-a716-446655440000", updateData) - ).rejects.toThrow("Transaction failed"); - }); - }); - - describe("getChannelById", () => { - let service: ChannelService; - - beforeEach(async () => { - service = await ChannelService.forTenant("tenant-123"); - }); - - it("should return channel when found", async () => { - vi.spyOn(service, "getChannelById").mockResolvedValue({ - ...mockChannel, - agents: [mockAgent], - slotTemplates: [mockSlotTemplate] - }); - - const result = await service.getChannelById("550e8400-e29b-41d4-a716-446655440000"); - - // Test basic properties without deep nesting - expect(result).not.toBeNull(); - expect(result?.id).toBe(mockChannel.id); - expect(result?.names).toEqual(mockChannel.names); - expect(result?.agents).toHaveLength(1); - expect(result?.slotTemplates).toHaveLength(1); - }); - - it("should return null when channel not found", async () => { - vi.spyOn(service, "getChannelById").mockResolvedValue(null); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); + }); + + describe("forTenant", () => { + it("should create channel service for tenant", async () => { + const service = await ChannelService.forTenant("tenant-123"); + + expect(service.tenantId).toBe("tenant-123"); + expect(getTenantDb).toHaveBeenCalledWith("tenant-123"); + }); + + it("should handle database connection error", async () => { + vi.mocked(getTenantDb).mockRejectedValue(new Error("DB connection failed")); + + await expect(ChannelService.forTenant("tenant-123")).rejects.toThrow("DB connection failed"); + }); + }); + + describe("createChannel", () => { + let service: ChannelService; + + beforeEach(async () => { + service = await ChannelService.forTenant("tenant-123"); + }); + + it("should create channel successfully", async () => { + const expectedResult = { + ...mockChannel, + agents: [mockAgent], + slotTemplates: [mockSlotTemplate], + }; + + mockDb.transaction.mockResolvedValue(expectedResult); + + // Use minimal valid request that matches schema exactly + const request = { + names: ["Test Channel"], + languages: ["de"], + }; + + const result = await service.createChannel(request as any); + + expect(result).toEqual(expectedResult); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it("should create channel with slot templates and agents", async () => { + const expectedResult = { + ...mockChannel, + agents: [mockAgent], + slotTemplates: [mockSlotTemplate], + }; + + mockDb.transaction.mockResolvedValue(expectedResult); + + const request = { + names: ["Test Channel"], + languages: ["de"], + agentIds: ["550e8400-e29b-41d4-a716-446655440001"], + slotTemplates: [ + { + name: "Test Slot", + from: "09:00", + to: "17:00", + duration: 30, + }, + ], + }; + + const result = await service.createChannel(request); + + expect(result).toEqual(expectedResult); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it("should handle validation error for invalid name", async () => { + const request = { + names: [""], + languages: ["de"], + agentIds: [], + slotTemplates: [], + }; + + await expect(service.createChannel(request)).rejects.toThrow(ValidationError); + }); + + it("should handle validation error for invalid time format", async () => { + const request = { + names: ["Test Channel"], + languages: ["de"], + agentIds: [], + slotTemplates: [ + { + name: "Test Slot", + from: "invalid-time", + to: "17:00", + duration: 30, + }, + ], + }; + + await expect(service.createChannel(request)).rejects.toThrow(ValidationError); + }); + + it("should handle database transaction error", async () => { + mockDb.transaction.mockRejectedValue(new Error("Transaction failed")); + + const request = { + names: ["Test Channel"], + languages: ["de"], + agentIds: [], + slotTemplates: [], + }; + + await expect(service.createChannel(request)).rejects.toThrow("Transaction failed"); + }); + }); + + describe("updateChannel", () => { + let service: ChannelService; + + beforeEach(async () => { + service = await ChannelService.forTenant("tenant-123"); + }); + + it("should update channel successfully", async () => { + const expectedResult = { + ...mockChannel, + names: ["Updated Channel"], + agents: [], + slotTemplates: [], + }; + + mockDb.transaction.mockResolvedValue(expectedResult); + + const updateData = { + names: ["Updated Channel"], + }; + + const result = await service.updateChannel( + "550e8400-e29b-41d4-a716-446655440000", + updateData, + ); + + expect(result).toEqual(expectedResult); + expect(mockDb.transaction).toHaveBeenCalled(); + }); + + it("should handle validation error for invalid name", async () => { + const updateData = { names: [""], languages: ["de"] }; + + await expect( + service.updateChannel("550e8400-e29b-41d4-a716-446655440000", updateData), + ).rejects.toThrow(ValidationError); + }); + + it("should handle transaction error", async () => { + mockDb.transaction.mockRejectedValue(new Error("Transaction failed")); + + const updateData = { names: ["Updated Channel"], languages: ["de"] }; + + await expect( + service.updateChannel("550e8400-e29b-41d4-a716-446655440000", updateData), + ).rejects.toThrow("Transaction failed"); + }); + }); + + describe("getChannelById", () => { + let service: ChannelService; + + beforeEach(async () => { + service = await ChannelService.forTenant("tenant-123"); + }); + + it("should return channel when found", async () => { + vi.spyOn(service, "getChannelById").mockResolvedValue({ + ...mockChannel, + agents: [mockAgent], + slotTemplates: [mockSlotTemplate], + }); + + const result = await service.getChannelById("550e8400-e29b-41d4-a716-446655440000"); + + // Test basic properties without deep nesting + expect(result).not.toBeNull(); + expect(result?.id).toBe(mockChannel.id); + expect(result?.names).toEqual(mockChannel.names); + expect(result?.agents).toHaveLength(1); + expect(result?.slotTemplates).toHaveLength(1); + }); + + it("should return null when channel not found", async () => { + vi.spyOn(service, "getChannelById").mockResolvedValue(null); - const result = await service.getChannelById("nonexistent-channel"); + const result = await service.getChannelById("nonexistent-channel"); - expect(result).toBeNull(); - }); + expect(result).toBeNull(); + }); - it("should handle database error", async () => { - vi.spyOn(service, "getChannelById").mockRejectedValue(new Error("DB error")); + it("should handle database error", async () => { + vi.spyOn(service, "getChannelById").mockRejectedValue(new Error("DB error")); - await expect(service.getChannelById("550e8400-e29b-41d4-a716-446655440000")).rejects.toThrow( - "DB error" - ); - }); - }); + await expect(service.getChannelById("550e8400-e29b-41d4-a716-446655440000")).rejects.toThrow( + "DB error", + ); + }); + }); - describe("getAllChannels", () => { - let service: ChannelService; + describe("getAllChannels", () => { + let service: ChannelService; - beforeEach(async () => { - service = await ChannelService.forTenant("tenant-123"); - }); + beforeEach(async () => { + service = await ChannelService.forTenant("tenant-123"); + }); - it("should return all channels", async () => { - const expectedChannels = [ - { - ...mockChannel, - agents: [mockAgent], - slotTemplates: [mockSlotTemplate] - } - ]; + it("should return all channels", async () => { + const expectedChannels = [ + { + ...mockChannel, + agents: [mockAgent], + slotTemplates: [mockSlotTemplate], + }, + ]; - vi.spyOn(service, "getAllChannels").mockResolvedValue(expectedChannels); + vi.spyOn(service, "getAllChannels").mockResolvedValue(expectedChannels); - const result = await service.getAllChannels(); + const result = await service.getAllChannels(); - // Test without deep object comparison - expect(result).toHaveLength(1); - expect(result[0]).toBeDefined(); - expect(result[0].id).toBe(mockChannel.id); - expect(result[0].names).toEqual(mockChannel.names); - }); + // Test without deep object comparison + expect(result).toHaveLength(1); + expect(result[0]).toBeDefined(); + expect(result[0].id).toBe(mockChannel.id); + expect(result[0].names).toEqual(mockChannel.names); + }); - it("should return empty array when no channels exist", async () => { - vi.spyOn(service, "getAllChannels").mockResolvedValue([]); + it("should return empty array when no channels exist", async () => { + vi.spyOn(service, "getAllChannels").mockResolvedValue([]); - const result = await service.getAllChannels(); + const result = await service.getAllChannels(); - expect(result).toEqual([]); - }); + expect(result).toEqual([]); + }); - it("should handle database error", async () => { - vi.spyOn(service, "getAllChannels").mockRejectedValue(new Error("DB error")); + it("should handle database error", async () => { + vi.spyOn(service, "getAllChannels").mockRejectedValue(new Error("DB error")); - await expect(service.getAllChannels()).rejects.toThrow("DB error"); - }); - }); + await expect(service.getAllChannels()).rejects.toThrow("DB error"); + }); + }); - describe("deleteChannel", () => { - let service: ChannelService; + describe("deleteChannel", () => { + let service: ChannelService; - beforeEach(async () => { - service = await ChannelService.forTenant("tenant-123"); - }); + beforeEach(async () => { + service = await ChannelService.forTenant("tenant-123"); + }); - it("should delete channel successfully", async () => { - mockDb.transaction.mockResolvedValue(true); + it("should delete channel successfully", async () => { + mockDb.transaction.mockResolvedValue(true); - const result = await service.deleteChannel("550e8400-e29b-41d4-a716-446655440000"); + const result = await service.deleteChannel("550e8400-e29b-41d4-a716-446655440000"); - expect(result).toBe(true); - expect(mockDb.transaction).toHaveBeenCalled(); - }); + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalled(); + }); - it("should return false when channel not found", async () => { - mockDb.transaction.mockResolvedValue(false); + it("should return false when channel not found", async () => { + mockDb.transaction.mockResolvedValue(false); - const result = await service.deleteChannel("nonexistent-channel"); + const result = await service.deleteChannel("nonexistent-channel"); - expect(result).toBe(false); - }); + expect(result).toBe(false); + }); - it("should handle database error", async () => { - mockDb.transaction.mockRejectedValue(new Error("DB error")); + it("should handle database error", async () => { + mockDb.transaction.mockRejectedValue(new Error("DB error")); - await expect(service.deleteChannel("550e8400-e29b-41d4-a716-446655440000")).rejects.toThrow( - "DB error" - ); - }); - }); + await expect(service.deleteChannel("550e8400-e29b-41d4-a716-446655440000")).rejects.toThrow( + "DB error", + ); + }); + }); }); diff --git a/src/lib/server/services/__tests__/tenant-admin-service.test.ts b/src/lib/server/services/__tests__/tenant-admin-service.test.ts index 4d20d8e..335c8a5 100644 --- a/src/lib/server/services/__tests__/tenant-admin-service.test.ts +++ b/src/lib/server/services/__tests__/tenant-admin-service.test.ts @@ -3,342 +3,342 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Mock the database module vi.mock("../../db", () => ({ - centralDb: { - insert: vi.fn(), - select: vi.fn(), - update: vi.fn(), - delete: vi.fn() - }, - getTenantDb: vi.fn() + centralDb: { + insert: vi.fn(), + select: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, + getTenantDb: vi.fn(), })); // Mock TenantConfig vi.mock("../../db/tenant-config", () => ({ - TenantConfig: { - create: vi.fn() - } + TenantConfig: { + create: vi.fn(), + }, })); // Mock TenantMigrationService vi.mock("../tenant-migration-service", () => ({ - TenantMigrationService: { - createAndInitializeTenantDatabase: vi.fn() - } + TenantMigrationService: { + createAndInitializeTenantDatabase: vi.fn(), + }, })); // Mock environment variables vi.mock("$env/dynamic/private", () => ({ - env: { - DATABASE_URL: "postgresql://user:pass@localhost:5432/central_db" - } + env: { + DATABASE_URL: "postgresql://user:pass@localhost:5432/central_db", + }, })); // Import the service after mocks are set up import { TenantAdminService } from "../tenant-admin-service"; describe("TenantAdminService", () => { - let mockCentralDb: any; - let mockGetTenantDb: any; - let mockTenantConfig: any; - let mockTenantMigrationService: any; + let mockCentralDb: any; + let mockGetTenantDb: any; + let mockTenantConfig: any; + let mockTenantMigrationService: any; - beforeEach(async () => { - vi.clearAllMocks(); + beforeEach(async () => { + vi.clearAllMocks(); - // Get mocked modules - const dbModule = await vi.importMock("../../db"); - mockCentralDb = dbModule.centralDb; - mockGetTenantDb = dbModule.getTenantDb; + // Get mocked modules + const dbModule = await vi.importMock("../../db"); + mockCentralDb = dbModule.centralDb; + mockGetTenantDb = dbModule.getTenantDb; - const configModule = await vi.importMock("../../db/tenant-config"); - mockTenantConfig = configModule.TenantConfig; + const configModule = await vi.importMock("../../db/tenant-config"); + mockTenantConfig = configModule.TenantConfig; - const migrationModule = await vi.importMock("../tenant-migration-service"); - mockTenantMigrationService = migrationModule.TenantMigrationService; - }); + const migrationModule = await vi.importMock("../tenant-migration-service"); + mockTenantMigrationService = migrationModule.TenantMigrationService; + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("createTenant", () => { - it("should create a new tenant with default configuration", async () => { - const newTenant = { - shortName: "test-clinic", - longName: "", - description: "A test clinic" - }; + describe("createTenant", () => { + it("should create a new tenant with default configuration", async () => { + const newTenant = { + shortName: "test-clinic", + longName: "", + description: "A test clinic", + }; - const mockCreatedTenant = { - id: "tenant-123", - ...newTenant, - databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic" - }; + const mockCreatedTenant = { + id: "tenant-123", + ...newTenant, + databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic", + }; - const mockConfig = { - setConfig: vi.fn() - }; + const mockConfig = { + setConfig: vi.fn(), + }; - const mockInsertBuilder = { - values: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockCreatedTenant]) - }; + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockCreatedTenant]), + }; - const mockDeleteBuilder = { - where: vi.fn().mockResolvedValue({ count: 1 }) - }; + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue({ count: 1 }), + }; - mockCentralDb.insert.mockReturnValue(mockInsertBuilder); - mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); - mockTenantConfig.create.mockResolvedValue(mockConfig); - mockTenantMigrationService.createAndInitializeTenantDatabase.mockResolvedValue(); + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockTenantMigrationService.createAndInitializeTenantDatabase.mockResolvedValue(); - const result = await TenantAdminService.createTenant(newTenant); + const result = await TenantAdminService.createTenant(newTenant); - expect(mockCentralDb.insert).toHaveBeenCalled(); - expect(mockInsertBuilder.values).toHaveBeenCalledWith({ - ...newTenant, - databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic" - }); - expect(mockTenantMigrationService.createAndInitializeTenantDatabase).toHaveBeenCalledWith( - "postgresql://user:pass@localhost:5432/test-clinic" - ); - expect(mockTenantConfig.create).toHaveBeenCalledWith("tenant-123"); - expect(mockConfig.setConfig).toHaveBeenCalledWith("brandColor", "#E11E15"); - expect(result).toBeInstanceOf(TenantAdminService); - }); + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith({ + ...newTenant, + databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic", + }); + expect(mockTenantMigrationService.createAndInitializeTenantDatabase).toHaveBeenCalledWith( + "postgresql://user:pass@localhost:5432/test-clinic", + ); + expect(mockTenantConfig.create).toHaveBeenCalledWith("tenant-123"); + expect(mockConfig.setConfig).toHaveBeenCalledWith("brandColor", "#E11E15"); + expect(result).toBeInstanceOf(TenantAdminService); + }); - it("should rollback tenant creation if database initialization fails", async () => { - const newTenant = { - shortName: "test-clinic", - longName: "", - description: "A test clinic" - }; + it("should rollback tenant creation if database initialization fails", async () => { + const newTenant = { + shortName: "test-clinic", + longName: "", + description: "A test clinic", + }; - const mockCreatedTenant = { - id: "tenant-123", - ...newTenant, - databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic" - }; + const mockCreatedTenant = { + id: "tenant-123", + ...newTenant, + databaseUrl: "postgresql://user:pass@localhost:5432/test-clinic", + }; - const mockInsertBuilder = { - values: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockCreatedTenant]) - }; + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockCreatedTenant]), + }; - const mockDeleteBuilder = { - where: vi.fn().mockResolvedValue({ count: 1 }) - }; + const mockDeleteBuilder = { + where: vi.fn().mockResolvedValue({ count: 1 }), + }; - mockCentralDb.insert.mockReturnValue(mockInsertBuilder); - mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); - mockTenantMigrationService.createAndInitializeTenantDatabase.mockRejectedValue( - new Error("Database initialization failed") - ); + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + mockTenantMigrationService.createAndInitializeTenantDatabase.mockRejectedValue( + new Error("Database initialization failed"), + ); - await expect(TenantAdminService.createTenant(newTenant)).rejects.toThrow( - "Failed to initialize tenant database" - ); + await expect(TenantAdminService.createTenant(newTenant)).rejects.toThrow( + "Failed to initialize tenant database", + ); - expect(mockCentralDb.delete).toHaveBeenCalled(); - expect(mockDeleteBuilder.where).toHaveBeenCalled(); - }); - }); + expect(mockCentralDb.delete).toHaveBeenCalled(); + expect(mockDeleteBuilder.where).toHaveBeenCalled(); + }); + }); - describe("getTenantById", () => { - it("should get tenant by ID and initialize configuration", async () => { - const tenantId = "tenant-123"; - const mockConfig = { - setConfig: vi.fn(), - getConfig: vi.fn() - }; + describe("getTenantById", () => { + it("should get tenant by ID and initialize configuration", async () => { + const tenantId = "tenant-123"; + const mockConfig = { + setConfig: vi.fn(), + getConfig: vi.fn(), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); + mockTenantConfig.create.mockResolvedValue(mockConfig); - const result = await TenantAdminService.getTenantById(tenantId); + const result = await TenantAdminService.getTenantById(tenantId); - expect(mockTenantConfig.create).toHaveBeenCalledWith(tenantId); - expect(result).toBeInstanceOf(TenantAdminService); - expect(result.tenantId).toBe(tenantId); - }); - }); + expect(mockTenantConfig.create).toHaveBeenCalledWith(tenantId); + expect(result).toBeInstanceOf(TenantAdminService); + expect(result.tenantId).toBe(tenantId); + }); + }); - describe("getDb", () => { - it("should return database connection", async () => { - const tenantId = "tenant-123"; - const mockConfig = { setConfig: vi.fn() }; - const mockTenantDb = { - select: vi.fn(), - insert: vi.fn(), - update: vi.fn() - }; + describe("getDb", () => { + it("should return database connection", async () => { + const tenantId = "tenant-123"; + const mockConfig = { setConfig: vi.fn() }; + const mockTenantDb = { + select: vi.fn(), + insert: vi.fn(), + update: vi.fn(), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); - mockGetTenantDb.mockResolvedValue(mockTenantDb); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockGetTenantDb.mockResolvedValue(mockTenantDb); - const service = await TenantAdminService.getTenantById(tenantId); - const db = await service.getDb(); + const service = await TenantAdminService.getTenantById(tenantId); + const db = await service.getDb(); - expect(mockGetTenantDb).toHaveBeenCalledWith(tenantId); - expect(db).toBe(mockTenantDb); - }); + expect(mockGetTenantDb).toHaveBeenCalledWith(tenantId); + expect(db).toBe(mockTenantDb); + }); - it("should cache database connection", async () => { - const tenantId = "tenant-123"; - const mockConfig = { setConfig: vi.fn() }; - const mockTenantDb = { select: vi.fn() }; + it("should cache database connection", async () => { + const tenantId = "tenant-123"; + const mockConfig = { setConfig: vi.fn() }; + const mockTenantDb = { select: vi.fn() }; - mockTenantConfig.create.mockResolvedValue(mockConfig); - mockGetTenantDb.mockResolvedValue(mockTenantDb); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockGetTenantDb.mockResolvedValue(mockTenantDb); - const service = await TenantAdminService.getTenantById(tenantId); + const service = await TenantAdminService.getTenantById(tenantId); - // First call - await service.getDb(); - // Second call should use cache - await service.getDb(); + // First call + await service.getDb(); + // Second call should use cache + await service.getDb(); - expect(mockGetTenantDb).toHaveBeenCalledTimes(1); - }); - }); + expect(mockGetTenantDb).toHaveBeenCalledTimes(1); + }); + }); - describe("configuration", () => { - it("should provide access to tenant configuration", async () => { - const tenantId = "tenant-123"; - const mockConfig = { - setConfig: vi.fn(), - getConfig: vi.fn().mockReturnValue("test-value") - }; + describe("configuration", () => { + it("should provide access to tenant configuration", async () => { + const tenantId = "tenant-123"; + const mockConfig = { + setConfig: vi.fn(), + getConfig: vi.fn().mockReturnValue("test-value"), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); + mockTenantConfig.create.mockResolvedValue(mockConfig); - const service = await TenantAdminService.getTenantById(tenantId); - const config = service.configuration; + const service = await TenantAdminService.getTenantById(tenantId); + const config = service.configuration; - expect(config).toBe(mockConfig); - }); - }); + expect(config).toBe(mockConfig); + }); + }); - describe("updateTenantData", () => { - it("should update tenant data successfully", async () => { - const tenantId = "tenant-123"; - const updateData = { - longName: "Updated Clinic Name", - description: "Updated description", - logo: Buffer.from("logo data") - }; + describe("updateTenantData", () => { + it("should update tenant data successfully", async () => { + const tenantId = "tenant-123"; + const updateData = { + longName: "Updated Clinic Name", + description: "Updated description", + logo: Buffer.from("logo data"), + }; - const mockUpdatedTenant = { - id: tenantId, - shortName: "test-clinic", - ...updateData, - updatedAt: new Date() - }; + const mockUpdatedTenant = { + id: tenantId, + shortName: "test-clinic", + ...updateData, + updatedAt: new Date(), + }; - const mockConfig = { setConfig: vi.fn() }; - const mockUpdateBuilder = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockUpdatedTenant]) - }; + const mockConfig = { setConfig: vi.fn() }; + const mockUpdateBuilder = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockUpdatedTenant]), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); - mockCentralDb.update.mockReturnValue(mockUpdateBuilder); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - const service = await TenantAdminService.getTenantById(tenantId); - const result = await service.updateTenantData(updateData); + const service = await TenantAdminService.getTenantById(tenantId); + const result = await service.updateTenantData(updateData); - expect(mockCentralDb.update).toHaveBeenCalled(); - expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - ...updateData, - updatedAt: expect.any(Date) - }); - expect(mockUpdateBuilder.where).toHaveBeenCalled(); - expect(result).toEqual(mockUpdatedTenant); - }); + expect(mockCentralDb.update).toHaveBeenCalled(); + expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ + ...updateData, + updatedAt: expect.any(Date), + }); + expect(mockUpdateBuilder.where).toHaveBeenCalled(); + expect(result).toEqual(mockUpdatedTenant); + }); - it("should throw NotFoundError when tenant not found", async () => { - const tenantId = "tenant-123"; - const updateData = { longName: "Updated Name" }; + it("should throw NotFoundError when tenant not found", async () => { + const tenantId = "tenant-123"; + const updateData = { longName: "Updated Name" }; - const mockConfig = { setConfig: vi.fn() }; - const mockUpdateBuilder = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([]) - }; + const mockConfig = { setConfig: vi.fn() }; + const mockUpdateBuilder = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([]), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); - mockCentralDb.update.mockReturnValue(mockUpdateBuilder); + mockTenantConfig.create.mockResolvedValue(mockConfig); + mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - const service = await TenantAdminService.getTenantById(tenantId); + const service = await TenantAdminService.getTenantById(tenantId); - await expect(service.updateTenantData(updateData)).rejects.toThrow( - "Tenant with ID tenant-123 not found" - ); - }); - }); + await expect(service.updateTenantData(updateData)).rejects.toThrow( + "Tenant with ID tenant-123 not found", + ); + }); + }); - describe("updateTenantConfig", () => { - it("should update tenant configuration successfully", async () => { - const tenantId = "tenant-123"; - const configUpdates = { - brandColor: "#FF0000", - maxChannels: 10, - requireEmail: false - }; + describe("updateTenantConfig", () => { + it("should update tenant configuration successfully", async () => { + const tenantId = "tenant-123"; + const configUpdates = { + brandColor: "#FF0000", + maxChannels: 10, + requireEmail: false, + }; - const mockConfig = { - setConfig: vi.fn().mockResolvedValue(undefined) - }; + const mockConfig = { + setConfig: vi.fn().mockResolvedValue(undefined), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); + mockTenantConfig.create.mockResolvedValue(mockConfig); - const service = await TenantAdminService.getTenantById(tenantId); - const result = await service.updateTenantConfig(configUpdates); + const service = await TenantAdminService.getTenantById(tenantId); + const result = await service.updateTenantConfig(configUpdates); - expect(mockConfig.setConfig).toHaveBeenCalledTimes(3); - expect(mockConfig.setConfig).toHaveBeenCalledWith("brandColor", "#FF0000"); - expect(mockConfig.setConfig).toHaveBeenCalledWith("maxChannels", 10); - expect(mockConfig.setConfig).toHaveBeenCalledWith("requireEmail", false); + expect(mockConfig.setConfig).toHaveBeenCalledTimes(3); + expect(mockConfig.setConfig).toHaveBeenCalledWith("brandColor", "#FF0000"); + expect(mockConfig.setConfig).toHaveBeenCalledWith("maxChannels", 10); + expect(mockConfig.setConfig).toHaveBeenCalledWith("requireEmail", false); - expect(result).toEqual([ - { key: "brandColor", value: "#FF0000" }, - { key: "maxChannels", value: 10 }, - { key: "requireEmail", value: false } - ]); - }); + expect(result).toEqual([ + { key: "brandColor", value: "#FF0000" }, + { key: "maxChannels", value: 10 }, + { key: "requireEmail", value: false }, + ]); + }); - it("should handle empty config updates", async () => { - const tenantId = "tenant-123"; - const configUpdates = {}; + it("should handle empty config updates", async () => { + const tenantId = "tenant-123"; + const configUpdates = {}; - const mockConfig = { - setConfig: vi.fn().mockResolvedValue(undefined) - }; + const mockConfig = { + setConfig: vi.fn().mockResolvedValue(undefined), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); + mockTenantConfig.create.mockResolvedValue(mockConfig); - const service = await TenantAdminService.getTenantById(tenantId); - const result = await service.updateTenantConfig(configUpdates); + const service = await TenantAdminService.getTenantById(tenantId); + const result = await service.updateTenantConfig(configUpdates); - expect(mockConfig.setConfig).not.toHaveBeenCalled(); - expect(result).toEqual([]); - }); + expect(mockConfig.setConfig).not.toHaveBeenCalled(); + expect(result).toEqual([]); + }); - it("should propagate config errors", async () => { - const tenantId = "tenant-123"; - const configUpdates = { brandColor: "#FF0000" }; + it("should propagate config errors", async () => { + const tenantId = "tenant-123"; + const configUpdates = { brandColor: "#FF0000" }; - const mockConfig = { - setConfig: vi.fn().mockRejectedValue(new Error("Config error")) - }; + const mockConfig = { + setConfig: vi.fn().mockRejectedValue(new Error("Config error")), + }; - mockTenantConfig.create.mockResolvedValue(mockConfig); + mockTenantConfig.create.mockResolvedValue(mockConfig); - const service = await TenantAdminService.getTenantById(tenantId); + const service = await TenantAdminService.getTenantById(tenantId); - await expect(service.updateTenantConfig(configUpdates)).rejects.toThrow("Config error"); - }); - }); + await expect(service.updateTenantConfig(configUpdates)).rejects.toThrow("Config error"); + }); + }); }); diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts index b1c4196..5dc6c93 100644 --- a/src/lib/server/services/__tests__/user-service.test.ts +++ b/src/lib/server/services/__tests__/user-service.test.ts @@ -4,351 +4,351 @@ import { NotFoundError } from "../../utils/errors"; // Mock the database module vi.mock("../../db", () => ({ - centralDb: { - insert: vi.fn(), - select: vi.fn(), - update: vi.fn(), - delete: vi.fn() - } + centralDb: { + insert: vi.fn(), + select: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }, })); // Mock the email service vi.mock("../../email/email-service", () => ({ - sendConfirmationEmail: vi.fn() + sendConfirmationEmail: vi.fn(), })); // Mock the tenant admin service vi.mock("../tenant-admin-service", () => ({ - TenantAdminService: { - getTenantById: vi.fn() - } + TenantAdminService: { + getTenantById: vi.fn(), + }, })); // Mock uuid generation vi.mock("uuidv7", () => ({ - uuidv7: vi.fn() + uuidv7: vi.fn(), })); // Mock date-fns vi.mock("date-fns", () => ({ - addMinutes: vi.fn() + addMinutes: vi.fn(), })); // Import the service after mocks are set up import { UserService } from "../user-service"; describe("UserService", () => { - let mockCentralDb: any; - let mockUuidv7: any; - let mockAddMinutes: any; - let mockSendConfirmationEmail: any; - let mockTenantAdminService: any; + let mockCentralDb: any; + let mockUuidv7: any; + let mockAddMinutes: any; + let mockSendConfirmationEmail: any; + let mockTenantAdminService: any; - beforeEach(async () => { - vi.clearAllMocks(); + beforeEach(async () => { + vi.clearAllMocks(); - // Get mocked modules - const dbModule = await vi.importMock("../../db"); - mockCentralDb = dbModule.centralDb; + // Get mocked modules + const dbModule = await vi.importMock("../../db"); + mockCentralDb = dbModule.centralDb; - const uuidModule = await vi.importMock("uuidv7"); - mockUuidv7 = uuidModule.uuidv7; + const uuidModule = await vi.importMock("uuidv7"); + mockUuidv7 = uuidModule.uuidv7; - const dateFnsModule = await vi.importMock("date-fns"); - mockAddMinutes = dateFnsModule.addMinutes; + const dateFnsModule = await vi.importMock("date-fns"); + mockAddMinutes = dateFnsModule.addMinutes; - const emailModule = await vi.importMock("../../email/email-service"); - mockSendConfirmationEmail = emailModule.sendConfirmationEmail; + const emailModule = await vi.importMock("../../email/email-service"); + mockSendConfirmationEmail = emailModule.sendConfirmationEmail; - const tenantModule = await vi.importMock("../tenant-admin-service"); - mockTenantAdminService = tenantModule.TenantAdminService; + const tenantModule = await vi.importMock("../tenant-admin-service"); + mockTenantAdminService = tenantModule.TenantAdminService; - // Setup default mock returns - mockUuidv7.mockReturnValue("018f-a1b2-c3d4-e5f6-789abcdef012"); - const futureDate = new Date("2024-01-01T12:10:00Z"); - mockAddMinutes.mockReturnValue(futureDate); - mockSendConfirmationEmail.mockResolvedValue(undefined); - mockTenantAdminService.getTenantById.mockResolvedValue({ - tenantData: { - id: "tenant-123", - shortName: "test", - longName: "Test Tenant" - } - }); - }); + // Setup default mock returns + mockUuidv7.mockReturnValue("018f-a1b2-c3d4-e5f6-789abcdef012"); + const futureDate = new Date("2024-01-01T12:10:00Z"); + mockAddMinutes.mockReturnValue(futureDate); + mockSendConfirmationEmail.mockResolvedValue(undefined); + mockTenantAdminService.getTenantById.mockResolvedValue({ + tenantData: { + id: "tenant-123", + shortName: "test", + longName: "Test Tenant", + }, + }); + }); - afterEach(() => { - vi.restoreAllMocks(); - }); + afterEach(() => { + vi.restoreAllMocks(); + }); - describe("createUser", () => { - it("should create a new admin with valid data", async () => { - const adminData = { - name: "Test Admin", - email: "test@example.com", - language: "de" as const - }; + describe("createUser", () => { + it("should create a new admin with valid data", async () => { + const adminData = { + name: "Test Admin", + email: "test@example.com", + language: "de" as const, + }; - const mockCreatedAdmin = { - id: "018f-a1b2-c3d4-e5f6-789abcdef012", - name: "Test Admin", - email: "test@example.com", - token: "018f-a1b2-c3d4-e5f6-789abcdef012", - tokenValidUntil: new Date("2024-01-01T12:10:00Z"), - confirmed: false, - isActive: false - }; + const mockCreatedAdmin = { + id: "018f-a1b2-c3d4-e5f6-789abcdef012", + name: "Test Admin", + email: "test@example.com", + token: "018f-a1b2-c3d4-e5f6-789abcdef012", + tokenValidUntil: new Date("2024-01-01T12:10:00Z"), + confirmed: false, + isActive: false, + }; - const mockInsertBuilder = { - values: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockCreatedAdmin]) - }; + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockCreatedAdmin]), + }; - mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); - const result = await UserService.createUser(adminData); + const result = await UserService.createUser(adminData); - expect(mockCentralDb.insert).toHaveBeenCalled(); - expect(mockInsertBuilder.values).toHaveBeenCalledWith({ - ...adminData, - token: "018f-a1b2-c3d4-e5f6-789abcdef012", - tokenValidUntil: expect.any(Date), - confirmed: false, - isActive: false - }); - expect(result).toEqual(mockCreatedAdmin); - }); - }); + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith({ + ...adminData, + token: "018f-a1b2-c3d4-e5f6-789abcdef012", + tokenValidUntil: expect.any(Date), + confirmed: false, + isActive: false, + }); + expect(result).toEqual(mockCreatedAdmin); + }); + }); - describe("resendConfirmationEmail", () => { - it("should resend confirmation email for existing admin", async () => { - const email = "test@example.com"; + describe("resendConfirmationEmail", () => { + it("should resend confirmation email for existing admin", async () => { + const email = "test@example.com"; - const mockUser = { - id: "user-123", - email: "test@example.com", - name: "Test User", - tenantId: null - }; + const mockUser = { + id: "user-123", + email: "test@example.com", + name: "Test User", + tenantId: null, + }; - const mockUpdateBuilder = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockUser]) - }; + const mockUpdateBuilder = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockUser]), + }; - mockCentralDb.update.mockReturnValue(mockUpdateBuilder); + mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - await UserService.resendConfirmationEmail(email); + await UserService.resendConfirmationEmail(email); - expect(mockCentralDb.update).toHaveBeenCalled(); - expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - token: "018f-a1b2-c3d4-e5f6-789abcdef012", - tokenValidUntil: expect.any(Date) - }); - expect(mockUpdateBuilder.where).toHaveBeenCalled(); - expect(mockUpdateBuilder.returning).toHaveBeenCalled(); - }); + expect(mockCentralDb.update).toHaveBeenCalled(); + expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ + token: "018f-a1b2-c3d4-e5f6-789abcdef012", + tokenValidUntil: expect.any(Date), + }); + expect(mockUpdateBuilder.where).toHaveBeenCalled(); + expect(mockUpdateBuilder.returning).toHaveBeenCalled(); + }); - it("should throw NotFoundError for non-existent admin", async () => { - const email = "nonexistent@example.com"; + it("should throw NotFoundError for non-existent admin", async () => { + const email = "nonexistent@example.com"; - const mockUpdateBuilder = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([]) - }; + const mockUpdateBuilder = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([]), + }; - mockCentralDb.update.mockReturnValue(mockUpdateBuilder); + mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - await expect(UserService.resendConfirmationEmail(email)).rejects.toThrow(NotFoundError); - }); - }); + await expect(UserService.resendConfirmationEmail(email)).rejects.toThrow(NotFoundError); + }); + }); - describe("confirm", () => { - it("should confirm admin with valid token", async () => { - const token = "018f-a1b2-c3d4-e5f6-789abcdef012"; + describe("confirm", () => { + it("should confirm admin with valid token", async () => { + const token = "018f-a1b2-c3d4-e5f6-789abcdef012"; - const mockSelectBuilder = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]) - }; + const mockSelectBuilder = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]), + }; - const mockCountSelectBuilder = { - from: vi.fn().mockResolvedValue([{ count: 1 }]) - }; + const mockCountSelectBuilder = { + from: vi.fn().mockResolvedValue([{ count: 1 }]), + }; - const mockUpdateBuilder = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - execute: vi.fn().mockResolvedValue({ count: 1 }) - }; + const mockUpdateBuilder = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue({ count: 1 }), + }; - // First call for user lookup, second call for count query - mockCentralDb.select - .mockReturnValueOnce(mockSelectBuilder) - .mockReturnValueOnce(mockCountSelectBuilder); - mockCentralDb.update.mockReturnValue(mockUpdateBuilder); + // First call for user lookup, second call for count query + mockCentralDb.select + .mockReturnValueOnce(mockSelectBuilder) + .mockReturnValueOnce(mockCountSelectBuilder); + mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - const result = await UserService.confirm(token); + const result = await UserService.confirm(token); - expect(mockCentralDb.select).toHaveBeenCalledTimes(2); - expect(mockCentralDb.update).toHaveBeenCalled(); - expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - confirmed: true, - isActive: true, - recoveryPassphrase: null - }); - expect(result.recoveryPassphrase).toBe("recovery-123"); - expect(result.isSetup).toBe(true); - }); + expect(mockCentralDb.select).toHaveBeenCalledTimes(2); + expect(mockCentralDb.update).toHaveBeenCalled(); + expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ + confirmed: true, + isActive: true, + recoveryPassphrase: null, + }); + expect(result.recoveryPassphrase).toBe("recovery-123"); + expect(result.isSetup).toBe(true); + }); - it("should throw NotFoundError for invalid token", async () => { - const token = "018f-0000-0000-0000-000000000000"; + it("should throw NotFoundError for invalid token", async () => { + const token = "018f-0000-0000-0000-000000000000"; - const mockSelectBuilder = { - select: vi.fn().mockReturnThis(), - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]) // Empty array for no user found - }; + const mockSelectBuilder = { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), // Empty array for no user found + }; - mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); - await expect(UserService.confirm(token)).rejects.toThrow(NotFoundError); - }); - }); + await expect(UserService.confirm(token)).rejects.toThrow(NotFoundError); + }); + }); - describe("getUserByEmail", () => { - it("should return admin for existing email", async () => { - const email = "test@example.com"; - const mockAdmin = { - id: "018f-a1b2-c3d4-e5f6-789abcdef012", - name: "Test Admin", - email: "test@example.com", - confirmed: true, - isActive: true - }; + describe("getUserByEmail", () => { + it("should return admin for existing email", async () => { + const email = "test@example.com"; + const mockAdmin = { + id: "018f-a1b2-c3d4-e5f6-789abcdef012", + name: "Test Admin", + email: "test@example.com", + confirmed: true, + isActive: true, + }; - const mockSelectBuilder = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([mockAdmin]) - }; + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([mockAdmin]), + }; - mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); - const result = await UserService.getUserByEmail(email); + const result = await UserService.getUserByEmail(email); - expect(mockCentralDb.select).toHaveBeenCalled(); - expect(mockSelectBuilder.from).toHaveBeenCalled(); - expect(mockSelectBuilder.where).toHaveBeenCalled(); - expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1); - expect(result).toEqual(mockAdmin); - }); + expect(mockCentralDb.select).toHaveBeenCalled(); + expect(mockSelectBuilder.from).toHaveBeenCalled(); + expect(mockSelectBuilder.where).toHaveBeenCalled(); + expect(mockSelectBuilder.limit).toHaveBeenCalledWith(1); + expect(result).toEqual(mockAdmin); + }); - it("should throw NotFoundError for non-existent email", async () => { - const email = "nonexistent@example.com"; + it("should throw NotFoundError for non-existent email", async () => { + const email = "nonexistent@example.com"; - const mockSelectBuilder = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]) - }; + const mockSelectBuilder = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; - mockCentralDb.select.mockReturnValue(mockSelectBuilder); + mockCentralDb.select.mockReturnValue(mockSelectBuilder); - await expect(UserService.getUserByEmail(email)).rejects.toThrow(NotFoundError); - }); - }); + await expect(UserService.getUserByEmail(email)).rejects.toThrow(NotFoundError); + }); + }); - describe("updateUser", () => { - it("should update admin successfully", async () => { - const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; - const updateData = { name: "Updated Admin" }; - const mockUpdatedAdmin = { - id: adminId, - name: "Updated Admin", - email: "test@example.com", - updatedAt: new Date() - }; + describe("updateUser", () => { + it("should update admin successfully", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + const updateData = { name: "Updated Admin" }; + const mockUpdatedAdmin = { + id: adminId, + name: "Updated Admin", + email: "test@example.com", + updatedAt: new Date(), + }; - const mockUpdateBuilder = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockUpdatedAdmin]) - }; + const mockUpdateBuilder = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockUpdatedAdmin]), + }; - mockCentralDb.update.mockReturnValue(mockUpdateBuilder); + mockCentralDb.update.mockReturnValue(mockUpdateBuilder); - const result = await UserService.updateUser(adminId, updateData); + const result = await UserService.updateUser(adminId, updateData); - expect(mockCentralDb.update).toHaveBeenCalled(); - expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ - ...updateData, - updatedAt: expect.any(Date) - }); - expect(result).toEqual(mockUpdatedAdmin); - }); - }); + expect(mockCentralDb.update).toHaveBeenCalled(); + expect(mockUpdateBuilder.set).toHaveBeenCalledWith({ + ...updateData, + updatedAt: expect.any(Date), + }); + expect(result).toEqual(mockUpdatedAdmin); + }); + }); - describe("deleteUser", () => { - it("should delete admin and associated passkeys", async () => { - const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; - const mockDeletedAdmin = { - id: adminId, - name: "Deleted Admin", - email: "deleted@example.com" - }; + describe("deleteUser", () => { + it("should delete admin and associated passkeys", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + const mockDeletedAdmin = { + id: adminId, + name: "Deleted Admin", + email: "deleted@example.com", + }; - const mockDeleteBuilder = { - where: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockDeletedAdmin]) - }; + const mockDeleteBuilder = { + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockDeletedAdmin]), + }; - mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); + mockCentralDb.delete.mockReturnValue(mockDeleteBuilder); - const result = await UserService.deleteUser(adminId); + const result = await UserService.deleteUser(adminId); - expect(mockCentralDb.delete).toHaveBeenCalledTimes(2); - expect(result).toEqual(mockDeletedAdmin); - }); - }); + expect(mockCentralDb.delete).toHaveBeenCalledTimes(2); + expect(result).toEqual(mockDeletedAdmin); + }); + }); - describe("addPasskey", () => { - it("should add passkey for admin", async () => { - const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; - const passkeyData = { - id: "018f-b2c3-d4e5-f6a7-89abcdef0123", - publicKey: "public-key-data", - counter: 0, - deviceName: "Test Device" - }; + describe("addPasskey", () => { + it("should add passkey for admin", async () => { + const adminId = "018f-a1b2-c3d4-e5f6-789abcdef012"; + const passkeyData = { + id: "018f-b2c3-d4e5-f6a7-89abcdef0123", + publicKey: "public-key-data", + counter: 0, + deviceName: "Test Device", + }; - const mockCreatedPasskey = { - ...passkeyData, - userId: adminId, - createdAt: new Date(), - updatedAt: new Date() - }; + const mockCreatedPasskey = { + ...passkeyData, + userId: adminId, + createdAt: new Date(), + updatedAt: new Date(), + }; - const mockInsertBuilder = { - values: vi.fn().mockReturnThis(), - returning: vi.fn().mockResolvedValue([mockCreatedPasskey]) - }; + const mockInsertBuilder = { + values: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([mockCreatedPasskey]), + }; - mockCentralDb.insert.mockReturnValue(mockInsertBuilder); + mockCentralDb.insert.mockReturnValue(mockInsertBuilder); - const result = await UserService.addPasskey(adminId, passkeyData); + const result = await UserService.addPasskey(adminId, passkeyData); - expect(mockCentralDb.insert).toHaveBeenCalled(); - expect(mockInsertBuilder.values).toHaveBeenCalledWith({ - ...passkeyData, - userId: adminId - }); - expect(result).toEqual(mockCreatedPasskey); - }); - }); + expect(mockCentralDb.insert).toHaveBeenCalled(); + expect(mockInsertBuilder.values).toHaveBeenCalledWith({ + ...passkeyData, + userId: adminId, + }); + expect(result).toEqual(mockCreatedPasskey); + }); + }); }); diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index 46291e3..b84a332 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -8,381 +8,381 @@ import z from "zod/v4"; import { ValidationError, NotFoundError } from "../utils/errors"; const agentCreationSchema = z.object({ - name: z.string().min(1).max(100), - description: z.string().optional(), - logo: z.instanceof(Buffer).optional() + name: z.string().min(1).max(100), + description: z.string().optional(), + logo: z.instanceof(Buffer).optional(), }); const agentUpdateSchema = z.object({ - name: z.string().min(1).max(100).optional(), - description: z.string().optional(), - logo: z.instanceof(Buffer).optional() + name: z.string().min(1).max(100).optional(), + description: z.string().optional(), + logo: z.instanceof(Buffer).optional(), }); export type AgentCreationRequest = z.infer; export type AgentUpdateRequest = z.infer; export class AgentService { - #db: Awaited> | null = null; + #db: Awaited> | null = null; - private constructor(public readonly tenantId: string) {} + private constructor(public readonly tenantId: string) {} - /** - * Create an agent service for a specific tenant - * @param tenantId The ID of the tenant - * @returns new AgentService instance - */ - static async forTenant(tenantId: string) { - const log = logger.setContext("AgentService"); - log.debug("Creating agent service for tenant", { tenantId }); + /** + * Create an agent service for a specific tenant + * @param tenantId The ID of the tenant + * @returns new AgentService instance + */ + static async forTenant(tenantId: string) { + const log = logger.setContext("AgentService"); + log.debug("Creating agent service for tenant", { tenantId }); - try { - const service = new AgentService(tenantId); - service.#db = await getTenantDb(tenantId); + try { + const service = new AgentService(tenantId); + service.#db = await getTenantDb(tenantId); - log.debug("Agent service created successfully", { tenantId }); - return service; - } catch (error) { - log.error("Failed to create agent service", { tenantId, error: String(error) }); - throw error; - } - } + log.debug("Agent service created successfully", { tenantId }); + return service; + } catch (error) { + log.error("Failed to create agent service", { tenantId, error: String(error) }); + throw error; + } + } - /** - * Create a new agent - * @param request Agent creation request data - * @returns Created agent - */ - async createAgent(request: AgentCreationRequest): Promise { - const log = logger.setContext("AgentService"); + /** + * Create a new agent + * @param request Agent creation request data + * @returns Created agent + */ + async createAgent(request: AgentCreationRequest): Promise { + const log = logger.setContext("AgentService"); - const validation = agentCreationSchema.safeParse(request); - if (!validation.success) { - throw new ValidationError("Invalid agent creation request"); - } + const validation = agentCreationSchema.safeParse(request); + if (!validation.success) { + throw new ValidationError("Invalid agent creation request"); + } - log.debug("Creating new agent", { - tenantId: this.tenantId, - name: request.name - }); + log.debug("Creating new agent", { + tenantId: this.tenantId, + name: request.name, + }); - try { - const db = await this.getDb(); - const result = await db - .insert(tenantSchema.agent) - .values({ - name: request.name, - description: request.description, - logo: request.logo - }) - .returning(); + try { + const db = await this.getDb(); + const result = await db + .insert(tenantSchema.agent) + .values({ + name: request.name, + description: request.description, + logo: request.logo, + }) + .returning(); - log.debug("Agent created successfully", { - tenantId: this.tenantId, - agentId: result[0].id, - name: result[0].name - }); + log.debug("Agent created successfully", { + tenantId: this.tenantId, + agentId: result[0].id, + name: result[0].name, + }); - return result[0]; - } catch (error) { - log.error("Failed to create agent", { - tenantId: this.tenantId, - name: request.name, - error: String(error) - }); - throw error; - } - } + return result[0]; + } catch (error) { + log.error("Failed to create agent", { + tenantId: this.tenantId, + name: request.name, + error: String(error), + }); + throw error; + } + } - /** - * Get an agent by ID - * @param agentId Agent ID - * @returns Agent or null if not found - */ - async getAgentById(agentId: string): Promise { - const log = logger.setContext("AgentService"); - log.debug("Getting agent by ID", { tenantId: this.tenantId, agentId }); + /** + * Get an agent by ID + * @param agentId Agent ID + * @returns Agent or null if not found + */ + async getAgentById(agentId: string): Promise { + const log = logger.setContext("AgentService"); + log.debug("Getting agent by ID", { tenantId: this.tenantId, agentId }); - try { - const db = await this.getDb(); - const result = await db - .select() - .from(tenantSchema.agent) - .where(eq(tenantSchema.agent.id, agentId)) - .limit(1); + try { + const db = await this.getDb(); + const result = await db + .select() + .from(tenantSchema.agent) + .where(eq(tenantSchema.agent.id, agentId)) + .limit(1); - if (result.length === 0) { - log.debug("Agent not found", { tenantId: this.tenantId, agentId }); - return null; - } + if (result.length === 0) { + log.debug("Agent not found", { tenantId: this.tenantId, agentId }); + return null; + } - log.debug("Agent found", { tenantId: this.tenantId, agentId }); - return result[0]; - } catch (error) { - log.error("Failed to get agent by ID", { - tenantId: this.tenantId, - agentId, - error: String(error) - }); - throw error; - } - } + log.debug("Agent found", { tenantId: this.tenantId, agentId }); + return result[0]; + } catch (error) { + log.error("Failed to get agent by ID", { + tenantId: this.tenantId, + agentId, + error: String(error), + }); + throw error; + } + } - /** - * Get all agents for the tenant - * @returns Array of agents - */ - async getAllAgents(): Promise { - const log = logger.setContext("AgentService"); - log.debug("Getting all agents", { tenantId: this.tenantId }); + /** + * Get all agents for the tenant + * @returns Array of agents + */ + async getAllAgents(): Promise { + const log = logger.setContext("AgentService"); + log.debug("Getting all agents", { tenantId: this.tenantId }); - try { - const db = await this.getDb(); - const result = await db.select().from(tenantSchema.agent).orderBy(tenantSchema.agent.name); + try { + const db = await this.getDb(); + const result = await db.select().from(tenantSchema.agent).orderBy(tenantSchema.agent.name); - log.debug("Retrieved all agents", { - tenantId: this.tenantId, - count: result.length - }); + log.debug("Retrieved all agents", { + tenantId: this.tenantId, + count: result.length, + }); - return result; - } catch (error) { - log.error("Failed to get all agents", { - tenantId: this.tenantId, - error: String(error) - }); - throw error; - } - } + return result; + } catch (error) { + log.error("Failed to get all agents", { + tenantId: this.tenantId, + error: String(error), + }); + throw error; + } + } - /** - * Update an existing agent - * @param agentId Agent ID - * @param updateData Agent update data - * @returns Updated agent - */ - async updateAgent(agentId: string, updateData: AgentUpdateRequest): Promise { - const log = logger.setContext("AgentService"); + /** + * Update an existing agent + * @param agentId Agent ID + * @param updateData Agent update data + * @returns Updated agent + */ + async updateAgent(agentId: string, updateData: AgentUpdateRequest): Promise { + const log = logger.setContext("AgentService"); - const validation = agentUpdateSchema.safeParse(updateData); - if (!validation.success) { - throw new ValidationError("Invalid agent update request"); - } + const validation = agentUpdateSchema.safeParse(updateData); + if (!validation.success) { + throw new ValidationError("Invalid agent update request"); + } - log.debug("Updating agent", { - tenantId: this.tenantId, - agentId, - updateFields: Object.keys(updateData) - }); + log.debug("Updating agent", { + tenantId: this.tenantId, + agentId, + updateFields: Object.keys(updateData), + }); - try { - const db = await this.getDb(); - const result = await db - .update(tenantSchema.agent) - .set(updateData) - .where(eq(tenantSchema.agent.id, agentId)) - .returning(); + try { + const db = await this.getDb(); + const result = await db + .update(tenantSchema.agent) + .set(updateData) + .where(eq(tenantSchema.agent.id, agentId)) + .returning(); - if (result.length === 0) { - log.warn("Agent update failed: Agent not found", { - tenantId: this.tenantId, - agentId - }); - throw new NotFoundError(`Agent with ID ${agentId} not found`); - } + if (result.length === 0) { + log.warn("Agent update failed: Agent not found", { + tenantId: this.tenantId, + agentId, + }); + throw new NotFoundError(`Agent with ID ${agentId} not found`); + } - log.debug("Agent updated successfully", { - tenantId: this.tenantId, - agentId, - updateFields: Object.keys(updateData) - }); + log.debug("Agent updated successfully", { + tenantId: this.tenantId, + agentId, + updateFields: Object.keys(updateData), + }); - return result[0]; - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to update agent", { - tenantId: this.tenantId, - agentId, - error: String(error) - }); - throw error; - } - } + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to update agent", { + tenantId: this.tenantId, + agentId, + error: String(error), + }); + throw error; + } + } - /** - * Delete an agent - * @param agentId Agent ID - * @returns true if deleted, false if not found - */ - async deleteAgent(agentId: string): Promise { - const log = logger.setContext("AgentService"); - log.debug("Deleting agent", { tenantId: this.tenantId, agentId }); + /** + * Delete an agent + * @param agentId Agent ID + * @returns true if deleted, false if not found + */ + async deleteAgent(agentId: string): Promise { + const log = logger.setContext("AgentService"); + log.debug("Deleting agent", { tenantId: this.tenantId, agentId }); - try { - const db = await this.getDb(); + try { + const db = await this.getDb(); - // First, remove all channel-agent associations - await db - .delete(tenantSchema.channelAgent) - .where(eq(tenantSchema.channelAgent.agentId, agentId)); + // First, remove all channel-agent associations + await db + .delete(tenantSchema.channelAgent) + .where(eq(tenantSchema.channelAgent.agentId, agentId)); - // Then delete the agent - const result = await db - .delete(tenantSchema.agent) - .where(eq(tenantSchema.agent.id, agentId)) - .returning(); + // Then delete the agent + const result = await db + .delete(tenantSchema.agent) + .where(eq(tenantSchema.agent.id, agentId)) + .returning(); - if (result.length === 0) { - log.debug("Agent deletion failed: Agent not found", { - tenantId: this.tenantId, - agentId - }); - return false; - } + if (result.length === 0) { + log.debug("Agent deletion failed: Agent not found", { + tenantId: this.tenantId, + agentId, + }); + return false; + } - log.debug("Agent deleted successfully", { - tenantId: this.tenantId, - agentId - }); + log.debug("Agent deleted successfully", { + tenantId: this.tenantId, + agentId, + }); - return true; - } catch (error) { - log.error("Failed to delete agent", { - tenantId: this.tenantId, - agentId, - error: String(error) - }); - throw error; - } - } + return true; + } catch (error) { + log.error("Failed to delete agent", { + tenantId: this.tenantId, + agentId, + error: String(error), + }); + throw error; + } + } - /** - * Get agents assigned to a specific channel - * @param channelId Channel ID - * @returns Array of agents assigned to the channel - */ - async getAgentsByChannel(channelId: string): Promise { - const log = logger.setContext("AgentService"); - log.debug("Getting agents by channel", { tenantId: this.tenantId, channelId }); + /** + * Get agents assigned to a specific channel + * @param channelId Channel ID + * @returns Array of agents assigned to the channel + */ + async getAgentsByChannel(channelId: string): Promise { + const log = logger.setContext("AgentService"); + log.debug("Getting agents by channel", { tenantId: this.tenantId, channelId }); - try { - const db = await this.getDb(); - const result = await db - .select({ - id: tenantSchema.agent.id, - name: tenantSchema.agent.name, - description: tenantSchema.agent.description, - logo: tenantSchema.agent.logo - }) - .from(tenantSchema.agent) - .innerJoin( - tenantSchema.channelAgent, - eq(tenantSchema.agent.id, tenantSchema.channelAgent.agentId) - ) - .where(eq(tenantSchema.channelAgent.channelId, channelId)) - .orderBy(tenantSchema.agent.name); + try { + const db = await this.getDb(); + const result = await db + .select({ + id: tenantSchema.agent.id, + name: tenantSchema.agent.name, + description: tenantSchema.agent.description, + logo: tenantSchema.agent.logo, + }) + .from(tenantSchema.agent) + .innerJoin( + tenantSchema.channelAgent, + eq(tenantSchema.agent.id, tenantSchema.channelAgent.agentId), + ) + .where(eq(tenantSchema.channelAgent.channelId, channelId)) + .orderBy(tenantSchema.agent.name); - log.debug("Retrieved agents by channel", { - tenantId: this.tenantId, - channelId, - count: result.length - }); + log.debug("Retrieved agents by channel", { + tenantId: this.tenantId, + channelId, + count: result.length, + }); - return result; - } catch (error) { - log.error("Failed to get agents by channel", { - tenantId: this.tenantId, - channelId, - error: String(error) - }); - throw error; - } - } + return result; + } catch (error) { + log.error("Failed to get agents by channel", { + tenantId: this.tenantId, + channelId, + error: String(error), + }); + throw error; + } + } - /** - * Assign an agent to a channel - * @param agentId Agent ID - * @param channelId Channel ID - */ - async assignAgentToChannel(agentId: string, channelId: string): Promise { - const log = logger.setContext("AgentService"); - log.debug("Assigning agent to channel", { - tenantId: this.tenantId, - agentId, - channelId - }); + /** + * Assign an agent to a channel + * @param agentId Agent ID + * @param channelId Channel ID + */ + async assignAgentToChannel(agentId: string, channelId: string): Promise { + const log = logger.setContext("AgentService"); + log.debug("Assigning agent to channel", { + tenantId: this.tenantId, + agentId, + channelId, + }); - try { - const db = await this.getDb(); - await db - .insert(tenantSchema.channelAgent) - .values({ - agentId, - channelId - }) - .onConflictDoNothing(); + try { + const db = await this.getDb(); + await db + .insert(tenantSchema.channelAgent) + .values({ + agentId, + channelId, + }) + .onConflictDoNothing(); - log.debug("Agent assigned to channel successfully", { - tenantId: this.tenantId, - agentId, - channelId - }); - } catch (error) { - log.error("Failed to assign agent to channel", { - tenantId: this.tenantId, - agentId, - channelId, - error: String(error) - }); - throw error; - } - } + log.debug("Agent assigned to channel successfully", { + tenantId: this.tenantId, + agentId, + channelId, + }); + } catch (error) { + log.error("Failed to assign agent to channel", { + tenantId: this.tenantId, + agentId, + channelId, + error: String(error), + }); + throw error; + } + } - /** - * Remove an agent from a channel - * @param agentId Agent ID - * @param channelId Channel ID - */ - async removeAgentFromChannel(agentId: string, channelId: string): Promise { - const log = logger.setContext("AgentService"); - log.debug("Removing agent from channel", { - tenantId: this.tenantId, - agentId, - channelId - }); + /** + * Remove an agent from a channel + * @param agentId Agent ID + * @param channelId Channel ID + */ + async removeAgentFromChannel(agentId: string, channelId: string): Promise { + const log = logger.setContext("AgentService"); + log.debug("Removing agent from channel", { + tenantId: this.tenantId, + agentId, + channelId, + }); - try { - const db = await this.getDb(); - await db - .delete(tenantSchema.channelAgent) - .where( - eq(tenantSchema.channelAgent.agentId, agentId) && - eq(tenantSchema.channelAgent.channelId, channelId) - ); + try { + const db = await this.getDb(); + await db + .delete(tenantSchema.channelAgent) + .where( + eq(tenantSchema.channelAgent.agentId, agentId) && + eq(tenantSchema.channelAgent.channelId, channelId), + ); - log.debug("Agent removed from channel successfully", { - tenantId: this.tenantId, - agentId, - channelId - }); - } catch (error) { - log.error("Failed to remove agent from channel", { - tenantId: this.tenantId, - agentId, - channelId, - error: String(error) - }); - throw error; - } - } + log.debug("Agent removed from channel successfully", { + tenantId: this.tenantId, + agentId, + channelId, + }); + } catch (error) { + log.error("Failed to remove agent from channel", { + tenantId: this.tenantId, + agentId, + channelId, + error: String(error), + }); + throw error; + } + } - /** - * Get the tenant's database connection (cached) - */ - private async getDb() { - if (!this.#db) { - this.#db = await getTenantDb(this.tenantId); - } - return this.#db; - } + /** + * Get the tenant's database connection (cached) + */ + private async getDb() { + if (!this.#db) { + this.#db = await getTenantDb(this.tenantId); + } + return this.#db; + } } diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index ecdf91f..1141cff 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -12,685 +12,685 @@ const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const; const NEXT_COLOR_KEY = "nextChannelColor"; const slotTemplateSchema = z.object({ - name: z.string().min(1).max(100), - weekdays: z.number().int().min(0).max(127).optional(), - from: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/), - to: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/), - duration: z.number().int().min(1).max(1440) + name: z.string().min(1).max(100), + weekdays: z.number().int().min(0).max(127).optional(), + from: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/), + to: z.string().regex(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/), + duration: z.number().int().min(1).max(1440), }); const channelCreationSchema = z - .object({ - names: z.array(z.string().min(1).max(100)).min(1), - color: z.string().optional(), - descriptions: z.array(z.string()).optional(), - languages: z.array(z.string().min(2).max(5)).min(1), - isPublic: z.boolean().optional(), - requiresConfirmation: z.boolean().optional(), - agentIds: z.array(z.string().uuid()).optional().default([]), - slotTemplates: z.array(slotTemplateSchema).optional().default([]) - }) - .refine( - (data) => - !data.descriptions || - data.descriptions.length === 0 || - data.descriptions.length === data.languages.length, - { message: "descriptions array must have same length as languages array" } - ) - .refine((data) => data.names.length === data.languages.length, { - message: "names array must have same length as languages array" - }); + .object({ + names: z.array(z.string().min(1).max(100)).min(1), + color: z.string().optional(), + descriptions: z.array(z.string()).optional(), + languages: z.array(z.string().min(2).max(5)).min(1), + isPublic: z.boolean().optional(), + requiresConfirmation: z.boolean().optional(), + agentIds: z.array(z.string().uuid()).optional().default([]), + slotTemplates: z.array(slotTemplateSchema).optional().default([]), + }) + .refine( + (data) => + !data.descriptions || + data.descriptions.length === 0 || + data.descriptions.length === data.languages.length, + { message: "descriptions array must have same length as languages array" }, + ) + .refine((data) => data.names.length === data.languages.length, { + message: "names array must have same length as languages array", + }); const channelUpdateSchema = z - .object({ - names: z.array(z.string().min(1).max(100)).optional(), - color: z.string().optional(), - descriptions: z.array(z.string()).optional(), - languages: z.array(z.string().min(2).max(5)).optional(), - isPublic: z.boolean().optional(), - requiresConfirmation: z.boolean().optional(), - agentIds: z.array(z.string().uuid()).optional(), - slotTemplates: z - .array( - slotTemplateSchema.extend({ - id: z.string().uuid().optional() - }) - ) - .optional() - }) - .refine( - (data) => - !data.descriptions || - !data.languages || - data.descriptions.length === 0 || - data.descriptions.length === data.languages.length, - { message: "descriptions array must have same length as languages array" } - ) - .refine((data) => !data.names || !data.languages || data.names.length === data.languages.length, { - message: "names array must have same length as languages array" - }); + .object({ + names: z.array(z.string().min(1).max(100)).optional(), + color: z.string().optional(), + descriptions: z.array(z.string()).optional(), + languages: z.array(z.string().min(2).max(5)).optional(), + isPublic: z.boolean().optional(), + requiresConfirmation: z.boolean().optional(), + agentIds: z.array(z.string().uuid()).optional(), + slotTemplates: z + .array( + slotTemplateSchema.extend({ + id: z.string().uuid().optional(), + }), + ) + .optional(), + }) + .refine( + (data) => + !data.descriptions || + !data.languages || + data.descriptions.length === 0 || + data.descriptions.length === data.languages.length, + { message: "descriptions array must have same length as languages array" }, + ) + .refine((data) => !data.names || !data.languages || data.names.length === data.languages.length, { + message: "names array must have same length as languages array", + }); export type ChannelCreationRequest = z.infer; export type ChannelUpdateRequest = z.infer; export type SlotTemplateRequest = z.infer; export interface ChannelWithRelations extends SelectChannel { - agents: SelectAgent[]; - slotTemplates: SelectSlotTemplate[]; + agents: SelectAgent[]; + slotTemplates: SelectSlotTemplate[]; } export class ChannelService { - #db: Awaited> | null = null; - - private constructor(public readonly tenantId: string) {} - - /** - * Create a channel service for a specific tenant - * @param tenantId The ID of the tenant - * @returns new ChannelService instance - */ - static async forTenant(tenantId: string) { - const log = logger.setContext("ChannelService"); - log.debug("Creating channel service for tenant", { tenantId }); - - try { - const service = new ChannelService(tenantId); - service.#db = await getTenantDb(tenantId); - - log.debug("Channel service created successfully", { tenantId }); - return service; - } catch (error) { - log.error("Failed to create channel service", { tenantId, error: String(error) }); - throw error; - } - } - - /** - * Create a new channel with agents and slot templates - * @param request Channel creation request data - * @returns Created channel with relations - */ - async createChannel(request: ChannelCreationRequest): Promise { - const log = logger.setContext("ChannelService"); - - const validation = channelCreationSchema.safeParse(request); - if (!validation.success) { - throw new ValidationError("Invalid channel creation request"); - } - - if (!request.color) { - // Automatically set the channel color if none is given - const configService = await TenantConfig.create(this.tenantId); - const config = configService.configuration; - const nextIndex = config[NEXT_COLOR_KEY] as number; - request.color = CHANNEL_COLORS[nextIndex]; - await configService.setConfig( - NEXT_COLOR_KEY, - nextIndex + 1 === CHANNEL_COLORS.length ? 0 : nextIndex + 1 - ); - } - - log.debug("Creating new channel", { - tenantId: this.tenantId, - names: request.names, - languages: request.languages, - agentCount: request.agentIds?.length || 0, - slotTemplateCount: request.slotTemplates?.length || 0 - }); - - try { - const db = await this.getDb(); - - // Start transaction - const result = await db.transaction(async (tx) => { - // 1. Create the channel - const channelResult = await tx - .insert(tenantSchema.channel) - .values({ - names: request.names, - color: request.color, - descriptions: request.descriptions || [], - languages: request.languages, - isPublic: request.isPublic, - requiresConfirmation: request.requiresConfirmation - }) - .returning(); - - const channel = channelResult[0]; - - // 2. Create slot templates and link them to the channel - const slotTemplates: SelectSlotTemplate[] = []; - if (request.slotTemplates && request.slotTemplates.length > 0) { - for (const slotTemplateData of request.slotTemplates) { - const slotTemplateResult = await tx - .insert(tenantSchema.slotTemplate) - .values({ - weekdays: slotTemplateData.weekdays, - from: slotTemplateData.from, - to: slotTemplateData.to, - duration: slotTemplateData.duration - }) - .returning(); - - const slotTemplate = slotTemplateResult[0]; - slotTemplates.push(slotTemplate); - - // Link slot template to channel - await tx.insert(tenantSchema.channelSlotTemplate).values({ - channelId: channel.id, - slotTemplateId: slotTemplate.id - }); - } - } - - // 3. Link agents to the channel - const agents: SelectAgent[] = []; - if (request.agentIds && request.agentIds.length > 0) { - // Verify agents exist - const existingAgents = await tx - .select() - .from(tenantSchema.agent) - .where(inArray(tenantSchema.agent.id, request.agentIds)); - - if (existingAgents.length !== request.agentIds.length) { - throw new ValidationError("One or more agents not found"); - } - - // Link agents to channel - for (const agentId of request.agentIds) { - await tx.insert(tenantSchema.channelAgent).values({ - channelId: channel.id, - agentId: agentId - }); - } - - agents.push(...existingAgents); - } - - return { - ...channel, - agents, - slotTemplates - }; - }); - - log.debug("Channel created successfully", { - tenantId: this.tenantId, - channelId: result.id, - names: result.names, - languages: result.languages, - agentCount: result.agents.length, - slotTemplateCount: result.slotTemplates.length - }); - - return result; - } catch (error) { - log.error("Failed to create channel", { - tenantId: this.tenantId, - names: request.names, - error: String(error) - }); - throw error; - } - } - - /** - * Update an existing channel with relationship management - * @param channelId Channel ID - * @param updateData Channel update data - * @returns Updated channel with relations - */ - async updateChannel( - channelId: string, - updateData: ChannelUpdateRequest - ): Promise { - const log = logger.setContext("ChannelService"); - - const validation = channelUpdateSchema.safeParse(updateData); - if (!validation.success) { - throw new ValidationError("Invalid channel update request"); - } - - log.debug("Updating channel", { - tenantId: this.tenantId, - channelId, - updateFields: Object.keys(updateData) - }); - - try { - const db = await this.getDb(); - - const result = await db.transaction(async (tx) => { - // 1. Update channel basic data - const channelData = { - names: updateData.names, - color: updateData.color, - descriptions: updateData.descriptions, - languages: updateData.languages, - isPublic: updateData.isPublic, - requiresConfirmation: updateData.requiresConfirmation - }; - - // Remove undefined values - const cleanChannelData = Object.fromEntries( - Object.entries(channelData).filter(([, value]) => value !== undefined) - ); - - let channel: SelectChannel; - if (Object.keys(cleanChannelData).length > 0) { - const channelResult = await tx - .update(tenantSchema.channel) - .set(cleanChannelData) - .where(eq(tenantSchema.channel.id, channelId)) - .returning(); - - if (channelResult.length === 0) { - throw new NotFoundError(`Channel with ID ${channelId} not found`); - } - channel = channelResult[0]; - } else { - // Get existing channel if no updates to basic data - const existingChannel = await tx - .select() - .from(tenantSchema.channel) - .where(eq(tenantSchema.channel.id, channelId)) - .limit(1); - - if (existingChannel.length === 0) { - throw new NotFoundError(`Channel with ID ${channelId} not found`); - } - channel = existingChannel[0]; - } - - // 2. Handle agent relationships - let agents: SelectAgent[] = []; - if (updateData.agentIds !== undefined) { - // Remove all existing agent assignments - await tx - .delete(tenantSchema.channelAgent) - .where(eq(tenantSchema.channelAgent.channelId, channelId)); - - // Add new agent assignments - if (updateData.agentIds.length > 0) { - // Verify agents exist - const existingAgents = await tx - .select() - .from(tenantSchema.agent) - .where(inArray(tenantSchema.agent.id, updateData.agentIds)); - - if (existingAgents.length !== updateData.agentIds.length) { - throw new ValidationError("One or more agents not found"); - } - - // Link agents to channel - for (const agentId of updateData.agentIds) { - await tx.insert(tenantSchema.channelAgent).values({ - channelId: channelId, - agentId: agentId - }); - } - - agents = existingAgents; - } - } else { - // Keep existing agent assignments - agents = await tx - .select({ - id: tenantSchema.agent.id, - name: tenantSchema.agent.name, - description: tenantSchema.agent.description, - logo: tenantSchema.agent.logo - }) - .from(tenantSchema.agent) - .innerJoin( - tenantSchema.channelAgent, - eq(tenantSchema.agent.id, tenantSchema.channelAgent.agentId) - ) - .where(eq(tenantSchema.channelAgent.channelId, channelId)); - } - - // 3. Handle slot template relationships - let slotTemplates: SelectSlotTemplate[] = []; - if (updateData.slotTemplates !== undefined) { - // Get existing slot template IDs for this channel - const existingSlotTemplateLinks = await tx - .select() - .from(tenantSchema.channelSlotTemplate) - .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); - - const existingSlotTemplateIds = existingSlotTemplateLinks.map( - (link) => link.slotTemplateId - ); - - // Process new/updated slot templates - const newSlotTemplateIds: string[] = []; - for (const slotTemplateData of updateData.slotTemplates) { - let slotTemplate: SelectSlotTemplate; - - if (slotTemplateData.id) { - // Update existing slot template - const updateResult = await tx - .update(tenantSchema.slotTemplate) - .set({ - weekdays: slotTemplateData.weekdays, - from: slotTemplateData.from, - to: slotTemplateData.to, - duration: slotTemplateData.duration - }) - .where(eq(tenantSchema.slotTemplate.id, slotTemplateData.id)) - .returning(); - - if (updateResult.length === 0) { - throw new ValidationError(`Slot template with ID ${slotTemplateData.id} not found`); - } - slotTemplate = updateResult[0]; - newSlotTemplateIds.push(slotTemplate.id); - } else { - // Create new slot template - const createResult = await tx - .insert(tenantSchema.slotTemplate) - .values({ - weekdays: slotTemplateData.weekdays, - from: slotTemplateData.from, - to: slotTemplateData.to, - duration: slotTemplateData.duration - }) - .returning(); - - slotTemplate = createResult[0]; - newSlotTemplateIds.push(slotTemplate.id); - - // Link new slot template to channel - await tx.insert(tenantSchema.channelSlotTemplate).values({ - channelId: channelId, - slotTemplateId: slotTemplate.id - }); - } - - slotTemplates.push(slotTemplate); - } - - // Remove slot templates that are no longer linked - const slotTemplatesToRemove = existingSlotTemplateIds.filter( - (id) => !newSlotTemplateIds.includes(id) - ); - - if (slotTemplatesToRemove.length > 0) { - // Remove channel-slot template links - await tx - .delete(tenantSchema.channelSlotTemplate) - .where( - eq(tenantSchema.channelSlotTemplate.channelId, channelId) && - inArray(tenantSchema.channelSlotTemplate.slotTemplateId, slotTemplatesToRemove) - ); - - // Check if any of these slot templates are used by other channels - for (const slotTemplateId of slotTemplatesToRemove) { - const otherChannelLinks = await tx - .select() - .from(tenantSchema.channelSlotTemplate) - .where(eq(tenantSchema.channelSlotTemplate.slotTemplateId, slotTemplateId)) - .limit(1); - - // Delete slot template if not used by other channels - if (otherChannelLinks.length === 0) { - await tx - .delete(tenantSchema.slotTemplate) - .where(eq(tenantSchema.slotTemplate.id, slotTemplateId)); - } - } - } - } else { - // Keep existing slot templates - slotTemplates = await tx - .select({ - id: tenantSchema.slotTemplate.id, - weekdays: tenantSchema.slotTemplate.weekdays, - from: tenantSchema.slotTemplate.from, - to: tenantSchema.slotTemplate.to, - duration: tenantSchema.slotTemplate.duration - }) - .from(tenantSchema.slotTemplate) - .innerJoin( - tenantSchema.channelSlotTemplate, - eq(tenantSchema.slotTemplate.id, tenantSchema.channelSlotTemplate.slotTemplateId) - ) - .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); - } - - return { - ...channel, - agents, - slotTemplates - }; - }); - - log.debug("Channel updated successfully", { - tenantId: this.tenantId, - channelId, - agentCount: result.agents.length, - slotTemplateCount: result.slotTemplates.length - }); - - return result; - } catch (error) { - if (error instanceof NotFoundError || error instanceof ValidationError) throw error; - log.error("Failed to update channel", { - tenantId: this.tenantId, - channelId, - error: String(error) - }); - throw error; - } - } - - /** - * Get a channel by ID with all relations - * @param channelId Channel ID - * @returns Channel with relations or null if not found - */ - async getChannelById(channelId: string): Promise { - const log = logger.setContext("ChannelService"); - log.debug("Getting channel by ID", { tenantId: this.tenantId, channelId }); - - try { - const db = await this.getDb(); - - // Get channel - const channelResult = await db - .select() - .from(tenantSchema.channel) - .where(eq(tenantSchema.channel.id, channelId)) - .limit(1); - - if (channelResult.length === 0) { - log.debug("Channel not found", { tenantId: this.tenantId, channelId }); - return null; - } - - const channel = channelResult[0]; - - // Get agents - const agents = await db - .select({ - id: tenantSchema.agent.id, - name: tenantSchema.agent.name, - description: tenantSchema.agent.description, - logo: tenantSchema.agent.logo - }) - .from(tenantSchema.agent) - .innerJoin( - tenantSchema.channelAgent, - eq(tenantSchema.agent.id, tenantSchema.channelAgent.agentId) - ) - .where(eq(tenantSchema.channelAgent.channelId, channelId)); - - // Get slot templates - const slotTemplates = await db - .select({ - id: tenantSchema.slotTemplate.id, - weekdays: tenantSchema.slotTemplate.weekdays, - from: tenantSchema.slotTemplate.from, - to: tenantSchema.slotTemplate.to, - duration: tenantSchema.slotTemplate.duration - }) - .from(tenantSchema.slotTemplate) - .innerJoin( - tenantSchema.channelSlotTemplate, - eq(tenantSchema.slotTemplate.id, tenantSchema.channelSlotTemplate.slotTemplateId) - ) - .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); - - const result = { - ...channel, - agents, - slotTemplates - }; - - log.debug("Channel found", { - tenantId: this.tenantId, - channelId, - agentCount: agents.length, - slotTemplateCount: slotTemplates.length - }); - - return result; - } catch (error) { - log.error("Failed to get channel by ID", { - tenantId: this.tenantId, - channelId, - error: String(error) - }); - throw error; - } - } - - /** - * Get all channels for the tenant with relations - * @returns Array of channels with relations - */ - async getAllChannels(): Promise { - const log = logger.setContext("ChannelService"); - log.debug("Getting all channels", { tenantId: this.tenantId }); - - try { - const db = await this.getDb(); - - // Get all channels - const channels = await db - .select() - .from(tenantSchema.channel) - .orderBy(tenantSchema.channel.names); - - // Get relations for each channel - const result: ChannelWithRelations[] = []; - for (const channel of channels) { - const channelWithRelations = await this.getChannelById(channel.id); - if (channelWithRelations) { - result.push(channelWithRelations); - } - } - - log.debug("Retrieved all channels", { - tenantId: this.tenantId, - count: result.length - }); - - return result; - } catch (error) { - log.error("Failed to get all channels", { - tenantId: this.tenantId, - error: String(error) - }); - throw error; - } - } - - /** - * Delete a channel with cascade cleanup - * @param channelId Channel ID - * @returns true if deleted, false if not found - */ - async deleteChannel(channelId: string): Promise { - const log = logger.setContext("ChannelService"); - log.debug("Deleting channel", { tenantId: this.tenantId, channelId }); - - try { - const db = await this.getDb(); - - const result = await db.transaction(async (tx) => { - // Get slot templates linked to this channel - const slotTemplateLinks = await tx - .select() - .from(tenantSchema.channelSlotTemplate) - .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); - - const slotTemplateIds = slotTemplateLinks.map((link) => link.slotTemplateId); - - // Remove channel-agent relationships - await tx - .delete(tenantSchema.channelAgent) - .where(eq(tenantSchema.channelAgent.channelId, channelId)); - - // Remove channel-slot template relationships - await tx - .delete(tenantSchema.channelSlotTemplate) - .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); - - // Delete slot templates that are not used by other channels - for (const slotTemplateId of slotTemplateIds) { - const otherChannelLinks = await tx - .select() - .from(tenantSchema.channelSlotTemplate) - .where(eq(tenantSchema.channelSlotTemplate.slotTemplateId, slotTemplateId)) - .limit(1); - - if (otherChannelLinks.length === 0) { - await tx - .delete(tenantSchema.slotTemplate) - .where(eq(tenantSchema.slotTemplate.id, slotTemplateId)); - } - } - - // Delete the channel - const deleteResult = await tx - .delete(tenantSchema.channel) - .where(eq(tenantSchema.channel.id, channelId)) - .returning(); - - return deleteResult.length > 0; - }); - - if (result) { - log.debug("Channel deleted successfully", { - tenantId: this.tenantId, - channelId - }); - } else { - log.debug("Channel deletion failed: Channel not found", { - tenantId: this.tenantId, - channelId - }); - } - - return result; - } catch (error) { - log.error("Failed to delete channel", { - tenantId: this.tenantId, - channelId, - error: String(error) - }); - throw error; - } - } - - /** - * Get the tenant's database connection (cached) - */ - private async getDb() { - if (!this.#db) { - this.#db = await getTenantDb(this.tenantId); - } - return this.#db; - } + #db: Awaited> | null = null; + + private constructor(public readonly tenantId: string) {} + + /** + * Create a channel service for a specific tenant + * @param tenantId The ID of the tenant + * @returns new ChannelService instance + */ + static async forTenant(tenantId: string) { + const log = logger.setContext("ChannelService"); + log.debug("Creating channel service for tenant", { tenantId }); + + try { + const service = new ChannelService(tenantId); + service.#db = await getTenantDb(tenantId); + + log.debug("Channel service created successfully", { tenantId }); + return service; + } catch (error) { + log.error("Failed to create channel service", { tenantId, error: String(error) }); + throw error; + } + } + + /** + * Create a new channel with agents and slot templates + * @param request Channel creation request data + * @returns Created channel with relations + */ + async createChannel(request: ChannelCreationRequest): Promise { + const log = logger.setContext("ChannelService"); + + const validation = channelCreationSchema.safeParse(request); + if (!validation.success) { + throw new ValidationError("Invalid channel creation request"); + } + + if (!request.color) { + // Automatically set the channel color if none is given + const configService = await TenantConfig.create(this.tenantId); + const config = configService.configuration; + const nextIndex = config[NEXT_COLOR_KEY] as number; + request.color = CHANNEL_COLORS[nextIndex]; + await configService.setConfig( + NEXT_COLOR_KEY, + nextIndex + 1 === CHANNEL_COLORS.length ? 0 : nextIndex + 1, + ); + } + + log.debug("Creating new channel", { + tenantId: this.tenantId, + names: request.names, + languages: request.languages, + agentCount: request.agentIds?.length || 0, + slotTemplateCount: request.slotTemplates?.length || 0, + }); + + try { + const db = await this.getDb(); + + // Start transaction + const result = await db.transaction(async (tx) => { + // 1. Create the channel + const channelResult = await tx + .insert(tenantSchema.channel) + .values({ + names: request.names, + color: request.color, + descriptions: request.descriptions || [], + languages: request.languages, + isPublic: request.isPublic, + requiresConfirmation: request.requiresConfirmation, + }) + .returning(); + + const channel = channelResult[0]; + + // 2. Create slot templates and link them to the channel + const slotTemplates: SelectSlotTemplate[] = []; + if (request.slotTemplates && request.slotTemplates.length > 0) { + for (const slotTemplateData of request.slotTemplates) { + const slotTemplateResult = await tx + .insert(tenantSchema.slotTemplate) + .values({ + weekdays: slotTemplateData.weekdays, + from: slotTemplateData.from, + to: slotTemplateData.to, + duration: slotTemplateData.duration, + }) + .returning(); + + const slotTemplate = slotTemplateResult[0]; + slotTemplates.push(slotTemplate); + + // Link slot template to channel + await tx.insert(tenantSchema.channelSlotTemplate).values({ + channelId: channel.id, + slotTemplateId: slotTemplate.id, + }); + } + } + + // 3. Link agents to the channel + const agents: SelectAgent[] = []; + if (request.agentIds && request.agentIds.length > 0) { + // Verify agents exist + const existingAgents = await tx + .select() + .from(tenantSchema.agent) + .where(inArray(tenantSchema.agent.id, request.agentIds)); + + if (existingAgents.length !== request.agentIds.length) { + throw new ValidationError("One or more agents not found"); + } + + // Link agents to channel + for (const agentId of request.agentIds) { + await tx.insert(tenantSchema.channelAgent).values({ + channelId: channel.id, + agentId: agentId, + }); + } + + agents.push(...existingAgents); + } + + return { + ...channel, + agents, + slotTemplates, + }; + }); + + log.debug("Channel created successfully", { + tenantId: this.tenantId, + channelId: result.id, + names: result.names, + languages: result.languages, + agentCount: result.agents.length, + slotTemplateCount: result.slotTemplates.length, + }); + + return result; + } catch (error) { + log.error("Failed to create channel", { + tenantId: this.tenantId, + names: request.names, + error: String(error), + }); + throw error; + } + } + + /** + * Update an existing channel with relationship management + * @param channelId Channel ID + * @param updateData Channel update data + * @returns Updated channel with relations + */ + async updateChannel( + channelId: string, + updateData: ChannelUpdateRequest, + ): Promise { + const log = logger.setContext("ChannelService"); + + const validation = channelUpdateSchema.safeParse(updateData); + if (!validation.success) { + throw new ValidationError("Invalid channel update request"); + } + + log.debug("Updating channel", { + tenantId: this.tenantId, + channelId, + updateFields: Object.keys(updateData), + }); + + try { + const db = await this.getDb(); + + const result = await db.transaction(async (tx) => { + // 1. Update channel basic data + const channelData = { + names: updateData.names, + color: updateData.color, + descriptions: updateData.descriptions, + languages: updateData.languages, + isPublic: updateData.isPublic, + requiresConfirmation: updateData.requiresConfirmation, + }; + + // Remove undefined values + const cleanChannelData = Object.fromEntries( + Object.entries(channelData).filter(([, value]) => value !== undefined), + ); + + let channel: SelectChannel; + if (Object.keys(cleanChannelData).length > 0) { + const channelResult = await tx + .update(tenantSchema.channel) + .set(cleanChannelData) + .where(eq(tenantSchema.channel.id, channelId)) + .returning(); + + if (channelResult.length === 0) { + throw new NotFoundError(`Channel with ID ${channelId} not found`); + } + channel = channelResult[0]; + } else { + // Get existing channel if no updates to basic data + const existingChannel = await tx + .select() + .from(tenantSchema.channel) + .where(eq(tenantSchema.channel.id, channelId)) + .limit(1); + + if (existingChannel.length === 0) { + throw new NotFoundError(`Channel with ID ${channelId} not found`); + } + channel = existingChannel[0]; + } + + // 2. Handle agent relationships + let agents: SelectAgent[] = []; + if (updateData.agentIds !== undefined) { + // Remove all existing agent assignments + await tx + .delete(tenantSchema.channelAgent) + .where(eq(tenantSchema.channelAgent.channelId, channelId)); + + // Add new agent assignments + if (updateData.agentIds.length > 0) { + // Verify agents exist + const existingAgents = await tx + .select() + .from(tenantSchema.agent) + .where(inArray(tenantSchema.agent.id, updateData.agentIds)); + + if (existingAgents.length !== updateData.agentIds.length) { + throw new ValidationError("One or more agents not found"); + } + + // Link agents to channel + for (const agentId of updateData.agentIds) { + await tx.insert(tenantSchema.channelAgent).values({ + channelId: channelId, + agentId: agentId, + }); + } + + agents = existingAgents; + } + } else { + // Keep existing agent assignments + agents = await tx + .select({ + id: tenantSchema.agent.id, + name: tenantSchema.agent.name, + description: tenantSchema.agent.description, + logo: tenantSchema.agent.logo, + }) + .from(tenantSchema.agent) + .innerJoin( + tenantSchema.channelAgent, + eq(tenantSchema.agent.id, tenantSchema.channelAgent.agentId), + ) + .where(eq(tenantSchema.channelAgent.channelId, channelId)); + } + + // 3. Handle slot template relationships + let slotTemplates: SelectSlotTemplate[] = []; + if (updateData.slotTemplates !== undefined) { + // Get existing slot template IDs for this channel + const existingSlotTemplateLinks = await tx + .select() + .from(tenantSchema.channelSlotTemplate) + .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); + + const existingSlotTemplateIds = existingSlotTemplateLinks.map( + (link) => link.slotTemplateId, + ); + + // Process new/updated slot templates + const newSlotTemplateIds: string[] = []; + for (const slotTemplateData of updateData.slotTemplates) { + let slotTemplate: SelectSlotTemplate; + + if (slotTemplateData.id) { + // Update existing slot template + const updateResult = await tx + .update(tenantSchema.slotTemplate) + .set({ + weekdays: slotTemplateData.weekdays, + from: slotTemplateData.from, + to: slotTemplateData.to, + duration: slotTemplateData.duration, + }) + .where(eq(tenantSchema.slotTemplate.id, slotTemplateData.id)) + .returning(); + + if (updateResult.length === 0) { + throw new ValidationError(`Slot template with ID ${slotTemplateData.id} not found`); + } + slotTemplate = updateResult[0]; + newSlotTemplateIds.push(slotTemplate.id); + } else { + // Create new slot template + const createResult = await tx + .insert(tenantSchema.slotTemplate) + .values({ + weekdays: slotTemplateData.weekdays, + from: slotTemplateData.from, + to: slotTemplateData.to, + duration: slotTemplateData.duration, + }) + .returning(); + + slotTemplate = createResult[0]; + newSlotTemplateIds.push(slotTemplate.id); + + // Link new slot template to channel + await tx.insert(tenantSchema.channelSlotTemplate).values({ + channelId: channelId, + slotTemplateId: slotTemplate.id, + }); + } + + slotTemplates.push(slotTemplate); + } + + // Remove slot templates that are no longer linked + const slotTemplatesToRemove = existingSlotTemplateIds.filter( + (id) => !newSlotTemplateIds.includes(id), + ); + + if (slotTemplatesToRemove.length > 0) { + // Remove channel-slot template links + await tx + .delete(tenantSchema.channelSlotTemplate) + .where( + eq(tenantSchema.channelSlotTemplate.channelId, channelId) && + inArray(tenantSchema.channelSlotTemplate.slotTemplateId, slotTemplatesToRemove), + ); + + // Check if any of these slot templates are used by other channels + for (const slotTemplateId of slotTemplatesToRemove) { + const otherChannelLinks = await tx + .select() + .from(tenantSchema.channelSlotTemplate) + .where(eq(tenantSchema.channelSlotTemplate.slotTemplateId, slotTemplateId)) + .limit(1); + + // Delete slot template if not used by other channels + if (otherChannelLinks.length === 0) { + await tx + .delete(tenantSchema.slotTemplate) + .where(eq(tenantSchema.slotTemplate.id, slotTemplateId)); + } + } + } + } else { + // Keep existing slot templates + slotTemplates = await tx + .select({ + id: tenantSchema.slotTemplate.id, + weekdays: tenantSchema.slotTemplate.weekdays, + from: tenantSchema.slotTemplate.from, + to: tenantSchema.slotTemplate.to, + duration: tenantSchema.slotTemplate.duration, + }) + .from(tenantSchema.slotTemplate) + .innerJoin( + tenantSchema.channelSlotTemplate, + eq(tenantSchema.slotTemplate.id, tenantSchema.channelSlotTemplate.slotTemplateId), + ) + .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); + } + + return { + ...channel, + agents, + slotTemplates, + }; + }); + + log.debug("Channel updated successfully", { + tenantId: this.tenantId, + channelId, + agentCount: result.agents.length, + slotTemplateCount: result.slotTemplates.length, + }); + + return result; + } catch (error) { + if (error instanceof NotFoundError || error instanceof ValidationError) throw error; + log.error("Failed to update channel", { + tenantId: this.tenantId, + channelId, + error: String(error), + }); + throw error; + } + } + + /** + * Get a channel by ID with all relations + * @param channelId Channel ID + * @returns Channel with relations or null if not found + */ + async getChannelById(channelId: string): Promise { + const log = logger.setContext("ChannelService"); + log.debug("Getting channel by ID", { tenantId: this.tenantId, channelId }); + + try { + const db = await this.getDb(); + + // Get channel + const channelResult = await db + .select() + .from(tenantSchema.channel) + .where(eq(tenantSchema.channel.id, channelId)) + .limit(1); + + if (channelResult.length === 0) { + log.debug("Channel not found", { tenantId: this.tenantId, channelId }); + return null; + } + + const channel = channelResult[0]; + + // Get agents + const agents = await db + .select({ + id: tenantSchema.agent.id, + name: tenantSchema.agent.name, + description: tenantSchema.agent.description, + logo: tenantSchema.agent.logo, + }) + .from(tenantSchema.agent) + .innerJoin( + tenantSchema.channelAgent, + eq(tenantSchema.agent.id, tenantSchema.channelAgent.agentId), + ) + .where(eq(tenantSchema.channelAgent.channelId, channelId)); + + // Get slot templates + const slotTemplates = await db + .select({ + id: tenantSchema.slotTemplate.id, + weekdays: tenantSchema.slotTemplate.weekdays, + from: tenantSchema.slotTemplate.from, + to: tenantSchema.slotTemplate.to, + duration: tenantSchema.slotTemplate.duration, + }) + .from(tenantSchema.slotTemplate) + .innerJoin( + tenantSchema.channelSlotTemplate, + eq(tenantSchema.slotTemplate.id, tenantSchema.channelSlotTemplate.slotTemplateId), + ) + .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); + + const result = { + ...channel, + agents, + slotTemplates, + }; + + log.debug("Channel found", { + tenantId: this.tenantId, + channelId, + agentCount: agents.length, + slotTemplateCount: slotTemplates.length, + }); + + return result; + } catch (error) { + log.error("Failed to get channel by ID", { + tenantId: this.tenantId, + channelId, + error: String(error), + }); + throw error; + } + } + + /** + * Get all channels for the tenant with relations + * @returns Array of channels with relations + */ + async getAllChannels(): Promise { + const log = logger.setContext("ChannelService"); + log.debug("Getting all channels", { tenantId: this.tenantId }); + + try { + const db = await this.getDb(); + + // Get all channels + const channels = await db + .select() + .from(tenantSchema.channel) + .orderBy(tenantSchema.channel.names); + + // Get relations for each channel + const result: ChannelWithRelations[] = []; + for (const channel of channels) { + const channelWithRelations = await this.getChannelById(channel.id); + if (channelWithRelations) { + result.push(channelWithRelations); + } + } + + log.debug("Retrieved all channels", { + tenantId: this.tenantId, + count: result.length, + }); + + return result; + } catch (error) { + log.error("Failed to get all channels", { + tenantId: this.tenantId, + error: String(error), + }); + throw error; + } + } + + /** + * Delete a channel with cascade cleanup + * @param channelId Channel ID + * @returns true if deleted, false if not found + */ + async deleteChannel(channelId: string): Promise { + const log = logger.setContext("ChannelService"); + log.debug("Deleting channel", { tenantId: this.tenantId, channelId }); + + try { + const db = await this.getDb(); + + const result = await db.transaction(async (tx) => { + // Get slot templates linked to this channel + const slotTemplateLinks = await tx + .select() + .from(tenantSchema.channelSlotTemplate) + .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); + + const slotTemplateIds = slotTemplateLinks.map((link) => link.slotTemplateId); + + // Remove channel-agent relationships + await tx + .delete(tenantSchema.channelAgent) + .where(eq(tenantSchema.channelAgent.channelId, channelId)); + + // Remove channel-slot template relationships + await tx + .delete(tenantSchema.channelSlotTemplate) + .where(eq(tenantSchema.channelSlotTemplate.channelId, channelId)); + + // Delete slot templates that are not used by other channels + for (const slotTemplateId of slotTemplateIds) { + const otherChannelLinks = await tx + .select() + .from(tenantSchema.channelSlotTemplate) + .where(eq(tenantSchema.channelSlotTemplate.slotTemplateId, slotTemplateId)) + .limit(1); + + if (otherChannelLinks.length === 0) { + await tx + .delete(tenantSchema.slotTemplate) + .where(eq(tenantSchema.slotTemplate.id, slotTemplateId)); + } + } + + // Delete the channel + const deleteResult = await tx + .delete(tenantSchema.channel) + .where(eq(tenantSchema.channel.id, channelId)) + .returning(); + + return deleteResult.length > 0; + }); + + if (result) { + log.debug("Channel deleted successfully", { + tenantId: this.tenantId, + channelId, + }); + } else { + log.debug("Channel deletion failed: Channel not found", { + tenantId: this.tenantId, + channelId, + }); + } + + return result; + } catch (error) { + log.error("Failed to delete channel", { + tenantId: this.tenantId, + channelId, + error: String(error), + }); + throw error; + } + } + + /** + * Get the tenant's database connection (cached) + */ + private async getDb() { + if (!this.#db) { + this.#db = await getTenantDb(this.tenantId); + } + return this.#db; + } } diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts index dea7b9c..8732357 100644 --- a/src/lib/server/services/invite-service.ts +++ b/src/lib/server/services/invite-service.ts @@ -1,9 +1,9 @@ import { db } from "$lib/server/db"; import { - userInvite, - tenant, - type InsertUserInvite, - type SelectUserInvite + userInvite, + tenant, + type InsertUserInvite, + type SelectUserInvite, } from "$lib/server/db/central-schema"; import { eq, and, lt } from "drizzle-orm"; import { UniversalLogger } from "$lib/logger"; @@ -11,192 +11,192 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("InviteService"); export class InviteService { - /** - * Create a new user invitation - * @param email Email address of the invited user - * @param name Name of the invited user - * @param role Role to assign (TENANT_ADMIN or STAFF) - * @param tenantId Tenant ID the user is being invited to - * @param invitedBy User ID of who sent the invitation - * @param language Language preference for the invitation - * @returns The created invitation with invite code - */ - static async createInvite( - email: string, - name: string, - role: "TENANT_ADMIN" | "STAFF", - tenantId: string, - invitedBy: string, - language: "de" | "en" = "de" - ): Promise { - try { - // Set expiration to 7 days from now - const expiresAt = new Date(); - expiresAt.setDate(expiresAt.getDate() + 7); + /** + * Create a new user invitation + * @param email Email address of the invited user + * @param name Name of the invited user + * @param role Role to assign (TENANT_ADMIN or STAFF) + * @param tenantId Tenant ID the user is being invited to + * @param invitedBy User ID of who sent the invitation + * @param language Language preference for the invitation + * @returns The created invitation with invite code + */ + static async createInvite( + email: string, + name: string, + role: "TENANT_ADMIN" | "STAFF", + tenantId: string, + invitedBy: string, + language: "de" | "en" = "de", + ): Promise { + try { + // Set expiration to 7 days from now + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); - const inviteData: InsertUserInvite = { - email, - name, - role, - tenantId, - invitedBy, - language, - expiresAt, - used: false - }; + const inviteData: InsertUserInvite = { + email, + name, + role, + tenantId, + invitedBy, + language, + expiresAt, + used: false, + }; - const [createdInvite] = await db.insert(userInvite).values(inviteData).returning(); + const [createdInvite] = await db.insert(userInvite).values(inviteData).returning(); - logger.info("User invitation created", { - inviteId: createdInvite.id, - inviteCode: createdInvite.inviteCode, - email: createdInvite.email, - tenantId: createdInvite.tenantId, - role: createdInvite.role, - invitedBy: createdInvite.invitedBy - }); + logger.info("User invitation created", { + inviteId: createdInvite.id, + inviteCode: createdInvite.inviteCode, + email: createdInvite.email, + tenantId: createdInvite.tenantId, + role: createdInvite.role, + invitedBy: createdInvite.invitedBy, + }); - return createdInvite; - } catch (error) { - logger.error("Failed to create user invitation", { error, email, tenantId, role }); - throw new Error(`Failed to create invitation: ${error}`); - } - } + return createdInvite; + } catch (error) { + logger.error("Failed to create user invitation", { error, email, tenantId, role }); + throw new Error(`Failed to create invitation: ${error}`); + } + } - /** - * Get an invitation by invite code - * @param inviteCode The secure invite code - * @returns The invitation with tenant information, or null if not found/expired - */ - static async getInviteByCode( - inviteCode: string - ): Promise<(SelectUserInvite & { tenant: typeof tenant.$inferSelect }) | null> { - try { - const result = await db - .select({ - invite: userInvite, - tenant: tenant - }) - .from(userInvite) - .innerJoin(tenant, eq(userInvite.tenantId, tenant.id)) - .where(and(eq(userInvite.inviteCode, inviteCode), eq(userInvite.used, false))) - .limit(1); + /** + * Get an invitation by invite code + * @param inviteCode The secure invite code + * @returns The invitation with tenant information, or null if not found/expired + */ + static async getInviteByCode( + inviteCode: string, + ): Promise<(SelectUserInvite & { tenant: typeof tenant.$inferSelect }) | null> { + try { + const result = await db + .select({ + invite: userInvite, + tenant: tenant, + }) + .from(userInvite) + .innerJoin(tenant, eq(userInvite.tenantId, tenant.id)) + .where(and(eq(userInvite.inviteCode, inviteCode), eq(userInvite.used, false))) + .limit(1); - if (result.length === 0) { - return null; - } + if (result.length === 0) { + return null; + } - const invitation = result[0]; + const invitation = result[0]; - // Check if invitation has expired - if (invitation.invite.expiresAt < new Date()) { - logger.warn("Attempted to use expired invitation", { - inviteCode, - expiresAt: invitation.invite.expiresAt - }); - return null; - } + // Check if invitation has expired + if (invitation.invite.expiresAt < new Date()) { + logger.warn("Attempted to use expired invitation", { + inviteCode, + expiresAt: invitation.invite.expiresAt, + }); + return null; + } - return { - ...invitation.invite, - tenant: invitation.tenant - }; - } catch (error) { - logger.error("Failed to get invitation by code", { error, inviteCode }); - throw new Error(`Failed to retrieve invitation: ${error}`); - } - } + return { + ...invitation.invite, + tenant: invitation.tenant, + }; + } catch (error) { + logger.error("Failed to get invitation by code", { error, inviteCode }); + throw new Error(`Failed to retrieve invitation: ${error}`); + } + } - /** - * Mark an invitation as used - * @param inviteCode The invite code to mark as used - * @param createdUserId The ID of the user that was created from this invitation - * @returns The updated invitation - */ - static async markInviteAsUsed( - inviteCode: string, - createdUserId: string - ): Promise { - try { - const [updatedInvite] = await db - .update(userInvite) - .set({ - used: true, - usedAt: new Date(), - createdUserId, - updatedAt: new Date() - }) - .where(eq(userInvite.inviteCode, inviteCode)) - .returning(); + /** + * Mark an invitation as used + * @param inviteCode The invite code to mark as used + * @param createdUserId The ID of the user that was created from this invitation + * @returns The updated invitation + */ + static async markInviteAsUsed( + inviteCode: string, + createdUserId: string, + ): Promise { + try { + const [updatedInvite] = await db + .update(userInvite) + .set({ + used: true, + usedAt: new Date(), + createdUserId, + updatedAt: new Date(), + }) + .where(eq(userInvite.inviteCode, inviteCode)) + .returning(); - if (!updatedInvite) { - throw new Error("Invitation not found"); - } + if (!updatedInvite) { + throw new Error("Invitation not found"); + } - logger.info("Invitation marked as used", { - inviteCode, - createdUserId, - email: updatedInvite.email - }); + logger.info("Invitation marked as used", { + inviteCode, + createdUserId, + email: updatedInvite.email, + }); - return updatedInvite; - } catch (error) { - logger.error("Failed to mark invitation as used", { error, inviteCode, createdUserId }); - throw new Error(`Failed to update invitation: ${error}`); - } - } + return updatedInvite; + } catch (error) { + logger.error("Failed to mark invitation as used", { error, inviteCode, createdUserId }); + throw new Error(`Failed to update invitation: ${error}`); + } + } - /** - * Check if an email already has a pending invitation for a tenant - * @param email Email address to check - * @param tenantId Tenant ID to check - * @returns True if there's already a pending invitation - */ - static async hasPendingInvite(email: string, tenantId: string): Promise { - try { - const result = await db - .select({ id: userInvite.id }) - .from(userInvite) - .where( - and( - eq(userInvite.email, email), - eq(userInvite.tenantId, tenantId), - eq(userInvite.used, false) - ) - ) - .limit(1); + /** + * Check if an email already has a pending invitation for a tenant + * @param email Email address to check + * @param tenantId Tenant ID to check + * @returns True if there's already a pending invitation + */ + static async hasPendingInvite(email: string, tenantId: string): Promise { + try { + const result = await db + .select({ id: userInvite.id }) + .from(userInvite) + .where( + and( + eq(userInvite.email, email), + eq(userInvite.tenantId, tenantId), + eq(userInvite.used, false), + ), + ) + .limit(1); - return result.length > 0; - } catch (error) { - logger.error("Failed to check for pending invitations", { error, email, tenantId }); - throw new Error(`Failed to check pending invitations: ${error}`); - } - } + return result.length > 0; + } catch (error) { + logger.error("Failed to check for pending invitations", { error, email, tenantId }); + throw new Error(`Failed to check pending invitations: ${error}`); + } + } - /** - * Delete expired invitations (cleanup task) - * @returns Number of deleted invitations - */ - static async cleanupExpiredInvites(): Promise { - try { - const result = await db.delete(userInvite).where( - and( - eq(userInvite.used, false), - // Delete invitations that expired more than 1 day ago - lt(userInvite.expiresAt, new Date(Date.now() - 24 * 60 * 60 * 1000)) - ) - ); + /** + * Delete expired invitations (cleanup task) + * @returns Number of deleted invitations + */ + static async cleanupExpiredInvites(): Promise { + try { + const result = await db.delete(userInvite).where( + and( + eq(userInvite.used, false), + // Delete invitations that expired more than 1 day ago + lt(userInvite.expiresAt, new Date(Date.now() - 24 * 60 * 60 * 1000)), + ), + ); - const deletedCount = result.length || 0; + const deletedCount = result.length || 0; - if (deletedCount > 0) { - logger.info("Cleaned up expired invitations", { deletedCount }); - } + if (deletedCount > 0) { + logger.info("Cleaned up expired invitations", { deletedCount }); + } - return deletedCount; - } catch (error) { - logger.error("Failed to cleanup expired invitations", { error }); - throw new Error(`Failed to cleanup invitations: ${error}`); - } - } + return deletedCount; + } catch (error) { + logger.error("Failed to cleanup expired invitations", { error }); + throw new Error(`Failed to cleanup invitations: ${error}`); + } + } } diff --git a/src/lib/server/services/startup-service.ts b/src/lib/server/services/startup-service.ts index 794b774..17d02d6 100644 --- a/src/lib/server/services/startup-service.ts +++ b/src/lib/server/services/startup-service.ts @@ -6,103 +6,103 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("StartupService"); export class StartupService { - private static initialized = false; + private static initialized = false; - /** - * Initialize the application on startup - * This should be called once when the application starts - */ - static async initialize(): Promise { - if (this.initialized) { - logger.debug("StartupService already initialized, skipping"); - return; - } + /** + * Initialize the application on startup + * This should be called once when the application starts + */ + static async initialize(): Promise { + if (this.initialized) { + logger.debug("StartupService already initialized, skipping"); + return; + } - logger.info("Starting application initialization"); + logger.info("Starting application initialization"); - try { - // Check and migrate all tenant databases - await this.migrateTenantDatabases(); + try { + // Check and migrate all tenant databases + await this.migrateTenantDatabases(); - this.initialized = true; - logger.info("Application initialization completed successfully"); - } catch (error) { - logger.error("Application initialization failed", { error: String(error) }); - throw error; - } - } + this.initialized = true; + logger.info("Application initialization completed successfully"); + } catch (error) { + logger.error("Application initialization failed", { error: String(error) }); + throw error; + } + } - /** - * Check and migrate all tenant databases - */ - private static async migrateTenantDatabases(): Promise { - logger.info("Checking tenant database migrations"); + /** + * Check and migrate all tenant databases + */ + private static async migrateTenantDatabases(): Promise { + logger.info("Checking tenant database migrations"); - try { - // Get all tenants from central database - const tenants = await centralDb - .select({ - id: tenant.id, - shortName: tenant.shortName, - databaseUrl: tenant.databaseUrl - }) - .from(tenant); + try { + // Get all tenants from central database + const tenants = await centralDb + .select({ + id: tenant.id, + shortName: tenant.shortName, + databaseUrl: tenant.databaseUrl, + }) + .from(tenant); - if (tenants.length === 0) { - logger.info("No tenants found, skipping tenant database migrations"); - return; - } + if (tenants.length === 0) { + logger.info("No tenants found, skipping tenant database migrations"); + return; + } - logger.info(`Found ${tenants.length} tenants, checking migrations`); + logger.info(`Found ${tenants.length} tenants, checking migrations`); - // Process tenants in parallel (but with concurrency limit) - const migrationPromises = tenants.map(async (tenantData) => { - try { - logger.debug("Checking tenant database migration", { - tenantId: tenantData.id, - shortName: tenantData.shortName - }); + // Process tenants in parallel (but with concurrency limit) + const migrationPromises = tenants.map(async (tenantData) => { + try { + logger.debug("Checking tenant database migration", { + tenantId: tenantData.id, + shortName: tenantData.shortName, + }); - await TenantMigrationService.ensureTenantDatabaseUpToDate(tenantData.databaseUrl); + await TenantMigrationService.ensureTenantDatabaseUpToDate(tenantData.databaseUrl); - logger.debug("Tenant database migration completed", { - tenantId: tenantData.id, - shortName: tenantData.shortName - }); - } catch (error) { - logger.error("Failed to migrate tenant database", { - tenantId: tenantData.id, - shortName: tenantData.shortName, - databaseUrl: tenantData.databaseUrl, - error: String(error) - }); + logger.debug("Tenant database migration completed", { + tenantId: tenantData.id, + shortName: tenantData.shortName, + }); + } catch (error) { + logger.error("Failed to migrate tenant database", { + tenantId: tenantData.id, + shortName: tenantData.shortName, + databaseUrl: tenantData.databaseUrl, + error: String(error), + }); - // Don't throw here - we want to continue with other tenants - // In production, you might want to implement retry logic or alerts - } - }); + // Don't throw here - we want to continue with other tenants + // In production, you might want to implement retry logic or alerts + } + }); - // Wait for all migrations to complete - await Promise.all(migrationPromises); + // Wait for all migrations to complete + await Promise.all(migrationPromises); - logger.info("All tenant database migrations completed"); - } catch (error) { - logger.error("Failed to migrate tenant databases", { error: String(error) }); - throw error; - } - } + logger.info("All tenant database migrations completed"); + } catch (error) { + logger.error("Failed to migrate tenant databases", { error: String(error) }); + throw error; + } + } - /** - * Force re-initialization (useful for testing) - */ - static reset(): void { - this.initialized = false; - } + /** + * Force re-initialization (useful for testing) + */ + static reset(): void { + this.initialized = false; + } - /** - * Check if the startup service has been initialized - */ - static isInitialized(): boolean { - return this.initialized; - } + /** + * Check if the startup service has been initialized + */ + static isInitialized(): boolean { + return this.initialized; + } } diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 70adc5e..bc7877a 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -14,343 +14,343 @@ import { sendTenantAdminInviteEmail } from "../email/email-service"; if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); const tentantCreationSchema = z.object({ - shortName: z.string().min(4).max(15), - inviteAdmin: z.email().optional() + shortName: z.string().min(4).max(15), + inviteAdmin: z.email().optional(), }); export type TenantCreationRequest = z.infer; export interface TenantConfiguration extends Record { - brandColor: string; - defaultLanguage: string; - maxChannels: number; - maxTeamMembers: number; - autoDeleteDays: number; - requireEmail: boolean; - requirePhone: boolean; - nextChannelColor: number; - website: string; - imprint: string; - privacyStatement: string; + brandColor: string; + defaultLanguage: string; + maxChannels: number; + maxTeamMembers: number; + autoDeleteDays: number; + requireEmail: boolean; + requirePhone: boolean; + nextChannelColor: number; + website: string; + imprint: string; + privacyStatement: string; } export class TenantAdminService { - #config!: TenantConfig; - #tenant: Awaited | null = null; - #db: Awaited> | null = null; + #config!: TenantConfig; + #tenant: Awaited | null = null; + #db: Awaited> | null = null; - private constructor(public readonly tenantId: string) {} + private constructor(public readonly tenantId: string) {} - static getConfigDefaults(): TenantConfiguration { - return { - brandColor: "#E11E15", - defaultLanguage: "DE", - maxChannels: -1, - maxTeamMembers: -1, - autoDeleteDays: 30, - requireEmail: true, - requirePhone: false, - nextChannelColor: 0, - website: "", - imprint: "", - privacyStatement: "" - }; - } + static getConfigDefaults(): TenantConfiguration { + return { + brandColor: "#E11E15", + defaultLanguage: "DE", + maxChannels: -1, + maxTeamMembers: -1, + autoDeleteDays: 30, + requireEmail: true, + requirePhone: false, + nextChannelColor: 0, + website: "", + imprint: "", + privacyStatement: "", + }; + } - static async createTenant(request: TenantCreationRequest) { - const log = logger.setContext("TenantAdminService"); + static async createTenant(request: TenantCreationRequest) { + const log = logger.setContext("TenantAdminService"); - const validation = tentantCreationSchema.safeParse(request); + const validation = tentantCreationSchema.safeParse(request); - if (!validation.success) throw new ValidationError("Invalid tenant creation request"); + if (!validation.success) throw new ValidationError("Invalid tenant creation request"); - log.debug("Creating new tenant", { - shortName: request.shortName - }); + log.debug("Creating new tenant", { + shortName: request.shortName, + }); - const configuration = TenantAdminService.getConfigDefaults(); + const configuration = TenantAdminService.getConfigDefaults(); - const urlParts = env.DATABASE_URL?.split("/") ?? []; - urlParts.pop(); + const urlParts = env.DATABASE_URL?.split("/") ?? []; + urlParts.pop(); - const newTenant: InsertTenant = { ...request, longName: "", databaseUrl: "" }; - newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName; + const newTenant: InsertTenant = { ...request, longName: "", databaseUrl: "" }; + newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName; - try { - const tenant = await centralDb.insert(centralSchema.tenant).values(newTenant).returning(); + try { + const tenant = await centralDb.insert(centralSchema.tenant).values(newTenant).returning(); - log.debug("Tenant created in database", { - tenantId: tenant[0].id, - shortName: newTenant.shortName - }); + log.debug("Tenant created in database", { + tenantId: tenant[0].id, + shortName: newTenant.shortName, + }); - // Initialize tenant database with schema - try { - await TenantMigrationService.createAndInitializeTenantDatabase(newTenant.databaseUrl); - log.debug("Tenant database initialized successfully", { - tenantId: tenant[0].id, - databaseUrl: newTenant.databaseUrl - }); - } catch (dbError) { - log.error("Failed to initialize tenant database", { - tenantId: tenant[0].id, - databaseUrl: newTenant.databaseUrl, - error: String(dbError) - }); + // Initialize tenant database with schema + try { + await TenantMigrationService.createAndInitializeTenantDatabase(newTenant.databaseUrl); + log.debug("Tenant database initialized successfully", { + tenantId: tenant[0].id, + databaseUrl: newTenant.databaseUrl, + }); + } catch (dbError) { + log.error("Failed to initialize tenant database", { + tenantId: tenant[0].id, + databaseUrl: newTenant.databaseUrl, + error: String(dbError), + }); - // Clean up the tenant record if database initialization fails - await centralDb - .delete(centralSchema.tenant) - .where(eq(centralSchema.tenant.id, tenant[0].id)); - throw new Error(`Failed to initialize tenant database: ${String(dbError)}`); - } + // Clean up the tenant record if database initialization fails + await centralDb + .delete(centralSchema.tenant) + .where(eq(centralSchema.tenant.id, tenant[0].id)); + throw new Error(`Failed to initialize tenant database: ${String(dbError)}`); + } - const config = await TenantConfig.create(tenant[0].id); - for (const [key, value] of Object.entries(configuration)) { - config.setConfig(key, value); - } + const config = await TenantConfig.create(tenant[0].id); + for (const [key, value] of Object.entries(configuration)) { + config.setConfig(key, value); + } - log.debug("Tenant configuration initialized", { - tenantId: tenant[0].id, - configCount: Object.keys(configuration).length - }); + log.debug("Tenant configuration initialized", { + tenantId: tenant[0].id, + configCount: Object.keys(configuration).length, + }); - const tenantService = new TenantAdminService(tenant[0].id); - tenantService.#config = config; - tenantService.#tenant = tenant[0]; + const tenantService = new TenantAdminService(tenant[0].id); + tenantService.#config = config; + tenantService.#tenant = tenant[0]; - log.debug("Tenant service created successfully", { tenantId: tenant[0].id }); + log.debug("Tenant service created successfully", { tenantId: tenant[0].id }); - // Send tenant admin invitation email if email is provided - if (request.inviteAdmin) { - try { - // For now, we'll use the email as name. In a real implementation, - // you might want to collect the name separately or parse it from the email - const adminName = request.inviteAdmin.split("@")[0]; + // Send tenant admin invitation email if email is provided + if (request.inviteAdmin) { + try { + // For now, we'll use the email as name. In a real implementation, + // you might want to collect the name separately or parse it from the email + const adminName = request.inviteAdmin.split("@")[0]; - // Generate registration URL for the tenant admin - // This should point to a registration page that pre-fills tenant info - const registrationUrl = `${env.PUBLIC_APP_URL || "http://localhost:5173"}/register?tenant=${tenant[0].id}&email=${encodeURIComponent(request.inviteAdmin)}&role=TENANT_ADMIN`; + // Generate registration URL for the tenant admin + // This should point to a registration page that pre-fills tenant info + const registrationUrl = `${env.PUBLIC_APP_URL || "http://localhost:5173"}/register?tenant=${tenant[0].id}&email=${encodeURIComponent(request.inviteAdmin)}&role=TENANT_ADMIN`; - await sendTenantAdminInviteEmail( - request.inviteAdmin, - adminName, - tenant[0], - registrationUrl - ); + await sendTenantAdminInviteEmail( + request.inviteAdmin, + adminName, + tenant[0], + registrationUrl, + ); - log.info("Tenant admin invitation email sent successfully", { - tenantId: tenant[0].id, - adminEmail: request.inviteAdmin - }); - } catch (emailError) { - log.error("Failed to send tenant admin invitation email", { - tenantId: tenant[0].id, - adminEmail: request.inviteAdmin, - error: String(emailError) - }); + log.info("Tenant admin invitation email sent successfully", { + tenantId: tenant[0].id, + adminEmail: request.inviteAdmin, + }); + } catch (emailError) { + log.error("Failed to send tenant admin invitation email", { + tenantId: tenant[0].id, + adminEmail: request.inviteAdmin, + error: String(emailError), + }); - // Don't fail the tenant creation if email fails - // Just log the error and continue - } - } + // Don't fail the tenant creation if email fails + // Just log the error and continue + } + } - return tenantService; - } catch (error) { - log.error("Failed to create tenant", { - shortName: newTenant.shortName, - error: String(error) - }); - throw error; - } - } + return tenantService; + } catch (error) { + log.error("Failed to create tenant", { + shortName: newTenant.shortName, + error: String(error), + }); + throw error; + } + } - /** - * Create a tenant admin service by its ID - * @param id - * @returns new TenantAdminService - */ - static async getTenantById(id: string) { - const log = logger.setContext("TenantAdminService"); - log.debug("Getting tenant by ID", { tenantId: id }); + /** + * Create a tenant admin service by its ID + * @param id + * @returns new TenantAdminService + */ + static async getTenantById(id: string) { + const log = logger.setContext("TenantAdminService"); + log.debug("Getting tenant by ID", { tenantId: id }); - try { - const tenant = new TenantAdminService(id); - tenant.#config = await TenantConfig.create(id); - const data = await tenant.#db - ?.select() - .from(centralSchema.tenant) - .where(eq(centralSchema.tenant.id, id)) - .limit(1); - if (data) { - tenant.#tenant = data[0]; - } + try { + const tenant = new TenantAdminService(id); + tenant.#config = await TenantConfig.create(id); + const data = await tenant.#db + ?.select() + .from(centralSchema.tenant) + .where(eq(centralSchema.tenant.id, id)) + .limit(1); + if (data) { + tenant.#tenant = data[0]; + } - log.debug("Tenant service loaded successfully", { tenantId: id }); - return tenant; - } catch (error) { - log.error("Failed to get tenant by ID", { tenantId: id, error: String(error) }); - throw error; - } - } + log.debug("Tenant service loaded successfully", { tenantId: id }); + return tenant; + } catch (error) { + log.error("Failed to get tenant by ID", { tenantId: id, error: String(error) }); + throw error; + } + } - /** - * Access the tenants configuration - */ - get configuration() { - return this.#config; - } + /** + * Access the tenants configuration + */ + get configuration() { + return this.#config; + } - get tenantData() { - return this.#tenant; - } + get tenantData() { + return this.#tenant; + } - /** - * Update tenant data (longName, shortName, description, logo) - */ - async updateTenantData( - updateData: Partial> - ) { - const log = logger.setContext("TenantAdminService"); - log.debug("Updating tenant data", { - tenantId: this.tenantId, - updateFields: Object.keys(updateData) - }); + /** + * Update tenant data (longName, shortName, description, logo) + */ + async updateTenantData( + updateData: Partial>, + ) { + const log = logger.setContext("TenantAdminService"); + log.debug("Updating tenant data", { + tenantId: this.tenantId, + updateFields: Object.keys(updateData), + }); - try { - const result = await centralDb - .update(centralSchema.tenant) - .set({ - ...updateData, - updatedAt: new Date() - }) - .where(eq(centralSchema.tenant.id, this.tenantId)) - .returning(); + try { + const result = await centralDb + .update(centralSchema.tenant) + .set({ + ...updateData, + updatedAt: new Date(), + }) + .where(eq(centralSchema.tenant.id, this.tenantId)) + .returning(); - if (!result[0]) { - log.warn("Tenant update failed: Tenant not found", { tenantId: this.tenantId }); - throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); - } + if (!result[0]) { + log.warn("Tenant update failed: Tenant not found", { tenantId: this.tenantId }); + throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); + } - log.debug("Tenant data updated successfully", { - tenantId: this.tenantId, - updateFields: Object.keys(updateData) - }); + log.debug("Tenant data updated successfully", { + tenantId: this.tenantId, + updateFields: Object.keys(updateData), + }); - return result[0]; - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to update tenant data", { - tenantId: this.tenantId, - error: String(error) - }); - throw error; - } - } + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to update tenant data", { + tenantId: this.tenantId, + error: String(error), + }); + throw error; + } + } - /** - * Update tenant configuration entries using TenantConfig - */ - async updateTenantConfig(configUpdates: Record) { - const log = logger.setContext("TenantAdminService"); - log.debug("Updating tenant configuration", { - tenantId: this.tenantId, - configKeys: Object.keys(configUpdates) - }); + /** + * Update tenant configuration entries using TenantConfig + */ + async updateTenantConfig(configUpdates: Record) { + const log = logger.setContext("TenantAdminService"); + log.debug("Updating tenant configuration", { + tenantId: this.tenantId, + configKeys: Object.keys(configUpdates), + }); - try { - const results = []; + try { + const results = []; - for (const [key, value] of Object.entries(configUpdates)) { - await this.#config.setConfig(key, value); - results.push({ key, value }); - } + for (const [key, value] of Object.entries(configUpdates)) { + await this.#config.setConfig(key, value); + results.push({ key, value }); + } - log.debug("Tenant configuration updated successfully", { - tenantId: this.tenantId, - configKeys: Object.keys(configUpdates), - updatedCount: results.length - }); + log.debug("Tenant configuration updated successfully", { + tenantId: this.tenantId, + configKeys: Object.keys(configUpdates), + updatedCount: results.length, + }); - return results; - } catch (error) { - log.error("Failed to update tenant configuration", { - tenantId: this.tenantId, - configKeys: Object.keys(configUpdates), - error: String(error) - }); - throw error; - } - } + return results; + } catch (error) { + log.error("Failed to update tenant configuration", { + tenantId: this.tenantId, + configKeys: Object.keys(configUpdates), + error: String(error), + }); + throw error; + } + } - /** - * Set the setup state of the tenant - */ - async setSetupState( - setupState: "NEW" | "SETTINGS_CREATED" | "AGENTS_SET_UP" | "FIRST_CHANNEL_CREATED" - ) { - const log = logger.setContext("TenantAdminService"); - log.debug("Setting tenant setup state", { - tenantId: this.tenantId, - setupState - }); + /** + * Set the setup state of the tenant + */ + async setSetupState( + setupState: "NEW" | "SETTINGS_CREATED" | "AGENTS_SET_UP" | "FIRST_CHANNEL_CREATED", + ) { + const log = logger.setContext("TenantAdminService"); + log.debug("Setting tenant setup state", { + tenantId: this.tenantId, + setupState, + }); - try { - const result = await centralDb - .update(centralSchema.tenant) - .set({ - setupState, - updatedAt: new Date() - }) - .where(eq(centralSchema.tenant.id, this.tenantId)) - .returning(); + try { + const result = await centralDb + .update(centralSchema.tenant) + .set({ + setupState, + updatedAt: new Date(), + }) + .where(eq(centralSchema.tenant.id, this.tenantId)) + .returning(); - if (!result[0]) { - log.warn("Tenant setup state update failed: Tenant not found", { tenantId: this.tenantId }); - throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); - } + if (!result[0]) { + log.warn("Tenant setup state update failed: Tenant not found", { tenantId: this.tenantId }); + throw new NotFoundError(`Tenant with ID ${this.tenantId} not found`); + } - this.#tenant = result[0]; + this.#tenant = result[0]; - log.debug("Tenant setup state updated successfully", { - tenantId: this.tenantId, - setupState - }); + log.debug("Tenant setup state updated successfully", { + tenantId: this.tenantId, + setupState, + }); - return result[0]; - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to set tenant setup state", { - tenantId: this.tenantId, - setupState, - error: String(error) - }); - throw error; - } - } + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to set tenant setup state", { + tenantId: this.tenantId, + setupState, + error: String(error), + }); + throw error; + } + } - /** - * Get the tenant's database connection (cached) - */ - async getDb() { - const log = logger.setContext("TenantAdminService"); + /** + * Get the tenant's database connection (cached) + */ + async getDb() { + const log = logger.setContext("TenantAdminService"); - if (!this.#db) { - log.debug("Creating new tenant database connection", { tenantId: this.tenantId }); - try { - this.#db = await getTenantDb(this.tenantId); - log.debug("Tenant database connection established", { tenantId: this.tenantId }); - } catch (error) { - log.error("Failed to establish tenant database connection", { - tenantId: this.tenantId, - error: String(error) - }); - throw error; - } - } else { - log.debug("Using cached tenant database connection", { tenantId: this.tenantId }); - } + if (!this.#db) { + log.debug("Creating new tenant database connection", { tenantId: this.tenantId }); + try { + this.#db = await getTenantDb(this.tenantId); + log.debug("Tenant database connection established", { tenantId: this.tenantId }); + } catch (error) { + log.error("Failed to establish tenant database connection", { + tenantId: this.tenantId, + error: String(error), + }); + throw error; + } + } else { + log.debug("Using cached tenant database connection", { tenantId: this.tenantId }); + } - return this.#db; - } + return this.#db; + } } diff --git a/src/lib/server/services/tenant-migration-service.ts b/src/lib/server/services/tenant-migration-service.ts index de99a0d..8641af2 100644 --- a/src/lib/server/services/tenant-migration-service.ts +++ b/src/lib/server/services/tenant-migration-service.ts @@ -9,107 +9,107 @@ import { ValidationError } from "$lib/server/utils/errors"; const logger = new UniversalLogger().setContext("TenantMigrationService"); export interface TenantDatabaseConfig { - host: string; - port: number; - database: string; - username: string; - password: string; + host: string; + port: number; + database: string; + username: string; + password: string; } export class TenantMigrationService { - /** - * Create a new tenant database - */ - static async createTenantDatabase(config: TenantDatabaseConfig): Promise { - logger.info("Creating new tenant database", { database: config.database }); + /** + * Create a new tenant database + */ + static async createTenantDatabase(config: TenantDatabaseConfig): Promise { + logger.info("Creating new tenant database", { database: config.database }); - // Connect to postgres database to create the new tenant database - const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`; - const adminClient = postgres(adminConnectionString); + // Connect to postgres database to create the new tenant database + const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`; + const adminClient = postgres(adminConnectionString); - try { - // Create the database - await adminClient.unsafe(`CREATE DATABASE "${config.database}"`); - logger.info("Tenant database created successfully", { database: config.database }); - } catch (error) { - if (error instanceof Error && error.message.includes("already exists")) { - logger.warn("Tenant database already exists", { database: config.database }); - } else { - logger.error("Failed to create tenant database", { - database: config.database, - error: String(error) - }); - throw error; - } - } finally { - await adminClient.end(); - } - } + try { + // Create the database + await adminClient.unsafe(`CREATE DATABASE "${config.database}"`); + logger.info("Tenant database created successfully", { database: config.database }); + } catch (error) { + if (error instanceof Error && error.message.includes("already exists")) { + logger.warn("Tenant database already exists", { database: config.database }); + } else { + logger.error("Failed to create tenant database", { + database: config.database, + error: String(error), + }); + throw error; + } + } finally { + await adminClient.end(); + } + } - /** - * Initialize tenant database with schema - */ - static async initializeTenantSchema(config: TenantDatabaseConfig): Promise { - logger.info("Initializing tenant database schema", { database: config.database }); + /** + * Initialize tenant database with schema + */ + static async initializeTenantSchema(config: TenantDatabaseConfig): Promise { + logger.info("Initializing tenant database schema", { database: config.database }); - const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; - const client = postgres(connectionString); + const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; + const client = postgres(connectionString); - try { - // Create drizzle instance for this tenant database - const db = drizzle(client); + try { + // Create drizzle instance for this tenant database + const db = drizzle(client); - // Get the path to tenant migrations - const migrationsPath = join(process.cwd(), "tenant-migrations"); + // Get the path to tenant migrations + const migrationsPath = join(process.cwd(), "tenant-migrations"); - // Apply migrations - await migrate(db, { migrationsFolder: migrationsPath }); + // Apply migrations + await migrate(db, { migrationsFolder: migrationsPath }); - logger.info("Tenant database schema initialized successfully", { database: config.database }); - } catch (error) { - logger.error("Failed to initialize tenant database schema", { - database: config.database, - error: String(error) - }); - throw error; - } finally { - await client.end(); - } - } + logger.info("Tenant database schema initialized successfully", { database: config.database }); + } catch (error) { + logger.error("Failed to initialize tenant database schema", { + database: config.database, + error: String(error), + }); + throw error; + } finally { + await client.end(); + } + } - /** - * Check if tenant database exists - */ - static async tenantDatabaseExists(config: TenantDatabaseConfig): Promise { - const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`; - const adminClient = postgres(adminConnectionString); + /** + * Check if tenant database exists + */ + static async tenantDatabaseExists(config: TenantDatabaseConfig): Promise { + const adminConnectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/postgres`; + const adminClient = postgres(adminConnectionString); - try { - const result = await adminClient` + try { + const result = await adminClient` SELECT 1 FROM pg_database WHERE datname = ${config.database} `; - return result.length > 0; - } catch (error) { - logger.error("Failed to check tenant database existence", { - database: config.database, - error: String(error) - }); - return false; - } finally { - await adminClient.end(); - } - } + return result.length > 0; + } catch (error) { + logger.error("Failed to check tenant database existence", { + database: config.database, + error: String(error), + }); + return false; + } finally { + await adminClient.end(); + } + } - /** - * Get current schema version for a tenant database - */ - static async getTenantSchemaVersion(config: TenantDatabaseConfig): Promise { - const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; - const client = postgres(connectionString); + /** + * Get current schema version for a tenant database + */ + static async getTenantSchemaVersion(config: TenantDatabaseConfig): Promise { + const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; + const client = postgres(connectionString); - try { - // Check if migrations table exists - const tableExists = await client` + try { + // Check if migrations table exists + const tableExists = await client` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' @@ -117,159 +117,159 @@ export class TenantMigrationService { ); `; - if (!tableExists[0].exists) { - return null; - } + if (!tableExists[0].exists) { + return null; + } - // Get the latest migration - const result = await client` + // Get the latest migration + const result = await client` SELECT hash FROM __drizzle_migrations ORDER BY created_at DESC LIMIT 1 `; - return result.length > 0 ? result[0].hash : null; - } catch (error) { - logger.error("Failed to get tenant schema version", { - database: config.database, - error: String(error) - }); - return null; - } finally { - await client.end(); - } - } + return result.length > 0 ? result[0].hash : null; + } catch (error) { + logger.error("Failed to get tenant schema version", { + database: config.database, + error: String(error), + }); + return null; + } finally { + await client.end(); + } + } - /** - * Get the latest available migration hash - */ - static getLatestMigrationHash(): string | null { - try { - const migrationsPath = join(process.cwd(), "tenant-migrations"); - const metaPath = join(migrationsPath, "meta", "_journal.json"); + /** + * Get the latest available migration hash + */ + static getLatestMigrationHash(): string | null { + try { + const migrationsPath = join(process.cwd(), "tenant-migrations"); + const metaPath = join(migrationsPath, "meta", "_journal.json"); - if (!existsSync(metaPath)) { - return null; - } + if (!existsSync(metaPath)) { + return null; + } - const journal = JSON.parse(readFileSync(metaPath, "utf-8")); - const entries = journal.entries || []; + const journal = JSON.parse(readFileSync(metaPath, "utf-8")); + const entries = journal.entries || []; - if (entries.length === 0) { - return null; - } + if (entries.length === 0) { + return null; + } - // Get the latest entry - const latestEntry = entries[entries.length - 1]; - return latestEntry.hash || null; - } catch (error) { - logger.error("Failed to get latest migration hash", { error: String(error) }); - return null; - } - } + // Get the latest entry + const latestEntry = entries[entries.length - 1]; + return latestEntry.hash || null; + } catch (error) { + logger.error("Failed to get latest migration hash", { error: String(error) }); + return null; + } + } - /** - * Check if tenant database needs migration - */ - static async tenantNeedsMigration(config: TenantDatabaseConfig): Promise { - const currentVersion = await this.getTenantSchemaVersion(config); - const latestVersion = this.getLatestMigrationHash(); + /** + * Check if tenant database needs migration + */ + static async tenantNeedsMigration(config: TenantDatabaseConfig): Promise { + const currentVersion = await this.getTenantSchemaVersion(config); + const latestVersion = this.getLatestMigrationHash(); - if (!latestVersion) { - return false; // No migrations available - } + if (!latestVersion) { + return false; // No migrations available + } - if (!currentVersion) { - return true; // Database has no schema yet - } + if (!currentVersion) { + return true; // Database has no schema yet + } - return currentVersion !== latestVersion; - } + return currentVersion !== latestVersion; + } - /** - * Apply pending migrations to a tenant database - */ - static async migrateTenantDatabase(config: TenantDatabaseConfig): Promise { - logger.info("Migrating tenant database", { database: config.database }); + /** + * Apply pending migrations to a tenant database + */ + static async migrateTenantDatabase(config: TenantDatabaseConfig): Promise { + logger.info("Migrating tenant database", { database: config.database }); - const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; - const client = postgres(connectionString); + const connectionString = `postgres://${config.username}:${config.password}@${config.host}:${config.port}/${config.database}`; + const client = postgres(connectionString); - try { - const db = drizzle(client); - const migrationsPath = join(process.cwd(), "tenant-migrations"); + try { + const db = drizzle(client); + const migrationsPath = join(process.cwd(), "tenant-migrations"); - await migrate(db, { migrationsFolder: migrationsPath }); + await migrate(db, { migrationsFolder: migrationsPath }); - logger.info("Tenant database migrated successfully", { database: config.database }); - } catch (error) { - logger.error("Failed to migrate tenant database", { - database: config.database, - error: String(error) - }); - throw error; - } finally { - await client.end(); - } - } + logger.info("Tenant database migrated successfully", { database: config.database }); + } catch (error) { + logger.error("Failed to migrate tenant database", { + database: config.database, + error: String(error), + }); + throw error; + } finally { + await client.end(); + } + } - /** - * Create tenant database configuration from database URL - */ - static parseDatabaseUrl(databaseUrl: string): TenantDatabaseConfig { - try { - const url = new URL(databaseUrl); + /** + * Create tenant database configuration from database URL + */ + static parseDatabaseUrl(databaseUrl: string): TenantDatabaseConfig { + try { + const url = new URL(databaseUrl); - return { - host: url.hostname, - port: parseInt(url.port) || 5432, - database: url.pathname.slice(1), // Remove leading slash - username: url.username, - password: url.password - }; - } catch { - throw new ValidationError(`Invalid database URL: ${databaseUrl}`); - } - } + return { + host: url.hostname, + port: parseInt(url.port) || 5432, + database: url.pathname.slice(1), // Remove leading slash + username: url.username, + password: url.password, + }; + } catch { + throw new ValidationError(`Invalid database URL: ${databaseUrl}`); + } + } - /** - * Create a complete tenant database with schema - */ - static async createAndInitializeTenantDatabase(databaseUrl: string): Promise { - const config = this.parseDatabaseUrl(databaseUrl); + /** + * Create a complete tenant database with schema + */ + static async createAndInitializeTenantDatabase(databaseUrl: string): Promise { + const config = this.parseDatabaseUrl(databaseUrl); - // Create the database - await this.createTenantDatabase(config); + // Create the database + await this.createTenantDatabase(config); - // Initialize the schema - await this.initializeTenantSchema(config); + // Initialize the schema + await this.initializeTenantSchema(config); - logger.info("Tenant database created and initialized", { - database: config.database, - host: config.host, - port: config.port - }); - } + logger.info("Tenant database created and initialized", { + database: config.database, + host: config.host, + port: config.port, + }); + } - /** - * Ensure tenant database is up to date - */ - static async ensureTenantDatabaseUpToDate(databaseUrl: string): Promise { - const config = this.parseDatabaseUrl(databaseUrl); + /** + * Ensure tenant database is up to date + */ + static async ensureTenantDatabaseUpToDate(databaseUrl: string): Promise { + const config = this.parseDatabaseUrl(databaseUrl); - // Check if database exists - const exists = await this.tenantDatabaseExists(config); - if (!exists) { - await this.createAndInitializeTenantDatabase(databaseUrl); - return; - } + // Check if database exists + const exists = await this.tenantDatabaseExists(config); + if (!exists) { + await this.createAndInitializeTenantDatabase(databaseUrl); + return; + } - // Check if migration is needed - const needsMigration = await this.tenantNeedsMigration(config); - if (needsMigration) { - await this.migrateTenantDatabase(config); - } + // Check if migration is needed + const needsMigration = await this.tenantNeedsMigration(config); + if (needsMigration) { + await this.migrateTenantDatabase(config); + } - logger.debug("Tenant database is up to date", { database: config.database }); - } + logger.debug("Tenant database is up to date", { database: config.database }); + } } diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 3458c85..937d370 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -8,9 +8,9 @@ import { uuidv7 } from "uuidv7"; import { addMinutes } from "date-fns"; import logger from "$lib/logger"; import { - generateRecoveryPassphrase, - hashPassphrase, - validatePassphraseStrength + generateRecoveryPassphrase, + hashPassphrase, + validatePassphraseStrength, } from "../utils/passphrase"; import { sendConfirmationEmail } from "../email/email-service"; import type { SelectTenant } from "../db/central-schema"; @@ -20,14 +20,14 @@ export type InsertUser = InferInsertModel; export type InsertUserPasskey = InferInsertModel; const userCreationSchema = z.object({ - name: z.string().min(5), - email: z.email(), - role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(), - tenantId: z.string().uuid().optional(), - passphrase: z.string().min(12).optional(), - token: z.uuidv7().optional(), - tokenValidUntil: z.date().optional(), - language: z.enum(["de", "en"]).optional().default("de") + name: z.string().min(5), + email: z.email(), + role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(), + tenantId: z.string().uuid().optional(), + passphrase: z.string().min(12).optional(), + token: z.uuidv7().optional(), + tokenValidUntil: z.date().optional(), + language: z.enum(["de", "en"]).optional().default("de"), }); type UserCreation = z.infer; @@ -37,590 +37,590 @@ type UserCreation = z.infer; * Since central users don't belong to a specific tenant, we use generic branding */ function createSystemTenant(): SelectTenant { - return { - id: "system", - shortName: "open-reception", - longName: "Open Reception", - description: "Secure appointment booking platform", - logo: null, - databaseUrl: "", - setupState: "FIRST_CHANNEL_CREATED", - createdAt: new Date(), - updatedAt: new Date() - }; + return { + id: "system", + shortName: "open-reception", + longName: "Open Reception", + description: "Secure appointment booking platform", + logo: null, + databaseUrl: "", + setupState: "FIRST_CHANNEL_CREATED", + createdAt: new Date(), + updatedAt: new Date(), + }; } /** * Get tenant data for a user, fallback to system tenant if no tenant assigned */ async function getTenantForUser(user: { tenantId?: string | null }): Promise { - if (!user.tenantId) { - return createSystemTenant(); - } + if (!user.tenantId) { + return createSystemTenant(); + } - try { - const tenantService = await TenantAdminService.getTenantById(user.tenantId); - return tenantService.tenantData || createSystemTenant(); - } catch { - // If tenant not found, use system tenant as fallback - return createSystemTenant(); - } + try { + const tenantService = await TenantAdminService.getTenantById(user.tenantId); + return tenantService.tenantData || createSystemTenant(); + } catch { + // If tenant not found, use system tenant as fallback + return createSystemTenant(); + } } export class UserService { - /** - * Create a new user - */ - static async createUser(userData: UserCreation, requestUrl?: URL) { - const log = logger.setContext("UserService"); - log.debug("Creating new user account", { - email: userData.email, - name: userData.name, - role: userData.role, - hasPassphrase: !!userData.passphrase - }); - - const validated = userCreationSchema.safeParse(userData); - if (!validated.success) { - log.warn("User creation failed: Invalid data", { - email: userData.email, - errors: validated.error - }); - throw new ValidationError("Invalid user data"); - } - - // Validate passphrase strength if provided - if (userData.passphrase && !validatePassphraseStrength(userData.passphrase)) { - throw new ValidationError("Passphrase must be at least 12 characters long"); - } - - userData.token = uuidv7(); - userData.tokenValidUntil = addMinutes(new Date(), 10); - - // Prepare user data for database - const userDataForDb: InsertUser = { - name: userData.name, - email: userData.email, - role: userData.role, - tenantId: userData.tenantId, - token: userData.token, - tokenValidUntil: userData.tokenValidUntil, - language: userData.language || "de", - confirmed: false, - isActive: false - }; - - // Handle passphrase or generate recovery passphrase - if (userData.passphrase) { - // User provided a passphrase, hash it - userDataForDb.passphraseHash = await hashPassphrase(userData.passphrase); - } else if (userData.role === "GLOBAL_ADMIN") { - // No passphrase provided, generate a recovery passphrase - userDataForDb.recoveryPassphrase = generateRecoveryPassphrase(); - } - - try { - const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning(); - - log.debug("User account created successfully", { - userId: result[0].id, - email: result[0].email, - tokenValidUntil: result[0].tokenValidUntil, - hasPassphrase: !!result[0].passphraseHash, - hasRecoveryPassphrase: !!result[0].recoveryPassphrase - }); - - // Send confirmation email to user (token is used as confirmation code) - try { - if (result[0].email && result[0].token) { - const tenant = await getTenantForUser(result[0]); - await sendConfirmationEmail( - result[0], - tenant, - result[0].token, - 10, // 10 minutes expiration to match tokenValidUntil - requestUrl - ); - log.debug("Confirmation email sent successfully", { - userId: result[0].id, - email: result[0].email, - tenantId: result[0].tenantId - }); - } - } catch (emailError) { - log.warn("Failed to send confirmation email", { - userId: result[0].id, - email: result[0].email, - error: String(emailError) - }); - // Don't throw - user creation succeeded, email is just a bonus - } - - return result[0]; - } catch (error) { - log.error("Failed to create user account", { email: userData.email, error: String(error) }); - throw error; - } - } - - /** - * Resend the confirmation email for a user - * @param email - Email of the user to confirm - * @param requestUrl - Optional request URL for generating correct baseUrl - */ - static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise { - const log = logger.setContext("UserService"); - log.debug("Resending confirmation email", { email }); - - const token = uuidv7(); - const tokenValidUntil = addMinutes(new Date(), 10); - - try { - const result = await centralDb - .update(centralSchema.user) - .set({ token, tokenValidUntil }) - .where(eq(centralSchema.user.email, email)) - .returning(); - - if (result.length !== 1) { - log.warn("Failed to resend confirmation email: User not found", { email }); - throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`); - } - - const user = result[0]; - log.debug("Confirmation email resent successfully", { email, tokenValidUntil }); - - // Send confirmation email with new token - use tenant-specific branding if available - try { - const tenant = await getTenantForUser(user); - await sendConfirmationEmail( - user, - tenant, - token, - 10, // 10 minutes expiration to match tokenValidUntil - requestUrl - ); - log.debug("Confirmation email sent successfully", { - userId: user.id, - email: user.email, - tenantId: user.tenantId - }); - } catch (emailError) { - log.error("Failed to send confirmation email", { - userId: user.id, - email: user.email, - error: String(emailError) - }); - throw emailError; // In this case, we do want to propagate the error - } - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to resend confirmation email", { email, error: String(error) }); - throw error; - } - } - - /** - * Confirm and activate user after confirmation link was clicked - * @param linkToken - The token from the link - */ - static async confirm( - linkToken: string - ): Promise<{ recoveryPassphrase?: string; isSetup: boolean }> { - const log = logger.setContext("UserService"); - log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." }); - - try { - // First, get the user data to check for recovery passphrase - const userData = await centralDb - .select({ - id: centralSchema.user.id, - recoveryPassphrase: centralSchema.user.recoveryPassphrase - }) - .from(centralSchema.user) - .where( - and( - eq(centralSchema.user.token, linkToken), - gt(centralSchema.user.tokenValidUntil, new Date()) - ) - ) - .limit(1); - - if (userData.length === 0) { - log.warn("User confirmation failed: Invalid or expired token", { - token: linkToken.substring(0, 8) + "..." - }); - throw new NotFoundError("Invalid or timed-out token"); - } - - const user = userData[0]; - - // Update the user to confirmed and active, and clear the recovery passphrase - const result = await centralDb - .update(centralSchema.user) - .set({ - confirmed: true, - isActive: true, - recoveryPassphrase: null // Clear it after showing it once - }) - .where(eq(centralSchema.user.id, user.id)) - .execute(); - - if (result.count != 1) { - throw new NotFoundError("Failed to confirm user"); - } - - const countResult = await centralDb.select({ count: count() }).from(centralSchema.user); - - log.debug("User account confirmed successfully", { - userId: user.id, - token: linkToken.substring(0, 8) + "...", - hadRecoveryPassphrase: !!user.recoveryPassphrase - }); - - return { - recoveryPassphrase: user.recoveryPassphrase || undefined, - isSetup: countResult[0].count === 1 - }; - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to confirm user account", { - token: linkToken.substring(0, 8) + "...", - error: String(error) - }); - throw error; - } - } - - /** - * Add additional WebAuthn passkey to existing user - */ - static async addAdditionalPasskey(userId: string, passkeyData: InsertUserPasskey): Promise { - const log = logger.setContext("UserService"); - log.debug("Adding additional passkey to user", { userId, passkeyId: passkeyData.id }); - - try { - // Check if user exists and is active - const user = await centralDb - .select({ id: centralSchema.user.id, confirmed: centralSchema.user.confirmed }) - .from(centralSchema.user) - .where(eq(centralSchema.user.id, userId)) - .limit(1); - - if (user.length === 0) { - throw new NotFoundError("User not found"); - } - - if (!user[0].confirmed) { - throw new ValidationError( - "User account must be confirmed before adding additional passkeys" - ); - } - - // Add the passkey - await centralDb.insert(centralSchema.userPasskey).values({ - ...passkeyData, - userId, - createdAt: new Date(), - updatedAt: new Date() - }); - - log.debug("Additional passkey added successfully", { userId, passkeyId: passkeyData.id }); - } catch (error) { - if (error instanceof NotFoundError || error instanceof ValidationError) throw error; - log.error("Failed to add additional passkey", { userId, error: String(error) }); - throw error; - } - } - - /** - * Get admin by email - */ - static async getUserByEmail(email: string) { - const log = logger.setContext("UserService"); - log.debug("Getting admin by email", { email }); - - try { - const result = await centralDb - .select() - .from(centralSchema.user) - .where(eq(centralSchema.user.email, email)) - .limit(1); - - if (!result[0]) { - log.warn("User not found by email", { email }); - throw new NotFoundError(`No user account for ${email}.`); - } - - log.debug("User found by email", { - email, - userId: result[0].id, - confirmed: result[0].confirmed - }); - return result[0]; - } catch (error) { - if (error instanceof NotFoundError) throw error; - log.error("Failed to get user by email", { email, error: String(error) }); - throw error; - } - } - - /** - * Get all admins - */ - static async getAllAdmins() { - const log = logger.setContext("UserService"); - log.debug("Getting all admins"); - - try { - const result = await centralDb - .select() - .from(centralSchema.user) - .orderBy(desc(centralSchema.user.createdAt)) - .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")); - - log.debug("Retrieved all admins", { count: result.length }); - return result; - } catch (error) { - log.error("Failed to get all admins", { error: String(error) }); - throw error; - } - } - - /** - * Get all admins - */ - static async getAllUsers() { - const log = logger.setContext("UserService"); - log.debug("Getting all users"); - - try { - const result = await centralDb - .select() - .from(centralSchema.user) - .orderBy(desc(centralSchema.user.createdAt)); - - log.debug("Retrieved all users", { count: result.length }); - return result; - } catch (error) { - log.error("Failed to get all users", { error: String(error) }); - throw error; - } - } - - /** - * Update a user's data - */ - static async updateUser( - userId: string, - updateData: Partial> - ) { - const log = logger.setContext("UserService"); - log.debug("Updating user", { userId, updateFields: Object.keys(updateData) }); - - try { - const result = await centralDb - .update(centralSchema.user) - .set({ - ...updateData, - updatedAt: new Date() - }) - .where(eq(centralSchema.user.id, userId)) - .returning(); - - if (result[0]) { - log.debug("User updated successfully", { userId, updateFields: Object.keys(updateData) }); - } else { - log.warn("User update failed: User not found", { userId }); - } - - return result[0] || null; - } catch (error) { - log.error("Failed to update user", { userId, error: String(error) }); - throw error; - } - } - - /** - * Permanently delete admin and all associated passkeys - */ - static async deleteUser(userId: string) { - const log = logger.setContext("UserService"); - log.debug("Deleting user and associated passkeys", { userId }); - - try { - // Delete associated passkeys first - const passkeyResult = await centralDb - .delete(centralSchema.userPasskey) - .where(eq(centralSchema.userPasskey.userId, userId)); - - log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 }); - - // Delete admin - const result = await centralDb - .delete(centralSchema.user) - .where(eq(centralSchema.user.id, userId)) - .returning(); - - if (result[0]) { - log.debug("User deleted successfully", { userId, email: result[0].email }); - } else { - log.warn("User deletion failed: User not found", { userId }); - } - - return result[0] || null; - } catch (error) { - log.error("Failed to delete user", { userId, error: String(error) }); - throw error; - } - } - - /** - * Update last login timestamp - */ - static async updateLastLogin(userId: string) { - const log = logger.setContext("UserService"); - log.debug("Updating last login timestamp", { userId }); - - return await this.updateUser(userId, { lastLoginAt: new Date() }); - } - - /** - * Add a passkey for a uaer - */ - static async addPasskey( - userId: string, - passkeyData: Omit - ) { - const log = logger.setContext("UserService"); - log.debug("Adding passkey for user", { - userId, - passkeyId: passkeyData.id, - deviceName: passkeyData.deviceName - }); - - try { - const result = await centralDb - .insert(centralSchema.userPasskey) - .values({ - ...passkeyData, - userId - }) - .returning(); - - log.debug("Passkey added successfully", { - userId, - passkeyId: result[0].id, - deviceName: result[0].deviceName - }); - return result[0]; - } catch (error) { - log.error("Failed to add passkey", { - userId, - passkeyId: passkeyData.id, - error: String(error) - }); - throw error; - } - } - - /** - * Get all passkeys for an admin - */ - static async getUserPasskeys(userId: string) { - return await centralDb - .select() - .from(centralSchema.userPasskey) - .where(eq(centralSchema.userPasskey.userId, userId)) - .orderBy(desc(centralSchema.userPasskey.createdAt)); - } - - /** - * Update passkey (e.g., counter, last used time) - */ - static async updatePasskey( - passkeyId: string, - updateData: Partial> - ) { - const result = await centralDb - .update(centralSchema.userPasskey) - .set({ - ...updateData, - updatedAt: new Date() - }) - .where(eq(centralSchema.userPasskey.id, passkeyId)) - .returning(); - - return result[0] || null; - } - - /** - * Delete a passkey - */ - static async deletePasskey(passkeyId: string) { - const result = await centralDb - .delete(centralSchema.userPasskey) - .where(eq(centralSchema.userPasskey.id, passkeyId)) - .returning(); - - return result[0] || null; - } - - /** - * Update passkey last used timestamp and counter - */ - static async updatePasskeyUsage(passkeyId: string, newCounter: number) { - const log = logger.setContext("UserService"); - log.debug("Updating passkey usage", { passkeyId, newCounter }); - - return await this.updatePasskey(passkeyId, { - counter: newCounter, - lastUsedAt: new Date() - }); - } - - /** - * Check if any admin exists in the system - */ - static async adminExists(): Promise { - const log = logger.setContext("UserService"); - log.debug("Checking if any admin exists"); - - try { - const result = await centralDb - .select({ id: centralSchema.user.id }) - .from(centralSchema.user) - .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")) - .limit(1); - - const exists = result.length > 0; - log.debug("Admin existence check completed", { exists }); - return exists; - } catch (error) { - log.error("Failed to check admin existence", { error: String(error) }); - throw error; - } - } - - /** - * Get total count of admins in the system - */ - static async getAdminCount(): Promise { - const log = logger.setContext("UserService"); - log.debug("Getting admin count"); - - try { - const result = await centralDb - .select() - .from(centralSchema.user) - .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")); - - const count = result.length; - log.debug("Admin count retrieved", { count }); - return count; - } catch (error) { - log.error("Failed to get admin count", { error: String(error) }); - throw error; - } - } + /** + * Create a new user + */ + static async createUser(userData: UserCreation, requestUrl?: URL) { + const log = logger.setContext("UserService"); + log.debug("Creating new user account", { + email: userData.email, + name: userData.name, + role: userData.role, + hasPassphrase: !!userData.passphrase, + }); + + const validated = userCreationSchema.safeParse(userData); + if (!validated.success) { + log.warn("User creation failed: Invalid data", { + email: userData.email, + errors: validated.error, + }); + throw new ValidationError("Invalid user data"); + } + + // Validate passphrase strength if provided + if (userData.passphrase && !validatePassphraseStrength(userData.passphrase)) { + throw new ValidationError("Passphrase must be at least 12 characters long"); + } + + userData.token = uuidv7(); + userData.tokenValidUntil = addMinutes(new Date(), 10); + + // Prepare user data for database + const userDataForDb: InsertUser = { + name: userData.name, + email: userData.email, + role: userData.role, + tenantId: userData.tenantId, + token: userData.token, + tokenValidUntil: userData.tokenValidUntil, + language: userData.language || "de", + confirmed: false, + isActive: false, + }; + + // Handle passphrase or generate recovery passphrase + if (userData.passphrase) { + // User provided a passphrase, hash it + userDataForDb.passphraseHash = await hashPassphrase(userData.passphrase); + } else if (userData.role === "GLOBAL_ADMIN") { + // No passphrase provided, generate a recovery passphrase + userDataForDb.recoveryPassphrase = generateRecoveryPassphrase(); + } + + try { + const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning(); + + log.debug("User account created successfully", { + userId: result[0].id, + email: result[0].email, + tokenValidUntil: result[0].tokenValidUntil, + hasPassphrase: !!result[0].passphraseHash, + hasRecoveryPassphrase: !!result[0].recoveryPassphrase, + }); + + // Send confirmation email to user (token is used as confirmation code) + try { + if (result[0].email && result[0].token) { + const tenant = await getTenantForUser(result[0]); + await sendConfirmationEmail( + result[0], + tenant, + result[0].token, + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl, + ); + log.debug("Confirmation email sent successfully", { + userId: result[0].id, + email: result[0].email, + tenantId: result[0].tenantId, + }); + } + } catch (emailError) { + log.warn("Failed to send confirmation email", { + userId: result[0].id, + email: result[0].email, + error: String(emailError), + }); + // Don't throw - user creation succeeded, email is just a bonus + } + + return result[0]; + } catch (error) { + log.error("Failed to create user account", { email: userData.email, error: String(error) }); + throw error; + } + } + + /** + * Resend the confirmation email for a user + * @param email - Email of the user to confirm + * @param requestUrl - Optional request URL for generating correct baseUrl + */ + static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise { + const log = logger.setContext("UserService"); + log.debug("Resending confirmation email", { email }); + + const token = uuidv7(); + const tokenValidUntil = addMinutes(new Date(), 10); + + try { + const result = await centralDb + .update(centralSchema.user) + .set({ token, tokenValidUntil }) + .where(eq(centralSchema.user.email, email)) + .returning(); + + if (result.length !== 1) { + log.warn("Failed to resend confirmation email: User not found", { email }); + throw new NotFoundError(`Could not resend confirmation mail for unknown user ${email}`); + } + + const user = result[0]; + log.debug("Confirmation email resent successfully", { email, tokenValidUntil }); + + // Send confirmation email with new token - use tenant-specific branding if available + try { + const tenant = await getTenantForUser(user); + await sendConfirmationEmail( + user, + tenant, + token, + 10, // 10 minutes expiration to match tokenValidUntil + requestUrl, + ); + log.debug("Confirmation email sent successfully", { + userId: user.id, + email: user.email, + tenantId: user.tenantId, + }); + } catch (emailError) { + log.error("Failed to send confirmation email", { + userId: user.id, + email: user.email, + error: String(emailError), + }); + throw emailError; // In this case, we do want to propagate the error + } + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to resend confirmation email", { email, error: String(error) }); + throw error; + } + } + + /** + * Confirm and activate user after confirmation link was clicked + * @param linkToken - The token from the link + */ + static async confirm( + linkToken: string, + ): Promise<{ recoveryPassphrase?: string; isSetup: boolean }> { + const log = logger.setContext("UserService"); + log.debug("Confirming user account", { token: linkToken.substring(0, 8) + "..." }); + + try { + // First, get the user data to check for recovery passphrase + const userData = await centralDb + .select({ + id: centralSchema.user.id, + recoveryPassphrase: centralSchema.user.recoveryPassphrase, + }) + .from(centralSchema.user) + .where( + and( + eq(centralSchema.user.token, linkToken), + gt(centralSchema.user.tokenValidUntil, new Date()), + ), + ) + .limit(1); + + if (userData.length === 0) { + log.warn("User confirmation failed: Invalid or expired token", { + token: linkToken.substring(0, 8) + "...", + }); + throw new NotFoundError("Invalid or timed-out token"); + } + + const user = userData[0]; + + // Update the user to confirmed and active, and clear the recovery passphrase + const result = await centralDb + .update(centralSchema.user) + .set({ + confirmed: true, + isActive: true, + recoveryPassphrase: null, // Clear it after showing it once + }) + .where(eq(centralSchema.user.id, user.id)) + .execute(); + + if (result.count != 1) { + throw new NotFoundError("Failed to confirm user"); + } + + const countResult = await centralDb.select({ count: count() }).from(centralSchema.user); + + log.debug("User account confirmed successfully", { + userId: user.id, + token: linkToken.substring(0, 8) + "...", + hadRecoveryPassphrase: !!user.recoveryPassphrase, + }); + + return { + recoveryPassphrase: user.recoveryPassphrase || undefined, + isSetup: countResult[0].count === 1, + }; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to confirm user account", { + token: linkToken.substring(0, 8) + "...", + error: String(error), + }); + throw error; + } + } + + /** + * Add additional WebAuthn passkey to existing user + */ + static async addAdditionalPasskey(userId: string, passkeyData: InsertUserPasskey): Promise { + const log = logger.setContext("UserService"); + log.debug("Adding additional passkey to user", { userId, passkeyId: passkeyData.id }); + + try { + // Check if user exists and is active + const user = await centralDb + .select({ id: centralSchema.user.id, confirmed: centralSchema.user.confirmed }) + .from(centralSchema.user) + .where(eq(centralSchema.user.id, userId)) + .limit(1); + + if (user.length === 0) { + throw new NotFoundError("User not found"); + } + + if (!user[0].confirmed) { + throw new ValidationError( + "User account must be confirmed before adding additional passkeys", + ); + } + + // Add the passkey + await centralDb.insert(centralSchema.userPasskey).values({ + ...passkeyData, + userId, + createdAt: new Date(), + updatedAt: new Date(), + }); + + log.debug("Additional passkey added successfully", { userId, passkeyId: passkeyData.id }); + } catch (error) { + if (error instanceof NotFoundError || error instanceof ValidationError) throw error; + log.error("Failed to add additional passkey", { userId, error: String(error) }); + throw error; + } + } + + /** + * Get admin by email + */ + static async getUserByEmail(email: string) { + const log = logger.setContext("UserService"); + log.debug("Getting admin by email", { email }); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .where(eq(centralSchema.user.email, email)) + .limit(1); + + if (!result[0]) { + log.warn("User not found by email", { email }); + throw new NotFoundError(`No user account for ${email}.`); + } + + log.debug("User found by email", { + email, + userId: result[0].id, + confirmed: result[0].confirmed, + }); + return result[0]; + } catch (error) { + if (error instanceof NotFoundError) throw error; + log.error("Failed to get user by email", { email, error: String(error) }); + throw error; + } + } + + /** + * Get all admins + */ + static async getAllAdmins() { + const log = logger.setContext("UserService"); + log.debug("Getting all admins"); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .orderBy(desc(centralSchema.user.createdAt)) + .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")); + + log.debug("Retrieved all admins", { count: result.length }); + return result; + } catch (error) { + log.error("Failed to get all admins", { error: String(error) }); + throw error; + } + } + + /** + * Get all admins + */ + static async getAllUsers() { + const log = logger.setContext("UserService"); + log.debug("Getting all users"); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .orderBy(desc(centralSchema.user.createdAt)); + + log.debug("Retrieved all users", { count: result.length }); + return result; + } catch (error) { + log.error("Failed to get all users", { error: String(error) }); + throw error; + } + } + + /** + * Update a user's data + */ + static async updateUser( + userId: string, + updateData: Partial>, + ) { + const log = logger.setContext("UserService"); + log.debug("Updating user", { userId, updateFields: Object.keys(updateData) }); + + try { + const result = await centralDb + .update(centralSchema.user) + .set({ + ...updateData, + updatedAt: new Date(), + }) + .where(eq(centralSchema.user.id, userId)) + .returning(); + + if (result[0]) { + log.debug("User updated successfully", { userId, updateFields: Object.keys(updateData) }); + } else { + log.warn("User update failed: User not found", { userId }); + } + + return result[0] || null; + } catch (error) { + log.error("Failed to update user", { userId, error: String(error) }); + throw error; + } + } + + /** + * Permanently delete admin and all associated passkeys + */ + static async deleteUser(userId: string) { + const log = logger.setContext("UserService"); + log.debug("Deleting user and associated passkeys", { userId }); + + try { + // Delete associated passkeys first + const passkeyResult = await centralDb + .delete(centralSchema.userPasskey) + .where(eq(centralSchema.userPasskey.userId, userId)); + + log.debug("Deleted user passkeys", { userId, deletedCount: passkeyResult.count || 0 }); + + // Delete admin + const result = await centralDb + .delete(centralSchema.user) + .where(eq(centralSchema.user.id, userId)) + .returning(); + + if (result[0]) { + log.debug("User deleted successfully", { userId, email: result[0].email }); + } else { + log.warn("User deletion failed: User not found", { userId }); + } + + return result[0] || null; + } catch (error) { + log.error("Failed to delete user", { userId, error: String(error) }); + throw error; + } + } + + /** + * Update last login timestamp + */ + static async updateLastLogin(userId: string) { + const log = logger.setContext("UserService"); + log.debug("Updating last login timestamp", { userId }); + + return await this.updateUser(userId, { lastLoginAt: new Date() }); + } + + /** + * Add a passkey for a uaer + */ + static async addPasskey( + userId: string, + passkeyData: Omit, + ) { + const log = logger.setContext("UserService"); + log.debug("Adding passkey for user", { + userId, + passkeyId: passkeyData.id, + deviceName: passkeyData.deviceName, + }); + + try { + const result = await centralDb + .insert(centralSchema.userPasskey) + .values({ + ...passkeyData, + userId, + }) + .returning(); + + log.debug("Passkey added successfully", { + userId, + passkeyId: result[0].id, + deviceName: result[0].deviceName, + }); + return result[0]; + } catch (error) { + log.error("Failed to add passkey", { + userId, + passkeyId: passkeyData.id, + error: String(error), + }); + throw error; + } + } + + /** + * Get all passkeys for an admin + */ + static async getUserPasskeys(userId: string) { + return await centralDb + .select() + .from(centralSchema.userPasskey) + .where(eq(centralSchema.userPasskey.userId, userId)) + .orderBy(desc(centralSchema.userPasskey.createdAt)); + } + + /** + * Update passkey (e.g., counter, last used time) + */ + static async updatePasskey( + passkeyId: string, + updateData: Partial>, + ) { + const result = await centralDb + .update(centralSchema.userPasskey) + .set({ + ...updateData, + updatedAt: new Date(), + }) + .where(eq(centralSchema.userPasskey.id, passkeyId)) + .returning(); + + return result[0] || null; + } + + /** + * Delete a passkey + */ + static async deletePasskey(passkeyId: string) { + const result = await centralDb + .delete(centralSchema.userPasskey) + .where(eq(centralSchema.userPasskey.id, passkeyId)) + .returning(); + + return result[0] || null; + } + + /** + * Update passkey last used timestamp and counter + */ + static async updatePasskeyUsage(passkeyId: string, newCounter: number) { + const log = logger.setContext("UserService"); + log.debug("Updating passkey usage", { passkeyId, newCounter }); + + return await this.updatePasskey(passkeyId, { + counter: newCounter, + lastUsedAt: new Date(), + }); + } + + /** + * Check if any admin exists in the system + */ + static async adminExists(): Promise { + const log = logger.setContext("UserService"); + log.debug("Checking if any admin exists"); + + try { + const result = await centralDb + .select({ id: centralSchema.user.id }) + .from(centralSchema.user) + .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")) + .limit(1); + + const exists = result.length > 0; + log.debug("Admin existence check completed", { exists }); + return exists; + } catch (error) { + log.error("Failed to check admin existence", { error: String(error) }); + throw error; + } + } + + /** + * Get total count of admins in the system + */ + static async getAdminCount(): Promise { + const log = logger.setContext("UserService"); + log.debug("Getting admin count"); + + try { + const result = await centralDb + .select() + .from(centralSchema.user) + .where(eq(centralSchema.user.role, "GLOBAL_ADMIN")); + + const count = result.length; + log.debug("Admin count retrieved", { count }); + return count; + } catch (error) { + log.error("Failed to get admin count", { error: String(error) }); + throw error; + } + } } diff --git a/src/lib/server/utils/errors.ts b/src/lib/server/utils/errors.ts index 03cb50f..2e9b24d 100644 --- a/src/lib/server/utils/errors.ts +++ b/src/lib/server/utils/errors.ts @@ -1,19 +1,19 @@ export class AuthenticationError extends Error { - constructor(message: string) { - super(message); - this.name = "AuthenticationError"; - } + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + } } export class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ValidationError"; - } + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } } export class NotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "ValidationError"; - } + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } } diff --git a/src/lib/server/utils/passphrase.ts b/src/lib/server/utils/passphrase.ts index a03bda2..33c2302 100644 --- a/src/lib/server/utils/passphrase.ts +++ b/src/lib/server/utils/passphrase.ts @@ -6,56 +6,56 @@ import { hash, verify } from "argon2"; * Uses a combination of words and numbers for better memorability */ export function generateRecoveryPassphrase(): string { - // Generate 16 random bytes and convert to base64 - const randomData = randomBytes(16); - const base64 = randomData.toString("base64"); + // Generate 16 random bytes and convert to base64 + const randomData = randomBytes(16); + const base64 = randomData.toString("base64"); - // Convert to a more user-friendly format - // Remove padding and special characters, add hyphens for readability - const cleaned = base64.replace(/[+/=]/g, "").toLowerCase(); + // Convert to a more user-friendly format + // Remove padding and special characters, add hyphens for readability + const cleaned = base64.replace(/[+/=]/g, "").toLowerCase(); - // Split into groups of 4 characters with hyphens - const groups = []; - for (let i = 0; i < cleaned.length; i += 4) { - groups.push(cleaned.slice(i, i + 4)); - } + // Split into groups of 4 characters with hyphens + const groups = []; + for (let i = 0; i < cleaned.length; i += 4) { + groups.push(cleaned.slice(i, i + 4)); + } - return groups.join("-"); + return groups.join("-"); } /** * Hash a passphrase using Argon2 */ export async function hashPassphrase(passphrase: string): Promise { - return hash(passphrase, { - type: 2, // Argon2id - memoryCost: 2 ** 16, // 64 MB - timeCost: 3, - parallelism: 1 - }); + return hash(passphrase, { + type: 2, // Argon2id + memoryCost: 2 ** 16, // 64 MB + timeCost: 3, + parallelism: 1, + }); } /** * Verify a passphrase against its hash */ export async function verifyPassphrase(hash: string, passphrase: string): Promise { - try { - return await verify(hash, passphrase); - } catch { - return false; - } + try { + return await verify(hash, passphrase); + } catch { + return false; + } } /** * Validate passphrase strength (minimum requirements) */ export function validatePassphraseStrength(passphrase: string): boolean { - // Minimum 12 characters for security - if (passphrase.length < 12) { - return false; - } + // Minimum 12 characters for security + if (passphrase.length < 12) { + return false; + } - // No additional complexity requirements for passphrases - // (they can be long sentences) - return true; + // No additional complexity requirements for passphrases + // (they can be long sentences) + return true; } diff --git a/src/lib/server/utils/routeUtils.ts b/src/lib/server/utils/routeUtils.ts index 023b21e..f42956b 100644 --- a/src/lib/server/utils/routeUtils.ts +++ b/src/lib/server/utils/routeUtils.ts @@ -8,9 +8,9 @@ import { NotFoundError } from "./errors"; * @returns core data and configuration of the active tenant */ export const getTenantForSession = async (sessionToken: string) => { - const session = await SessionService.getUserFromSession(sessionToken); - if (!session || !session.tenantId) throw new NotFoundError("Unknown session token"); - const tenant = await TenantAdminService.getTenantById(session.tenantId); - const tenantData = tenant.tenantData; - return { tenantData, config: tenant.configuration }; + const session = await SessionService.getUserFromSession(sessionToken); + if (!session || !session.tenantId) throw new NotFoundError("Unknown session token"); + const tenant = await TenantAdminService.getTenantById(session.tenantId); + const tenantData = tenant.tenantData; + return { tenantData, config: tenant.configuration }; }; diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 907113b..2a8f4a1 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -1,43 +1,43 @@ import { writable } from "svelte/store"; interface AuthState { - isAuthenticated: boolean; - isRefreshing: boolean; - user?: { - id: string; - email: string; - name: string; - role: string; - // The currently selected tenant - tenantId?: string | null; - }; + isAuthenticated: boolean; + isRefreshing: boolean; + user?: { + id: string; + email: string; + name: string; + role: string; + // The currently selected tenant + tenantId?: string | null; + }; } function createAuthStore() { - const store = writable({ - isAuthenticated: false, - isRefreshing: false - }); + const store = writable({ + isAuthenticated: false, + isRefreshing: false, + }); - return { - ...store, - setRefreshing: (isRefreshing: boolean) => { - store.update((state) => ({ ...state, isRefreshing })); - }, - setAuthenticated: (isAuthenticated: boolean) => { - store.update((state) => ({ ...state, isAuthenticated })); - }, - setUser: (user: AuthState["user"]) => { - store.update((state) => ({ ...state, isAuthenticated: true, user })); - }, - reset: () => { - store.set({ - isAuthenticated: false, - isRefreshing: false, - user: undefined - }); - } - }; + return { + ...store, + setRefreshing: (isRefreshing: boolean) => { + store.update((state) => ({ ...state, isRefreshing })); + }, + setAuthenticated: (isAuthenticated: boolean) => { + store.update((state) => ({ ...state, isAuthenticated })); + }, + setUser: (user: AuthState["user"]) => { + store.update((state) => ({ ...state, isAuthenticated: true, user })); + }, + reset: () => { + store.set({ + isAuthenticated: false, + isRefreshing: false, + user: undefined, + }); + }, + }; } export const auth = createAuthStore(); diff --git a/src/lib/stores/sidebar.ts b/src/lib/stores/sidebar.ts index 3cba045..2584bfa 100644 --- a/src/lib/stores/sidebar.ts +++ b/src/lib/stores/sidebar.ts @@ -2,24 +2,24 @@ import { browser } from "$app/environment"; import { writable } from "svelte/store"; interface AuthState { - isOpen: boolean; + isOpen: boolean; } const LOCAL_STORAGE_KEY = "sidebar-is-open"; function createSidebarStore() { - const storedValue = browser ? localStorage.getItem(LOCAL_STORAGE_KEY) : null; - const store = writable({ - isOpen: storedValue === "true" ? true : false - }); + const storedValue = browser ? localStorage.getItem(LOCAL_STORAGE_KEY) : null; + const store = writable({ + isOpen: storedValue === "true" ? true : false, + }); - return { - ...store, - setOpen: (isOpen: boolean) => { - localStorage.setItem(LOCAL_STORAGE_KEY, `${isOpen}`); - store.update((state) => ({ ...state, isOpen })); - } - }; + return { + ...store, + setOpen: (isOpen: boolean) => { + localStorage.setItem(LOCAL_STORAGE_KEY, `${isOpen}`); + store.update((state) => ({ ...state, isOpen })); + }, + }; } export const sidebar = createSidebarStore(); diff --git a/src/lib/types/tenant.ts b/src/lib/types/tenant.ts index 0b2419c..4410803 100644 --- a/src/lib/types/tenant.ts +++ b/src/lib/types/tenant.ts @@ -1,6 +1,6 @@ export type TTenant = { - id: string; - name: string; - url: string; - logo?: string; + id: string; + name: string; + url: string; + logo?: string; }; diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts index 2ee0868..094ed0c 100644 --- a/src/lib/types/user.ts +++ b/src/lib/types/user.ts @@ -1,9 +1,9 @@ import type { UserRole } from "$lib/server/auth/authorization-service"; export type TUser = { - id: string; - email: string; - name: string; - role: UserRole; - tenantId: string | null; + id: string; + email: string; + name: string; + role: UserRole; + tenantId: string | null; }; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 55b3a91..ac0b00a 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,7 +2,7 @@ import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { - return twMerge(clsx(inputs)); + return twMerge(clsx(inputs)); } // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/src/lib/utils/name.ts b/src/lib/utils/name.ts index 0801ddc..d9e15b3 100644 --- a/src/lib/utils/name.ts +++ b/src/lib/utils/name.ts @@ -1,8 +1,8 @@ export const nameToAvatarFallback = (name?: string) => { - if (!name) return "NN"; - return name - .split(" ") - .map((n) => n[0]) - .join("") - .toUpperCase(); + if (!name) return "NN"; + return name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase(); }; diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index f40258b..f39d3b2 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -1,109 +1,109 @@ export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { - const bytes = new Uint8Array(buffer); - let binary = ""; + const bytes = new Uint8Array(buffer); + let binary = ""; - // Simple loop, no fancy operations - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } + // Simple loop, no fancy operations + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } - return window.btoa(binary); + return window.btoa(binary); }; export function base64ToArrayBuffer(base64: string) { - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - return bytes.buffer; + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; } export const fetchChallenge = async (email: string) => { - const resp = await fetch("/api/auth/challenge", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ email }) - }); + const resp = await fetch("/api/auth/challenge", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email }), + }); - try { - const data = await resp.json(); - return { - id: data.rpId, - challenge: data.challenge - }; - } catch { - return null; - } + try { + const data = await resp.json(); + return { + id: data.rpId, + challenge: data.challenge, + }; + } catch { + return null; + } }; export type GeneratePasskeyResponse = { - response: AuthenticatorAttestationResponse; - id: string; - getClientExtensionResults: () => { deviceName?: string }; + response: AuthenticatorAttestationResponse; + id: string; + getClientExtensionResults: () => { deviceName?: string }; } | null; export const generatePasskey = async ({ - id, - challenge, - email + id, + challenge, + email, }: { - id: string; - challenge: string; - email: string; + id: string; + challenge: string; + email: string; }): Promise => { - const publicKey: PublicKeyCredentialCreationOptions = { - challenge: base64ToArrayBuffer(challenge), - rp: { - id, - name: "Open Reception" - }, - user: { - id: new Uint8Array(16), - name: email, - displayName: email - }, - pubKeyCredParams: [ - { alg: -7, type: "public-key" } // ES256 - ] - }; - return (await navigator.credentials.create({ publicKey })) as GeneratePasskeyResponse; + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64ToArrayBuffer(challenge), + rp: { + id, + name: "Open Reception", + }, + user: { + id: new Uint8Array(16), + name: email, + displayName: email, + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 + ], + }; + return (await navigator.credentials.create({ publicKey })) as GeneratePasskeyResponse; }; export type GetCredentialResponse = PublicKeyCredential & { - response: PublicKeyCredential; - id: string; + response: PublicKeyCredential; + id: string; }; export const getCredential = async ({ - id, - challenge, - email + id, + challenge, + email, }: { - id: string; - challenge: string; - email: string; + id: string; + challenge: string; + email: string; }) => { - const publicKey: PublicKeyCredentialCreationOptions = { - challenge: base64ToArrayBuffer(challenge), - rp: { - id, - name: "Open Reception" - }, - user: { - id: new Uint8Array(16), - name: email, - displayName: email - }, - pubKeyCredParams: [ - { alg: -7, type: "public-key" } // ES256 - ] - }; - return (await navigator.credentials.get({ publicKey })) as GetCredentialResponse; + const publicKey: PublicKeyCredentialCreationOptions = { + challenge: base64ToArrayBuffer(challenge), + rp: { + id, + name: "Open Reception", + }, + user: { + id: new Uint8Array(16), + name: email, + displayName: email, + }, + pubKeyCredParams: [ + { alg: -7, type: "public-key" }, // ES256 + ], + }; + return (await navigator.credentials.get({ publicKey })) as GetCredentialResponse; }; export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => { - const view = new DataView(authenticatorData); - // Counter is at offset 33, 4 bytes, big-endian - return view.getUint32(33, false); // false = big-endian + const view = new DataView(authenticatorData); + // Counter is at offset 33, 4 bytes, big-endian + return view.getUint32(33, false); // false = big-endian }; diff --git a/src/lib/utils/routes.ts b/src/lib/utils/routes.ts index ca4a9bc..8d60cc8 100644 --- a/src/lib/utils/routes.ts +++ b/src/lib/utils/routes.ts @@ -2,15 +2,15 @@ import { page } from "$app/state"; import { ROUTES } from "$lib/const/routes"; export const isCurrentSection = (sectionPath: string): boolean => { - // Select home when on dashboard main - if (page.url.pathname === ROUTES.DASHBOARD.MAIN && sectionPath === ROUTES.DASHBOARD.MAIN) { - return true; - } + // Select home when on dashboard main + if (page.url.pathname === ROUTES.DASHBOARD.MAIN && sectionPath === ROUTES.DASHBOARD.MAIN) { + return true; + } - // Not always select home - if (page.url.pathname.startsWith(sectionPath) && sectionPath === ROUTES.DASHBOARD.MAIN) { - return false; - } + // Not always select home + if (page.url.pathname.startsWith(sectionPath) && sectionPath === ROUTES.DASHBOARD.MAIN) { + return false; + } - return page.url.pathname.startsWith(sectionPath); + return page.url.pathname.startsWith(sectionPath); }; diff --git a/src/lib/utils/session.ts b/src/lib/utils/session.ts index 743a9a3..e1b6213 100644 --- a/src/lib/utils/session.ts +++ b/src/lib/utils/session.ts @@ -3,45 +3,45 @@ import { ROUTES } from "$lib/const/routes"; import { auth } from "$lib/stores/auth"; export const refreshSession = async () => { - auth.setRefreshing(true); + auth.setRefreshing(true); - try { - const response = await fetch("/api/auth/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - credentials: "same-origin" - }); + try { + const response = await fetch("/api/auth/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }); - if (!response.ok) { - // Token is invalid, clear and redirect to login - if (response.status === 401) { - auth.setRefreshing(false); - goto(ROUTES.LOGOUT); - return; - } - } + if (!response.ok) { + // Token is invalid, clear and redirect to login + if (response.status === 401) { + auth.setRefreshing(false); + goto(ROUTES.LOGOUT); + return; + } + } - refreshUserData(); - } finally { - auth.setRefreshing(false); - } + refreshUserData(); + } finally { + auth.setRefreshing(false); + } }; export const refreshUserData = async () => { - try { - const response = await fetch("/api/auth/session", { - method: "GET", - headers: { - "Content-Type": "application/json" - }, - credentials: "same-origin" - }); + try { + const response = await fetch("/api/auth/session", { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }); - const data = await response.json(); - auth.setUser(data.user); - } catch (error) { - console.error("Failed to refresh user data", error); - } + const data = await response.json(); + auth.setUser(data.user); + } catch (error) { + console.error("Failed to refresh user data", error); + } }; diff --git a/src/routes/(pages)/+layout.svelte b/src/routes/(pages)/+layout.svelte index a7f5835..29709d6 100644 --- a/src/routes/(pages)/+layout.svelte +++ b/src/routes/(pages)/+layout.svelte @@ -1,23 +1,23 @@ diff --git a/src/routes/(pages)/+page.server.ts b/src/routes/(pages)/+page.server.ts index 347fa94..c114543 100644 --- a/src/routes/(pages)/+page.server.ts +++ b/src/routes/(pages)/+page.server.ts @@ -3,26 +3,26 @@ import { UserService } from "$lib/server/services/user-service"; import { redirect } from "@sveltejs/kit"; export const load = async (event) => { - // Check if global admin exists - // If not, redirect to setup page - const adminExists = await UserService.adminExists(); - if (!adminExists) { - redirect(302, ROUTES.SETUP.MAIN); - } + // Check if global admin exists + // If not, redirect to setup page + const adminExists = await UserService.adminExists(); + if (!adminExists) { + redirect(302, ROUTES.SETUP.MAIN); + } - // Dummy placeholder for streaming data - const fetchEnvOk = async () => { - const response = await event.fetch("/api/env"); - try { - return (await response.json()).envOkay; - } catch { - return false; - } - }; + // Dummy placeholder for streaming data + const fetchEnvOk = async () => { + const response = await event.fetch("/api/env"); + try { + return (await response.json()).envOkay; + } catch { + return false; + } + }; - return { - streamed: { - isEnvOk: fetchEnvOk() - } - }; + return { + streamed: { + isEnvOk: fetchEnvOk(), + }, + }; }; diff --git a/src/routes/(pages)/+page.svelte b/src/routes/(pages)/+page.svelte index e16f841..ec1c128 100644 --- a/src/routes/(pages)/+page.svelte +++ b/src/routes/(pages)/+page.svelte @@ -1,26 +1,26 @@ - Hello - OpenReception + Hello - OpenReception -
    - - Hello World - - Environment configuration is - {#await data.streamed.isEnvOk} - unknown - {:then isEnvOk} - {isEnvOk ? "OK" : "NOT OK"} - {/await}. - - -
    +
    + + Hello World + + Environment configuration is + {#await data.streamed.isEnvOk} + unknown + {:then isEnvOk} + {isEnvOk ? "OK" : "NOT OK"} + {/await}. + + +
    diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index d1718f9..f5f262c 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -4,29 +4,29 @@ import type { PageServerLoad } from "./$types"; const log = logger.setContext("BFF"); export const load: PageServerLoad = async (event) => { - const confirmation: Promise<{ success: boolean; isSetup: boolean }> = event - .fetch("/api/auth/confirm", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ token: event.params.token }) - }) - .then(async (resp) => { - const success = resp.status < 400; - try { - const body = await resp.json(); - const isSetup = body.isSetup ?? false; - return { success, isSetup }; - } catch (error) { - log.error("Unable to parse JSON from API repsonse", { path: "/confirm/[token]", error }); - return { success, isSetup: false }; - } - }); + const confirmation: Promise<{ success: boolean; isSetup: boolean }> = event + .fetch("/api/auth/confirm", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: event.params.token }), + }) + .then(async (resp) => { + const success = resp.status < 400; + try { + const body = await resp.json(); + const isSetup = body.isSetup ?? false; + return { success, isSetup }; + } catch (error) { + log.error("Unable to parse JSON from API repsonse", { path: "/confirm/[token]", error }); + return { success, isSetup: false }; + } + }); - return { - streaming: { - confirmation - } - }; + return { + streaming: { + confirmation, + }, + }; }; diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index 5a0cd9d..3edab60 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -1,76 +1,76 @@ - {m["confirm.title"]()} - OpenReception + {m["confirm.title"]()} - OpenReception - - - {#await data.streaming.confirmation} - - {:then confirmation} - {#if confirmation.success} - {#if confirmation.isSetup} - - {:else} - - {/if} - {:else} - - {/if} - {/await} - - - {#await data.streaming.confirmation} - - - - {:then confirmation} - {#if confirmation.success} - {#if confirmation.isSetup} - - {m["setup.confirm.success.hint"]()} - - - {:else} - - {/if} - {:else} - - {/if} - {/await} - - + + + {#await data.streaming.confirmation} + + {:then confirmation} + {#if confirmation.success} + {#if confirmation.isSetup} + + {:else} + + {/if} + {:else} + + {/if} + {/await} + + + {#await data.streaming.confirmation} + + + + {:then confirmation} + {#if confirmation.success} + {#if confirmation.isSetup} + + {m["setup.confirm.success.hint"]()} + + + {:else} + + {/if} + {:else} + + {/if} + {/await} + + diff --git a/src/routes/(pages)/confirm/resend/+page.server.ts b/src/routes/(pages)/confirm/resend/+page.server.ts index c215441..456078c 100644 --- a/src/routes/(pages)/confirm/resend/+page.server.ts +++ b/src/routes/(pages)/confirm/resend/+page.server.ts @@ -5,29 +5,29 @@ import type { Actions, PageServerLoad } from "./$types"; import { formSchema } from "./schema"; export const load: PageServerLoad = async () => { - return { - form: await superValidate(zod(formSchema)) - }; + return { + form: await superValidate(zod(formSchema)), + }; }; export const actions: Actions = { - default: async (event) => { - const form = await superValidate(event, zod(formSchema)); + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); - if (!form.valid) { - return fail(400, { - form - }); - } + if (!form.valid) { + return fail(400, { + form, + }); + } - await event.fetch("/api/auth/resend-confirmation", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ email: form.data.email }) - }); + await event.fetch("/api/auth/resend-confirmation", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email: form.data.email }), + }); - return { form }; - } + return { form }; + }, }; diff --git a/src/routes/(pages)/confirm/resend/+page.svelte b/src/routes/(pages)/confirm/resend/+page.svelte index a65b080..22594de 100644 --- a/src/routes/(pages)/confirm/resend/+page.svelte +++ b/src/routes/(pages)/confirm/resend/+page.svelte @@ -1,53 +1,53 @@ - {m["confirm.resend.title"]()} - OpenReception + {m["confirm.resend.title"]()} - OpenReception - - - - {m["confirm.resend.title"]()} - - - {m["confirm.resend.description"]()} - - - - - - - - {m["confirm.resend.action"]()} - - - + + + + {m["confirm.resend.title"]()} + + + {m["confirm.resend.description"]()} + + + + + + + + {m["confirm.resend.action"]()} + + + diff --git a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte index d506b48..dc26585 100644 --- a/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte +++ b/src/routes/(pages)/confirm/resend/resend-confirmation-form.svelte @@ -1,43 +1,43 @@ - - - {#snippet children({ props })} - {m["form.email"]()} - - {/snippet} - - - + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + diff --git a/src/routes/(pages)/confirm/resend/schema.ts b/src/routes/(pages)/confirm/resend/schema.ts index 9c07ee8..fed8ce5 100644 --- a/src/routes/(pages)/confirm/resend/schema.ts +++ b/src/routes/(pages)/confirm/resend/schema.ts @@ -2,7 +2,7 @@ import { m } from "$i18n/messages"; import { z } from "zod"; export const formSchema = z.object({ - email: z.string().email(m["form.errors.email"]()) + email: z.string().email(m["form.errors.email"]()), }); export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 0bfe2c8..c1626f9 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -1,32 +1,32 @@ {@render children()} diff --git a/src/routes/(pages)/dashboard/+page.svelte b/src/routes/(pages)/dashboard/+page.svelte index e73314b..19568ac 100644 --- a/src/routes/(pages)/dashboard/+page.svelte +++ b/src/routes/(pages)/dashboard/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/dashboard/absences/+page.svelte b/src/routes/(pages)/dashboard/absences/+page.svelte index cffe1af..92cae5b 100644 --- a/src/routes/(pages)/dashboard/absences/+page.svelte +++ b/src/routes/(pages)/dashboard/absences/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/dashboard/account/+page.svelte b/src/routes/(pages)/dashboard/account/+page.svelte index bb69be5..bd65b26 100644 --- a/src/routes/(pages)/dashboard/account/+page.svelte +++ b/src/routes/(pages)/dashboard/account/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/dashboard/agents/+page.svelte b/src/routes/(pages)/dashboard/agents/+page.svelte index 001ed86..497f39f 100644 --- a/src/routes/(pages)/dashboard/agents/+page.svelte +++ b/src/routes/(pages)/dashboard/agents/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/dashboard/calendar/+page.svelte b/src/routes/(pages)/dashboard/calendar/+page.svelte index 144e228..6c5174d 100644 --- a/src/routes/(pages)/dashboard/calendar/+page.svelte +++ b/src/routes/(pages)/dashboard/calendar/+page.svelte @@ -1,7 +1,7 @@ - import { m } from "$i18n/messages"; - import { SidebarLayout } from "$lib/components/layouts/sidebar-layout"; - import { ROUTES } from "$lib/const/routes"; + import { m } from "$i18n/messages"; + import { SidebarLayout } from "$lib/components/layouts/sidebar-layout"; + import { ROUTES } from "$lib/const/routes"; diff --git a/src/routes/(pages)/dashboard/settings/+page.svelte b/src/routes/(pages)/dashboard/settings/+page.svelte index f705c4a..f3401b8 100644 --- a/src/routes/(pages)/dashboard/settings/+page.svelte +++ b/src/routes/(pages)/dashboard/settings/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/dashboard/staff/+page.svelte b/src/routes/(pages)/dashboard/staff/+page.svelte index 4de5067..c745be6 100644 --- a/src/routes/(pages)/dashboard/staff/+page.svelte +++ b/src/routes/(pages)/dashboard/staff/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/dashboard/tenants/+page.svelte b/src/routes/(pages)/dashboard/tenants/+page.svelte index 8fa19eb..a8b3aa5 100644 --- a/src/routes/(pages)/dashboard/tenants/+page.svelte +++ b/src/routes/(pages)/dashboard/tenants/+page.svelte @@ -1,14 +1,14 @@ diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts index c6926ce..b44a4f5 100644 --- a/src/routes/(pages)/login/+page.server.ts +++ b/src/routes/(pages)/login/+page.server.ts @@ -10,71 +10,71 @@ import logger from "$lib/logger"; const log = logger.setContext("/login"); export const load: PageServerLoad = async () => { - return { - form: await superValidate(zod(formSchema)) - }; + return { + form: await superValidate(zod(formSchema)), + }; }; export const actions: Actions = { - default: async (event) => { - const form = await superValidate(event, zod(formSchema)); + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); - if (!form.valid) { - log.error("Login form is not valid", { errors: form.errors }); - return fail(400, { - form: { ...form, data: { ...form.data, type: "passkey" } } - }); - } + if (!form.valid) { + log.error("Login form is not valid", { errors: form.errors }); + return fail(400, { + form: { ...form, data: { ...form.data, type: "passkey" } }, + }); + } - let body: { credential?: WebAuthnCredential; email: string; passphrase?: string } = { - email: form.data.email - }; - if (form.data.type === "passphrase") { - body = { ...body, passphrase: form.data.passphrase }; - } + let body: { credential?: WebAuthnCredential; email: string; passphrase?: string } = { + email: form.data.email, + }; + if (form.data.type === "passphrase") { + body = { ...body, passphrase: form.data.passphrase }; + } - if (form.data.type === "passkey") { - body = { - ...body, - credential: { - id: form.data.id, - response: { - clientDataJSON: form.data.clientDataBase64, - authenticatorData: form.data.authenticatorDataBase64, - signature: form.data.signatureBase64 - } - } - }; - } + if (form.data.type === "passkey") { + body = { + ...body, + credential: { + id: form.data.id, + response: { + clientDataJSON: form.data.clientDataBase64, + authenticatorData: form.data.authenticatorDataBase64, + signature: form.data.signatureBase64, + }, + }, + }; + } - const resp = await event.fetch("/api/auth/login", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify(body) - }); + const resp = await event.fetch("/api/auth/login", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); - let user: TUser | null = null; - try { - const respJson = await resp.json(); - user = { - id: respJson.user.id, - email: respJson.user.email, - name: respJson.user.name, - role: respJson.user.role, - tenantId: respJson.user.tenantId - }; - } catch (error) { - log.error("Error parsing login response JSON", { error }); - } + let user: TUser | null = null; + try { + const respJson = await resp.json(); + user = { + id: respJson.user.id, + email: respJson.user.email, + name: respJson.user.name, + role: respJson.user.role, + tenantId: respJson.user.tenantId, + }; + } catch (error) { + log.error("Error parsing login response JSON", { error }); + } - if (resp.status < 400 && user) { - return { form, user }; - } else { - return fail(400, { - form - }); - } - } + if (resp.status < 400 && user) { + return { form, user }; + } else { + return fail(400, { + form, + }); + } + }, }; diff --git a/src/routes/(pages)/login/+page.svelte b/src/routes/(pages)/login/+page.svelte index 24de273..529fd16 100644 --- a/src/routes/(pages)/login/+page.svelte +++ b/src/routes/(pages)/login/+page.svelte @@ -1,53 +1,53 @@ - {m["login.title"]()} - OpenReception + {m["login.title"]()} - OpenReception - - - - {m["login.title"]()} - - - {m["login.description_passphrase"]()} - - - - - - - - {m["login.action"]()} - - - + + + + {m["login.title"]()} + + + {m["login.description_passphrase"]()} + + + + + + + + {m["login.action"]()} + + + diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 5da576c..7ce74b8 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -1,230 +1,230 @@ - - - - {#snippet children({ props })} - {m["form.email"]()} - - {/snippet} - - - - {#if $formData.type === "passphrase"} - - - {#snippet children({ props })} - {m["form.passphrase"]()} - - + + {#snippet children({ props })} + + {/snippet} + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + {#if $formData.type === "passphrase"} + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + - {/snippet} - - - - {m["login.or"]()} - - - - {/if} - {#if $formData.type === "passkey"} -
    - + {/if} + {#if $formData.type === "passkey"} +
    + - + - + - + - - - - {m["login.or"]()} - . - -
    - {/if} + {/snippet} + + + + + + {m["login.or"]()} + . + +
    + {/if}
    diff --git a/src/routes/(pages)/login/schema.ts b/src/routes/(pages)/login/schema.ts index 0d4ded3..714e814 100644 --- a/src/routes/(pages)/login/schema.ts +++ b/src/routes/(pages)/login/schema.ts @@ -2,19 +2,19 @@ import { m } from "$i18n/messages"; import { z } from "zod"; export const baseSchema = z.object({ - email: z.string().email(m["form.errors.email"]()) + email: z.string().email(m["form.errors.email"]()), }); const passkeySchema = baseSchema.extend({ - type: z.literal("passkey"), - id: z.string().min(3), - authenticatorDataBase64: z.string().base64(), - clientDataBase64: z.string().base64(), - signatureBase64: z.string().base64() + type: z.literal("passkey"), + id: z.string().min(3), + authenticatorDataBase64: z.string().base64(), + clientDataBase64: z.string().base64(), + signatureBase64: z.string().base64(), }); const passphraseSchema = baseSchema.extend({ - type: z.literal("passphrase"), - passphrase: z.string().min(30, m["form.errors.passphrase"]()) + type: z.literal("passphrase"), + passphrase: z.string().min(30, m["form.errors.passphrase"]()), }); export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]); diff --git a/src/routes/(pages)/logout/+page.server.ts b/src/routes/(pages)/logout/+page.server.ts index 81414c0..ccbcf54 100644 --- a/src/routes/(pages)/logout/+page.server.ts +++ b/src/routes/(pages)/logout/+page.server.ts @@ -2,19 +2,19 @@ import { auth } from "$lib/stores/auth"; import type { PageServerLoad } from "./$types"; export const load: PageServerLoad = async (event) => { - const success: Promise = event - .fetch("/api/auth/logout", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - credentials: "same-origin" - }) - .then(async (resp) => { - return resp.status < 400; - }); + const success: Promise = event + .fetch("/api/auth/logout", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "same-origin", + }) + .then(async (resp) => { + return resp.status < 400; + }); - auth.reset(); + auth.reset(); - return { streaming: { success } }; + return { streaming: { success } }; }; diff --git a/src/routes/(pages)/logout/+page.svelte b/src/routes/(pages)/logout/+page.svelte index 27a0cdc..f5ce902 100644 --- a/src/routes/(pages)/logout/+page.svelte +++ b/src/routes/(pages)/logout/+page.svelte @@ -1,41 +1,41 @@ - {m["logout.title"]()} - OpenReception + {m["logout.title"]()} - OpenReception - - - {#await data.streaming.success} - - {:then} - - {/await} - - - {#await data.streaming.success} - - {:then} - - {/await} - - + + + {#await data.streaming.success} + + {:then} + + {/await} + + + {#await data.streaming.success} + + {:then} + + {/await} + + diff --git a/src/routes/(pages)/page.svelte.test.ts b/src/routes/(pages)/page.svelte.test.ts index d77e6f4..f47f961 100644 --- a/src/routes/(pages)/page.svelte.test.ts +++ b/src/routes/(pages)/page.svelte.test.ts @@ -4,27 +4,27 @@ import { render, screen } from "@testing-library/svelte"; import Page from "./+page.svelte"; describe("/+page.svelte", () => { - beforeEach(() => { - // Mock fetch to avoid the "Invalid URL" error - global.fetch = vi.fn(() => - Promise.resolve({ - json: () => Promise.resolve({ envOkay: true }) - } as Response) - ); - }); + beforeEach(() => { + // Mock fetch to avoid the "Invalid URL" error + global.fetch = vi.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ envOkay: true }), + } as Response), + ); + }); - test("should render h1", async () => { - const mockData = { - streamed: { - isEnvOk: Promise.resolve(true) - } - }; - render(Page, { - props: { - data: mockData - } - }); - await mockData.streamed.isEnvOk; - expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); - }); + test("should render h1", async () => { + const mockData = { + streamed: { + isEnvOk: Promise.resolve(true), + }, + }; + render(Page, { + props: { + data: mockData, + }, + }); + await mockData.streamed.isEnvOk; + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + }); }); diff --git a/src/routes/(pages)/setup/+layout.server.ts b/src/routes/(pages)/setup/+layout.server.ts index bdeb9ff..fcd47d7 100644 --- a/src/routes/(pages)/setup/+layout.server.ts +++ b/src/routes/(pages)/setup/+layout.server.ts @@ -3,13 +3,13 @@ import { UserService } from "$lib/server/services/user-service"; import { redirect } from "@sveltejs/kit"; export const load = async (event) => { - // Some routes should not be accessed, once an admin is created - const cleanedId = event.route.id.replace("/(pages)", ""); - const blocklist = [ROUTES.SETUP.MAIN, ROUTES.SETUP.CREATE_ADMIN_ACCOUNT]; + // Some routes should not be accessed, once an admin is created + const cleanedId = event.route.id.replace("/(pages)", ""); + const blocklist = [ROUTES.SETUP.MAIN, ROUTES.SETUP.CREATE_ADMIN_ACCOUNT]; - // Check if global admin exists - const adminExists = await UserService.adminExists(); - if (blocklist.includes(cleanedId) && adminExists) { - redirect(302, ROUTES.LOGIN); - } + // Check if global admin exists + const adminExists = await UserService.adminExists(); + if (blocklist.includes(cleanedId) && adminExists) { + redirect(302, ROUTES.LOGIN); + } }; diff --git a/src/routes/(pages)/setup/+page.svelte b/src/routes/(pages)/setup/+page.svelte index 24fe485..3974b5b 100644 --- a/src/routes/(pages)/setup/+page.svelte +++ b/src/routes/(pages)/setup/+page.svelte @@ -1,27 +1,27 @@ - {m.welcome()} - OpenReception + {m.welcome()} - OpenReception - - - {m.logo()} - {m.welcome()} - {m.slogan()} - - - - - + + + {m.logo()} + {m.welcome()} + {m.slogan()} + + + + + diff --git a/src/routes/(pages)/setup/check-email/+page.server.ts b/src/routes/(pages)/setup/check-email/+page.server.ts index 4f5f243..b09a857 100644 --- a/src/routes/(pages)/setup/check-email/+page.server.ts +++ b/src/routes/(pages)/setup/check-email/+page.server.ts @@ -9,29 +9,29 @@ import logger from "$lib/logger"; const log = logger.setContext("Setup"); export const load: PageServerLoad = async () => { - return { - form: await superValidate(zod(formSchema)) - }; + return { + form: await superValidate(zod(formSchema)), + }; }; export const actions: Actions = { - default: async (event) => { - const form = await superValidate(event, zod(formSchema)); + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); - if (!form.valid) { - return fail(400, { - form - }); - } + if (!form.valid) { + return fail(400, { + form, + }); + } - await UserService.resendConfirmationEmail(form.data.email, event.url); + await UserService.resendConfirmationEmail(form.data.email, event.url); - log.debug("Resent confirmation e-mail", { - email: form.data.email - }); + log.debug("Resent confirmation e-mail", { + email: form.data.email, + }); - return { - form - }; - } + return { + form, + }; + }, }; diff --git a/src/routes/(pages)/setup/check-email/+page.svelte b/src/routes/(pages)/setup/check-email/+page.svelte index 5339a5f..7f084b4 100644 --- a/src/routes/(pages)/setup/check-email/+page.svelte +++ b/src/routes/(pages)/setup/check-email/+page.svelte @@ -1,91 +1,91 @@ - {m["setup.verify_email.title"]()} - OpenReception + {m["setup.verify_email.title"]()} - OpenReception - - - - - - - - - {m["setup.verify_email.action"]()} - - - - + + + + + + + + + {m["setup.verify_email.action"]()} + + + + diff --git a/src/routes/(pages)/setup/check-email/schema.ts b/src/routes/(pages)/setup/check-email/schema.ts index 9c07ee8..fed8ce5 100644 --- a/src/routes/(pages)/setup/check-email/schema.ts +++ b/src/routes/(pages)/setup/check-email/schema.ts @@ -2,7 +2,7 @@ import { m } from "$i18n/messages"; import { z } from "zod"; export const formSchema = z.object({ - email: z.string().email(m["form.errors.email"]()) + email: z.string().email(m["form.errors.email"]()), }); export type FormSchema = typeof formSchema; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.server.ts b/src/routes/(pages)/setup/create-admin-account/+page.server.ts index 82a6def..fcd9a4f 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.server.ts +++ b/src/routes/(pages)/setup/create-admin-account/+page.server.ts @@ -10,54 +10,54 @@ import { base64ToArrayBuffer, getCounterFromAuthenticatorData } from "$lib/utils const log = logger.setContext("Setup"); export const load: PageServerLoad = async () => { - return { - form: await superValidate(zod(formSchema)) - }; + return { + form: await superValidate(zod(formSchema)), + }; }; export const actions: Actions = { - default: async (event) => { - const form = await superValidate(event, zod(formSchema)); - if (!form.valid) { - return fail(400, { - form: { ...form, data: { ...form.data, type: "passkey" } } - }); - } + default: async (event) => { + const form = await superValidate(event, zod(formSchema)); + if (!form.valid) { + return fail(400, { + form: { ...form, data: { ...form.data, type: "passkey" } }, + }); + } - // Create admin account - const admin = await UserService.createUser( - { - name: "Admin", - email: form.data.email, - passphrase: - form.data.type === "passphrase" && form.data.passphrase - ? form.data.passphrase - : undefined, - language: form.data.language - }, - event.url - ); + // Create admin account + const admin = await UserService.createUser( + { + name: "Admin", + email: form.data.email, + passphrase: + form.data.type === "passphrase" && form.data.passphrase + ? form.data.passphrase + : undefined, + language: form.data.language, + }, + event.url, + ); - if (form.data.type === "passkey") { - const publicKey = form.data.publicKeyBase64; - const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); - const counter = getCounterFromAuthenticatorData(authenticatorData); + if (form.data.type === "passkey") { + const publicKey = form.data.publicKeyBase64; + const authenticatorData = base64ToArrayBuffer(form.data.authenticatorDataBase64); + const counter = getCounterFromAuthenticatorData(authenticatorData); - await UserService.addPasskey(admin.id, { - id: form.data.id, - publicKey: publicKey, - counter, - deviceName: "Unknown Device" - }); - } + await UserService.addPasskey(admin.id, { + id: form.data.id, + publicKey: publicKey, + counter, + deviceName: "Unknown Device", + }); + } - log.debug("Admin account created successfully", { - adminId: admin.id, - email: admin.email, - authMethod: form.data.type === "passkey" ? "passkey" : "passphrase", - passkeyId: form.data.type === "passkey" ? form.data.id : undefined - }); + log.debug("Admin account created successfully", { + adminId: admin.id, + email: admin.email, + authMethod: form.data.type === "passkey" ? "passkey" : "passphrase", + passkeyId: form.data.type === "passkey" ? form.data.id : undefined, + }); - return { form }; - } + return { form }; + }, }; diff --git a/src/routes/(pages)/setup/create-admin-account/+page.svelte b/src/routes/(pages)/setup/create-admin-account/+page.svelte index c7a3574..9e2c08f 100644 --- a/src/routes/(pages)/setup/create-admin-account/+page.svelte +++ b/src/routes/(pages)/setup/create-admin-account/+page.svelte @@ -1,53 +1,53 @@ - {m["setup.create_admin_account.title"]()} - OpenReception + {m["setup.create_admin_account.title"]()} - OpenReception - - - - {m["setup.create_admin_account.title"]()} - - - {m["setup.create_admin_account.description"]()} - - - - - - - - {m["setup.create_admin_account.action"]()} - - - + + + + {m["setup.create_admin_account.title"]()} + + + {m["setup.create_admin_account.description"]()} + + + + + + + + {m["setup.create_admin_account.action"]()} + + + diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index 431b79c..dc9713a 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -1,222 +1,222 @@ - - - - - {#snippet children({ props })} - {m["form.email"]()} - - {/snippet} - - - - {#if $formData.type === "passphrase"} - - - {#snippet children({ props })} - {m["form.passphrase"]()} - - + + {#snippet children({ props })} + + {/snippet} + + + + + + {#snippet children({ props })} + {m["form.email"]()} + + {/snippet} + + + + {#if $formData.type === "passphrase"} + + + {#snippet children({ props })} + {m["form.passphrase"]()} + + - {/snippet} - - - - {m["form.passphraseRequirements"]()} - {m["login.or"]()} - . - - - {/if} - {#if $formData.type === "passkey"} -
    - - - - - - - {m["login.or"]()} - . - -
    - {/if} + {/snippet} + + + + {m["form.passphraseRequirements"]()} + {m["login.or"]()} + . + + + {/if} + {#if $formData.type === "passkey"} +
    + + + + + + + {m["login.or"]()} + . + +
    + {/if}
    diff --git a/src/routes/(pages)/setup/create-admin-account/schema.ts b/src/routes/(pages)/setup/create-admin-account/schema.ts index efc0bef..b55190f 100644 --- a/src/routes/(pages)/setup/create-admin-account/schema.ts +++ b/src/routes/(pages)/setup/create-admin-account/schema.ts @@ -2,19 +2,19 @@ import { m } from "$i18n/messages"; import { z } from "zod"; export const baseSchema = z.object({ - email: z.string().email(m["form.errors.email"]()), - language: z.enum(["de", "en"]) + email: z.string().email(m["form.errors.email"]()), + language: z.enum(["de", "en"]), }); const passkeySchema = baseSchema.extend({ - type: z.literal("passkey"), - id: z.string().min(3), - publicKeyBase64: z.string().base64(), - authenticatorDataBase64: z.string().base64() + type: z.literal("passkey"), + id: z.string().min(3), + publicKeyBase64: z.string().base64(), + authenticatorDataBase64: z.string().base64(), }); const passphraseSchema = baseSchema.extend({ - type: z.literal("passphrase"), - passphrase: z.string().min(30, m["form.errors.passphrase"]()) + type: z.literal("passphrase"), + passphrase: z.string().min(30, m["form.errors.passphrase"]()), }); export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]); diff --git a/src/routes/api/admin/exists/+server.ts b/src/routes/api/admin/exists/+server.ts index 2071b99..9e87ccd 100644 --- a/src/routes/api/admin/exists/+server.ts +++ b/src/routes/api/admin/exists/+server.ts @@ -6,64 +6,64 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/admin/exists", "GET", { - summary: "Check if global admin exists", - description: - "Checks whether any global administrator account exists in the system. Used by frontend to determine available authentication routes.", - tags: ["Admin"], - responses: { - "200": { - description: "Admin existence status retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - exists: { - type: "boolean", - description: "Whether at least one global admin exists" - }, - count: { - type: "integer", - description: "Total number of global admins" - } - }, - required: ["exists", "count"] - } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Check if global admin exists", + description: + "Checks whether any global administrator account exists in the system. Used by frontend to determine available authentication routes.", + tags: ["Admin"], + responses: { + "200": { + description: "Admin existence status retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + exists: { + type: "boolean", + description: "Whether at least one global admin exists", + }, + count: { + type: "integer", + description: "Total number of global admins", + }, + }, + required: ["exists", "count"], + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const GET: RequestHandler = async () => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - log.debug("Checking if global admin exists"); + try { + log.debug("Checking if global admin exists"); - const adminExists = await UserService.adminExists(); - const adminCount = await UserService.getAdminCount(); + const adminExists = await UserService.adminExists(); + const adminCount = await UserService.getAdminCount(); - log.debug("Admin existence check completed", { - exists: adminExists, - count: adminCount - }); + log.debug("Admin existence check completed", { + exists: adminExists, + count: adminCount, + }); - return json({ - exists: adminExists, - count: adminCount - }); - } catch (error) { - log.error("Error checking admin existence:", String(error)); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ + exists: adminExists, + count: adminCount, + }); + } catch (error) { + log.error("Error checking admin existence:", String(error)); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/admin/exists/server.test.ts b/src/routes/api/admin/exists/server.test.ts index b214a2f..86387b2 100644 --- a/src/routes/api/admin/exists/server.test.ts +++ b/src/routes/api/admin/exists/server.test.ts @@ -4,87 +4,87 @@ import { GET } from "./+server"; // Mock dependencies vi.mock("$lib/server/services/user-service", () => ({ - UserService: { - adminExists: vi.fn(), - getAdminCount: vi.fn() - } + UserService: { + adminExists: vi.fn(), + getAdminCount: vi.fn(), + }, })); vi.mock("$lib/logger", () => ({ - default: { - setContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn() - })) - } + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + })), + }, })); // Import after mocking import { UserService } from "$lib/server/services/user-service"; describe("GET /api/admin/exists", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("should return admin exists status when admin exists", async () => { - vi.mocked(UserService.adminExists).mockResolvedValue(true); - vi.mocked(UserService.getAdminCount).mockResolvedValue(2); + it("should return admin exists status when admin exists", async () => { + vi.mocked(UserService.adminExists).mockResolvedValue(true); + vi.mocked(UserService.getAdminCount).mockResolvedValue(2); - const response = await GET({} as any); - const data = await response.json(); + const response = await GET({} as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data).toEqual({ - exists: true, - count: 2 - }); - expect(UserService.adminExists).toHaveBeenCalledOnce(); - expect(UserService.getAdminCount).toHaveBeenCalledOnce(); - }); + expect(response.status).toBe(200); + expect(data).toEqual({ + exists: true, + count: 2, + }); + expect(UserService.adminExists).toHaveBeenCalledOnce(); + expect(UserService.getAdminCount).toHaveBeenCalledOnce(); + }); - it("should return admin does not exist when no admin found", async () => { - vi.mocked(UserService.adminExists).mockResolvedValue(false); - vi.mocked(UserService.getAdminCount).mockResolvedValue(0); + it("should return admin does not exist when no admin found", async () => { + vi.mocked(UserService.adminExists).mockResolvedValue(false); + vi.mocked(UserService.getAdminCount).mockResolvedValue(0); - const response = await GET({} as any); - const data = await response.json(); + const response = await GET({} as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data).toEqual({ - exists: false, - count: 0 - }); - expect(UserService.adminExists).toHaveBeenCalledOnce(); - expect(UserService.getAdminCount).toHaveBeenCalledOnce(); - }); + expect(response.status).toBe(200); + expect(data).toEqual({ + exists: false, + count: 0, + }); + expect(UserService.adminExists).toHaveBeenCalledOnce(); + expect(UserService.getAdminCount).toHaveBeenCalledOnce(); + }); - it("should handle service errors", async () => { - vi.mocked(UserService.adminExists).mockRejectedValue(new Error("Database error")); + it("should handle service errors", async () => { + vi.mocked(UserService.adminExists).mockRejectedValue(new Error("Database error")); - const response = await GET({} as any); - const data = await response.json(); + const response = await GET({} as any); + const data = await response.json(); - expect(response.status).toBe(500); - expect(data).toEqual({ - error: "Internal server error" - }); - expect(UserService.adminExists).toHaveBeenCalledOnce(); - expect(UserService.getAdminCount).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(500); + expect(data).toEqual({ + error: "Internal server error", + }); + expect(UserService.adminExists).toHaveBeenCalledOnce(); + expect(UserService.getAdminCount).not.toHaveBeenCalled(); + }); - it("should handle getAdminCount error", async () => { - vi.mocked(UserService.adminExists).mockResolvedValue(true); - vi.mocked(UserService.getAdminCount).mockRejectedValue(new Error("Count error")); + it("should handle getAdminCount error", async () => { + vi.mocked(UserService.adminExists).mockResolvedValue(true); + vi.mocked(UserService.getAdminCount).mockRejectedValue(new Error("Count error")); - const response = await GET({} as any); - const data = await response.json(); + const response = await GET({} as any); + const data = await response.json(); - expect(response.status).toBe(500); - expect(data).toEqual({ - error: "Internal server error" - }); - expect(UserService.adminExists).toHaveBeenCalledOnce(); - expect(UserService.getAdminCount).toHaveBeenCalledOnce(); - }); + expect(response.status).toBe(500); + expect(data).toEqual({ + error: "Internal server error", + }); + expect(UserService.adminExists).toHaveBeenCalledOnce(); + expect(UserService.getAdminCount).toHaveBeenCalledOnce(); + }); }); diff --git a/src/routes/api/admin/init/+server.ts b/src/routes/api/admin/init/+server.ts index 13ef8fa..78740a6 100644 --- a/src/routes/api/admin/init/+server.ts +++ b/src/routes/api/admin/init/+server.ts @@ -8,202 +8,202 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/admin/init", "POST", { - summary: "Register an initial admin account", - description: - "Creates an initial admin account that requires email confirmation. Authentication can be set up using either a WebAuthn passkey or a passphrase. Only works if no global admin account exists yet.", - tags: ["Admin"], - requestBody: { - description: "Admin registration data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { type: "string", description: "Admin's full name", example: "Admin Name" }, - email: { - type: "string", - format: "email", - description: "Admin's email address", - example: "admin@example.com" - }, - passkey: { - type: "object", - description: "WebAuthn passkey data (alternative to passphrase)", - properties: { - id: { type: "string", description: "Credential ID from WebAuthn" }, - publicKey: { type: "string", description: "Base64 encoded public key" }, - counter: { type: "integer", description: "Signature counter", default: 0 }, - deviceName: { - type: "string", - description: "Device name for identification", - example: "MacBook Pro" - } - }, - required: ["id", "publicKey"] - }, - passphrase: { - type: "string", - minLength: 12, - description: "User passphrase (alternative to passkey, minimum 12 characters)", - example: "MySecurePassphrase123" - } - }, - required: ["name", "email"] - } - } - } - }, - responses: { - "201": { - description: "Admin account created successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - adminId: { type: "string", description: "Generated admin ID" }, - email: { type: "string", description: "Admin's email address" } - }, - required: ["message", "adminId", "email"] - }, - example: { - message: - "Admin account created successfully. Please check your email for confirmation.", - adminId: "01234567-89ab-cdef-0123-456789abcdef", - email: "admin@example.com" - } - } - } - }, - "400": { - description: "Invalid input data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid admin data" } - } - } - }, - "409": { - description: "Admin account already exists", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "An admin already exists" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Register an initial admin account", + description: + "Creates an initial admin account that requires email confirmation. Authentication can be set up using either a WebAuthn passkey or a passphrase. Only works if no global admin account exists yet.", + tags: ["Admin"], + requestBody: { + description: "Admin registration data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", description: "Admin's full name", example: "Admin Name" }, + email: { + type: "string", + format: "email", + description: "Admin's email address", + example: "admin@example.com", + }, + passkey: { + type: "object", + description: "WebAuthn passkey data (alternative to passphrase)", + properties: { + id: { type: "string", description: "Credential ID from WebAuthn" }, + publicKey: { type: "string", description: "Base64 encoded public key" }, + counter: { type: "integer", description: "Signature counter", default: 0 }, + deviceName: { + type: "string", + description: "Device name for identification", + example: "MacBook Pro", + }, + }, + required: ["id", "publicKey"], + }, + passphrase: { + type: "string", + minLength: 12, + description: "User passphrase (alternative to passkey, minimum 12 characters)", + example: "MySecurePassphrase123", + }, + }, + required: ["name", "email"], + }, + }, + }, + }, + responses: { + "201": { + description: "Admin account created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + adminId: { type: "string", description: "Generated admin ID" }, + email: { type: "string", description: "Admin's email address" }, + }, + required: ["message", "adminId", "email"], + }, + example: { + message: + "Admin account created successfully. Please check your email for confirmation.", + adminId: "01234567-89ab-cdef-0123-456789abcdef", + email: "admin@example.com", + }, + }, + }, + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid admin data" }, + }, + }, + }, + "409": { + description: "Admin account already exists", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "An admin already exists" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request, cookies, url }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const body = await request.json(); + try { + const body = await request.json(); - if (await UserService.adminExists()) { - return json({ error: "System was already initialized" }, { status: 409 }); - } + if (await UserService.adminExists()) { + return json({ error: "System was already initialized" }, { status: 409 }); + } - // Validate that either passkey or passphrase is provided (but not both) - const hasPasskey = !!body.passkey; - const hasPassphrase = !!body.passphrase; + // Validate that either passkey or passphrase is provided (but not both) + const hasPasskey = !!body.passkey; + const hasPassphrase = !!body.passphrase; - if (!hasPasskey && !hasPassphrase) { - return json({ error: "Either passkey or passphrase must be provided" }, { status: 400 }); - } + if (!hasPasskey && !hasPassphrase) { + return json({ error: "Either passkey or passphrase must be provided" }, { status: 400 }); + } - if (hasPasskey && hasPassphrase) { - return json({ error: "Cannot provide both passkey and passphrase" }, { status: 400 }); - } + if (hasPasskey && hasPassphrase) { + return json({ error: "Cannot provide both passkey and passphrase" }, { status: 400 }); + } - log.debug("Creating admin account", { - email: body.email, - authMethod: hasPasskey ? "passkey" : "passphrase", - passkeyId: body.passkey?.id, - deviceName: body.passkey?.deviceName - }); + log.debug("Creating admin account", { + email: body.email, + authMethod: hasPasskey ? "passkey" : "passphrase", + passkeyId: body.passkey?.id, + deviceName: body.passkey?.deviceName, + }); - // Create admin account - const admin = await UserService.createUser( - { - name: body.name, - email: body.email, - passphrase: body.passphrase, // Will be undefined if passkey is used - language: body.language || "de" - }, - url - ); + // Create admin account + const admin = await UserService.createUser( + { + name: body.name, + email: body.email, + passphrase: body.passphrase, // Will be undefined if passkey is used + language: body.language || "de", + }, + url, + ); - // Add the passkey to the admin account if provided - if (hasPasskey) { - // Validate that this registration was preceded by a challenge request - const registrationEmail = cookies.get("webauthn-registration-email"); + // Add the passkey to the admin account if provided + if (hasPasskey) { + // Validate that this registration was preceded by a challenge request + const registrationEmail = cookies.get("webauthn-registration-email"); - if (!registrationEmail || registrationEmail !== body.email) { - return json( - { error: "Invalid passkey registration. Please request a new challenge first." }, - { status: 400 } - ); - } + if (!registrationEmail || registrationEmail !== body.email) { + return json( + { error: "Invalid passkey registration. Please request a new challenge first." }, + { status: 400 }, + ); + } - // Clear the registration cookie after validation (challenge cookie is cleared by login route) - cookies.delete("webauthn-registration-email", { path: "/" }); + // Clear the registration cookie after validation (challenge cookie is cleared by login route) + cookies.delete("webauthn-registration-email", { path: "/" }); - // Extract counter from WebAuthn credential - const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); - await UserService.addPasskey(admin.id, { - id: body.passkey.id, - publicKey: body.passkey.publicKey, - counter, - deviceName: body.passkey.deviceName || "Unknown Device" - }); + await UserService.addPasskey(admin.id, { + id: body.passkey.id, + publicKey: body.passkey.publicKey, + counter, + deviceName: body.passkey.deviceName || "Unknown Device", + }); - log.debug("Passkey added to admin account", { - adminId: admin.id, - passkeyId: body.passkey.id - }); - } + log.debug("Passkey added to admin account", { + adminId: admin.id, + passkeyId: body.passkey.id, + }); + } - log.debug("Admin account created successfully", { - adminId: admin.id, - email: admin.email, - authMethod: hasPasskey ? "passkey" : "passphrase", - passkeyId: hasPasskey ? body.passkey.id : undefined - }); + log.debug("Admin account created successfully", { + adminId: admin.id, + email: admin.email, + authMethod: hasPasskey ? "passkey" : "passphrase", + passkeyId: hasPasskey ? body.passkey.id : undefined, + }); - return json( - { - message: "Admin account created successfully. Please check your email for confirmation.", - adminId: admin.id, - email: admin.email - }, - { status: 201 } - ); - } catch (error) { - log.error("Admin registration error:", JSON.stringify(error || "?")); + return json( + { + message: "Admin account created successfully. Please check your email for confirmation.", + adminId: admin.id, + email: admin.email, + }, + { status: 201 }, + ); + } catch (error) { + log.error("Admin registration error:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - // Handle unique constraint violation (email already exists) - if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "An admin with this email already exists" }, { status: 409 }); - } + // Handle unique constraint violation (email already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "An admin with this email already exists" }, { status: 409 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/admin/tenant/+server.ts b/src/routes/api/admin/tenant/+server.ts index 07332fd..89cc3d3 100644 --- a/src/routes/api/admin/tenant/+server.ts +++ b/src/routes/api/admin/tenant/+server.ts @@ -12,7 +12,7 @@ import { registerOpenAPIRoute } from "$lib/server/openapi"; const logger = new UniversalLogger().setContext("AdminTenantSwitch"); const tenantSwitchSchema = z.object({ - tenantId: z.string().uuid() + tenantId: z.string().uuid(), }); /** @@ -20,176 +20,176 @@ const tenantSwitchSchema = z.object({ * Switch active tenant for global admin */ export const POST: RequestHandler = async ({ request, locals, cookies }) => { - try { - // Verify user is authenticated and is global admin - if (!locals.user) { - throw error(401, "Authentication required"); - } + try { + // Verify user is authenticated and is global admin + if (!locals.user) { + throw error(401, "Authentication required"); + } - if (locals.user.role !== "GLOBAL_ADMIN") { - throw error(403, "Only global admins can switch tenants"); - } + if (locals.user.role !== "GLOBAL_ADMIN") { + throw error(403, "Only global admins can switch tenants"); + } - const body = await request.json(); - const validation = tenantSwitchSchema.safeParse(body); + const body = await request.json(); + const validation = tenantSwitchSchema.safeParse(body); - if (!validation.success) { - logger.warn("Invalid tenant switch request", { - userId: locals.user.id, - errors: validation.error.errors - }); - throw error(400, "Invalid request body"); - } + if (!validation.success) { + logger.warn("Invalid tenant switch request", { + userId: locals.user.id, + errors: validation.error.errors, + }); + throw error(400, "Invalid request body"); + } - const { tenantId } = validation.data; + const { tenantId } = validation.data; - // If tenantId is provided, verify the tenant exists - const tenantExists = await centralDb - .select({ id: tenant.id }) - .from(tenant) - .where(eq(tenant.id, tenantId)) - .limit(1); + // If tenantId is provided, verify the tenant exists + const tenantExists = await centralDb + .select({ id: tenant.id }) + .from(tenant) + .where(eq(tenant.id, tenantId)) + .limit(1); - if (tenantExists.length === 0) { - logger.warn("Tenant not found for switching", { - userId: locals.user.id, - tenantId - }); - throw error(404, "Tenant not found"); - } + if (tenantExists.length === 0) { + logger.warn("Tenant not found for switching", { + userId: locals.user.id, + tenantId, + }); + throw error(404, "Tenant not found"); + } - // Current user is already authenticated via authHandle + // Current user is already authenticated via authHandle - // Update user's active tenant in the database - const updatedUser = await UserService.updateUser(locals.user.userId as string, { - tenantId: tenantId || null - }); + // Update user's active tenant in the database + const updatedUser = await UserService.updateUser(locals.user.userId as string, { + tenantId: tenantId || null, + }); - if (!updatedUser) { - logger.error("Failed to update user tenant", { - userId: locals.user.userId, - tenantId - }); - throw error(500, "Failed to update user data"); - } + if (!updatedUser) { + logger.error("Failed to update user tenant", { + userId: locals.user.userId, + tenantId, + }); + throw error(500, "Failed to update user data"); + } - // Generate new access token with updated tenant context - const newAccessToken = await generateAccessToken( - updatedUser, - (locals.user.sessionId as string) || "temp-session" - ); + // Generate new access token with updated tenant context + const newAccessToken = await generateAccessToken( + updatedUser, + (locals.user.sessionId as string) || "temp-session", + ); - // Set new access token cookie - cookies.set("access_token", newAccessToken, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 60 * 24 * 7 // 7 days - }); + // Set new access token cookie + cookies.set("access_token", newAccessToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days + }); - logger.info("Tenant switched successfully", { - userId: locals.user.userId, - fromTenant: locals.user.tenantId, - toTenant: tenantId - }); + logger.info("Tenant switched successfully", { + userId: locals.user.userId, + fromTenant: locals.user.tenantId, + toTenant: tenantId, + }); - return json({ - success: true, - message: tenantId ? "Tenant switched successfully" : "Switched to global admin mode", - tenantId, - user: { - id: updatedUser.id, - email: updatedUser.email, - name: updatedUser.name, - role: updatedUser.role, - tenantId: updatedUser.tenantId - } - }); - } catch (err) { - if (err instanceof ValidationError) { - logger.warn("Validation error in tenant switch", { error: err.message }); - throw error(400, err.message); - } + return json({ + success: true, + message: tenantId ? "Tenant switched successfully" : "Switched to global admin mode", + tenantId, + user: { + id: updatedUser.id, + email: updatedUser.email, + name: updatedUser.name, + role: updatedUser.role, + tenantId: updatedUser.tenantId, + }, + }); + } catch (err) { + if (err instanceof ValidationError) { + logger.warn("Validation error in tenant switch", { error: err.message }); + throw error(400, err.message); + } - if (err instanceof NotFoundError) { - logger.warn("Not found error in tenant switch", { error: err.message }); - throw error(404, err.message); - } + if (err instanceof NotFoundError) { + logger.warn("Not found error in tenant switch", { error: err.message }); + throw error(404, err.message); + } - logger.error("Unexpected error in tenant switch", { error: String(err) }); - throw error(500, "Internal server error"); - } + logger.error("Unexpected error in tenant switch", { error: String(err) }); + throw error(500, "Internal server error"); + } }; // Register OpenAPI documentation registerOpenAPIRoute("/admin/tenant", "POST", { - summary: "Switch active tenant for global admin", - description: - "Allows a global admin to switch their active tenant context or return to global admin mode", - tags: ["Admin"], - requestBody: { - content: { - "application/json": { - schema: { - type: "object", - properties: { - tenantId: { - type: "string", - format: "uuid", - description: "Target tenant ID" - } - }, - example: { - tenantId: "123e4567-e89b-12d3-a456-426614174000" - } - } - } - } - }, - responses: { - 200: { - description: "Tenant switched successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - success: { type: "boolean" }, - message: { type: "string" }, - tenantId: { - type: "string", - format: "uuid" - }, - user: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - email: { type: "string", format: "email" }, - name: { type: "string" }, - role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, - tenantId: { type: "string", format: "uuid" } - } - } - } - } - } - } - }, - 400: { - description: "Invalid request body" - }, - 401: { - description: "Authentication required" - }, - 403: { - description: "Only global admins can switch tenants" - }, - 404: { - description: "Tenant not found" - }, - 500: { - description: "Internal server error" - } - } + summary: "Switch active tenant for global admin", + description: + "Allows a global admin to switch their active tenant context or return to global admin mode", + tags: ["Admin"], + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + tenantId: { + type: "string", + format: "uuid", + description: "Target tenant ID", + }, + }, + example: { + tenantId: "123e4567-e89b-12d3-a456-426614174000", + }, + }, + }, + }, + }, + responses: { + 200: { + description: "Tenant switched successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + success: { type: "boolean" }, + message: { type: "string" }, + tenantId: { + type: "string", + format: "uuid", + }, + user: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + email: { type: "string", format: "email" }, + name: { type: "string" }, + role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, + tenantId: { type: "string", format: "uuid" }, + }, + }, + }, + }, + }, + }, + }, + 400: { + description: "Invalid request body", + }, + 401: { + description: "Authentication required", + }, + 403: { + description: "Only global admins can switch tenants", + }, + 404: { + description: "Tenant not found", + }, + 500: { + description: "Internal server error", + }, + }, }); diff --git a/src/routes/api/admin/tenant/server.test.ts b/src/routes/api/admin/tenant/server.test.ts index 1fca447..b6cde5c 100644 --- a/src/routes/api/admin/tenant/server.test.ts +++ b/src/routes/api/admin/tenant/server.test.ts @@ -5,88 +5,88 @@ import type { RequestEvent } from "@sveltejs/kit"; // Mock SvelteKit's error function vi.mock("@sveltejs/kit", async () => { - const actual = await vi.importActual("@sveltejs/kit"); - return { - ...actual, - error: vi.fn((status: number, message: string) => { - const error = new Error(message); - (error as any).status = status; - (error as any).body = { message }; - throw error; - }), - json: vi.fn((data: any) => { - return new Response(JSON.stringify(data), { status: 200 }); - }) - }; + const actual = await vi.importActual("@sveltejs/kit"); + return { + ...actual, + error: vi.fn((status: number, message: string) => { + const error = new Error(message); + (error as any).status = status; + (error as any).body = { message }; + throw error; + }), + json: vi.fn((data: any) => { + return new Response(JSON.stringify(data), { status: 200 }); + }), + }; }); // Mock the auth service vi.mock("$lib/server/auth/auth-service", () => ({ - AuthService: { - validateSession: vi.fn(), - refreshSession: vi.fn() - } + AuthService: { + validateSession: vi.fn(), + refreshSession: vi.fn(), + }, })); // Mock the session service vi.mock("$lib/server/auth/session-service", () => ({ - SessionService: { - validateSession: vi.fn(), - refreshSession: vi.fn() - } + SessionService: { + validateSession: vi.fn(), + refreshSession: vi.fn(), + }, })); // Mock the database vi.mock("$lib/server/db", () => ({ - centralDb: { - select: vi.fn(), - update: vi.fn() - } + centralDb: { + select: vi.fn(), + update: vi.fn(), + }, })); // Mock the user service vi.mock("$lib/server/services/user-service", () => ({ - UserService: { - updateUser: vi.fn() - } + UserService: { + updateUser: vi.fn(), + }, })); // Mock JWT utils vi.mock("$lib/server/auth/jwt-utils", () => ({ - generateAccessToken: vi.fn() + generateAccessToken: vi.fn(), })); // Mock universal logger vi.mock("$lib/logger", () => ({ - UniversalLogger: vi.fn(() => ({ - setContext: vi.fn().mockReturnThis(), - warn: vi.fn(), - error: vi.fn(), - info: vi.fn() - })) + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn().mockReturnThis(), + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + })), })); // Mock openapi vi.mock("$lib/server/openapi", () => ({ - registerOpenAPIRoute: vi.fn() + registerOpenAPIRoute: vi.fn(), })); // Mock database schemas vi.mock("$lib/server/db/central-schema", () => ({ - tenant: { id: "tenant.id" }, - user: { id: "user.id" }, - userSession: { id: "userSession.id", sessionToken: "userSession.sessionToken" } + tenant: { id: "tenant.id" }, + user: { id: "user.id" }, + userSession: { id: "userSession.id", sessionToken: "userSession.sessionToken" }, })); // Mock drizzle-orm vi.mock("drizzle-orm", () => ({ - eq: vi.fn(() => "eq-condition") + eq: vi.fn(() => "eq-condition"), })); // Mock error classes vi.mock("$lib/server/utils/errors", () => ({ - ValidationError: class ValidationError extends Error {}, - NotFoundError: class NotFoundError extends Error {} + ValidationError: class ValidationError extends Error {}, + NotFoundError: class NotFoundError extends Error {}, })); import { centralDb } from "$lib/server/db"; @@ -94,130 +94,130 @@ import { UserService } from "$lib/server/services/user-service"; import { generateAccessToken } from "$lib/server/auth/jwt-utils"; describe("POST /api/admin/tenant", () => { - const mockUser = { - id: "user-123", - email: "admin@example.com", - name: "Admin User", - role: "GLOBAL_ADMIN" as const, - tenantId: null, - confirmed: true, - isActive: true, - createdAt: new Date(), - updatedAt: new Date(), - lastLoginAt: null, - token: null, - tokenValidUntil: null, - passphraseHash: null, - recoveryPassphrase: null - }; + const mockUser = { + id: "user-123", + email: "admin@example.com", + name: "Admin User", + role: "GLOBAL_ADMIN" as const, + tenantId: null, + confirmed: true, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + lastLoginAt: null, + token: null, + tokenValidUntil: null, + passphraseHash: null, + recoveryPassphrase: null, + }; - const mockCookies = { - get: vi.fn(), - set: vi.fn(), - getAll: vi.fn(), - delete: vi.fn(), - serialize: vi.fn() - }; + const mockCookies = { + get: vi.fn(), + set: vi.fn(), + getAll: vi.fn(), + delete: vi.fn(), + serialize: vi.fn(), + }; - const createRequestEvent = (body: any, user: any = mockUser): RequestEvent => ({ - request: { - json: () => Promise.resolve(body) - } as Request, - locals: { user: { ...user, sessionId: "session-123" } }, - cookies: mockCookies, - params: {}, - url: new URL("http://localhost/api/admin/tenant"), - route: { id: "/api/admin/tenant" }, - platform: undefined, - getClientAddress: () => "127.0.0.1", - isDataRequest: false, - isSubRequest: false, - fetch: fetch, - setHeaders: vi.fn() - }); + const createRequestEvent = (body: any, user: any = mockUser): RequestEvent => ({ + request: { + json: () => Promise.resolve(body), + } as Request, + locals: { user: { ...user, sessionId: "session-123" } }, + cookies: mockCookies, + params: {}, + url: new URL("http://localhost/api/admin/tenant"), + route: { id: "/api/admin/tenant" }, + platform: undefined, + getClientAddress: () => "127.0.0.1", + isDataRequest: false, + isSubRequest: false, + fetch: fetch, + setHeaders: vi.fn(), + }); - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("should successfully switch to a tenant", async () => { - const tenantId = "550e8400-e29b-41d4-a716-446655440000"; - const requestEvent = createRequestEvent({ tenantId }); + it("should successfully switch to a tenant", async () => { + const tenantId = "550e8400-e29b-41d4-a716-446655440000"; + const requestEvent = createRequestEvent({ tenantId }); - // Mock tenant exists query - const mockSelectQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([{ id: tenantId }]) - }; - vi.mocked(centralDb.select).mockReturnValue(mockSelectQuery as any); + // Mock tenant exists query + const mockSelectQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ id: tenantId }]), + }; + vi.mocked(centralDb.select).mockReturnValue(mockSelectQuery as any); - // No session handling needed anymore + // No session handling needed anymore - // Mock user update - const updatedUser = { ...mockUser, tenantId }; - vi.mocked(UserService.updateUser).mockResolvedValue(updatedUser as any); + // Mock user update + const updatedUser = { ...mockUser, tenantId }; + vi.mocked(UserService.updateUser).mockResolvedValue(updatedUser as any); - // Mock token generation - vi.mocked(generateAccessToken).mockResolvedValue("new-access-123"); + // Mock token generation + vi.mocked(generateAccessToken).mockResolvedValue("new-access-123"); - // Mock database update queries - const mockUpdateQuery = { - set: vi.fn().mockReturnThis(), - where: vi.fn().mockResolvedValue({ count: 1 }) - }; - vi.mocked(centralDb.update).mockReturnValue(mockUpdateQuery as any); + // Mock database update queries + const mockUpdateQuery = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue({ count: 1 }), + }; + vi.mocked(centralDb.update).mockReturnValue(mockUpdateQuery as any); - const response = await POST(requestEvent); - const result = await response.json(); + const response = await POST(requestEvent); + const result = await response.json(); - expect(response.status).toBe(200); - expect(result.success).toBe(true); - expect(result.tenantId).toBe(tenantId); - expect(result.user.tenantId).toBe(tenantId); - expect(mockCookies.set).toHaveBeenCalledWith( - "access_token", - "new-access-123", - expect.any(Object) - ); - }); + expect(response.status).toBe(200); + expect(result.success).toBe(true); + expect(result.tenantId).toBe(tenantId); + expect(result.user.tenantId).toBe(tenantId); + expect(mockCookies.set).toHaveBeenCalledWith( + "access_token", + "new-access-123", + expect.any(Object), + ); + }); - it("should return 401 when user is not authenticated", async () => { - const requestEvent = createRequestEvent( - { tenantId: "550e8400-e29b-41d4-a716-446655440000" }, - null - ); + it("should return 401 when user is not authenticated", async () => { + const requestEvent = createRequestEvent( + { tenantId: "550e8400-e29b-41d4-a716-446655440000" }, + null, + ); - await expect(POST(requestEvent)).rejects.toThrow(); - }); + await expect(POST(requestEvent)).rejects.toThrow(); + }); - it("should return 403 when user is not a global admin", async () => { - const tenantAdminUser = { ...mockUser, role: "TENANT_ADMIN" as const }; - const requestEvent = createRequestEvent( - { tenantId: "550e8400-e29b-41d4-a716-446655440000" }, - tenantAdminUser - ); + it("should return 403 when user is not a global admin", async () => { + const tenantAdminUser = { ...mockUser, role: "TENANT_ADMIN" as const }; + const requestEvent = createRequestEvent( + { tenantId: "550e8400-e29b-41d4-a716-446655440000" }, + tenantAdminUser, + ); - await expect(POST(requestEvent)).rejects.toThrow(); - }); + await expect(POST(requestEvent)).rejects.toThrow(); + }); - it("should return 400 when request body is invalid", async () => { - const requestEvent = createRequestEvent({ tenantId: "invalid-uuid" }); + it("should return 400 when request body is invalid", async () => { + const requestEvent = createRequestEvent({ tenantId: "invalid-uuid" }); - await expect(POST(requestEvent)).rejects.toThrow(); - }); + await expect(POST(requestEvent)).rejects.toThrow(); + }); - it("should return 404 when tenant does not exist", async () => { - const requestEvent = createRequestEvent({ tenantId: "550e8400-e29b-41d4-a716-446655440000" }); + it("should return 404 when tenant does not exist", async () => { + const requestEvent = createRequestEvent({ tenantId: "550e8400-e29b-41d4-a716-446655440000" }); - // Mock tenant doesn't exist - const mockSelectQuery = { - from: vi.fn().mockReturnThis(), - where: vi.fn().mockReturnThis(), - limit: vi.fn().mockResolvedValue([]) - }; - vi.mocked(centralDb.select).mockReturnValue(mockSelectQuery as any); + // Mock tenant doesn't exist + const mockSelectQuery = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + vi.mocked(centralDb.select).mockReturnValue(mockSelectQuery as any); - await expect(POST(requestEvent)).rejects.toThrow(); - }); + await expect(POST(requestEvent)).rejects.toThrow(); + }); }); diff --git a/src/routes/api/auth/challenge/+server.ts b/src/routes/api/auth/challenge/+server.ts index 03fee91..7dfa7ba 100644 --- a/src/routes/api/auth/challenge/+server.ts +++ b/src/routes/api/auth/challenge/+server.ts @@ -10,202 +10,202 @@ import { env } from "$env/dynamic/private"; const logger = new UniversalLogger().setContext("AuthChallengeAPI"); registerOpenAPIRoute("/auth/challenge", "POST", { - summary: "Generate WebAuthn authentication challenge", - description: - "Generate a challenge for WebAuthn authentication (login) or registration and return registered passkeys if user exists", - tags: ["Authentication"], - requestBody: { - description: "User email to generate challenge for", - content: { - "application/json": { - schema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - description: "User's email address", - example: "admin@example.com" - } - }, - required: ["email"] - } - } - } - }, - responses: { - "200": { - description: "Challenge generated successfully for login or registration", - content: { - "application/json": { - schema: { - type: "object", - properties: { - challenge: { - type: "string", - description: "Base64url encoded challenge" - }, - allowCredentials: { - type: "array", - description: "List of registered passkeys for this user (empty for registration)", - items: { - type: "object", - properties: { - id: { type: "string", description: "Credential ID" }, - type: { type: "string", enum: ["public-key"] }, - transports: { - type: "array", - items: { type: "string" }, - description: "Supported transports" - } - } - } - }, - timeout: { - type: "number", - description: "Timeout in milliseconds" - }, - isRegistration: { - type: "boolean", - description: "True if this is for user registration (user not found)" - } - }, - required: ["challenge", "allowCredentials"] - } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Generate WebAuthn authentication challenge", + description: + "Generate a challenge for WebAuthn authentication (login) or registration and return registered passkeys if user exists", + tags: ["Authentication"], + requestBody: { + description: "User email to generate challenge for", + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "User's email address", + example: "admin@example.com", + }, + }, + required: ["email"], + }, + }, + }, + }, + responses: { + "200": { + description: "Challenge generated successfully for login or registration", + content: { + "application/json": { + schema: { + type: "object", + properties: { + challenge: { + type: "string", + description: "Base64url encoded challenge", + }, + allowCredentials: { + type: "array", + description: "List of registered passkeys for this user (empty for registration)", + items: { + type: "object", + properties: { + id: { type: "string", description: "Credential ID" }, + type: { type: "string", enum: ["public-key"] }, + transports: { + type: "array", + items: { type: "string" }, + description: "Supported transports", + }, + }, + }, + }, + timeout: { + type: "number", + description: "Timeout in milliseconds", + }, + isRegistration: { + type: "boolean", + description: "True if this is for user registration (user not found)", + }, + }, + required: ["challenge", "allowCredentials"], + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); /** * Get the appropriate rpId (Relying Party ID) for WebAuthn based on environment */ function getRpId(requestUrl: URL): string { - if (env.NODE_ENV === "production") { - // In production, use the hostname (without subdomain for main domain) - const hostname = requestUrl.hostname; - const parts = hostname.split("."); + if (env.NODE_ENV === "production") { + // In production, use the hostname (without subdomain for main domain) + const hostname = requestUrl.hostname; + const parts = hostname.split("."); - // If it's a subdomain (e.g., tenant.example.com), use the main domain (example.com) - // This allows passkeys to work across all subdomains - if (parts.length > 2) { - return parts.slice(-2).join("."); - } - return hostname; - } + // If it's a subdomain (e.g., tenant.example.com), use the main domain (example.com) + // This allows passkeys to work across all subdomains + if (parts.length > 2) { + return parts.slice(-2).join("."); + } + return hostname; + } - // Development: use localhost - return "localhost"; + // Development: use localhost + return "localhost"; } export const POST: RequestHandler = async ({ request, cookies, url }) => { - try { - const body = await request.json(); + try { + const body = await request.json(); - logger.debug("Generating WebAuthn challenge", { email: body.email }); + logger.debug("Generating WebAuthn challenge", { email: body.email }); - // Try to get user by email - but don't fail if not found - let user = null; - let isRegistration = false; + // Try to get user by email - but don't fail if not found + let user = null; + let isRegistration = false; - try { - user = await UserService.getUserByEmail(body.email); - } catch (error) { - if (error instanceof NotFoundError) { - // User doesn't exist yet - this is a registration flow - isRegistration = true; - logger.debug("User not found - generating challenge for registration", { - email: body.email - }); - } else { - throw error; - } - } + try { + user = await UserService.getUserByEmail(body.email); + } catch (error) { + if (error instanceof NotFoundError) { + // User doesn't exist yet - this is a registration flow + isRegistration = true; + logger.debug("User not found - generating challenge for registration", { + email: body.email, + }); + } else { + throw error; + } + } - // Generate challenge - const challenge = WebAuthnService.generateChallenge(); + // Generate challenge + const challenge = WebAuthnService.generateChallenge(); - if (isRegistration) { - // For registration, only store the email for validation - cookies.set("webauthn-registration-email", body.email, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 5 // 5 minutes - }); - } else { - // For login, store the challenge for signature verification - cookies.set("webauthn-challenge", challenge, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 5 // 5 minutes - }); - } + if (isRegistration) { + // For registration, only store the email for validation + cookies.set("webauthn-registration-email", body.email, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 5, // 5 minutes + }); + } else { + // For login, store the challenge for signature verification + cookies.set("webauthn-challenge", challenge, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 5, // 5 minutes + }); + } - let allowCredentials: Array<{ - id: string; - type: "public-key"; - transports: string[]; - }> = []; + let allowCredentials: Array<{ + id: string; + type: "public-key"; + transports: string[]; + }> = []; - if (user) { - // Get user's registered passkeys for login - const passkeys = await WebAuthnService.getUserPasskeys(user.id); + if (user) { + // Get user's registered passkeys for login + const passkeys = await WebAuthnService.getUserPasskeys(user.id); - // Format passkeys for WebAuthn API - allowCredentials = passkeys.map((passkey) => ({ - id: passkey.id, - type: "public-key" as const, - transports: ["usb", "nfc", "ble", "internal"] // All possible transports - })); + // Format passkeys for WebAuthn API + allowCredentials = passkeys.map((passkey) => ({ + id: passkey.id, + type: "public-key" as const, + transports: ["usb", "nfc", "ble", "internal"], // All possible transports + })); - logger.debug("WebAuthn challenge generated for login", { - userId: user.id, - email: user.email, - passkeyCount: passkeys.length, - challenge: challenge.substring(0, 8) + "..." - }); - } else { - logger.debug("WebAuthn challenge generated for registration", { - email: body.email, - challenge: challenge.substring(0, 8) + "..." - }); - } + logger.debug("WebAuthn challenge generated for login", { + userId: user.id, + email: user.email, + passkeyCount: passkeys.length, + challenge: challenge.substring(0, 8) + "...", + }); + } else { + logger.debug("WebAuthn challenge generated for registration", { + email: body.email, + challenge: challenge.substring(0, 8) + "...", + }); + } - const rpId = getRpId(url); + const rpId = getRpId(url); - logger.debug("Returning challenge data", { - challenge, - allowCredentials, - timeout: 60000, // 60 seconds - rpId, - userVerification: "preferred", - isRegistration - }); + logger.debug("Returning challenge data", { + challenge, + allowCredentials, + timeout: 60000, // 60 seconds + rpId, + userVerification: "preferred", + isRegistration, + }); - return json({ - challenge, - allowCredentials, - timeout: 60000, // 60 seconds - rpId, - userVerification: "preferred", - isRegistration - }); - } catch (error) { - logger.error("Challenge generation error", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ + challenge, + allowCredentials, + timeout: 60000, // 60 seconds + rpId, + userVerification: "preferred", + isRegistration, + }); + } catch (error) { + logger.error("Challenge generation error", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/confirm/+server.ts b/src/routes/api/auth/confirm/+server.ts index 7b5557e..9695281 100644 --- a/src/routes/api/auth/confirm/+server.ts +++ b/src/routes/api/auth/confirm/+server.ts @@ -7,97 +7,97 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/auth/confirm", "POST", { - summary: "Confirm user account", - description: "Confirms a user account using the email confirmation token", - tags: ["Authentication"], - requestBody: { - description: "Confirmation token", - content: { - "application/json": { - schema: { - type: "object", - properties: { - token: { - type: "string", - description: "Confirmation token from email", - example: "01234567-89ab-cdef-0123-456789abcdef" - } - }, - required: ["token"] - } - } - } - }, - responses: { - "200": { - description: "User account confirmed successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - isSetup: { - type: "string", - description: "Whether this is the first account that was setup on the server" - } - }, - required: ["message"] - }, - example: { - message: "User account confirmed successfully. You can now log in." - } - } - } - }, - "404": { - description: "Invalid or expired confirmation token", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid or expired confirmation token" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Confirm user account", + description: "Confirms a user account using the email confirmation token", + tags: ["Authentication"], + requestBody: { + description: "Confirmation token", + content: { + "application/json": { + schema: { + type: "object", + properties: { + token: { + type: "string", + description: "Confirmation token from email", + example: "01234567-89ab-cdef-0123-456789abcdef", + }, + }, + required: ["token"], + }, + }, + }, + }, + responses: { + "200": { + description: "User account confirmed successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + isSetup: { + type: "string", + description: "Whether this is the first account that was setup on the server", + }, + }, + required: ["message"], + }, + example: { + message: "User account confirmed successfully. You can now log in.", + }, + }, + }, + }, + "404": { + description: "Invalid or expired confirmation token", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid or expired confirmation token" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request }) => { - try { - const body = await request.json(); + try { + const body = await request.json(); - const confirmationResult = await UserService.confirm(body.token); + const confirmationResult = await UserService.confirm(body.token); - const response: Record = { - message: "User account confirmed successfully. You can now log in.", - isSetup: confirmationResult.isSetup - }; + const response: Record = { + message: "User account confirmed successfully. You can now log in.", + isSetup: confirmationResult.isSetup, + }; - // Include recovery passphrase if it exists (for WebAuthn-only users) - if (confirmationResult.recoveryPassphrase) { - response.recoveryPassphrase = confirmationResult.recoveryPassphrase; - response.recoveryMessage = - "Please save this recovery passphrase in a secure location. It will not be shown again."; - } + // Include recovery passphrase if it exists (for WebAuthn-only users) + if (confirmationResult.recoveryPassphrase) { + response.recoveryPassphrase = confirmationResult.recoveryPassphrase; + response.recoveryMessage = + "Please save this recovery passphrase in a secure location. It will not be shown again."; + } - return json(response, { status: 200 }); - } catch (error) { - const log = logger.setContext("API"); - log.error("User confirmation error:", JSON.stringify(error || "?")); + return json(response, { status: 200 }); + } catch (error) { + const log = logger.setContext("API"); + log.error("User confirmation error:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Invalid or expired confirmation token" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Invalid or expired confirmation token" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/invite/+server.ts b/src/routes/api/auth/invite/+server.ts index 43efcb1..a2d23c4 100644 --- a/src/routes/api/auth/invite/+server.ts +++ b/src/routes/api/auth/invite/+server.ts @@ -12,224 +12,224 @@ import { env } from "$env/dynamic/private"; const logger = new UniversalLogger().setContext("AuthInviteAPI"); const inviteUserSchema = z.object({ - email: z.string().email(), - name: z.string().min(1), - role: z.enum(["TENANT_ADMIN", "STAFF"]), - tenantId: z.string().uuid(), - language: z.enum(["de", "en"]).optional().default("de") + email: z.string().email(), + name: z.string().min(1), + role: z.enum(["TENANT_ADMIN", "STAFF"]), + tenantId: z.string().uuid(), + language: z.enum(["de", "en"]).optional().default("de"), }); registerOpenAPIRoute("/auth/invite", "POST", { - summary: "Invite user to tenant", - description: - "Send an invitation email to a user to join an existing tenant with a specific role (not for initial tenant admin!)", - tags: ["Authentication"], - requestBody: { - description: "User invitation data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - description: "Email address of the user to invite", - example: "user@example.com" - }, - name: { - type: "string", - description: "Full name of the user to invite", - example: "John Doe" - }, - role: { - type: "string", - enum: ["TENANT_ADMIN", "STAFF"], - description: "Role to assign to the invited user", - example: "STAFF" - }, - tenantId: { - type: "string", - format: "uuid", - description: "ID of the tenant to invite the user to", - example: "01234567-89ab-cdef-0123-456789abcdef" - }, - language: { - type: "string", - enum: ["de", "en"], - description: "Language for the invitation email", - example: "de", - default: "de" - } - }, - required: ["email", "name", "role", "tenantId"] - } - } - } - }, - responses: { - "200": { - description: "Invitation sent successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - email: { type: "string", description: "Email address invitation was sent to" }, - tenantId: { type: "string", description: "Tenant ID" }, - role: { type: "string", description: "Assigned role" } - }, - required: ["message", "email", "tenantId", "role"] - } - } - } - }, - "400": { - description: "Invalid request 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 not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Invite user to tenant", + description: + "Send an invitation email to a user to join an existing tenant with a specific role (not for initial tenant admin!)", + tags: ["Authentication"], + requestBody: { + description: "User invitation data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "Email address of the user to invite", + example: "user@example.com", + }, + name: { + type: "string", + description: "Full name of the user to invite", + example: "John Doe", + }, + role: { + type: "string", + enum: ["TENANT_ADMIN", "STAFF"], + description: "Role to assign to the invited user", + example: "STAFF", + }, + tenantId: { + type: "string", + format: "uuid", + description: "ID of the tenant to invite the user to", + example: "01234567-89ab-cdef-0123-456789abcdef", + }, + language: { + type: "string", + enum: ["de", "en"], + description: "Language for the invitation email", + example: "de", + default: "de", + }, + }, + required: ["email", "name", "role", "tenantId"], + }, + }, + }, + }, + responses: { + "200": { + description: "Invitation sent successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + email: { type: "string", description: "Email address invitation was sent to" }, + tenantId: { type: "string", description: "Tenant ID" }, + role: { type: "string", description: "Assigned role" }, + }, + required: ["message", "email", "tenantId", "role"], + }, + }, + }, + }, + "400": { + description: "Invalid request 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 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 POST: RequestHandler = async ({ request, locals }) => { - try { - // Verify user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + try { + // Verify user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - const body = await request.json(); - const validation = inviteUserSchema.safeParse(body); + const body = await request.json(); + const validation = inviteUserSchema.safeParse(body); - if (!validation.success) { - logger.warn("Invalid invite request", { - userId: locals.user.userId, - errors: validation.error.errors - }); - throw new ValidationError("Invalid request data"); - } + if (!validation.success) { + logger.warn("Invalid invite request", { + userId: locals.user.userId, + errors: validation.error.errors, + }); + throw new ValidationError("Invalid request data"); + } - const { email, name, role, tenantId, language } = validation.data; + const { email, name, role, tenantId, language } = validation.data; - // Check if user has permission to invite to this tenant - // Global admins can invite to any tenant - // Tenant admins can only invite to their own tenant - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can invite to any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can invite to their own tenant - } else { - logger.warn("Insufficient permissions for invitation", { - userId: locals.user.userId, - userRole: locals.user.role, - userTenantId: locals.user.tenantId, - targetTenantId: tenantId - }); - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Check if user has permission to invite to this tenant + // Global admins can invite to any tenant + // Tenant admins can only invite to their own tenant + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can invite to any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can invite to their own tenant + } else { + logger.warn("Insufficient permissions for invitation", { + userId: locals.user.userId, + userRole: locals.user.role, + userTenantId: locals.user.tenantId, + targetTenantId: tenantId, + }); + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - // Get tenant information - let tenantService; - try { - tenantService = await TenantAdminService.getTenantById(tenantId); - } catch (error) { - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } - throw error; - } + // Get tenant information + let tenantService; + try { + tenantService = await TenantAdminService.getTenantById(tenantId); + } catch (error) { + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } + throw error; + } - const tenant = tenantService.tenantData; - if (!tenant) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + const tenant = tenantService.tenantData; + if (!tenant) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - // Check if user already has a pending invitation for this tenant - const hasPendingInvite = await InviteService.hasPendingInvite(email, tenantId); - if (hasPendingInvite) { - return json( - { error: "User already has a pending invitation for this tenant" }, - { status: 409 } - ); - } + // Check if user already has a pending invitation for this tenant + const hasPendingInvite = await InviteService.hasPendingInvite(email, tenantId); + if (hasPendingInvite) { + return json( + { error: "User already has a pending invitation for this tenant" }, + { status: 409 }, + ); + } - // Create invitation in database - const invitation = await InviteService.createInvite( - email, - name, - role, - tenantId, - locals.user.userId, - language - ); + // Create invitation in database + const invitation = await InviteService.createInvite( + email, + name, + role, + tenantId, + locals.user.userId, + language, + ); - // Generate registration URL with secure invite code - const registrationUrl = `${env.PUBLIC_APP_URL || "http://localhost:5173"}/register?invite=${invitation.inviteCode}`; + // Generate registration URL with secure invite code + const registrationUrl = `${env.PUBLIC_APP_URL || "http://localhost:5173"}/register?invite=${invitation.inviteCode}`; - // Send invitation email - await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language); + // Send invitation email + await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language); - logger.info("User invitation sent successfully", { - invitedBy: locals.user.userId, - invitedEmail: email, - tenantId, - role - }); + logger.info("User invitation sent successfully", { + invitedBy: locals.user.userId, + invitedEmail: email, + tenantId, + role, + }); - return json({ - message: "Invitation sent successfully", - email, - tenantId, - role, - inviteCode: invitation.inviteCode - }); - } catch (error) { - logger.error("User invitation error:", { - error: String(error), - userId: locals.user?.userId - }); + return json({ + message: "Invitation sent successfully", + email, + tenantId, + role, + inviteCode: invitation.inviteCode, + }); + } catch (error) { + logger.error("User invitation error:", { + error: String(error), + userId: locals.user?.userId, + }); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/invite/__tests__/invite.test.ts b/src/routes/api/auth/invite/__tests__/invite.test.ts index 73922b5..ce41de0 100644 --- a/src/routes/api/auth/invite/__tests__/invite.test.ts +++ b/src/routes/api/auth/invite/__tests__/invite.test.ts @@ -4,26 +4,26 @@ import { POST } from "../+server"; // Mock dependencies vi.mock("$lib/server/services/tenant-admin-service", () => ({ - TenantAdminService: { - getTenantById: vi.fn() - } + TenantAdminService: { + getTenantById: vi.fn(), + }, })); vi.mock("$lib/server/services/invite-service", () => ({ - InviteService: { - hasPendingInvite: vi.fn(), - createInvite: vi.fn() - } + InviteService: { + hasPendingInvite: vi.fn(), + createInvite: vi.fn(), + }, })); vi.mock("$lib/server/email/email-service", () => ({ - sendUserInviteEmail: vi.fn() + sendUserInviteEmail: vi.fn(), })); vi.mock("$env/dynamic/private", () => ({ - env: { - PUBLIC_APP_URL: "http://localhost:5173" - } + env: { + PUBLIC_APP_URL: "http://localhost:5173", + }, })); import { TenantAdminService } from "$lib/server/services/tenant-admin-service"; @@ -31,268 +31,268 @@ import { InviteService } from "$lib/server/services/invite-service"; import { sendUserInviteEmail } from "$lib/server/email/email-service"; describe("POST /api/auth/invite", () => { - const mockTenant = { - id: "12345678-1234-1234-1234-123456789012", - shortName: "testcorp", - longName: "Test Corporation GmbH", - description: "A test corporation", - databaseUrl: "postgresql://test", - setupState: "NEW" as const, - createdAt: new Date(), - updatedAt: new Date(), - logo: null - }; + const mockTenant = { + id: "12345678-1234-1234-1234-123456789012", + shortName: "testcorp", + longName: "Test Corporation GmbH", + description: "A test corporation", + databaseUrl: "postgresql://test", + setupState: "NEW" as const, + createdAt: new Date(), + updatedAt: new Date(), + logo: null, + }; - const mockTenantService = { - tenantData: mockTenant - }; + const mockTenantService = { + tenantData: mockTenant, + }; - const mockInvitation = { - id: "invite-123", - inviteCode: "invite-code-123", - email: "user@example.com", - name: "Test User", - role: "STAFF" as const, - tenantId: "12345678-1234-1234-1234-123456789012", - invitedBy: "admin-id", - language: "de" as const, - used: false, - usedAt: null, - createdUserId: null, - createdAt: new Date(), - updatedAt: new Date(), - expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) - }; + const mockInvitation = { + id: "invite-123", + inviteCode: "invite-code-123", + email: "user@example.com", + name: "Test User", + role: "STAFF" as const, + tenantId: "12345678-1234-1234-1234-123456789012", + invitedBy: "admin-id", + language: "de" as const, + used: false, + usedAt: null, + createdUserId: null, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + }; - const createRequestEvent = (body: any, user: any = null) => - ({ - request: { - json: () => Promise.resolve(body) - } as Request, - locals: { user }, - url: new URL("http://localhost/api/auth/invite"), - params: {}, - route: { id: "/api/auth/invite" } as any, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "127.0.0.1", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any - }) as any; + const createRequestEvent = (body: any, user: any = null) => + ({ + request: { + json: () => Promise.resolve(body), + } as Request, + locals: { user }, + url: new URL("http://localhost/api/auth/invite"), + params: {}, + route: { id: "/api/auth/invite" } as any, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "127.0.0.1", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + }) as any; - beforeEach(() => { - vi.clearAllMocks(); - vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); - vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(false); - vi.mocked(InviteService.createInvite).mockResolvedValue(mockInvitation as any); - vi.mocked(sendUserInviteEmail).mockResolvedValue(); - }); + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); + vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(false); + vi.mocked(InviteService.createInvite).mockResolvedValue(mockInvitation as any); + vi.mocked(sendUserInviteEmail).mockResolvedValue(); + }); - it("should reject unauthenticated requests", async () => { - const requestEvent = createRequestEvent({ - email: "user@example.com", - name: "Test User", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }); + it("should reject unauthenticated requests", async () => { + const requestEvent = createRequestEvent({ + email: "user@example.com", + name: "Test User", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(401); - expect(data.error).toBe("Authentication required"); - }); + expect(response.status).toBe(401); + expect(data.error).toBe("Authentication required"); + }); - it("should allow global admin to invite to any tenant", async () => { - const globalAdmin = { - userId: "admin-id", - role: "GLOBAL_ADMIN", - tenantId: null - }; + it("should allow global admin to invite to any tenant", async () => { + const globalAdmin = { + userId: "admin-id", + role: "GLOBAL_ADMIN", + tenantId: null, + }; - const requestEvent = createRequestEvent( - { - email: "user@example.com", - name: "Test User", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }, - globalAdmin - ); + const requestEvent = createRequestEvent( + { + email: "user@example.com", + name: "Test User", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }, + globalAdmin, + ); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data.message).toBe("Invitation sent successfully"); - expect(data.email).toBe("user@example.com"); - expect(data.inviteCode).toBe("invite-code-123"); - expect(vi.mocked(InviteService.hasPendingInvite)).toHaveBeenCalledWith( - "user@example.com", - "12345678-1234-1234-1234-123456789012" - ); - expect(vi.mocked(InviteService.createInvite)).toHaveBeenCalledWith( - "user@example.com", - "Test User", - "STAFF", - "12345678-1234-1234-1234-123456789012", - "admin-id", - "de" - ); - expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled(); - }); + expect(response.status).toBe(200); + expect(data.message).toBe("Invitation sent successfully"); + expect(data.email).toBe("user@example.com"); + expect(data.inviteCode).toBe("invite-code-123"); + expect(vi.mocked(InviteService.hasPendingInvite)).toHaveBeenCalledWith( + "user@example.com", + "12345678-1234-1234-1234-123456789012", + ); + expect(vi.mocked(InviteService.createInvite)).toHaveBeenCalledWith( + "user@example.com", + "Test User", + "STAFF", + "12345678-1234-1234-1234-123456789012", + "admin-id", + "de", + ); + expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled(); + }); - it("should allow tenant admin to invite to their own tenant", async () => { - const tenantAdmin = { - userId: "tenant-admin-id", - role: "TENANT_ADMIN", - tenantId: "12345678-1234-1234-1234-123456789012" - }; + it("should allow tenant admin to invite to their own tenant", async () => { + const tenantAdmin = { + userId: "tenant-admin-id", + role: "TENANT_ADMIN", + tenantId: "12345678-1234-1234-1234-123456789012", + }; - const requestEvent = createRequestEvent( - { - email: "user@example.com", - name: "Test User", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }, - tenantAdmin - ); + const requestEvent = createRequestEvent( + { + email: "user@example.com", + name: "Test User", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }, + tenantAdmin, + ); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data.message).toBe("Invitation sent successfully"); - expect(data.inviteCode).toBe("invite-code-123"); - expect(vi.mocked(InviteService.createInvite)).toHaveBeenCalledWith( - "user@example.com", - "Test User", - "STAFF", - "12345678-1234-1234-1234-123456789012", - "tenant-admin-id", - "de" - ); - expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith( - "user@example.com", - "Test User", - mockTenant, - "STAFF", - expect.stringContaining("invite=invite-code-123"), - "de" - ); - }); + expect(response.status).toBe(200); + expect(data.message).toBe("Invitation sent successfully"); + expect(data.inviteCode).toBe("invite-code-123"); + expect(vi.mocked(InviteService.createInvite)).toHaveBeenCalledWith( + "user@example.com", + "Test User", + "STAFF", + "12345678-1234-1234-1234-123456789012", + "tenant-admin-id", + "de", + ); + expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith( + "user@example.com", + "Test User", + mockTenant, + "STAFF", + expect.stringContaining("invite=invite-code-123"), + "de", + ); + }); - it("should reject tenant admin inviting to different tenant", async () => { - const tenantAdmin = { - userId: "tenant-admin-id", - role: "TENANT_ADMIN", - tenantId: "87654321-4321-4321-4321-210987654321" - }; + it("should reject tenant admin inviting to different tenant", async () => { + const tenantAdmin = { + userId: "tenant-admin-id", + role: "TENANT_ADMIN", + tenantId: "87654321-4321-4321-4321-210987654321", + }; - const requestEvent = createRequestEvent( - { - email: "user@example.com", - name: "Test User", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }, - tenantAdmin - ); + const requestEvent = createRequestEvent( + { + email: "user@example.com", + name: "Test User", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }, + tenantAdmin, + ); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(403); - expect(data.error).toBe("Insufficient permissions"); - expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); - expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(403); + expect(data.error).toBe("Insufficient permissions"); + expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); + expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); + }); - it("should reject staff members from inviting", async () => { - const staff = { - userId: "staff-id", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }; + it("should reject staff members from inviting", async () => { + const staff = { + userId: "staff-id", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }; - const requestEvent = createRequestEvent( - { - email: "user@example.com", - name: "Test User", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }, - staff - ); + const requestEvent = createRequestEvent( + { + email: "user@example.com", + name: "Test User", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }, + staff, + ); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(403); - expect(data.error).toBe("Insufficient permissions"); - expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); - expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(403); + expect(data.error).toBe("Insufficient permissions"); + expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); + expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); + }); - it("should validate request data", async () => { - const globalAdmin = { - userId: "admin-id", - role: "GLOBAL_ADMIN", - tenantId: null - }; + it("should validate request data", async () => { + const globalAdmin = { + userId: "admin-id", + role: "GLOBAL_ADMIN", + tenantId: null, + }; - const requestEvent = createRequestEvent( - { - email: "invalid-email", - name: "", - role: "INVALID_ROLE", - tenantId: "invalid-uuid" - }, - globalAdmin - ); + const requestEvent = createRequestEvent( + { + email: "invalid-email", + name: "", + role: "INVALID_ROLE", + tenantId: "invalid-uuid", + }, + globalAdmin, + ); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(400); - expect(data.error).toBe("Invalid request data"); - expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); - expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(400); + expect(data.error).toBe("Invalid request data"); + expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); + expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); + }); - it("should reject if user already has pending invitation", async () => { - const globalAdmin = { - userId: "admin-id", - role: "GLOBAL_ADMIN", - tenantId: null - }; + it("should reject if user already has pending invitation", async () => { + const globalAdmin = { + userId: "admin-id", + role: "GLOBAL_ADMIN", + tenantId: null, + }; - // Mock that there's already a pending invitation - vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(true); + // Mock that there's already a pending invitation + vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(true); - const requestEvent = createRequestEvent( - { - email: "user@example.com", - name: "Test User", - role: "STAFF", - tenantId: "12345678-1234-1234-1234-123456789012" - }, - globalAdmin - ); + const requestEvent = createRequestEvent( + { + email: "user@example.com", + name: "Test User", + role: "STAFF", + tenantId: "12345678-1234-1234-1234-123456789012", + }, + globalAdmin, + ); - const response = await POST(requestEvent); - const data = await response.json(); + const response = await POST(requestEvent); + const data = await response.json(); - expect(response.status).toBe(409); - expect(data.error).toBe("User already has a pending invitation for this tenant"); - expect(vi.mocked(InviteService.hasPendingInvite)).toHaveBeenCalledWith( - "user@example.com", - "12345678-1234-1234-1234-123456789012" - ); - expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); - expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); - }); + expect(response.status).toBe(409); + expect(data.error).toBe("User already has a pending invitation for this tenant"); + expect(vi.mocked(InviteService.hasPendingInvite)).toHaveBeenCalledWith( + "user@example.com", + "12345678-1234-1234-1234-123456789012", + ); + expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled(); + expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled(); + }); }); diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index ad7c93c..bdee2c4 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -13,299 +13,299 @@ import { env } from "$env/dynamic/private"; const logger = new UniversalLogger().setContext("AuthLoginAPI"); registerOpenAPIRoute("/auth/login", "POST", { - summary: "Login with WebAuthn passkey or passphrase", - description: - "Authenticate user with WebAuthn passkey or passphrase and create session. For WebAuthn, first call /auth/challenge to get a challenge.", - tags: ["Authentication"], - requestBody: { - description: "Authentication data (either WebAuthn credential or passphrase)", - content: { - "application/json": { - schema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - description: "User's email address", - example: "admin@example.com" - }, - passphrase: { - type: "string", - description: "User's passphrase (alternative to WebAuthn)", - example: "my-secure-passphrase-123" - }, - credential: { - type: "object", - description: "WebAuthn credential data (alternative to passphrase)", - properties: { - id: { type: "string", description: "Credential ID" }, - response: { - type: "object", - description: "WebAuthn response data", - properties: { - authenticatorData: { - type: "string", - description: "Base64 encoded authenticator data" - }, - signature: { type: "string", description: "Base64 encoded signature" }, - userHandle: { type: "string", description: "User handle" }, - clientDataJSON: { - type: "string", - description: "Base64 encoded client data JSON" - } - }, - required: ["authenticatorData", "signature", "clientDataJSON"] - } - }, - required: ["id", "response"] - } - }, - required: ["email"] - } - } - } - }, - responses: { - "200": { - description: "Login successful", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - user: { - type: "object", - properties: { - id: { type: "string", description: "User ID" }, - email: { type: "string", description: "User email" }, - name: { type: "string", description: "User name" }, - role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, - tenantId: { type: "string", description: "Tenant ID (if applicable)" } - }, - required: ["id", "email", "name", "role"] - }, - expiresAt: { - type: "string", - format: "date-time", - description: "Session expiration time" - } - }, - required: ["message", "user", "expiresAt"] - } - } - } - }, - "400": { - description: "Invalid credentials or request data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "401": { - description: "Authentication failed", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Login with WebAuthn passkey or passphrase", + description: + "Authenticate user with WebAuthn passkey or passphrase and create session. For WebAuthn, first call /auth/challenge to get a challenge.", + tags: ["Authentication"], + requestBody: { + description: "Authentication data (either WebAuthn credential or passphrase)", + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "User's email address", + example: "admin@example.com", + }, + passphrase: { + type: "string", + description: "User's passphrase (alternative to WebAuthn)", + example: "my-secure-passphrase-123", + }, + credential: { + type: "object", + description: "WebAuthn credential data (alternative to passphrase)", + properties: { + id: { type: "string", description: "Credential ID" }, + response: { + type: "object", + description: "WebAuthn response data", + properties: { + authenticatorData: { + type: "string", + description: "Base64 encoded authenticator data", + }, + signature: { type: "string", description: "Base64 encoded signature" }, + userHandle: { type: "string", description: "User handle" }, + clientDataJSON: { + type: "string", + description: "Base64 encoded client data JSON", + }, + }, + required: ["authenticatorData", "signature", "clientDataJSON"], + }, + }, + required: ["id", "response"], + }, + }, + required: ["email"], + }, + }, + }, + }, + responses: { + "200": { + description: "Login successful", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + user: { + type: "object", + properties: { + id: { type: "string", description: "User ID" }, + email: { type: "string", description: "User email" }, + name: { type: "string", description: "User name" }, + role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, + tenantId: { type: "string", description: "Tenant ID (if applicable)" }, + }, + required: ["id", "email", "name", "role"], + }, + expiresAt: { + type: "string", + format: "date-time", + description: "Session expiration time", + }, + }, + required: ["message", "user", "expiresAt"], + }, + }, + }, + }, + "400": { + description: "Invalid credentials or request data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "401": { + description: "Authentication failed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request, cookies, getClientAddress, url }) => { - try { - const body = await request.json(); - const ipAddress = getClientAddress(); - const userAgent = request.headers.get("user-agent"); + try { + const body = await request.json(); + const ipAddress = getClientAddress(); + const userAgent = request.headers.get("user-agent"); - logger.debug("Login attempt", { - email: body.email, - ipAddress, - authMethod: body.passphrase ? "passphrase" : "webauthn" - }); + logger.debug("Login attempt", { + email: body.email, + ipAddress, + authMethod: body.passphrase ? "passphrase" : "webauthn", + }); - // Validate that either passphrase or credential is provided - if (!body.passphrase && !body.credential) { - return json( - { error: "Either passphrase or WebAuthn credential must be provided" }, - { status: 400 } - ); - } + // Validate that either passphrase or credential is provided + if (!body.passphrase && !body.credential) { + return json( + { error: "Either passphrase or WebAuthn credential must be provided" }, + { status: 400 }, + ); + } - // Get user by email - let user; - try { - user = await UserService.getUserByEmail(body.email); - } catch (error) { - if (error instanceof NotFoundError) { - return json({ error: "Invalid email or authentication method" }, { status: 401 }); - } - throw error; - } + // Get user by email + let user; + try { + user = await UserService.getUserByEmail(body.email); + } catch (error) { + if (error instanceof NotFoundError) { + return json({ error: "Invalid email or authentication method" }, { status: 401 }); + } + throw error; + } - // Production-only: Validate subdomain for non-global admins - if (env.NODE_ENV === "production") { - const hostname = url.hostname; - const hostParts = hostname.split("."); - const hasSubdomain = hostParts.length > 2; - const subdomain = hasSubdomain ? hostParts[0] : null; + // Production-only: Validate subdomain for non-global admins + if (env.NODE_ENV === "production") { + const hostname = url.hostname; + const hostParts = hostname.split("."); + const hasSubdomain = hostParts.length > 2; + const subdomain = hasSubdomain ? hostParts[0] : null; - // Global admins can login from anywhere - if (user.role !== "GLOBAL_ADMIN") { - if (!hasSubdomain || !subdomain) { - logger.warn("Login attempt from non-subdomain by non-global admin", { - userId: user.id, - email: user.email, - hostname, - role: user.role - }); - return json({ error: "Unknown user does for tenant" }, { status: 401 }); - } + // Global admins can login from anywhere + if (user.role !== "GLOBAL_ADMIN") { + if (!hasSubdomain || !subdomain) { + logger.warn("Login attempt from non-subdomain by non-global admin", { + userId: user.id, + email: user.email, + hostname, + role: user.role, + }); + return json({ error: "Unknown user does for tenant" }, { status: 401 }); + } - if (user.tenantId) { - try { - const tenantService = await TenantAdminService.getTenantById(user.tenantId); - const tenant = tenantService.tenantData; + if (user.tenantId) { + try { + const tenantService = await TenantAdminService.getTenantById(user.tenantId); + const tenant = tenantService.tenantData; - if (!tenant || tenant.shortName !== subdomain) { - logger.warn("Login attempt from wrong subdomain", { - userId: user.id, - email: user.email, - expectedSubdomain: tenant?.shortName, - actualSubdomain: subdomain, - tenantId: user.tenantId - }); - return json({ error: "Unknown user does for tenant" }, { status: 401 }); - } - } catch (error) { - logger.error("Failed to validate tenant for subdomain login", { - userId: user.id, - tenantId: user.tenantId, - subdomain, - error: String(error) - }); - return json({ error: "Unknown user does for tenant" }, { status: 401 }); - } - } else { - logger.warn("Login attempt from subdomain by user without matching tenant", { - userId: user.id, - email: user.email, - subdomain, - role: user.role - }); - return json({ error: "Unknown user does for tenant" }, { status: 401 }); - } - } - } + if (!tenant || tenant.shortName !== subdomain) { + logger.warn("Login attempt from wrong subdomain", { + userId: user.id, + email: user.email, + expectedSubdomain: tenant?.shortName, + actualSubdomain: subdomain, + tenantId: user.tenantId, + }); + return json({ error: "Unknown user does for tenant" }, { status: 401 }); + } + } catch (error) { + logger.error("Failed to validate tenant for subdomain login", { + userId: user.id, + tenantId: user.tenantId, + subdomain, + error: String(error), + }); + return json({ error: "Unknown user does for tenant" }, { status: 401 }); + } + } else { + logger.warn("Login attempt from subdomain by user without matching tenant", { + userId: user.id, + email: user.email, + subdomain, + role: user.role, + }); + return json({ error: "Unknown user does for tenant" }, { status: 401 }); + } + } + } - // Handle passphrase authentication - if (body.passphrase) { - if (!user.passphraseHash) { - return json( - { error: "Passphrase authentication not enabled for this user" }, - { status: 401 } - ); - } + // Handle passphrase authentication + if (body.passphrase) { + if (!user.passphraseHash) { + return json( + { error: "Passphrase authentication not enabled for this user" }, + { status: 401 }, + ); + } - const isPassphraseValid = await verifyPassphrase(user.passphraseHash, body.passphrase); - if (!isPassphraseValid) { - return json({ error: "Invalid passphrase" }, { status: 401 }); - } + const isPassphraseValid = await verifyPassphrase(user.passphraseHash, body.passphrase); + if (!isPassphraseValid) { + return json({ error: "Invalid passphrase" }, { status: 401 }); + } - logger.debug("Passphrase authentication successful", { userId: user.id }); - } + logger.debug("Passphrase authentication successful", { userId: user.id }); + } - // Handle WebAuthn authentication - if (body.credential) { - // Get the challenge from the session - const challengeFromSession = cookies.get("webauthn-challenge"); - if (!challengeFromSession) { - return json( - { error: "No WebAuthn challenge found. Please request a new challenge." }, - { status: 400 } - ); - } + // Handle WebAuthn authentication + if (body.credential) { + // Get the challenge from the session + const challengeFromSession = cookies.get("webauthn-challenge"); + if (!challengeFromSession) { + return json( + { error: "No WebAuthn challenge found. Please request a new challenge." }, + { status: 400 }, + ); + } - // Clear the challenge cookie after use - cookies.delete("webauthn-challenge", { path: "/" }); + // Clear the challenge cookie after use + cookies.delete("webauthn-challenge", { path: "/" }); - const verificationResult = await WebAuthnService.verifyAuthentication( - body.credential, - challengeFromSession - ); + const verificationResult = await WebAuthnService.verifyAuthentication( + body.credential, + challengeFromSession, + ); - if (!verificationResult.verified) { - return json({ error: "Invalid WebAuthn credential" }, { status: 401 }); - } + if (!verificationResult.verified) { + return json({ error: "Invalid WebAuthn credential" }, { status: 401 }); + } - // Verify that the credential belongs to the user - if (verificationResult.userId !== user.id) { - return json({ error: "WebAuthn credential does not belong to this user" }, { status: 401 }); - } + // Verify that the credential belongs to the user + if (verificationResult.userId !== user.id) { + return json({ error: "WebAuthn credential does not belong to this user" }, { status: 401 }); + } - // Update the counter to prevent replay attacks - if (verificationResult.newCounter && verificationResult.passkeyId) { - await WebAuthnService.updatePasskeyCounter( - verificationResult.passkeyId, - verificationResult.newCounter - ); - } + // Update the counter to prevent replay attacks + if (verificationResult.newCounter && verificationResult.passkeyId) { + await WebAuthnService.updatePasskeyCounter( + verificationResult.passkeyId, + verificationResult.newCounter, + ); + } - logger.debug("WebAuthn authentication successful", { userId: user.id }); - } + logger.debug("WebAuthn authentication successful", { userId: user.id }); + } - const sessionData = await SessionService.createSession( - user.id, - ipAddress, - userAgent || undefined - ); + const sessionData = await SessionService.createSession( + user.id, + ipAddress, + userAgent || undefined, + ); - // Set HTTP-only cookie for access token - cookies.set("access_token", sessionData.accessToken, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 60 * 24 * 7 // 7 days - }); + // Set HTTP-only cookie for access token + cookies.set("access_token", sessionData.accessToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days + }); - logger.info("Login successful", { - userId: sessionData.user.id, - email: sessionData.user.email, - role: sessionData.user.role, - authMethod: body.passphrase ? "passphrase" : "webauthn" - }); + logger.info("Login successful", { + userId: sessionData.user.id, + email: sessionData.user.email, + role: sessionData.user.role, + authMethod: body.passphrase ? "passphrase" : "webauthn", + }); - return json({ - message: "Login successful", - user: { - id: sessionData.user.id, - email: sessionData.user.email, - name: sessionData.user.name, - role: sessionData.user.role, - tenantId: sessionData.user.tenantId - }, - expiresAt: sessionData.expiresAt.toISOString() - }); - } catch (error) { - logger.error("Login error:", { error: String(error) }); + return json({ + message: "Login successful", + user: { + id: sessionData.user.id, + email: sessionData.user.email, + name: sessionData.user.name, + role: sessionData.user.role, + tenantId: sessionData.user.tenantId, + }, + expiresAt: sessionData.expiresAt.toISOString(), + }); + } catch (error) { + logger.error("Login error:", { error: String(error) }); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - return json({ error: "Authentication failed" }, { status: 401 }); - } + return json({ error: "Authentication failed" }, { status: 401 }); + } }; diff --git a/src/routes/api/auth/logout/+server.ts b/src/routes/api/auth/logout/+server.ts index e02410d..f5c0ff9 100644 --- a/src/routes/api/auth/logout/+server.ts +++ b/src/routes/api/auth/logout/+server.ts @@ -7,67 +7,67 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("AuthLogoutAPI"); registerOpenAPIRoute("/auth/logout", "POST", { - summary: "Logout user session", - description: "Invalidate current user session and clear access token cookie", - tags: ["Authentication"], - responses: { - "200": { - description: "Logout successful", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" } - }, - required: ["message"] - } - } - } - }, - "400": { - description: "No access token found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Logout user session", + description: "Invalidate current user session and clear access token cookie", + tags: ["Authentication"], + responses: { + "200": { + description: "Logout successful", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "400": { + description: "No access token found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ locals, cookies }) => { - try { - if (!locals.user) { - return json({ error: "Not authenticated" }, { status: 401 }); - } + try { + if (!locals.user) { + return json({ error: "Not authenticated" }, { status: 401 }); + } - // Delete the session from database - if (locals.user.sessionId) { - await SessionService.revokeSession(locals.user.sessionId); - } + // Delete the session from database + if (locals.user.sessionId) { + await SessionService.revokeSession(locals.user.sessionId); + } - // Clear the access token cookie - cookies.delete("access_token", { - path: "/", - httpOnly: true, - secure: true, - sameSite: "strict" - }); + // Clear the access token cookie + cookies.delete("access_token", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict", + }); - logger.info("Logout successful", { userId: locals.user.id }); + logger.info("Logout successful", { userId: locals.user.id }); - return json({ message: "Logout successful" }); - } catch (error) { - logger.error("Logout error:", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ message: "Logout successful" }); + } catch (error) { + logger.error("Logout error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/passkeys/+server.ts b/src/routes/api/auth/passkeys/+server.ts index 80206a5..16acd3b 100644 --- a/src/routes/api/auth/passkeys/+server.ts +++ b/src/routes/api/auth/passkeys/+server.ts @@ -8,154 +8,154 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/auth/passkeys", "POST", { - summary: "Add additional WebAuthn passkey to user account", - description: "Allows authenticated users to add additional WebAuthn keys to their accounts", - tags: ["Authentication"], - requestBody: { - description: "WebAuthn passkey data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - userId: { - type: "string", - format: "uuid", - description: "User ID to add the passkey to", - example: "01234567-89ab-cdef-0123-456789abcdef" - }, - passkey: { - type: "object", - description: "WebAuthn passkey data", - properties: { - id: { type: "string", description: "Credential ID from WebAuthn" }, - publicKey: { type: "string", description: "Base64 encoded public key" }, - counter: { type: "integer", description: "Signature counter", default: 0 }, - deviceName: { - type: "string", - description: "Device name for identification", - example: "iPhone 15" - } - }, - required: ["id", "publicKey"] - } - }, - required: ["userId", "passkey"] - } - } - } - }, - responses: { - "200": { - description: "WebAuthn passkey added successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - passkeyId: { type: "string", description: "ID of the added passkey" } - }, - required: ["message", "passkeyId"] - }, - example: { - message: "WebAuthn passkey added successfully", - passkeyId: "credential_id_123" - } - } - } - }, - "400": { - description: "Invalid input data or user account not confirmed", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "User account must be confirmed before adding additional passkeys" } - } - } - }, - "404": { - description: "User not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "User not found" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Add additional WebAuthn passkey to user account", + description: "Allows authenticated users to add additional WebAuthn keys to their accounts", + tags: ["Authentication"], + requestBody: { + description: "WebAuthn passkey data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + userId: { + type: "string", + format: "uuid", + description: "User ID to add the passkey to", + example: "01234567-89ab-cdef-0123-456789abcdef", + }, + passkey: { + type: "object", + description: "WebAuthn passkey data", + properties: { + id: { type: "string", description: "Credential ID from WebAuthn" }, + publicKey: { type: "string", description: "Base64 encoded public key" }, + counter: { type: "integer", description: "Signature counter", default: 0 }, + deviceName: { + type: "string", + description: "Device name for identification", + example: "iPhone 15", + }, + }, + required: ["id", "publicKey"], + }, + }, + required: ["userId", "passkey"], + }, + }, + }, + }, + responses: { + "200": { + description: "WebAuthn passkey added successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + passkeyId: { type: "string", description: "ID of the added passkey" }, + }, + required: ["message", "passkeyId"], + }, + example: { + message: "WebAuthn passkey added successfully", + passkeyId: "credential_id_123", + }, + }, + }, + }, + "400": { + description: "Invalid input data or user account not confirmed", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "User account must be confirmed before adding additional passkeys" }, + }, + }, + }, + "404": { + description: "User not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "User not found" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const body = await request.json(); + try { + const body = await request.json(); - // Validate required fields - if (!body.userId || !body.passkey) { - return json({ error: "userId and passkey are required" }, { status: 400 }); - } + // Validate required fields + if (!body.userId || !body.passkey) { + return json({ error: "userId and passkey are required" }, { status: 400 }); + } - if (!body.passkey.id || !body.passkey.publicKey) { - return json({ error: "Passkey must include id and publicKey" }, { status: 400 }); - } + if (!body.passkey.id || !body.passkey.publicKey) { + return json({ error: "Passkey must include id and publicKey" }, { status: 400 }); + } - log.debug("Adding additional passkey to user", { - userId: body.userId, - passkeyId: body.passkey.id, - deviceName: body.passkey.deviceName - }); + log.debug("Adding additional passkey to user", { + userId: body.userId, + passkeyId: body.passkey.id, + deviceName: body.passkey.deviceName, + }); - // Extract counter from WebAuthn credential - const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); - // Add the passkey using the UserService - await UserService.addAdditionalPasskey(body.userId, { - id: body.passkey.id, - userId: body.userId, - publicKey: body.passkey.publicKey, - counter, - deviceName: body.passkey.deviceName || "Unknown Device" - }); + // Add the passkey using the UserService + await UserService.addAdditionalPasskey(body.userId, { + id: body.passkey.id, + userId: body.userId, + publicKey: body.passkey.publicKey, + counter, + deviceName: body.passkey.deviceName || "Unknown Device", + }); - log.debug("Additional passkey added successfully", { - userId: body.userId, - passkeyId: body.passkey.id - }); + log.debug("Additional passkey added successfully", { + userId: body.userId, + passkeyId: body.passkey.id, + }); - return json( - { - message: "WebAuthn passkey added successfully", - passkeyId: body.passkey.id - }, - { status: 200 } - ); - } catch (error) { - log.error("Add passkey error:", JSON.stringify(error || "?")); + return json( + { + message: "WebAuthn passkey added successfully", + passkeyId: body.passkey.id, + }, + { status: 200 }, + ); + } catch (error) { + log.error("Add passkey error:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "User not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "User not found" }, { status: 404 }); + } - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - // Handle unique constraint violation (passkey already exists) - if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "This passkey is already registered" }, { status: 409 }); - } + // Handle unique constraint violation (passkey already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "This passkey is already registered" }, { status: 409 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/refresh/+server.ts b/src/routes/api/auth/refresh/+server.ts index ddfcd53..2276fa8 100644 --- a/src/routes/api/auth/refresh/+server.ts +++ b/src/routes/api/auth/refresh/+server.ts @@ -7,94 +7,98 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("AuthRefreshAPI"); registerOpenAPIRoute("/auth/refresh", "POST", { - summary: "Refresh access token", - description: "Generate new access and refresh tokens using the current session", - tags: ["Authentication"], - responses: { - "200": { - description: "Token refresh successful", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - expiresAt: { type: "string", format: "date-time", description: "New expiration time" } - }, - required: ["message", "expiresAt"] - } - } - } - }, - "400": { - description: "No valid session found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "401": { - description: "Refresh token expired or invalid", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Refresh access token", + description: "Generate new access and refresh tokens using the current session", + tags: ["Authentication"], + responses: { + "200": { + description: "Token refresh successful", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + expiresAt: { + type: "string", + format: "date-time", + description: "New expiration time", + }, + }, + required: ["message", "expiresAt"], + }, + }, + }, + }, + "400": { + description: "No valid session found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "401": { + description: "Refresh token expired or invalid", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ locals, cookies }) => { - try { - // Get current access token from cookie to extract session info - const accessToken = cookies.get("access_token"); + try { + // Get current access token from cookie to extract session info + const accessToken = cookies.get("access_token"); - if (!accessToken) { - return json({ error: "Access token is required" }, { status: 400 }); - } + if (!accessToken) { + return json({ error: "Access token is required" }, { status: 400 }); + } - // The user object is already available from authHandle, - // so we can create a new session based on that - if (!locals.user) { - return json({ error: "Invalid user context" }, { status: 400 }); - } + // The user object is already available from authHandle, + // so we can create a new session based on that + if (!locals.user) { + return json({ error: "Invalid user context" }, { status: 400 }); + } - const result = await SessionService.createSession( - locals.user.userId as string, - "", // IP address - could be extracted from request if needed - undefined // user agent - ); + const result = await SessionService.createSession( + locals.user.userId as string, + "", // IP address - could be extracted from request if needed + undefined, // user agent + ); - if (!result) { - return json({ error: "Invalid or expired refresh token" }, { status: 401 }); - } + if (!result) { + return json({ error: "Invalid or expired refresh token" }, { status: 401 }); + } - logger.info("Token refresh successful"); + logger.info("Token refresh successful"); - // Set HTTP-only cookie for new access token - cookies.set("access_token", result.accessToken, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: 60 * 60 * 24 * 7 // 7 days - }); + // Set HTTP-only cookie for new access token + cookies.set("access_token", result.accessToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 60 * 24 * 7, // 7 days + }); - return json({ - message: "Token refresh successful", - expiresAt: result.expiresAt.toISOString() - }); - } catch (error) { - logger.error("Token refresh error:", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ + message: "Token refresh successful", + expiresAt: result.expiresAt.toISOString(), + }); + } catch (error) { + logger.error("Token refresh error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/register/+server.ts b/src/routes/api/auth/register/+server.ts index 89beabc..197d72a 100644 --- a/src/routes/api/auth/register/+server.ts +++ b/src/routes/api/auth/register/+server.ts @@ -9,259 +9,259 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/auth/register", "POST", { - summary: "Register a new user account", - description: "Creates a new user account that requires email confirmation", - tags: ["Authentication"], - requestBody: { - description: "User registration data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { type: "string", description: "User's full name", example: "John Doe" }, - email: { - type: "string", - format: "email", - description: "User's email address", - example: "user@example.com" - }, - invite: { - type: "string", - format: "uuid", - description: "Invitation code (optional - if provided, overrides role/tenantId)", - example: "01234567-89ab-cdef-0123-456789abcdef" - }, - role: { - type: "string", - enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"], - description: "User's role in the system (ignored if invite is provided)", - example: "STAFF" - }, - tenantId: { - type: "string", - format: "uuid", - description: - "Tenant ID for TENANT_ADMIN and STAFF roles (ignored if invite is provided)", - example: "01234567-89ab-cdef-0123-456789abcdef" - }, - passphrase: { - type: "string", - minLength: 12, - description: "Optional passphrase for password authentication (min 12 chars)", - example: "my-secure-passphrase-123" - }, - passkey: { - type: "object", - description: "WebAuthn passkey data", - properties: { - id: { type: "string", description: "Credential ID from WebAuthn" }, - publicKey: { type: "string", description: "Base64 encoded public key" }, - counter: { type: "integer", description: "Signature counter", default: 0 }, - deviceName: { - type: "string", - description: "Device name for identification", - example: "MacBook Pro" - } - }, - required: ["id", "publicKey"] - } - }, - required: ["name", "email"] - } - } - } - }, - responses: { - "201": { - description: "User account created successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - userId: { type: "string", description: "Generated user ID" }, - email: { type: "string", description: "User's email address" } - }, - required: ["message", "userId", "email"] - }, - example: { - message: "User account created successfully. Please check your email for confirmation.", - userId: "01234567-89ab-cdef-0123-456789abcdef", - email: "user@example.com" - } - } - } - }, - "400": { - description: "Invalid input data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid user data" } - } - } - }, - "409": { - description: "User with this email already exists", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "A user with this email already exists" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Register a new user account", + description: "Creates a new user account that requires email confirmation", + tags: ["Authentication"], + requestBody: { + description: "User registration data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { type: "string", description: "User's full name", example: "John Doe" }, + email: { + type: "string", + format: "email", + description: "User's email address", + example: "user@example.com", + }, + invite: { + type: "string", + format: "uuid", + description: "Invitation code (optional - if provided, overrides role/tenantId)", + example: "01234567-89ab-cdef-0123-456789abcdef", + }, + role: { + type: "string", + enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"], + description: "User's role in the system (ignored if invite is provided)", + example: "STAFF", + }, + tenantId: { + type: "string", + format: "uuid", + description: + "Tenant ID for TENANT_ADMIN and STAFF roles (ignored if invite is provided)", + example: "01234567-89ab-cdef-0123-456789abcdef", + }, + passphrase: { + type: "string", + minLength: 12, + description: "Optional passphrase for password authentication (min 12 chars)", + example: "my-secure-passphrase-123", + }, + passkey: { + type: "object", + description: "WebAuthn passkey data", + properties: { + id: { type: "string", description: "Credential ID from WebAuthn" }, + publicKey: { type: "string", description: "Base64 encoded public key" }, + counter: { type: "integer", description: "Signature counter", default: 0 }, + deviceName: { + type: "string", + description: "Device name for identification", + example: "MacBook Pro", + }, + }, + required: ["id", "publicKey"], + }, + }, + required: ["name", "email"], + }, + }, + }, + }, + responses: { + "201": { + description: "User account created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + userId: { type: "string", description: "Generated user ID" }, + email: { type: "string", description: "User's email address" }, + }, + required: ["message", "userId", "email"], + }, + example: { + message: "User account created successfully. Please check your email for confirmation.", + userId: "01234567-89ab-cdef-0123-456789abcdef", + email: "user@example.com", + }, + }, + }, + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid user data" }, + }, + }, + }, + "409": { + description: "User with this email already exists", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "A user with this email already exists" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request, cookies, url }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const body = await request.json(); + try { + const body = await request.json(); - // Validate that either passphrase or passkey is provided - if (!body.passphrase && !body.passkey) { - return json({ error: "Either passphrase or passkey must be provided" }, { status: 400 }); - } + // Validate that either passphrase or passkey is provided + if (!body.passphrase && !body.passkey) { + return json({ error: "Either passphrase or passkey must be provided" }, { status: 400 }); + } - let finalRole = body.role; - let finalTenantId = body.tenantId; - let inviteUsed = null; + let finalRole = body.role; + let finalTenantId = body.tenantId; + let inviteUsed = null; - // If invite code is provided, validate and use it - if (body.invite) { - const invitation = await InviteService.getInviteByCode(body.invite); + // If invite code is provided, validate and use it + if (body.invite) { + const invitation = await InviteService.getInviteByCode(body.invite); - if (!invitation) { - return json({ error: "Invalid or expired invitation code" }, { status: 400 }); - } + if (!invitation) { + return json({ error: "Invalid or expired invitation code" }, { status: 400 }); + } - // Verify email matches invitation - if (invitation.email !== body.email) { - return json({ error: "Email address does not match invitation" }, { status: 400 }); - } + // Verify email matches invitation + if (invitation.email !== body.email) { + return json({ error: "Email address does not match invitation" }, { status: 400 }); + } - // Use invitation data instead of request body - finalRole = invitation.role; - finalTenantId = invitation.tenantId; - inviteUsed = invitation; + // Use invitation data instead of request body + finalRole = invitation.role; + finalTenantId = invitation.tenantId; + inviteUsed = invitation; - log.debug("Using invitation for registration", { - inviteCode: body.invite, - email: invitation.email, - role: invitation.role, - tenantId: invitation.tenantId - }); - } else { - // Validate that role is provided if no invite - if (!body.role) { - return json({ error: "Role is required when not using an invitation" }, { status: 400 }); - } - } + log.debug("Using invitation for registration", { + inviteCode: body.invite, + email: invitation.email, + role: invitation.role, + tenantId: invitation.tenantId, + }); + } else { + // Validate that role is provided if no invite + if (!body.role) { + return json({ error: "Role is required when not using an invitation" }, { status: 400 }); + } + } - log.debug("Creating user account", { - email: body.email, - role: finalRole, - tenantId: finalTenantId, - hasPassphrase: !!body.passphrase, - passkeyId: body.passkey?.id, - deviceName: body.passkey?.deviceName, - usingInvite: !!body.invite - }); + log.debug("Creating user account", { + email: body.email, + role: finalRole, + tenantId: finalTenantId, + hasPassphrase: !!body.passphrase, + passkeyId: body.passkey?.id, + deviceName: body.passkey?.deviceName, + usingInvite: !!body.invite, + }); - // Create user account - const user = await UserService.createUser( - { - name: body.name, - email: body.email, - role: finalRole, - tenantId: finalTenantId, - passphrase: body.passphrase, - language: inviteUsed?.language || body.language || "de" - }, - url - ); + // Create user account + const user = await UserService.createUser( + { + name: body.name, + email: body.email, + role: finalRole, + tenantId: finalTenantId, + passphrase: body.passphrase, + language: inviteUsed?.language || body.language || "de", + }, + url, + ); - // Add the passkey to the user account if provided - if (body.passkey) { - // Validate that this registration was preceded by a challenge request - const registrationEmail = cookies.get("webauthn-registration-email"); + // Add the passkey to the user account if provided + if (body.passkey) { + // Validate that this registration was preceded by a challenge request + const registrationEmail = cookies.get("webauthn-registration-email"); - if (!registrationEmail || registrationEmail !== body.email) { - return json( - { error: "Invalid passkey registration. Please request a new challenge first." }, - { status: 400 } - ); - } + if (!registrationEmail || registrationEmail !== body.email) { + return json( + { error: "Invalid passkey registration. Please request a new challenge first." }, + { status: 400 }, + ); + } - // Clear the registration cookie after validation (challenge cookie is cleared by login route) - cookies.delete("webauthn-registration-email", { path: "/" }); + // Clear the registration cookie after validation (challenge cookie is cleared by login route) + cookies.delete("webauthn-registration-email", { path: "/" }); - // Extract counter from WebAuthn credential - const counter = WebAuthnService.extractCounterFromCredential(body.passkey); + // Extract counter from WebAuthn credential + const counter = WebAuthnService.extractCounterFromCredential(body.passkey); - await UserService.addPasskey(user.id, { - id: body.passkey.id, - publicKey: body.passkey.publicKey, - counter: counter - 1, // Should start at -1, first login is counter 0 - deviceName: body.passkey.deviceName || "Unknown Device" - }); + await UserService.addPasskey(user.id, { + id: body.passkey.id, + publicKey: body.passkey.publicKey, + counter: counter - 1, // Should start at -1, first login is counter 0 + deviceName: body.passkey.deviceName || "Unknown Device", + }); - log.debug("Passkey added to user account", { - userId: user.id, - passkeyId: body.passkey.id - }); - } + log.debug("Passkey added to user account", { + userId: user.id, + passkeyId: body.passkey.id, + }); + } - // Mark invitation as used if one was provided - if (inviteUsed) { - await InviteService.markInviteAsUsed(body.invite, user.id); - log.debug("Invitation marked as used", { - inviteCode: body.invite, - userId: user.id - }); - } + // Mark invitation as used if one was provided + if (inviteUsed) { + await InviteService.markInviteAsUsed(body.invite, user.id); + log.debug("Invitation marked as used", { + inviteCode: body.invite, + userId: user.id, + }); + } - log.debug("User account and passkey created successfully", { - userId: user.id, - email: user.email, - role: user.role, - tenantId: user.tenantId, - passkeyId: body.passkey?.id, - usedInvitation: !!inviteUsed - }); + log.debug("User account and passkey created successfully", { + userId: user.id, + email: user.email, + role: user.role, + tenantId: user.tenantId, + passkeyId: body.passkey?.id, + usedInvitation: !!inviteUsed, + }); - return json( - { - message: "User account created successfully. Please check your email for confirmation.", - userId: user.id, - email: user.email - }, - { status: 201 } - ); - } catch (error) { - log.error("User registration error:", JSON.stringify(error || "?")); + return json( + { + message: "User account created successfully. Please check your email for confirmation.", + userId: user.id, + email: user.email, + }, + { status: 201 }, + ); + } catch (error) { + log.error("User registration error:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - // Handle unique constraint violation (email already exists) - if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "A user with this email already exists" }, { status: 409 }); - } + // Handle unique constraint violation (email already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "A user with this email already exists" }, { status: 409 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/resend-confirmation/+server.ts b/src/routes/api/auth/resend-confirmation/+server.ts index 8d0d288..f6c6c8b 100644 --- a/src/routes/api/auth/resend-confirmation/+server.ts +++ b/src/routes/api/auth/resend-confirmation/+server.ts @@ -7,88 +7,88 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/auth/resend-confirmation", "POST", { - summary: "Resend confirmation email", - description: "Resends the confirmation email to a user account", - tags: ["Authentication"], - requestBody: { - description: "User email address", - content: { - "application/json": { - schema: { - type: "object", - properties: { - email: { - type: "string", - format: "email", - description: "User's email address", - example: "user@example.com" - } - }, - required: ["email"] - } - } - } - }, - responses: { - "200": { - description: "Confirmation email resent successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" } - }, - required: ["message"] - }, - example: { - message: "Confirmation email resent successfully. Please check your email." - } - } - } - }, - "404": { - description: "No user found with this email address", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "No user found with this email address" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Resend confirmation email", + description: "Resends the confirmation email to a user account", + tags: ["Authentication"], + requestBody: { + description: "User email address", + content: { + "application/json": { + schema: { + type: "object", + properties: { + email: { + type: "string", + format: "email", + description: "User's email address", + example: "user@example.com", + }, + }, + required: ["email"], + }, + }, + }, + }, + responses: { + "200": { + description: "Confirmation email resent successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + example: { + message: "Confirmation email resent successfully. Please check your email.", + }, + }, + }, + }, + "404": { + description: "No user found with this email address", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "No user found with this email address" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request, url }) => { - try { - const body = await request.json(); + try { + const body = await request.json(); - // Resend confirmation email - await UserService.resendConfirmationEmail(body.email, url); + // Resend confirmation email + await UserService.resendConfirmationEmail(body.email, url); - return json( - { - message: "Confirmation email resent successfully. Please check your email." - }, - { status: 200 } - ); - } catch (error) { - const log = logger.setContext("API"); - log.error("Resend confirmation error:", JSON.stringify(error || "?")); + return json( + { + message: "Confirmation email resent successfully. Please check your email.", + }, + { status: 200 }, + ); + } catch (error) { + const log = logger.setContext("API"); + log.error("Resend confirmation error:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "No user found with this email address" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "No user found with this email address" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/session/+server.ts b/src/routes/api/auth/session/+server.ts index 3bcfae0..eee79ef 100644 --- a/src/routes/api/auth/session/+server.ts +++ b/src/routes/api/auth/session/+server.ts @@ -6,88 +6,88 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("AuthSessionAPI"); registerOpenAPIRoute("/auth/session", "GET", { - summary: "Get current session status", - description: "Retrieve current user session information and authentication status", - tags: ["Authentication"], - responses: { - "200": { - description: "Session information retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - authenticated: { type: "boolean", description: "Whether user is authenticated" }, - user: { - type: "object", - properties: { - id: { type: "string", description: "User ID" }, - email: { type: "string", description: "User email" }, - name: { type: "string", description: "User name" }, - role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, - tenantId: { type: "string", description: "Tenant ID (if applicable)" } - }, - required: ["id", "email", "name", "role"] - }, - expiresAt: { - type: "string", - format: "date-time", - description: "Session expiration time" - } - }, - required: ["authenticated"] - } - } - } - }, - "401": { - description: "Not authenticated", - content: { - "application/json": { - schema: { - type: "object", - properties: { - authenticated: { type: "boolean", example: false }, - message: { type: "string", description: "Authentication status message" } - }, - required: ["authenticated"] - } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Get current session status", + description: "Retrieve current user session information and authentication status", + tags: ["Authentication"], + responses: { + "200": { + description: "Session information retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + authenticated: { type: "boolean", description: "Whether user is authenticated" }, + user: { + type: "object", + properties: { + id: { type: "string", description: "User ID" }, + email: { type: "string", description: "User email" }, + name: { type: "string", description: "User name" }, + role: { type: "string", enum: ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] }, + tenantId: { type: "string", description: "Tenant ID (if applicable)" }, + }, + required: ["id", "email", "name", "role"], + }, + expiresAt: { + type: "string", + format: "date-time", + description: "Session expiration time", + }, + }, + required: ["authenticated"], + }, + }, + }, + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { + type: "object", + properties: { + authenticated: { type: "boolean", example: false }, + message: { type: "string", description: "Authentication status message" }, + }, + required: ["authenticated"], + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); export const GET: RequestHandler = async ({ locals }) => { - try { - // User is already authenticated via authHandle - if (!locals.user) { - return json({ authenticated: false, message: "Not authenticated" }, { status: 401 }); - } + try { + // User is already authenticated via authHandle + if (!locals.user) { + return json({ authenticated: false, message: "Not authenticated" }, { status: 401 }); + } - logger.debug("Session check", { userId: locals.user.userId }); + logger.debug("Session check", { userId: locals.user.userId }); - return json({ - authenticated: true, - user: { - id: locals.user.userId, - email: locals.user.email, - name: locals.user.name, - role: locals.user.role, - tenantId: locals.user.tenantId - }, - expiresAt: new Date(locals.user.exp ?? 0).toISOString() - }); - } catch (error) { - logger.error("Session check error:", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ + authenticated: true, + user: { + id: locals.user.userId, + email: locals.user.email, + name: locals.user.name, + role: locals.user.role, + tenantId: locals.user.tenantId, + }, + expiresAt: new Date(locals.user.exp ?? 0).toISOString(), + }); + } catch (error) { + logger.error("Session check error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/session/__tests__/server.test.ts b/src/routes/api/auth/session/__tests__/server.test.ts index f24823c..99ed6d3 100644 --- a/src/routes/api/auth/session/__tests__/server.test.ts +++ b/src/routes/api/auth/session/__tests__/server.test.ts @@ -5,209 +5,209 @@ import type { RequestEvent } from "@sveltejs/kit"; // Mock the logger vi.mock("$lib/logger", () => ({ - UniversalLogger: vi.fn(() => ({ - setContext: vi.fn(() => ({ - error: vi.fn(), - debug: vi.fn() - })) - })) + UniversalLogger: vi.fn(() => ({ + setContext: vi.fn(() => ({ + error: vi.fn(), + debug: vi.fn(), + })), + })), })); // Mock OpenAPI registration vi.mock("$lib/server/openapi", () => ({ - registerOpenAPIRoute: vi.fn() + registerOpenAPIRoute: vi.fn(), })); const createMockRequestEvent = (locals: any): RequestEvent => - ({ - locals, - request: new Request("http://localhost/api/auth/session"), - url: new URL("http://localhost/api/auth/session"), - params: {}, - route: { id: "/api/auth/session" }, - cookies: {} as any, - fetch: fetch, - getClientAddress: () => "127.0.0.1", - isDataRequest: false, - platform: undefined, - setHeaders: vi.fn(), - depends: vi.fn(), - parent: vi.fn() - }) as any; + ({ + locals, + request: new Request("http://localhost/api/auth/session"), + url: new URL("http://localhost/api/auth/session"), + params: {}, + route: { id: "/api/auth/session" }, + cookies: {} as any, + fetch: fetch, + getClientAddress: () => "127.0.0.1", + isDataRequest: false, + platform: undefined, + setHeaders: vi.fn(), + depends: vi.fn(), + parent: vi.fn(), + }) as any; describe("/api/auth/session GET endpoint", () => { - it("should return user session data with correct exp field when user is authenticated", async () => { - const mockUser = { - userId: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123", - sessionId: "session-123", - exp: 1640995200000, // timestamp in milliseconds - isActive: true, - confirmed: true - }; + it("should return user session data with correct exp field when user is authenticated", async () => { + const mockUser = { + userId: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + sessionId: "session-123", + exp: 1640995200000, // timestamp in milliseconds + isActive: true, + confirmed: true, + }; - const event = createMockRequestEvent({ user: mockUser }); - const response = await GET(event as any); - const data = await response.json(); + const event = createMockRequestEvent({ user: mockUser }); + const response = await GET(event as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data).toEqual({ - authenticated: true, - user: { - id: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123" - }, - expiresAt: new Date(1640995200000).toISOString() - }); - }); + expect(response.status).toBe(200); + expect(data).toEqual({ + authenticated: true, + user: { + id: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + }, + expiresAt: new Date(1640995200000).toISOString(), + }); + }); - it("should handle exp as undefined gracefully", async () => { - const mockUser = { - userId: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123", - sessionId: "session-123", - exp: undefined, // Test the null coalescing operator - isActive: true, - confirmed: true - }; + it("should handle exp as undefined gracefully", async () => { + const mockUser = { + userId: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + sessionId: "session-123", + exp: undefined, // Test the null coalescing operator + isActive: true, + confirmed: true, + }; - const event = createMockRequestEvent({ user: mockUser }); - const response = await GET(event as any); - const data = await response.json(); + const event = createMockRequestEvent({ user: mockUser }); + const response = await GET(event as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data).toEqual({ - authenticated: true, - user: { - id: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123" - }, - expiresAt: new Date(0).toISOString() // Should default to 0 - }); - }); + expect(response.status).toBe(200); + expect(data).toEqual({ + authenticated: true, + user: { + id: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + }, + expiresAt: new Date(0).toISOString(), // Should default to 0 + }); + }); - it("should handle exp as null gracefully", async () => { - const mockUser = { - userId: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123", - sessionId: "session-123", - exp: null, // Test the null coalescing operator - isActive: true, - confirmed: true - }; + it("should handle exp as null gracefully", async () => { + const mockUser = { + userId: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + sessionId: "session-123", + exp: null, // Test the null coalescing operator + isActive: true, + confirmed: true, + }; - const event = createMockRequestEvent({ user: mockUser }); - const response = await GET(event as any); - const data = await response.json(); + const event = createMockRequestEvent({ user: mockUser }); + const response = await GET(event as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data).toEqual({ - authenticated: true, - user: { - id: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123" - }, - expiresAt: new Date(0).toISOString() // Should default to 0 - }); - }); + expect(response.status).toBe(200); + expect(data).toEqual({ + authenticated: true, + user: { + id: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + }, + expiresAt: new Date(0).toISOString(), // Should default to 0 + }); + }); - it("should return 401 when user is not authenticated", async () => { - const event = createMockRequestEvent({ user: null }); - const response = await GET(event as any); + it("should return 401 when user is not authenticated", async () => { + const event = createMockRequestEvent({ user: null }); + const response = await GET(event as any); - expect(response.status).toBe(401); - const data = await response.json(); - expect(data).toEqual({ - authenticated: false, - message: "Not authenticated" - }); - }); + expect(response.status).toBe(401); + const data = await response.json(); + expect(data).toEqual({ + authenticated: false, + message: "Not authenticated", + }); + }); - it("should handle global admin without tenantId", async () => { - const mockGlobalAdmin = { - userId: "admin-123", - email: "admin@example.com", - name: "Global Admin", - role: "GLOBAL_ADMIN", - tenantId: undefined, // Global admin has no tenant - sessionId: "session-123", - exp: 1640995200000, - isActive: true, - confirmed: true - }; + it("should handle global admin without tenantId", async () => { + const mockGlobalAdmin = { + userId: "admin-123", + email: "admin@example.com", + name: "Global Admin", + role: "GLOBAL_ADMIN", + tenantId: undefined, // Global admin has no tenant + sessionId: "session-123", + exp: 1640995200000, + isActive: true, + confirmed: true, + }; - const event = createMockRequestEvent({ user: mockGlobalAdmin }); - const response = await GET(event as any); - const data = await response.json(); + const event = createMockRequestEvent({ user: mockGlobalAdmin }); + const response = await GET(event as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data).toEqual({ - authenticated: true, - user: { - id: "admin-123", - email: "admin@example.com", - name: "Global Admin", - role: "GLOBAL_ADMIN", - tenantId: undefined - }, - expiresAt: new Date(1640995200000).toISOString() - }); - }); + expect(response.status).toBe(200); + expect(data).toEqual({ + authenticated: true, + user: { + id: "admin-123", + email: "admin@example.com", + name: "Global Admin", + role: "GLOBAL_ADMIN", + tenantId: undefined, + }, + expiresAt: new Date(1640995200000).toISOString(), + }); + }); - it("should handle exp as 0 (epoch time)", async () => { - const mockUser = { - userId: "user-123", - email: "test@example.com", - name: "Test User", - role: "STAFF", - tenantId: "tenant-123", - sessionId: "session-123", - exp: 0, // Epoch time - isActive: true, - confirmed: true - }; + it("should handle exp as 0 (epoch time)", async () => { + const mockUser = { + userId: "user-123", + email: "test@example.com", + name: "Test User", + role: "STAFF", + tenantId: "tenant-123", + sessionId: "session-123", + exp: 0, // Epoch time + isActive: true, + confirmed: true, + }; - const event = createMockRequestEvent({ user: mockUser }); - const response = await GET(event as any); - const data = await response.json(); + const event = createMockRequestEvent({ user: mockUser }); + const response = await GET(event as any); + const data = await response.json(); - expect(response.status).toBe(200); - expect(data.expiresAt).toBe(new Date(0).toISOString()); - }); + expect(response.status).toBe(200); + expect(data.expiresAt).toBe(new Date(0).toISOString()); + }); - it("should return 500 when an error occurs", async () => { - // Create a mock that throws an error when accessing properties - const problematicLocals = { - get user() { - throw new Error("Database connection failed"); - } - }; + it("should return 500 when an error occurs", async () => { + // Create a mock that throws an error when accessing properties + const problematicLocals = { + get user() { + throw new Error("Database connection failed"); + }, + }; - const event = createMockRequestEvent(problematicLocals); - const response = await GET(event as any); + const event = createMockRequestEvent(problematicLocals); + const response = await GET(event as any); - expect(response.status).toBe(500); - const data = await response.json(); - expect(data).toEqual({ - error: "Internal server error" - }); - }); + expect(response.status).toBe(500); + const data = await response.json(); + expect(data).toEqual({ + error: "Internal server error", + }); + }); }); diff --git a/src/routes/api/auth/sessions/+server.ts b/src/routes/api/auth/sessions/+server.ts index aee2c77..e2653e9 100644 --- a/src/routes/api/auth/sessions/+server.ts +++ b/src/routes/api/auth/sessions/+server.ts @@ -7,164 +7,167 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("AuthSessionsAPI"); registerOpenAPIRoute("/auth/sessions", "GET", { - summary: "Get all active sessions", - description: "Retrieve all active sessions for the current user", - tags: ["Authentication"], - responses: { - "200": { - description: "Active sessions retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - sessions: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", description: "Session ID" }, - ipAddress: { type: "string", description: "IP address of session" }, - userAgent: { type: "string", description: "User agent string" }, - createdAt: { - type: "string", - format: "date-time", - description: "Session creation time" - }, - lastUsedAt: { - type: "string", - format: "date-time", - description: "Last activity time" - }, - expiresAt: { - type: "string", - format: "date-time", - description: "Session expiration time" - }, - current: { type: "boolean", description: "Whether this is the current session" } - }, - required: ["id", "createdAt", "lastUsedAt", "expiresAt", "current"] - } - } - }, - required: ["sessions"] - } - } - } - }, - "401": { - description: "Not authenticated", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Get all active sessions", + description: "Retrieve all active sessions for the current user", + tags: ["Authentication"], + responses: { + "200": { + description: "Active sessions retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + sessions: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", description: "Session ID" }, + ipAddress: { type: "string", description: "IP address of session" }, + userAgent: { type: "string", description: "User agent string" }, + createdAt: { + type: "string", + format: "date-time", + description: "Session creation time", + }, + lastUsedAt: { + type: "string", + format: "date-time", + description: "Last activity time", + }, + expiresAt: { + type: "string", + format: "date-time", + description: "Session expiration time", + }, + current: { + type: "boolean", + description: "Whether this is the current session", + }, + }, + required: ["id", "createdAt", "lastUsedAt", "expiresAt", "current"], + }, + }, + }, + required: ["sessions"], + }, + }, + }, + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); registerOpenAPIRoute("/auth/sessions", "DELETE", { - summary: "Logout all sessions", - description: "Invalidate all active sessions for the current user", - tags: ["Authentication"], - responses: { - "200": { - description: "All sessions logged out successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" } - }, - required: ["message"] - } - } - } - }, - "401": { - description: "Not authenticated", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Logout all sessions", + description: "Invalidate all active sessions for the current user", + tags: ["Authentication"], + responses: { + "200": { + description: "All sessions logged out successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "401": { + description: "Not authenticated", + 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 ({ locals }) => { - try { - if (!locals.user) { - return json({ error: "Not authenticated" }, { status: 401 }); - } + try { + if (!locals.user) { + return json({ error: "Not authenticated" }, { status: 401 }); + } - // Get all active sessions for the user from database - const activeSessions = await SessionService.getActiveSessions(locals.user.userId); + // Get all active sessions for the user from database + const activeSessions = await SessionService.getActiveSessions(locals.user.userId); - // Get current session ID from access token - const currentSessionId = locals.user.sessionId; + // Get current session ID from access token + const currentSessionId = locals.user.sessionId; - const sessions = activeSessions.map((session) => ({ - id: session.id, - ipAddress: session.ipAddress || "unknown", - userAgent: session.userAgent || "unknown", - createdAt: session.createdAt?.toISOString() || new Date().toISOString(), - lastUsedAt: session.lastUsedAt?.toISOString() || new Date().toISOString(), - expiresAt: session.expiresAt.toISOString(), - current: session.id === currentSessionId - })); + const sessions = activeSessions.map((session) => ({ + id: session.id, + ipAddress: session.ipAddress || "unknown", + userAgent: session.userAgent || "unknown", + createdAt: session.createdAt?.toISOString() || new Date().toISOString(), + lastUsedAt: session.lastUsedAt?.toISOString() || new Date().toISOString(), + expiresAt: session.expiresAt.toISOString(), + current: session.id === currentSessionId, + })); - logger.debug("Active sessions retrieved from database", { - userId: locals.user.id, - sessionCount: sessions.length - }); + logger.debug("Active sessions retrieved from database", { + userId: locals.user.id, + sessionCount: sessions.length, + }); - return json({ sessions }); - } catch (error) { - logger.error("Get sessions error:", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ sessions }); + } catch (error) { + logger.error("Get sessions error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const DELETE: RequestHandler = async ({ locals, cookies }) => { - try { - if (!locals.user) { - return json({ error: "Not authenticated" }, { status: 401 }); - } + try { + if (!locals.user) { + return json({ error: "Not authenticated" }, { status: 401 }); + } - // Logout all sessions for the user - await SessionService.logoutAllSessions(locals.user.userId); + // Logout all sessions for the user + await SessionService.logoutAllSessions(locals.user.userId); - // Clear the current access token cookie - cookies.delete("access_token", { - path: "/", - httpOnly: true, - secure: true, - sameSite: "strict" - }); + // Clear the current access token cookie + cookies.delete("access_token", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict", + }); - logger.info("All sessions logged out", { userId: locals.user.id }); + logger.info("All sessions logged out", { userId: locals.user.id }); - return json({ message: "Logged out successfully" }); - } catch (error) { - logger.error("Logout all sessions error:", { error: String(error) }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ message: "Logged out successfully" }); + } catch (error) { + logger.error("Logout all sessions error:", { error: String(error) }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/auth/sessions/[id]/+server.ts b/src/routes/api/auth/sessions/[id]/+server.ts index 6361446..3bb4374 100644 --- a/src/routes/api/auth/sessions/[id]/+server.ts +++ b/src/routes/api/auth/sessions/[id]/+server.ts @@ -7,134 +7,134 @@ import { UniversalLogger } from "$lib/logger"; const logger = new UniversalLogger().setContext("AuthSessionAPI"); registerOpenAPIRoute("/auth/sessions/{id}", "DELETE", { - summary: "Revoke specific session", - description: - "Revoke a specific session by ID. Global admins can revoke any session, regular users can only revoke their own sessions.", - tags: ["Authentication"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Session ID to revoke" - } - ], - responses: { - "200": { - description: "Session revoked successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" } - }, - required: ["message"] - } - } - } - }, - "401": { - description: "Not authenticated", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "403": { - description: "Insufficient permissions", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "404": { - description: "Session not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Revoke specific session", + description: + "Revoke a specific session by ID. Global admins can revoke any session, regular users can only revoke their own sessions.", + tags: ["Authentication"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Session ID to revoke", + }, + ], + responses: { + "200": { + description: "Session revoked successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "401": { + description: "Not authenticated", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Insufficient permissions", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "404": { + description: "Session 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, cookies }) => { - try { - const sessionId = params.id; + try { + const sessionId = params.id; - if (!locals.user) { - return json({ error: "Not authenticated" }, { status: 401 }); - } + if (!locals.user) { + return json({ error: "Not authenticated" }, { status: 401 }); + } - if (!sessionId) { - return json({ error: "Session ID is required" }, { status: 400 }); - } + if (!sessionId) { + return json({ error: "Session ID is required" }, { status: 400 }); + } - logger.debug("Revoking session", { - sessionId, - requestedBy: locals.user.userId, - userRole: locals.user.role - }); + logger.debug("Revoking session", { + sessionId, + requestedBy: locals.user.userId, + userRole: locals.user.role, + }); - // Get current session ID from locals (set by auth middleware) - const currentSessionId = locals.user.sessionId; + // Get current session ID from locals (set by auth middleware) + const currentSessionId = locals.user.sessionId; - // Check if user is trying to revoke their own session or if they're a global admin - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admins can revoke any session - await SessionService.revokeSession(sessionId); - } else { - // Regular users can only revoke their own sessions - // First, get all user's sessions to verify ownership - const userSessions = await SessionService.getActiveSessions(locals.user.userId); - const sessionToRevoke = userSessions.find((session) => session.id === sessionId); + // Check if user is trying to revoke their own session or if they're a global admin + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admins can revoke any session + await SessionService.revokeSession(sessionId); + } else { + // Regular users can only revoke their own sessions + // First, get all user's sessions to verify ownership + const userSessions = await SessionService.getActiveSessions(locals.user.userId); + const sessionToRevoke = userSessions.find((session) => session.id === sessionId); - if (!sessionToRevoke) { - return json({ error: "Session not found or access denied" }, { status: 404 }); - } + if (!sessionToRevoke) { + return json({ error: "Session not found or access denied" }, { status: 404 }); + } - await SessionService.revokeSession(sessionId); - } + await SessionService.revokeSession(sessionId); + } - // If the user revoked their current session, clear the cookie - if (sessionId === currentSessionId) { - cookies.delete("access_token", { - path: "/", - httpOnly: true, - secure: true, - sameSite: "strict" - }); - logger.info("Current session revoked, cookie cleared", { - sessionId, - userId: locals.user.userId - }); - } + // If the user revoked their current session, clear the cookie + if (sessionId === currentSessionId) { + cookies.delete("access_token", { + path: "/", + httpOnly: true, + secure: true, + sameSite: "strict", + }); + logger.info("Current session revoked, cookie cleared", { + sessionId, + userId: locals.user.userId, + }); + } - logger.info("Session revoked successfully", { - sessionId, - revokedBy: locals.user.userId, - wasCurrent: sessionId === currentSessionId - }); + logger.info("Session revoked successfully", { + sessionId, + revokedBy: locals.user.userId, + wasCurrent: sessionId === currentSessionId, + }); - return json({ message: "Session revoked successfully" }); - } catch (error) { - logger.error("Revoke session error:", { - error: String(error), - sessionId: params.id, - userId: locals.user?.userId - }); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ message: "Session revoked successfully" }); + } catch (error) { + logger.error("Revoke session error:", { + error: String(error), + sessionId: params.id, + userId: locals.user?.userId, + }); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/docs/+server.ts b/src/routes/api/docs/+server.ts index 889c8f4..05b4c8e 100644 --- a/src/routes/api/docs/+server.ts +++ b/src/routes/api/docs/+server.ts @@ -1,5 +1,5 @@ export async function GET() { - const html = ` + const html = ` @@ -52,9 +52,9 @@ export async function GET() { `; - return new Response(html, { - headers: { - "Content-Type": "text/html" - } - }); + return new Response(html, { + headers: { + "Content-Type": "text/html", + }, + }); } diff --git a/src/routes/api/env/+server.ts b/src/routes/api/env/+server.ts index 8f8699f..28465df 100644 --- a/src/routes/api/env/+server.ts +++ b/src/routes/api/env/+server.ts @@ -6,14 +6,14 @@ const cfg = dotenv.config(); dotenvExpand.expand(cfg); export async function GET() { - let envOkay = true; - if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "development") - envOkay = false; - if (!process.env.DATABASE_URL?.startsWith("postgres:")) envOkay = false; + let envOkay = true; + if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "development") + envOkay = false; + if (!process.env.DATABASE_URL?.startsWith("postgres:")) envOkay = false; - console.warn("Environment is okay", envOkay); + console.warn("Environment is okay", envOkay); - return json({ - envOkay - }); + return json({ + envOkay, + }); } diff --git a/src/routes/api/health/+server.ts b/src/routes/api/health/+server.ts index 4162e15..7ff14c1 100644 --- a/src/routes/api/health/+server.ts +++ b/src/routes/api/health/+server.ts @@ -1,5 +1,5 @@ import { json } from "@sveltejs/kit"; export async function GET() { - return json(true); + return json(true); } diff --git a/src/routes/api/health/services/+server.ts b/src/routes/api/health/services/+server.ts index 273d2ad..da4a6eb 100644 --- a/src/routes/api/health/services/+server.ts +++ b/src/routes/api/health/services/+server.ts @@ -5,57 +5,57 @@ import os from "os"; import { registerOpenAPIRoute } from "$lib/server/openapi"; registerOpenAPIRoute("/health/services", "GET", { - summary: "Get service health status", - description: - "Returns the health status of core services including database connectivity, memory usage, and CPU load", - tags: ["Health"], - responses: { - "200": { - description: "Service health status", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/HealthStatus" }, - example: { - core: true, - database: true, - memory: 2048, - load: 0.75 - } - } - } - }, - "429": { - description: "Too Many Requests - Rate limit exceeded", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Too Many Requests" - } - } - }, - "500": { - description: "Internal Server Error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Get service health status", + description: + "Returns the health status of core services including database connectivity, memory usage, and CPU load", + tags: ["Health"], + responses: { + "200": { + description: "Service health status", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/HealthStatus" }, + example: { + core: true, + database: true, + memory: 2048, + load: 0.75, + }, + }, + }, + }, + "429": { + description: "Too Many Requests - Rate limit exceeded", + content: { + "text/plain": { + schema: { type: "string" }, + example: "Too Many Requests", + }, + }, + }, + "500": { + description: "Internal Server Error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); /** * Represents the health status response for core services */ class ServiceHealthResponse { - /** Core service availability status */ - core: boolean = true; - /** Database connectivity status */ - database: boolean = false; - /** Available memory in MB */ - memory: number = 0; - /** Current CPU load average */ - load: number = 0; + /** Core service availability status */ + core: boolean = true; + /** Database connectivity status */ + database: boolean = false; + /** Available memory in MB */ + memory: number = 0; + /** Current CPU load average */ + load: number = 0; } /** @@ -69,19 +69,19 @@ class ServiceHealthResponse { * @returns {Response} JSON response containing service health status */ export async function GET() { - const serviceHealth = new ServiceHealthResponse(); + const serviceHealth = new ServiceHealthResponse(); - try { - await db.execute(sql`SELECT 1`); - serviceHealth.database = true; - } catch { - serviceHealth.database = false; - } + try { + await db.execute(sql`SELECT 1`); + serviceHealth.database = true; + } catch { + serviceHealth.database = false; + } - serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024); + serviceHealth.memory = Math.round(os.freemem() / 1024 / 1024); - const loadAvg = os.loadavg(); - serviceHealth.load = Math.round(loadAvg[0] * 100) / 100; + const loadAvg = os.loadavg(); + serviceHealth.load = Math.round(loadAvg[0] * 100) / 100; - return json(serviceHealth); + return json(serviceHealth); } diff --git a/src/routes/api/health/services/server.test.ts b/src/routes/api/health/services/server.test.ts index 018d688..23c3067 100644 --- a/src/routes/api/health/services/server.test.ts +++ b/src/routes/api/health/services/server.test.ts @@ -4,124 +4,124 @@ import { GET } from "./+server.js"; // Mock the database module vi.mock("$lib/server/db", () => ({ - db: { - execute: vi.fn() - } + db: { + execute: vi.fn(), + }, })); // Mock the OpenAPI registration vi.mock("$lib/server/openapi", () => ({ - registerOpenAPIRoute: vi.fn() + registerOpenAPIRoute: vi.fn(), })); // Mock os module vi.mock("os", () => ({ - default: { - freemem: vi.fn(), - loadavg: vi.fn() - } + default: { + freemem: vi.fn(), + loadavg: vi.fn(), + }, })); // Mock SvelteKit json helper vi.mock("@sveltejs/kit", () => ({ - json: vi.fn((data) => ({ - json: () => Promise.resolve(data), - data - })) + json: vi.fn((data) => ({ + json: () => Promise.resolve(data), + data, + })), })); describe("/api/health/services", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - it("should return healthy status when database connection succeeds", async () => { - const { db } = await import("$lib/server/db"); - const os = (await import("os")).default; + it("should return healthy status when database connection succeeds", async () => { + const { db } = await import("$lib/server/db"); + const os = (await import("os")).default; - // Mock successful database connection - vi.mocked(db.execute).mockResolvedValue([] as any); + // Mock successful database connection + vi.mocked(db.execute).mockResolvedValue([] as any); - // Mock system metrics - vi.mocked(os.freemem).mockReturnValue(2048 * 1024 * 1024); // 2048 MB in bytes - vi.mocked(os.loadavg).mockReturnValue([0.75, 0.5, 0.3]); + // Mock system metrics + vi.mocked(os.freemem).mockReturnValue(2048 * 1024 * 1024); // 2048 MB in bytes + vi.mocked(os.loadavg).mockReturnValue([0.75, 0.5, 0.3]); - const response = await GET(); - const data = await response.json(); + const response = await GET(); + const data = await response.json(); - expect(data).toEqual({ - core: true, - database: true, - memory: 2048, - load: 0.75 - }); - }); + expect(data).toEqual({ + core: true, + database: true, + memory: 2048, + load: 0.75, + }); + }); - it("should return unhealthy database status when connection fails", async () => { - const { db } = await import("$lib/server/db"); - const os = (await import("os")).default; + it("should return unhealthy database status when connection fails", async () => { + const { db } = await import("$lib/server/db"); + const os = (await import("os")).default; - // Mock failed database connection - vi.mocked(db.execute).mockRejectedValue(new Error("Connection failed")); + // Mock failed database connection + vi.mocked(db.execute).mockRejectedValue(new Error("Connection failed")); - // Mock system metrics - vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024); // 1024 MB in bytes - vi.mocked(os.loadavg).mockReturnValue([1.25, 1.0, 0.8]); + // Mock system metrics + vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024); // 1024 MB in bytes + vi.mocked(os.loadavg).mockReturnValue([1.25, 1.0, 0.8]); - const response = await GET(); - const data = await response.json(); + const response = await GET(); + const data = await response.json(); - expect(data).toEqual({ - core: true, - database: false, - memory: 1024, - load: 1.25 - }); - }); + expect(data).toEqual({ + core: true, + database: false, + memory: 1024, + load: 1.25, + }); + }); - it("should calculate memory in MB correctly", async () => { - const { db } = await import("$lib/server/db"); - const os = (await import("os")).default; + it("should calculate memory in MB correctly", async () => { + const { db } = await import("$lib/server/db"); + const os = (await import("os")).default; - vi.mocked(db.execute).mockResolvedValue([] as any); + vi.mocked(db.execute).mockResolvedValue([] as any); - // Test different memory values - vi.mocked(os.freemem).mockReturnValue(512 * 1024 * 1024); // 512 MB in bytes - vi.mocked(os.loadavg).mockReturnValue([0.1, 0.1, 0.1]); + // Test different memory values + vi.mocked(os.freemem).mockReturnValue(512 * 1024 * 1024); // 512 MB in bytes + vi.mocked(os.loadavg).mockReturnValue([0.1, 0.1, 0.1]); - const response = await GET(); - const data = await response.json(); + const response = await GET(); + const data = await response.json(); - expect(data.memory).toBe(512); - }); + expect(data.memory).toBe(512); + }); - it("should round load average to 2 decimal places", async () => { - const { db } = await import("$lib/server/db"); - const os = (await import("os")).default; + it("should round load average to 2 decimal places", async () => { + const { db } = await import("$lib/server/db"); + const os = (await import("os")).default; - vi.mocked(db.execute).mockResolvedValue([] as any); - vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024); + vi.mocked(db.execute).mockResolvedValue([] as any); + vi.mocked(os.freemem).mockReturnValue(1024 * 1024 * 1024); - // Test load average rounding - vi.mocked(os.loadavg).mockReturnValue([1.23456, 0.5, 0.3]); + // Test load average rounding + vi.mocked(os.loadavg).mockReturnValue([1.23456, 0.5, 0.3]); - const response = await GET(); - const data = await response.json(); + const response = await GET(); + const data = await response.json(); - expect(data.load).toBe(1.23); - }); + expect(data.load).toBe(1.23); + }); - it("should always return core as true", async () => { - const { db } = await import("$lib/server/db"); - const os = (await import("os")).default; + it("should always return core as true", async () => { + const { db } = await import("$lib/server/db"); + const os = (await import("os")).default; - vi.mocked(db.execute).mockRejectedValue(new Error("Database error")); - vi.mocked(os.freemem).mockReturnValue(0); - vi.mocked(os.loadavg).mockReturnValue([10.0, 5.0, 2.0]); + vi.mocked(db.execute).mockRejectedValue(new Error("Database error")); + vi.mocked(os.freemem).mockReturnValue(0); + vi.mocked(os.loadavg).mockReturnValue([10.0, 5.0, 2.0]); - const response = await GET(); - const data = await response.json(); + const response = await GET(); + const data = await response.json(); - expect(data.core).toBe(true); - }); + expect(data.core).toBe(true); + }); }); diff --git a/src/routes/api/log/+server.ts b/src/routes/api/log/+server.ts index aafe0c3..3bb4457 100644 --- a/src/routes/api/log/+server.ts +++ b/src/routes/api/log/+server.ts @@ -2,35 +2,35 @@ import { json } from "@sveltejs/kit"; import { logger } from "$lib/logger"; export async function POST({ request }) { - try { - const { level, message, meta } = await request.json(); + try { + const { level, message, meta } = await request.json(); - const clientLogger = logger.setContext("CLIENT"); + const clientLogger = logger.setContext("CLIENT"); - switch (level) { - case "debug": - clientLogger.debug(message, meta); - break; - case "info": - clientLogger.info(message, meta); - break; - case "warn": - clientLogger.warn(message, meta); - break; - case "error": - clientLogger.error(message, meta); - break; - default: - clientLogger.info(message, meta); - } + switch (level) { + case "debug": + clientLogger.debug(message, meta); + break; + case "info": + clientLogger.info(message, meta); + break; + case "warn": + clientLogger.warn(message, meta); + break; + case "error": + clientLogger.error(message, meta); + break; + default: + clientLogger.info(message, meta); + } - return json({ success: true }); - } catch (error: unknown) { - if (error instanceof Error) { - logger.error("Failed to process client log", { error: error?.message ?? "Unknown error" }); - } else { - logger.error("Unknown error on processing client error message"); - } - return json({ success: false }, { status: 500 }); - } + return json({ success: true }); + } catch (error: unknown) { + if (error instanceof Error) { + logger.error("Failed to process client log", { error: error?.message ?? "Unknown error" }); + } else { + logger.error("Unknown error on processing client error message"); + } + return json({ success: false }, { status: 500 }); + } } diff --git a/src/routes/api/openapi.json/+server.ts b/src/routes/api/openapi.json/+server.ts index 01e8ec4..f310e47 100644 --- a/src/routes/api/openapi.json/+server.ts +++ b/src/routes/api/openapi.json/+server.ts @@ -31,6 +31,6 @@ import "../tenants/[id]/+server.js"; import "../tenants/[id]/config/+server.js"; export async function GET() { - const spec = generateOpenApiSpec(); - return json(spec); + const spec = generateOpenApiSpec(); + return json(spec); } diff --git a/src/routes/api/tenants/+server.ts b/src/routes/api/tenants/+server.ts index 448d0e1..38a3d5d 100644 --- a/src/routes/api/tenants/+server.ts +++ b/src/routes/api/tenants/+server.ts @@ -9,226 +9,226 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/tenants", "POST", { - summary: "Create a new tenant", - description: "Creates a new tenant with initial configuration", - tags: ["Tenants"], - requestBody: { - description: "Tenant creation data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - shortName: { - type: "string", - minLength: 4, - maxLength: 15, - description: "Short name for the tenant (4-15 characters)", - example: "acme-corp" - }, - inviteAdmin: { - type: "string", - format: "email", - description: "Email address to invite as tenant admin", - example: "admin@acme-corp.com" - } - }, - required: ["shortName"] - } - } - } - }, - responses: { - "201": { - description: "Tenant created successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - tenantId: { type: "string", description: "Generated tenant ID" }, - shortName: { type: "string", description: "Tenant short name" } - }, - required: ["message", "tenantId", "shortName"] - }, - example: { - message: "Tenant created successfully", - tenantId: "01234567-89ab-cdef-0123-456789abcdef", - shortName: "acme-corp" - } - } - } - }, - "400": { - description: "Invalid input data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid tenant creation request" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Create a new tenant", + description: "Creates a new tenant with initial configuration", + tags: ["Tenants"], + requestBody: { + description: "Tenant creation data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + shortName: { + type: "string", + minLength: 4, + maxLength: 15, + description: "Short name for the tenant (4-15 characters)", + example: "acme-corp", + }, + inviteAdmin: { + type: "string", + format: "email", + description: "Email address to invite as tenant admin", + example: "admin@acme-corp.com", + }, + }, + required: ["shortName"], + }, + }, + }, + }, + responses: { + "201": { + description: "Tenant created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + tenantId: { type: "string", description: "Generated tenant ID" }, + shortName: { type: "string", description: "Tenant short name" }, + }, + required: ["message", "tenantId", "shortName"], + }, + example: { + message: "Tenant created successfully", + tenantId: "01234567-89ab-cdef-0123-456789abcdef", + shortName: "acme-corp", + }, + }, + }, + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid tenant creation request" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants", "GET", { - summary: "Get all tenant IDs", - description: "Returns a list of all tenant IDs and basic information", - tags: ["Tenants"], - responses: { - "200": { - description: "List of all tenants", - content: { - "application/json": { - schema: { - type: "object", - properties: { - tenants: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Tenant ID" }, - shortName: { type: "string", description: "Tenant short name" }, - longName: { type: "string", description: "Tenant long name" }, - setupState: { - type: "string", - enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], - description: "Current setup state" - } - }, - required: ["id", "shortName", "setupState"] - } - } - }, - required: ["tenants"] - }, - example: { - tenants: [ - { - id: "01234567-89ab-cdef-0123-456789abcdef", - shortName: "acme-corp", - longName: "ACME Corporation", - setupState: "FIRST_CHANNEL_CREATED" - } - ] - } - } - } - }, - "403": { - description: "Insufficient permissions (Global Admin required)", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Get all tenant IDs", + description: "Returns a list of all tenant IDs and basic information", + tags: ["Tenants"], + responses: { + "200": { + description: "List of all tenants", + content: { + "application/json": { + schema: { + type: "object", + properties: { + tenants: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Tenant ID" }, + shortName: { type: "string", description: "Tenant short name" }, + longName: { type: "string", description: "Tenant long name" }, + setupState: { + type: "string", + enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], + description: "Current setup state", + }, + }, + required: ["id", "shortName", "setupState"], + }, + }, + }, + required: ["tenants"], + }, + example: { + tenants: [ + { + id: "01234567-89ab-cdef-0123-456789abcdef", + shortName: "acme-corp", + longName: "ACME Corporation", + setupState: "FIRST_CHANNEL_CREATED", + }, + ], + }, + }, + }, + }, + "403": { + description: "Insufficient permissions (Global Admin required)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, }); export const POST: RequestHandler = async ({ request }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const body = await request.json(); + try { + const body = await request.json(); - log.debug("Creating tenant", { - shortName: body.shortName, - hasInviteAdmin: !!body.inviteAdmin - }); + log.debug("Creating tenant", { + shortName: body.shortName, + hasInviteAdmin: !!body.inviteAdmin, + }); - const tenantService = await TenantAdminService.createTenant({ - shortName: body.shortName, - inviteAdmin: body.inviteAdmin - }); + const tenantService = await TenantAdminService.createTenant({ + shortName: body.shortName, + inviteAdmin: body.inviteAdmin, + }); - log.debug("Tenant created successfully", { - tenantId: tenantService.tenantId, - shortName: body.shortName - }); + log.debug("Tenant created successfully", { + tenantId: tenantService.tenantId, + shortName: body.shortName, + }); - return json( - { - message: "Tenant created successfully", - tenantId: tenantService.tenantId, - shortName: body.shortName - }, - { status: 201 } - ); - } catch (error) { - log.error("Tenant creation error:", JSON.stringify(error || "?")); + return json( + { + message: "Tenant created successfully", + tenantId: tenantService.tenantId, + shortName: body.shortName, + }, + { status: 201 }, + ); + } catch (error) { + log.error("Tenant creation error:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - // Handle unique constraint violation (shortName already exists) - if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "A tenant with this short name already exists" }, { status: 409 }); - } + // Handle unique constraint violation (shortName already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "A tenant with this short name already exists" }, { status: 409 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const GET: RequestHandler = async ({ locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - // Check if user is authenticated and is a global admin - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + try { + // Check if user is authenticated and is a global admin + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (locals.user.role !== "GLOBAL_ADMIN") { - return json({ error: "Global admin access required" }, { status: 403 }); - } + if (locals.user.role !== "GLOBAL_ADMIN") { + return json({ error: "Global admin access required" }, { status: 403 }); + } - log.debug("Getting all tenants", { - requestedBy: locals.user.userId - }); + log.debug("Getting all tenants", { + requestedBy: locals.user.userId, + }); - // Get all tenants from database - const tenants = await db - .select({ - id: tenant.id, - shortName: tenant.shortName, - longName: tenant.longName, - setupState: tenant.setupState - }) - .from(tenant) - .orderBy(tenant.shortName); + // Get all tenants from database + const tenants = await db + .select({ + id: tenant.id, + shortName: tenant.shortName, + longName: tenant.longName, + setupState: tenant.setupState, + }) + .from(tenant) + .orderBy(tenant.shortName); - log.debug("Retrieved tenants successfully", { - tenantCount: tenants.length, - requestedBy: locals.user.userId - }); + log.debug("Retrieved tenants successfully", { + tenantCount: tenants.length, + requestedBy: locals.user.userId, + }); - return json({ - tenants: tenants - }); - } catch (error) { - log.error("Failed to get tenants:", JSON.stringify(error || "?")); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ + tenants: tenants, + }); + } catch (error) { + log.error("Failed to get tenants:", JSON.stringify(error || "?")); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/+server.ts b/src/routes/api/tenants/[id]/+server.ts index acc761a..8674620 100644 --- a/src/routes/api/tenants/[id]/+server.ts +++ b/src/routes/api/tenants/[id]/+server.ts @@ -7,325 +7,325 @@ import logger from "$lib/logger"; // Register OpenAPI documentation for PUT registerOpenAPIRoute("/tenants/{id}", "PUT", { - summary: "Update tenant metadata", - description: - "Updates the metadata for a specific tenant (longName, shortName, description, logo)", - tags: ["Tenants"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string" }, - description: "Tenant ID" - } - ], - requestBody: { - description: "Tenant metadata updates", - content: { - "application/json": { - schema: { - type: "object", - properties: { - longName: { - type: "string", - description: "Full name of the tenant organization", - example: "ACME Corporation" - }, - shortName: { - type: "string", - minLength: 4, - maxLength: 15, - description: "Short name for the tenant (4-15 characters)", - example: "acme-corp" - }, - description: { - type: "string", - description: "Description of the tenant organization", - example: "Leading provider of innovative solutions" - }, - logo: { - type: "string", - description: "URL or base64 encoded logo image", - example: "https://example.com/logo.png" - } - } - } - } - } - }, - responses: { - "200": { - description: "Tenant metadata updated successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - tenant: { - type: "object", - properties: { - id: { type: "string", description: "Tenant ID" }, - shortName: { type: "string", description: "Short name" }, - longName: { type: "string", description: "Long name" }, - description: { type: "string", description: "Description" }, - logo: { type: "string", description: "Logo URL" }, - updatedAt: { - type: "string", - format: "date-time", - description: "Last update timestamp" - } - } - } - }, - required: ["message", "tenant"] - }, - example: { - message: "Tenant metadata updated successfully", - tenant: { - id: "01234567-89ab-cdef-0123-456789abcdef", - shortName: "acme-corp", - longName: "ACME Corporation", - description: "Leading provider of innovative solutions", - logo: "https://example.com/logo.png", - updatedAt: "2024-01-01T12:00:00Z" - } - } - } - } - }, - "400": { - description: "Invalid input data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid tenant data" } - } - } - }, - "404": { - description: "Tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Tenant not found" } - } - } - }, - "409": { - description: "Short name already exists", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "A tenant with this short name already exists" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Update tenant metadata", + description: + "Updates the metadata for a specific tenant (longName, shortName, description, logo)", + tags: ["Tenants"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Tenant metadata updates", + content: { + "application/json": { + schema: { + type: "object", + properties: { + longName: { + type: "string", + description: "Full name of the tenant organization", + example: "ACME Corporation", + }, + shortName: { + type: "string", + minLength: 4, + maxLength: 15, + description: "Short name for the tenant (4-15 characters)", + example: "acme-corp", + }, + description: { + type: "string", + description: "Description of the tenant organization", + example: "Leading provider of innovative solutions", + }, + logo: { + type: "string", + description: "URL or base64 encoded logo image", + example: "https://example.com/logo.png", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Tenant metadata updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + tenant: { + type: "object", + properties: { + id: { type: "string", description: "Tenant ID" }, + shortName: { type: "string", description: "Short name" }, + longName: { type: "string", description: "Long name" }, + description: { type: "string", description: "Description" }, + logo: { type: "string", description: "Logo URL" }, + updatedAt: { + type: "string", + format: "date-time", + description: "Last update timestamp", + }, + }, + }, + }, + required: ["message", "tenant"], + }, + example: { + message: "Tenant metadata updated successfully", + tenant: { + id: "01234567-89ab-cdef-0123-456789abcdef", + shortName: "acme-corp", + longName: "ACME Corporation", + description: "Leading provider of innovative solutions", + logo: "https://example.com/logo.png", + updatedAt: "2024-01-01T12:00:00Z", + }, + }, + }, + }, + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid tenant data" }, + }, + }, + }, + "404": { + description: "Tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Tenant not found" }, + }, + }, + }, + "409": { + description: "Short name already exists", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "A tenant with this short name already exists" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}", "GET", { - summary: "Get tenant details", - description: "Retrieves detailed information about a specific tenant", - tags: ["Tenants"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - } - ], - responses: { - "200": { - description: "Tenant details retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - tenant: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Tenant ID" }, - shortName: { type: "string", description: "Short name" }, - longName: { type: "string", description: "Long name" }, - description: { type: "string", description: "Description" }, - logo: { type: "string", description: "Logo data" }, - setupState: { - type: "string", - enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], - description: "Current setup state" - }, - createdAt: { - type: "string", - format: "date-time", - description: "Creation timestamp" - }, - updatedAt: { - type: "string", - format: "date-time", - description: "Last update timestamp" - } - }, - required: ["id", "shortName", "setupState", "createdAt", "updatedAt"] - } - }, - required: ["tenant"] - }, - example: { - tenant: { - id: "01234567-89ab-cdef-0123-456789abcdef", - shortName: "acme-corp", - longName: "ACME Corporation", - description: "Leading provider of innovative solutions", - logo: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", - setupState: "FIRST_CHANNEL_CREATED", - createdAt: "2024-01-01T10:00:00Z", - updatedAt: "2024-01-01T12:00:00Z" - } - } - } - } - }, - "403": { - description: "Insufficient permissions", - 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" } - } - } - } - } + summary: "Get tenant details", + description: "Retrieves detailed information about a specific tenant", + tags: ["Tenants"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + responses: { + "200": { + description: "Tenant details retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + tenant: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Tenant ID" }, + shortName: { type: "string", description: "Short name" }, + longName: { type: "string", description: "Long name" }, + description: { type: "string", description: "Description" }, + logo: { type: "string", description: "Logo data" }, + setupState: { + type: "string", + enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], + description: "Current setup state", + }, + createdAt: { + type: "string", + format: "date-time", + description: "Creation timestamp", + }, + updatedAt: { + type: "string", + format: "date-time", + description: "Last update timestamp", + }, + }, + required: ["id", "shortName", "setupState", "createdAt", "updatedAt"], + }, + }, + required: ["tenant"], + }, + example: { + tenant: { + id: "01234567-89ab-cdef-0123-456789abcdef", + shortName: "acme-corp", + longName: "ACME Corporation", + description: "Leading provider of innovative solutions", + logo: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==", + setupState: "FIRST_CHANNEL_CREATED", + createdAt: "2024-01-01T10:00:00Z", + updatedAt: "2024-01-01T12:00:00Z", + }, + }, + }, + }, + }, + "403": { + description: "Insufficient permissions", + 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 PUT: RequestHandler = async ({ params, request }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const body = await request.json(); + try { + const tenantId = params.id; + const body = await request.json(); - log.debug("Updating tenant metadata", { - tenantId, - updateFields: Object.keys(body) - }); + log.debug("Updating tenant metadata", { + tenantId, + updateFields: Object.keys(body), + }); - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - const tenantService = await TenantAdminService.getTenantById(tenantId); - const updatedTenant = await tenantService.updateTenantData(body); + const tenantService = await TenantAdminService.getTenantById(tenantId); + const updatedTenant = await tenantService.updateTenantData(body); - log.debug("Tenant metadata updated successfully", { - tenantId, - updateFields: Object.keys(body) - }); + log.debug("Tenant metadata updated successfully", { + tenantId, + updateFields: Object.keys(body), + }); - return json({ - message: "Tenant metadata updated successfully", - tenant: updatedTenant - }); - } catch (error) { - log.error("Error updating tenant metadata:", JSON.stringify(error || "?")); + return json({ + message: "Tenant metadata updated successfully", + tenant: updatedTenant, + }); + } catch (error) { + log.error("Error updating tenant metadata:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - // Handle unique constraint violation (shortName already exists) - if (error instanceof Error && error.message.includes("unique constraint")) { - return json({ error: "A tenant with this short name already exists" }, { status: 409 }); - } + // Handle unique constraint violation (shortName already exists) + if (error instanceof Error && error.message.includes("unique constraint")) { + return json({ error: "A tenant with this short name already exists" }, { status: 409 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const GET: RequestHandler = async ({ params, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; + try { + const tenantId = params.id; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - log.debug("Getting tenant details", { - tenantId, - requestedBy: locals.user.userId - }); + log.debug("Getting tenant details", { + tenantId, + requestedBy: locals.user.userId, + }); - // Authorization check: Global admins can access any tenant, tenant admins only their own - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can access any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can access their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Global admins can access any tenant, tenant admins only their own + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can access any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can access their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - const tenantService = await TenantAdminService.getTenantById(tenantId); - const tenantData = tenantService.tenantData; + const tenantService = await TenantAdminService.getTenantById(tenantId); + const tenantData = tenantService.tenantData; - if (!tenantData) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (!tenantData) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - log.debug("Tenant details retrieved successfully", { - tenantId, - requestedBy: locals.user.userId - }); + log.debug("Tenant details retrieved successfully", { + tenantId, + requestedBy: locals.user.userId, + }); - return json({ - tenant: tenantData - }); - } catch (error) { - log.error("Error getting tenant details:", JSON.stringify(error || "?")); + return json({ + tenant: tenantData, + }); + } catch (error) { + log.error("Error getting tenant details:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/agents/+server.ts b/src/routes/api/tenants/[id]/agents/+server.ts index 9307021..5413921 100644 --- a/src/routes/api/tenants/[id]/agents/+server.ts +++ b/src/routes/api/tenants/[id]/agents/+server.ts @@ -7,305 +7,305 @@ import logger from "$lib/logger"; // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/agents", "POST", { - summary: "Create a new agent", - description: - "Creates a new agent for a specific tenant. Only global admins and tenant admins can create agents.", - tags: ["Agents"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - } - ], - requestBody: { - description: "Agent creation data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { - type: "string", - minLength: 1, - maxLength: 100, - description: "Agent name", - example: "Support Agent" - }, - description: { - type: "string", - description: "Agent description", - example: "Handles customer support requests" - }, - logo: { - type: "string", - format: "byte", - description: "Base64 encoded agent logo" - } - }, - required: ["name"] - } - } - } - }, - responses: { - "201": { - description: "Agent created successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - agent: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Agent ID" }, - name: { type: "string", description: "Agent name" }, - description: { type: "string", description: "Agent description" }, - logo: { type: "string", format: "byte", description: "Agent logo" } - }, - required: ["id", "name"] - } - }, - required: ["message", "agent"] - } - } - } - }, - "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 not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Create a new agent", + description: + "Creates a new agent for a specific tenant. Only global admins and tenant admins can create agents.", + tags: ["Agents"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Agent creation data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + maxLength: 100, + description: "Agent name", + example: "Support Agent", + }, + description: { + type: "string", + description: "Agent description", + example: "Handles customer support requests", + }, + logo: { + type: "string", + format: "byte", + description: "Base64 encoded agent logo", + }, + }, + required: ["name"], + }, + }, + }, + }, + responses: { + "201": { + description: "Agent created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + agent: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Agent ID" }, + name: { type: "string", description: "Agent name" }, + description: { type: "string", description: "Agent description" }, + logo: { type: "string", format: "byte", description: "Agent logo" }, + }, + required: ["id", "name"], + }, + }, + required: ["message", "agent"], + }, + }, + }, + }, + "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 not found", + 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}/agents", "GET", { - summary: "List all agents", - description: - "Retrieves all agents for a specific tenant. Only global admins and tenant admins can view agents.", - tags: ["Agents"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - } - ], - responses: { - "200": { - description: "Agents retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - agents: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Agent ID" }, - name: { type: "string", description: "Agent name" }, - description: { type: "string", description: "Agent description" }, - logo: { type: "string", format: "byte", description: "Agent logo" } - }, - required: ["id", "name"] - } - } - }, - required: ["agents"] - } - } - } - }, - "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 not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "List all agents", + description: + "Retrieves all agents for a specific tenant. Only global admins and tenant admins can view agents.", + tags: ["Agents"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + responses: { + "200": { + description: "Agents retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + agents: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Agent ID" }, + name: { type: "string", description: "Agent name" }, + description: { type: "string", description: "Agent description" }, + logo: { type: "string", format: "byte", description: "Agent logo" }, + }, + required: ["id", "name"], + }, + }, + }, + required: ["agents"], + }, + }, + }, + }, + "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 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 POST: RequestHandler = async ({ params, request, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; + try { + const tenantId = params.id; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can create agents - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can create agents for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can create agents for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can create agents + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can create agents for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can create agents for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - const body = await request.json(); + const body = await request.json(); - log.debug("Creating new agent", { - tenantId, - requestedBy: locals.user.userId, - agentName: body.name - }); + log.debug("Creating new agent", { + tenantId, + requestedBy: locals.user.userId, + agentName: body.name, + }); - const agentService = await AgentService.forTenant(tenantId); - const newAgent = await agentService.createAgent(body); + const agentService = await AgentService.forTenant(tenantId); + const newAgent = await agentService.createAgent(body); - log.debug("Agent created successfully", { - tenantId, - agentId: newAgent.id, - requestedBy: locals.user.userId - }); + log.debug("Agent created successfully", { + tenantId, + agentId: newAgent.id, + requestedBy: locals.user.userId, + }); - return json( - { - message: "Agent created successfully", - agent: newAgent - }, - { status: 201 } - ); - } catch (error) { - log.error("Error creating agent:", JSON.stringify(error || "?")); + return json( + { + message: "Agent created successfully", + agent: newAgent, + }, + { status: 201 }, + ); + } catch (error) { + log.error("Error creating agent:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const GET: RequestHandler = async ({ params, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; + try { + const tenantId = params.id; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can view agents - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can view agents for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can view agents for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can view agents + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can view agents for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can view agents for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - log.debug("Getting all agents", { - tenantId, - requestedBy: locals.user.userId - }); + log.debug("Getting all agents", { + tenantId, + requestedBy: locals.user.userId, + }); - const agentService = await AgentService.forTenant(tenantId); - const agents = await agentService.getAllAgents(); + const agentService = await AgentService.forTenant(tenantId); + const agents = await agentService.getAllAgents(); - log.debug("Agents retrieved successfully", { - tenantId, - count: agents.length, - requestedBy: locals.user.userId - }); + log.debug("Agents retrieved successfully", { + tenantId, + count: agents.length, + requestedBy: locals.user.userId, + }); - return json({ - agents - }); - } catch (error) { - log.error("Error getting agents:", JSON.stringify(error || "?")); + return json({ + agents, + }); + } catch (error) { + log.error("Error getting agents:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts index 6e47157..0e788ff 100644 --- a/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts +++ b/src/routes/api/tenants/[id]/agents/[agentId]/+server.ts @@ -7,450 +7,450 @@ import logger from "$lib/logger"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "GET", { - summary: "Get agent details", - description: - "Retrieves detailed information about a specific agent. Only global admins and tenant admins can view agent details.", - tags: ["Agents"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - }, - { - name: "agentId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Agent ID" - } - ], - responses: { - "200": { - description: "Agent details retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - agent: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Agent ID" }, - name: { type: "string", description: "Agent name" }, - description: { type: "string", description: "Agent description" }, - logo: { type: "string", format: "byte", description: "Agent logo" } - }, - required: ["id", "name"] - } - }, - required: ["agent"] - } - } - } - }, - "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: "Agent or tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Get agent details", + description: + "Retrieves detailed information about a specific agent. Only global admins and tenant admins can view agent details.", + tags: ["Agents"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "agentId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Agent ID", + }, + ], + responses: { + "200": { + description: "Agent details retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + agent: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Agent ID" }, + name: { type: "string", description: "Agent name" }, + description: { type: "string", description: "Agent description" }, + logo: { type: "string", format: "byte", description: "Agent logo" }, + }, + required: ["id", "name"], + }, + }, + required: ["agent"], + }, + }, + }, + }, + "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: "Agent or tenant not found", + 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}/agents/{agentId}", "PUT", { - summary: "Update an agent", - description: - "Updates an existing agent for a specific tenant. Only global admins and tenant admins can update agents.", - tags: ["Agents"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - }, - { - name: "agentId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Agent ID" - } - ], - requestBody: { - description: "Agent update data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { - type: "string", - minLength: 1, - maxLength: 100, - description: "Agent name", - example: "Updated Support Agent" - }, - description: { - type: "string", - description: "Agent description", - example: "Updated description for customer support" - }, - logo: { - type: "string", - format: "byte", - description: "Base64 encoded agent logo" - } - } - } - } - } - }, - responses: { - "200": { - description: "Agent updated successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - agent: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Agent ID" }, - name: { type: "string", description: "Agent name" }, - description: { type: "string", description: "Agent description" }, - logo: { type: "string", format: "byte", description: "Agent logo" } - }, - required: ["id", "name"] - } - }, - required: ["message", "agent"] - } - } - } - }, - "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: "Agent or tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Update an agent", + description: + "Updates an existing agent for a specific tenant. Only global admins and tenant admins can update agents.", + tags: ["Agents"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "agentId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Agent ID", + }, + ], + requestBody: { + description: "Agent update data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + maxLength: 100, + description: "Agent name", + example: "Updated Support Agent", + }, + description: { + type: "string", + description: "Agent description", + example: "Updated description for customer support", + }, + logo: { + type: "string", + format: "byte", + description: "Base64 encoded agent logo", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Agent updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + agent: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Agent ID" }, + name: { type: "string", description: "Agent name" }, + description: { type: "string", description: "Agent description" }, + logo: { type: "string", format: "byte", description: "Agent logo" }, + }, + required: ["id", "name"], + }, + }, + required: ["message", "agent"], + }, + }, + }, + }, + "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: "Agent or tenant not found", + 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 DELETE registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "DELETE", { - summary: "Delete an agent", - description: - "Deletes an existing agent from a specific tenant. Only global admins and tenant admins can delete agents. This will also remove all channel assignments for this agent.", - tags: ["Agents"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - }, - { - name: "agentId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Agent ID" - } - ], - responses: { - "200": { - description: "Agent deleted successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" } - }, - required: ["message"] - } - } - } - }, - "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: "Agent or tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Delete an agent", + description: + "Deletes an existing agent from a specific tenant. Only global admins and tenant admins can delete agents. This will also remove all channel assignments for this agent.", + tags: ["Agents"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "agentId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Agent ID", + }, + ], + responses: { + "200": { + description: "Agent deleted successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "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: "Agent or 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, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const agentId = params.agentId; + try { + const tenantId = params.id; + const agentId = params.agentId; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); - } + if (!tenantId || !agentId) { + return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can view agent details - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can view agents for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can view agents for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can view agent details + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can view agents for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can view agents for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - log.debug("Getting agent details", { - tenantId, - agentId, - requestedBy: locals.user.userId - }); + log.debug("Getting agent details", { + tenantId, + agentId, + requestedBy: locals.user.userId, + }); - const agentService = await AgentService.forTenant(tenantId); - const agent = await agentService.getAgentById(agentId); + const agentService = await AgentService.forTenant(tenantId); + const agent = await agentService.getAgentById(agentId); - if (!agent) { - return json({ error: "Agent not found" }, { status: 404 }); - } + if (!agent) { + return json({ error: "Agent not found" }, { status: 404 }); + } - log.debug("Agent details retrieved successfully", { - tenantId, - agentId, - requestedBy: locals.user.userId - }); + log.debug("Agent details retrieved successfully", { + tenantId, + agentId, + requestedBy: locals.user.userId, + }); - return json({ - agent - }); - } catch (error) { - log.error("Error getting agent details:", JSON.stringify(error || "?")); + return json({ + agent, + }); + } catch (error) { + log.error("Error getting agent details:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Agent not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const PUT: RequestHandler = async ({ params, request, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const agentId = params.agentId; + try { + const tenantId = params.id; + const agentId = params.agentId; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); - } + if (!tenantId || !agentId) { + return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can update agents - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can update agents for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can update agents for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can update agents + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can update agents for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can update agents for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - const body = await request.json(); + const body = await request.json(); - log.debug("Updating agent", { - tenantId, - agentId, - requestedBy: locals.user.userId, - updateFields: Object.keys(body) - }); + log.debug("Updating agent", { + tenantId, + agentId, + requestedBy: locals.user.userId, + updateFields: Object.keys(body), + }); - const agentService = await AgentService.forTenant(tenantId); - const updatedAgent = await agentService.updateAgent(agentId, body); + const agentService = await AgentService.forTenant(tenantId); + const updatedAgent = await agentService.updateAgent(agentId, body); - log.debug("Agent updated successfully", { - tenantId, - agentId, - requestedBy: locals.user.userId - }); + log.debug("Agent updated successfully", { + tenantId, + agentId, + requestedBy: locals.user.userId, + }); - return json({ - message: "Agent updated successfully", - agent: updatedAgent - }); - } catch (error) { - log.error("Error updating agent:", JSON.stringify(error || "?")); + return json({ + message: "Agent updated successfully", + agent: updatedAgent, + }); + } catch (error) { + log.error("Error updating agent:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Agent not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const DELETE: RequestHandler = async ({ params, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const agentId = params.agentId; + try { + const tenantId = params.id; + const agentId = params.agentId; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId || !agentId) { - return json({ error: "Missing tenant or agent ID" }, { status: 400 }); - } + if (!tenantId || !agentId) { + return json({ error: "Missing tenant or agent ID" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can delete agents - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can delete agents for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can delete agents for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can delete agents + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can delete agents for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can delete agents for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - log.debug("Deleting agent", { - tenantId, - agentId, - requestedBy: locals.user.userId - }); + log.debug("Deleting agent", { + tenantId, + agentId, + requestedBy: locals.user.userId, + }); - const agentService = await AgentService.forTenant(tenantId); - const deleted = await agentService.deleteAgent(agentId); + const agentService = await AgentService.forTenant(tenantId); + const deleted = await agentService.deleteAgent(agentId); - if (!deleted) { - return json({ error: "Agent not found" }, { status: 404 }); - } + if (!deleted) { + return json({ error: "Agent not found" }, { status: 404 }); + } - log.debug("Agent deleted successfully", { - tenantId, - agentId, - requestedBy: locals.user.userId - }); + log.debug("Agent deleted successfully", { + tenantId, + agentId, + requestedBy: locals.user.userId, + }); - return json({ - message: "Agent deleted successfully" - }); - } catch (error) { - log.error("Error deleting agent:", JSON.stringify(error || "?")); + return json({ + message: "Agent deleted successfully", + }); + } catch (error) { + log.error("Error deleting agent:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Agent not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Agent not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/channels/+server.ts b/src/routes/api/tenants/[id]/channels/+server.ts index 91c2cb4..138f81c 100644 --- a/src/routes/api/tenants/[id]/channels/+server.ts +++ b/src/routes/api/tenants/[id]/channels/+server.ts @@ -7,424 +7,424 @@ import logger from "$lib/logger"; // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/channels", "POST", { - summary: "Create a new channel", - description: - "Creates a new channel for a specific tenant. Only global admins and tenant admins can create channels.", - tags: ["Channels"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - } - ], - requestBody: { - description: "Channel creation data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - names: { - type: "array", - items: { type: "string", minLength: 1, maxLength: 100 }, - description: "Channel names (one per language)", - example: ["Support", "Unterstützung"] - }, - color: { - type: "string", - description: "Channel color (optional, will be auto-assigned if not provided)", - example: "#FF0000" - }, - descriptions: { - type: "array", - items: { type: "string" }, - description: "Channel descriptions (optional, one per language)", - example: ["Customer support channel", "Kundensupport-Kanal"] - }, - languages: { - type: "array", - items: { type: "string", minLength: 2, maxLength: 5 }, - description: "Supported languages", - example: ["en", "de"] - }, - isPublic: { - type: "boolean", - description: "Whether the channel is publicly visible", - example: true - }, - requiresConfirmation: { - type: "boolean", - description: "Whether appointments require confirmation", - example: false - }, - agentIds: { - type: "array", - items: { type: "string", format: "uuid" }, - description: "IDs of agents to assign to this channel", - example: ["01234567-89ab-cdef-0123-456789abcdef"] - }, - slotTemplates: { - type: "array", - items: { - type: "object", - properties: { - name: { - type: "string", - minLength: 1, - maxLength: 100, - description: "Template name" - }, - weekdays: { type: "number", description: "Weekdays bitmask" }, - from: { type: "string", description: "Start time ((HH:MM))" }, - to: { type: "string", description: "End time (HH:MM)" }, - duration: { type: "number", description: "Slot duration in minutes" } - }, - required: ["name", "from", "to", "duration"] - }, - description: "Slot templates for the channel" - } - }, - required: ["names", "languages"] - } - } - } - }, - responses: { - "201": { - description: "Channel created successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - channel: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Channel ID" }, - names: { type: "array", items: { type: "string" }, description: "Channel names" }, - color: { type: "string", description: "Channel color" }, - descriptions: { - type: "array", - items: { type: "string" }, - description: "Channel descriptions" - }, - languages: { - type: "array", - items: { type: "string" }, - description: "Supported languages" - }, - isPublic: { type: "boolean", description: "Public visibility" }, - requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, - agents: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - description: { type: "string" }, - logo: { type: "string", format: "byte" } - } - } - }, - slotTemplates: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - weekdays: { type: "number" }, - from: { type: "string" }, - to: { type: "string" }, - duration: { type: "number" } - } - } - } - }, - required: ["id", "names", "languages", "agents", "slotTemplates"] - } - }, - required: ["message", "channel"] - } - } - } - }, - "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 not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Create a new channel", + description: + "Creates a new channel for a specific tenant. Only global admins and tenant admins can create channels.", + tags: ["Channels"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Channel creation data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + names: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 100 }, + description: "Channel names (one per language)", + example: ["Support", "Unterstützung"], + }, + color: { + type: "string", + description: "Channel color (optional, will be auto-assigned if not provided)", + example: "#FF0000", + }, + descriptions: { + type: "array", + items: { type: "string" }, + description: "Channel descriptions (optional, one per language)", + example: ["Customer support channel", "Kundensupport-Kanal"], + }, + languages: { + type: "array", + items: { type: "string", minLength: 2, maxLength: 5 }, + description: "Supported languages", + example: ["en", "de"], + }, + isPublic: { + type: "boolean", + description: "Whether the channel is publicly visible", + example: true, + }, + requiresConfirmation: { + type: "boolean", + description: "Whether appointments require confirmation", + example: false, + }, + agentIds: { + type: "array", + items: { type: "string", format: "uuid" }, + description: "IDs of agents to assign to this channel", + example: ["01234567-89ab-cdef-0123-456789abcdef"], + }, + slotTemplates: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + minLength: 1, + maxLength: 100, + description: "Template name", + }, + weekdays: { type: "number", description: "Weekdays bitmask" }, + from: { type: "string", description: "Start time ((HH:MM))" }, + to: { type: "string", description: "End time (HH:MM)" }, + duration: { type: "number", description: "Slot duration in minutes" }, + }, + required: ["name", "from", "to", "duration"], + }, + description: "Slot templates for the channel", + }, + }, + required: ["names", "languages"], + }, + }, + }, + }, + responses: { + "201": { + description: "Channel created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + channel: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Channel ID" }, + names: { type: "array", items: { type: "string" }, description: "Channel names" }, + color: { type: "string", description: "Channel color" }, + descriptions: { + type: "array", + items: { type: "string" }, + description: "Channel descriptions", + }, + languages: { + type: "array", + items: { type: "string" }, + description: "Supported languages", + }, + isPublic: { type: "boolean", description: "Public visibility" }, + requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, + agents: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + description: { type: "string" }, + logo: { type: "string", format: "byte" }, + }, + }, + }, + slotTemplates: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + weekdays: { type: "number" }, + from: { type: "string" }, + to: { type: "string" }, + duration: { type: "number" }, + }, + }, + }, + }, + required: ["id", "names", "languages", "agents", "slotTemplates"], + }, + }, + required: ["message", "channel"], + }, + }, + }, + }, + "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 not found", + 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}/channels", "GET", { - summary: "List all channels", - description: - "Retrieves all channels for a specific tenant with their agents and slot templates. Only global admins and tenant admins can view channels.", - tags: ["Channels"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - } - ], - responses: { - "200": { - description: "Channels retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - channels: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Channel ID" }, - names: { - type: "array", - items: { type: "string" }, - description: "Channel names" - }, - color: { type: "string", description: "Channel color" }, - descriptions: { - type: "array", - items: { type: "string" }, - description: "Channel descriptions" - }, - languages: { - type: "array", - items: { type: "string" }, - description: "Supported languages" - }, - isPublic: { type: "boolean", description: "Public visibility" }, - requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, - agents: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - description: { type: "string" }, - logo: { type: "string", format: "byte" } - } - } - }, - slotTemplates: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - weekdays: { type: "number" }, - from: { type: "string" }, - to: { type: "string" }, - duration: { type: "number" } - } - } - } - }, - required: ["id", "names", "languages", "agents", "slotTemplates"] - } - } - }, - required: ["channels"] - } - } - } - }, - "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 not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "List all channels", + description: + "Retrieves all channels for a specific tenant with their agents and slot templates. Only global admins and tenant admins can view channels.", + tags: ["Channels"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + responses: { + "200": { + description: "Channels retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + channels: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Channel ID" }, + names: { + type: "array", + items: { type: "string" }, + description: "Channel names", + }, + color: { type: "string", description: "Channel color" }, + descriptions: { + type: "array", + items: { type: "string" }, + description: "Channel descriptions", + }, + languages: { + type: "array", + items: { type: "string" }, + description: "Supported languages", + }, + isPublic: { type: "boolean", description: "Public visibility" }, + requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, + agents: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + description: { type: "string" }, + logo: { type: "string", format: "byte" }, + }, + }, + }, + slotTemplates: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + weekdays: { type: "number" }, + from: { type: "string" }, + to: { type: "string" }, + duration: { type: "number" }, + }, + }, + }, + }, + required: ["id", "names", "languages", "agents", "slotTemplates"], + }, + }, + }, + required: ["channels"], + }, + }, + }, + }, + "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 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 POST: RequestHandler = async ({ params, request, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; + try { + const tenantId = params.id; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can create channels - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can create channels for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can create channels for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can create channels + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can create channels for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can create channels for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - const body = await request.json(); + const body = await request.json(); - log.debug("Creating new channel", { - tenantId, - requestedBy: locals.user.userId, - channelNames: body.names, - languages: body.languages - }); + log.debug("Creating new channel", { + tenantId, + requestedBy: locals.user.userId, + channelNames: body.names, + languages: body.languages, + }); - const channelService = await ChannelService.forTenant(tenantId); - const newChannel = await channelService.createChannel(body); + const channelService = await ChannelService.forTenant(tenantId); + const newChannel = await channelService.createChannel(body); - log.debug("Channel created successfully", { - tenantId, - channelId: newChannel.id, - requestedBy: locals.user.userId - }); + log.debug("Channel created successfully", { + tenantId, + channelId: newChannel.id, + requestedBy: locals.user.userId, + }); - return json( - { - message: "Channel created successfully", - channel: newChannel - }, - { status: 201 } - ); - } catch (error) { - log.error("Error creating channel:", JSON.stringify(error || "?")); + return json( + { + message: "Channel created successfully", + channel: newChannel, + }, + { status: 201 }, + ); + } catch (error) { + log.error("Error creating channel:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const GET: RequestHandler = async ({ params, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; + try { + const tenantId = params.id; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can view channels - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can view channels for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can view channels for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can view channels + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can view channels for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can view channels for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - log.debug("Getting all channels", { - tenantId, - requestedBy: locals.user.userId - }); + log.debug("Getting all channels", { + tenantId, + requestedBy: locals.user.userId, + }); - const channelService = await ChannelService.forTenant(tenantId); - const channels = await channelService.getAllChannels(); + const channelService = await ChannelService.forTenant(tenantId); + const channels = await channelService.getAllChannels(); - log.debug("Channels retrieved successfully", { - tenantId, - count: channels.length, - requestedBy: locals.user.userId - }); + log.debug("Channels retrieved successfully", { + tenantId, + count: channels.length, + requestedBy: locals.user.userId, + }); - return json({ - channels - }); - } catch (error) { - log.error("Error getting channels:", JSON.stringify(error || "?")); + return json({ + channels, + }); + } catch (error) { + log.error("Error getting channels:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts index d68c8db..d0a6c07 100644 --- a/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts +++ b/src/routes/api/tenants/[id]/channels/[channelId]/+server.ts @@ -7,569 +7,569 @@ import logger from "$lib/logger"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "GET", { - summary: "Get channel details", - description: - "Retrieves detailed information about a specific channel including agents and slot templates. Only global admins and tenant admins can view channel details.", - tags: ["Channels"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - }, - { - name: "channelId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Channel ID" - } - ], - responses: { - "200": { - description: "Channel details retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - channel: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Channel ID" }, - names: { type: "array", items: { type: "string" }, description: "Channel names" }, - color: { type: "string", description: "Channel color" }, - descriptions: { - type: "array", - items: { type: "string" }, - description: "Channel descriptions" - }, - languages: { - type: "array", - items: { type: "string" }, - description: "Supported languages" - }, - isPublic: { type: "boolean", description: "Public visibility" }, - requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, - agents: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - description: { type: "string" }, - logo: { type: "string", format: "byte" } - } - } - }, - slotTemplates: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - weekdays: { type: "number" }, - from: { type: "string" }, - to: { type: "string" }, - duration: { type: "number" } - } - } - } - }, - required: ["id", "names", "languages", "agents", "slotTemplates"] - } - }, - required: ["channel"] - } - } - } - }, - "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: "Channel or tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Get channel details", + description: + "Retrieves detailed information about a specific channel including agents and slot templates. Only global admins and tenant admins can view channel details.", + tags: ["Channels"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "channelId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Channel ID", + }, + ], + responses: { + "200": { + description: "Channel details retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + channel: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Channel ID" }, + names: { type: "array", items: { type: "string" }, description: "Channel names" }, + color: { type: "string", description: "Channel color" }, + descriptions: { + type: "array", + items: { type: "string" }, + description: "Channel descriptions", + }, + languages: { + type: "array", + items: { type: "string" }, + description: "Supported languages", + }, + isPublic: { type: "boolean", description: "Public visibility" }, + requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, + agents: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + description: { type: "string" }, + logo: { type: "string", format: "byte" }, + }, + }, + }, + slotTemplates: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + weekdays: { type: "number" }, + from: { type: "string" }, + to: { type: "string" }, + duration: { type: "number" }, + }, + }, + }, + }, + required: ["id", "names", "languages", "agents", "slotTemplates"], + }, + }, + required: ["channel"], + }, + }, + }, + }, + "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: "Channel or tenant not found", + 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}/channels/{channelId}", "PUT", { - summary: "Update a channel", - description: - "Updates an existing channel for a specific tenant. Only global admins and tenant admins can update channels.", - tags: ["Channels"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - }, - { - name: "channelId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Channel ID" - } - ], - requestBody: { - description: "Channel update data", - content: { - "application/json": { - schema: { - type: "object", - properties: { - names: { - type: "array", - items: { type: "string", minLength: 1, maxLength: 100 }, - description: "Channel names (one per language)", - example: ["Updated Support", "Aktualisierte Unterstützung"] - }, - color: { - type: "string", - description: "Channel color", - example: "#00FF00" - }, - descriptions: { - type: "array", - items: { type: "string" }, - description: "Channel descriptions (one per language)", - example: ["Updated customer support channel", "Aktualisierter Kundensupport-Kanal"] - }, - languages: { - type: "array", - items: { type: "string", minLength: 2, maxLength: 5 }, - description: "Supported languages", - example: ["en", "de"] - }, - isPublic: { - type: "boolean", - description: "Whether the channel is publicly visible", - example: false - }, - requiresConfirmation: { - type: "boolean", - description: "Whether appointments require confirmation", - example: true - }, - agentIds: { - type: "array", - items: { type: "string", format: "uuid" }, - description: "IDs of agents to assign to this channel", - example: ["01234567-89ab-cdef-0123-456789abcdef"] - }, - slotTemplates: { - type: "array", - items: { - type: "object", - properties: { - id: { - type: "string", - format: "uuid", - description: "Template ID (for existing templates)" - }, - name: { - type: "string", - minLength: 1, - maxLength: 100, - description: "Template name" - }, - weekdays: { type: "number", description: "Weekdays bitmask" }, - from: { type: "string", description: "Start time (HH:MM)" }, - to: { type: "string", description: "End time (HH:MM)" }, - duration: { type: "number", description: "Slot duration in minutes" } - }, - required: ["from", "to", "duration"] - }, - description: "Slot templates for the channel" - } - } - } - } - } - }, - responses: { - "200": { - description: "Channel updated successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - channel: { - type: "object", - properties: { - id: { type: "string", format: "uuid", description: "Channel ID" }, - names: { type: "array", items: { type: "string" }, description: "Channel names" }, - color: { type: "string", description: "Channel color" }, - descriptions: { - type: "array", - items: { type: "string" }, - description: "Channel descriptions" - }, - languages: { - type: "array", - items: { type: "string" }, - description: "Supported languages" - }, - isPublic: { type: "boolean", description: "Public visibility" }, - requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, - agents: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - name: { type: "string" }, - description: { type: "string" }, - logo: { type: "string", format: "byte" } - } - } - }, - slotTemplates: { - type: "array", - items: { - type: "object", - properties: { - id: { type: "string", format: "uuid" }, - weekdays: { type: "number" }, - from: { type: "string" }, - to: { type: "string" }, - duration: { type: "number" } - } - } - } - }, - required: ["id", "names", "languages", "agents", "slotTemplates"] - } - }, - required: ["message", "channel"] - } - } - } - }, - "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: "Channel or tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Update a channel", + description: + "Updates an existing channel for a specific tenant. Only global admins and tenant admins can update channels.", + tags: ["Channels"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "channelId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Channel ID", + }, + ], + requestBody: { + description: "Channel update data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + names: { + type: "array", + items: { type: "string", minLength: 1, maxLength: 100 }, + description: "Channel names (one per language)", + example: ["Updated Support", "Aktualisierte Unterstützung"], + }, + color: { + type: "string", + description: "Channel color", + example: "#00FF00", + }, + descriptions: { + type: "array", + items: { type: "string" }, + description: "Channel descriptions (one per language)", + example: ["Updated customer support channel", "Aktualisierter Kundensupport-Kanal"], + }, + languages: { + type: "array", + items: { type: "string", minLength: 2, maxLength: 5 }, + description: "Supported languages", + example: ["en", "de"], + }, + isPublic: { + type: "boolean", + description: "Whether the channel is publicly visible", + example: false, + }, + requiresConfirmation: { + type: "boolean", + description: "Whether appointments require confirmation", + example: true, + }, + agentIds: { + type: "array", + items: { type: "string", format: "uuid" }, + description: "IDs of agents to assign to this channel", + example: ["01234567-89ab-cdef-0123-456789abcdef"], + }, + slotTemplates: { + type: "array", + items: { + type: "object", + properties: { + id: { + type: "string", + format: "uuid", + description: "Template ID (for existing templates)", + }, + name: { + type: "string", + minLength: 1, + maxLength: 100, + description: "Template name", + }, + weekdays: { type: "number", description: "Weekdays bitmask" }, + from: { type: "string", description: "Start time (HH:MM)" }, + to: { type: "string", description: "End time (HH:MM)" }, + duration: { type: "number", description: "Slot duration in minutes" }, + }, + required: ["from", "to", "duration"], + }, + description: "Slot templates for the channel", + }, + }, + }, + }, + }, + }, + responses: { + "200": { + description: "Channel updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + channel: { + type: "object", + properties: { + id: { type: "string", format: "uuid", description: "Channel ID" }, + names: { type: "array", items: { type: "string" }, description: "Channel names" }, + color: { type: "string", description: "Channel color" }, + descriptions: { + type: "array", + items: { type: "string" }, + description: "Channel descriptions", + }, + languages: { + type: "array", + items: { type: "string" }, + description: "Supported languages", + }, + isPublic: { type: "boolean", description: "Public visibility" }, + requiresConfirmation: { type: "boolean", description: "Requires confirmation" }, + agents: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + name: { type: "string" }, + description: { type: "string" }, + logo: { type: "string", format: "byte" }, + }, + }, + }, + slotTemplates: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string", format: "uuid" }, + weekdays: { type: "number" }, + from: { type: "string" }, + to: { type: "string" }, + duration: { type: "number" }, + }, + }, + }, + }, + required: ["id", "names", "languages", "agents", "slotTemplates"], + }, + }, + required: ["message", "channel"], + }, + }, + }, + }, + "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: "Channel or tenant not found", + 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 DELETE registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "DELETE", { - summary: "Delete a channel", - description: - "Deletes an existing channel from a specific tenant. Only global admins and tenant admins can delete channels. This will also remove all agent assignments and slot templates that are not used by other channels.", - tags: ["Channels"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Tenant ID" - }, - { - name: "channelId", - in: "path", - required: true, - schema: { type: "string", format: "uuid" }, - description: "Channel ID" - } - ], - responses: { - "200": { - description: "Channel deleted successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" } - }, - required: ["message"] - } - } - } - }, - "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: "Channel or tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" } - } - } - } - } + summary: "Delete a channel", + description: + "Deletes an existing channel from a specific tenant. Only global admins and tenant admins can delete channels. This will also remove all agent assignments and slot templates that are not used by other channels.", + tags: ["Channels"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + { + name: "channelId", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Channel ID", + }, + ], + responses: { + "200": { + description: "Channel deleted successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + }, + required: ["message"], + }, + }, + }, + }, + "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: "Channel or 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, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const channelId = params.channelId; + try { + const tenantId = params.id; + const channelId = params.channelId; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId || !channelId) { - return json({ error: "Missing tenant or channel ID" }, { status: 400 }); - } + if (!tenantId || !channelId) { + return json({ error: "Missing tenant or channel ID" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can view channel details - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can view channels for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can view channels for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can view channel details + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can view channels for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can view channels for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - log.debug("Getting channel details", { - tenantId, - channelId, - requestedBy: locals.user.userId - }); + log.debug("Getting channel details", { + tenantId, + channelId, + requestedBy: locals.user.userId, + }); - const channelService = await ChannelService.forTenant(tenantId); - const channel = await channelService.getChannelById(channelId); + const channelService = await ChannelService.forTenant(tenantId); + const channel = await channelService.getChannelById(channelId); - if (!channel) { - return json({ error: "Channel not found" }, { status: 404 }); - } + if (!channel) { + return json({ error: "Channel not found" }, { status: 404 }); + } - log.debug("Channel details retrieved successfully", { - tenantId, - channelId, - requestedBy: locals.user.userId - }); + log.debug("Channel details retrieved successfully", { + tenantId, + channelId, + requestedBy: locals.user.userId, + }); - return json({ - channel - }); - } catch (error) { - log.error("Error getting channel details:", JSON.stringify(error || "?")); + return json({ + channel, + }); + } catch (error) { + log.error("Error getting channel details:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Channel not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Channel not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const PUT: RequestHandler = async ({ params, request, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const channelId = params.channelId; + try { + const tenantId = params.id; + const channelId = params.channelId; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId || !channelId) { - return json({ error: "Missing tenant or channel ID" }, { status: 400 }); - } + if (!tenantId || !channelId) { + return json({ error: "Missing tenant or channel ID" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can update channels - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can update channels for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can update channels for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can update channels + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can update channels for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can update channels for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - const body = await request.json(); + const body = await request.json(); - log.debug("Updating channel", { - tenantId, - channelId, - requestedBy: locals.user.userId, - updateFields: Object.keys(body) - }); + log.debug("Updating channel", { + tenantId, + channelId, + requestedBy: locals.user.userId, + updateFields: Object.keys(body), + }); - const channelService = await ChannelService.forTenant(tenantId); - const updatedChannel = await channelService.updateChannel(channelId, body); + const channelService = await ChannelService.forTenant(tenantId); + const updatedChannel = await channelService.updateChannel(channelId, body); - log.debug("Channel updated successfully", { - tenantId, - channelId, - requestedBy: locals.user.userId - }); + log.debug("Channel updated successfully", { + tenantId, + channelId, + requestedBy: locals.user.userId, + }); - return json({ - message: "Channel updated successfully", - channel: updatedChannel - }); - } catch (error) { - log.error("Error updating channel:", JSON.stringify(error || "?")); + return json({ + message: "Channel updated successfully", + channel: updatedChannel, + }); + } catch (error) { + log.error("Error updating channel:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Channel not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Channel not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const DELETE: RequestHandler = async ({ params, locals }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const channelId = params.channelId; + try { + const tenantId = params.id; + const channelId = params.channelId; - // Check if user is authenticated - if (!locals.user) { - return json({ error: "Authentication required" }, { status: 401 }); - } + // Check if user is authenticated + if (!locals.user) { + return json({ error: "Authentication required" }, { status: 401 }); + } - if (!tenantId || !channelId) { - return json({ error: "Missing tenant or channel ID" }, { status: 400 }); - } + if (!tenantId || !channelId) { + return json({ error: "Missing tenant or channel ID" }, { status: 400 }); + } - // Authorization check: Only global admins and tenant admins can delete channels - if (locals.user.role === "GLOBAL_ADMIN") { - // Global admin can delete channels for any tenant - } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { - // Tenant admin can delete channels for their own tenant - } else { - return json({ error: "Insufficient permissions" }, { status: 403 }); - } + // Authorization check: Only global admins and tenant admins can delete channels + if (locals.user.role === "GLOBAL_ADMIN") { + // Global admin can delete channels for any tenant + } else if (locals.user.role === "TENANT_ADMIN" && locals.user.tenantId === tenantId) { + // Tenant admin can delete channels for their own tenant + } else { + return json({ error: "Insufficient permissions" }, { status: 403 }); + } - log.debug("Deleting channel", { - tenantId, - channelId, - requestedBy: locals.user.userId - }); + log.debug("Deleting channel", { + tenantId, + channelId, + requestedBy: locals.user.userId, + }); - const channelService = await ChannelService.forTenant(tenantId); - const deleted = await channelService.deleteChannel(channelId); + const channelService = await ChannelService.forTenant(tenantId); + const deleted = await channelService.deleteChannel(channelId); - if (!deleted) { - return json({ error: "Channel not found" }, { status: 404 }); - } + if (!deleted) { + return json({ error: "Channel not found" }, { status: 404 }); + } - log.debug("Channel deleted successfully", { - tenantId, - channelId, - requestedBy: locals.user.userId - }); + log.debug("Channel deleted successfully", { + tenantId, + channelId, + requestedBy: locals.user.userId, + }); - return json({ - message: "Channel deleted successfully" - }); - } catch (error) { - log.error("Error deleting channel:", JSON.stringify(error || "?")); + return json({ + message: "Channel deleted successfully", + }); + } catch (error) { + log.error("Error deleting channel:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Channel not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Channel not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/config/+server.ts b/src/routes/api/tenants/[id]/config/+server.ts index 0818be7..3a4520e 100644 --- a/src/routes/api/tenants/[id]/config/+server.ts +++ b/src/routes/api/tenants/[id]/config/+server.ts @@ -7,245 +7,245 @@ import logger from "$lib/logger"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/config", "GET", { - summary: "Get tenant configuration", - description: "Returns the current configuration for a specific tenant", - tags: ["Tenants", "Configuration"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string" }, - description: "Tenant ID" - } - ], - responses: { - "200": { - description: "Tenant configuration retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - brandColor: { type: "string", description: "Brand color", example: "#E11E15" }, - defaultLanguage: { - type: "string", - description: "Default language code", - example: "DE" - }, - maxChannels: { - type: "number", - description: "Maximum channels (-1 for unlimited)", - example: -1 - }, - maxTeamMembers: { - type: "number", - description: "Maximum team members (-1 for unlimited)", - example: -1 - }, - autoDeleteDays: { - type: "number", - description: "Auto-delete data after days", - example: 30 - }, - requireEmail: { - type: "boolean", - description: "Require email for bookings", - example: true - }, - requirePhone: { - type: "boolean", - description: "Require phone for bookings", - example: false - } - } - } - } - } - }, - "404": { - description: "Tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Tenant not found" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Get tenant configuration", + description: "Returns the current configuration for a specific tenant", + tags: ["Tenants", "Configuration"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Tenant ID", + }, + ], + responses: { + "200": { + description: "Tenant configuration retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + brandColor: { type: "string", description: "Brand color", example: "#E11E15" }, + defaultLanguage: { + type: "string", + description: "Default language code", + example: "DE", + }, + maxChannels: { + type: "number", + description: "Maximum channels (-1 for unlimited)", + example: -1, + }, + maxTeamMembers: { + type: "number", + description: "Maximum team members (-1 for unlimited)", + example: -1, + }, + autoDeleteDays: { + type: "number", + description: "Auto-delete data after days", + example: 30, + }, + requireEmail: { + type: "boolean", + description: "Require email for bookings", + example: true, + }, + requirePhone: { + type: "boolean", + description: "Require phone for bookings", + example: false, + }, + }, + }, + }, + }, + }, + "404": { + description: "Tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Tenant not found" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); // Register OpenAPI documentation for PUT registerOpenAPIRoute("/tenants/{id}/config", "PUT", { - summary: "Update tenant configuration", - description: "Updates the configuration for a specific tenant", - tags: ["Tenants", "Configuration"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string" }, - description: "Tenant ID" - } - ], - requestBody: { - description: "Configuration updates", - content: { - "application/json": { - schema: { - type: "object", - properties: { - brandColor: { type: "string", description: "Brand color" }, - defaultLanguage: { type: "string", description: "Default language code" }, - maxChannels: { type: "number", description: "Maximum channels (-1 for unlimited)" }, - maxTeamMembers: { - type: "number", - description: "Maximum team members (-1 for unlimited)" - }, - autoDeleteDays: { type: "number", description: "Auto-delete data after days" }, - requireEmail: { type: "boolean", description: "Require email for bookings" }, - requirePhone: { type: "boolean", description: "Require phone for bookings" } - }, - additionalProperties: true - } - } - } - }, - responses: { - "200": { - description: "Configuration updated successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - updatedKeys: { - type: "array", - items: { type: "string" }, - description: "List of configuration keys that were updated" - } - }, - required: ["message", "updatedKeys"] - }, - example: { - message: "Configuration updated successfully", - updatedKeys: ["brandColor", "requireEmail"] - } - } - } - }, - "400": { - description: "Invalid input data", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Invalid configuration data" } - } - } - }, - "404": { - description: "Tenant not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Tenant not found" } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Update tenant configuration", + description: "Updates the configuration for a specific tenant", + tags: ["Tenants", "Configuration"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Configuration updates", + content: { + "application/json": { + schema: { + type: "object", + properties: { + brandColor: { type: "string", description: "Brand color" }, + defaultLanguage: { type: "string", description: "Default language code" }, + maxChannels: { type: "number", description: "Maximum channels (-1 for unlimited)" }, + maxTeamMembers: { + type: "number", + description: "Maximum team members (-1 for unlimited)", + }, + autoDeleteDays: { type: "number", description: "Auto-delete data after days" }, + requireEmail: { type: "boolean", description: "Require email for bookings" }, + requirePhone: { type: "boolean", description: "Require phone for bookings" }, + }, + additionalProperties: true, + }, + }, + }, + }, + responses: { + "200": { + description: "Configuration updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + updatedKeys: { + type: "array", + items: { type: "string" }, + description: "List of configuration keys that were updated", + }, + }, + required: ["message", "updatedKeys"], + }, + example: { + message: "Configuration updated successfully", + updatedKeys: ["brandColor", "requireEmail"], + }, + }, + }, + }, + "400": { + description: "Invalid input data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Invalid configuration data" }, + }, + }, + }, + "404": { + description: "Tenant not found", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Tenant not found" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const GET: RequestHandler = async ({ params }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; + try { + const tenantId = params.id; - log.debug("Getting tenant configuration", { tenantId }); + log.debug("Getting tenant configuration", { tenantId }); - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - const tenantService = await TenantAdminService.getTenantById(tenantId); - const config = await tenantService.configuration; + const tenantService = await TenantAdminService.getTenantById(tenantId); + const config = await tenantService.configuration; - log.debug("Tenant configuration retrieved successfully", { - tenantId, - configKeys: Object.keys(config) - }); + log.debug("Tenant configuration retrieved successfully", { + tenantId, + configKeys: Object.keys(config), + }); - return json(config); - } catch (error) { - log.error("Error getting tenant configuration:", JSON.stringify(error || "?")); + return json(config); + } catch (error) { + log.error("Error getting tenant configuration:", JSON.stringify(error || "?")); - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; export const PUT: RequestHandler = async ({ params, request }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const body = await request.json(); + try { + const tenantId = params.id; + const body = await request.json(); - log.debug("Updating tenant configuration", { - tenantId, - configKeys: Object.keys(body) - }); + log.debug("Updating tenant configuration", { + tenantId, + configKeys: Object.keys(body), + }); - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - const tenantService = await TenantAdminService.getTenantById(tenantId); - await tenantService.updateTenantConfig(body); + const tenantService = await TenantAdminService.getTenantById(tenantId); + await tenantService.updateTenantConfig(body); - log.debug("Tenant configuration updated successfully", { - tenantId, - configKeys: Object.keys(body) - }); + log.debug("Tenant configuration updated successfully", { + tenantId, + configKeys: Object.keys(body), + }); - return json({ - message: "Configuration updated successfully", - updatedKeys: Object.keys(body) - }); - } catch (error) { - log.error("Error updating tenant configuration:", JSON.stringify(error || "?")); + return json({ + message: "Configuration updated successfully", + updatedKeys: Object.keys(body), + }); + } catch (error) { + log.error("Error updating tenant configuration:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/[id]/setup-state/+server.ts b/src/routes/api/tenants/[id]/setup-state/+server.ts index 7a7d828..1fd9f3b 100644 --- a/src/routes/api/tenants/[id]/setup-state/+server.ts +++ b/src/routes/api/tenants/[id]/setup-state/+server.ts @@ -7,143 +7,143 @@ import logger from "$lib/logger"; import z from "zod/v4"; const setupStateSchema = z.object({ - setupState: z.enum(["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]) + setupState: z.enum(["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"]), }); // Register OpenAPI documentation for PUT registerOpenAPIRoute("/tenants/{id}/setup-state", "PUT", { - summary: "Update tenant setup state", - description: "Updates the setup state for a specific tenant", - tags: ["Tenants"], - parameters: [ - { - name: "id", - in: "path", - required: true, - schema: { type: "string" }, - description: "Tenant ID" - } - ], - requestBody: { - description: "Setup state update", - content: { - "application/json": { - schema: { - type: "object", - properties: { - setupState: { - type: "string", - enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], - description: "The new setup state for the tenant" - } - }, - required: ["setupState"] - } - } - } - }, - responses: { - "200": { - description: "Tenant setup state updated successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - message: { type: "string", description: "Success message" }, - tenant: { - type: "object", - properties: { - id: { type: "string", description: "Tenant ID" }, - setupState: { - type: "string", - enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], - description: "Current setup state" - }, - updatedAt: { - type: "string", - format: "date-time", - description: "Last update timestamp" - } - } - } - }, - required: ["message", "tenant"] - } - } - } - }, - "400": { - description: "Invalid input data", - 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" } - } - } - } - } + summary: "Update tenant setup state", + description: "Updates the setup state for a specific tenant", + tags: ["Tenants"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Setup state update", + content: { + "application/json": { + schema: { + type: "object", + properties: { + setupState: { + type: "string", + enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], + description: "The new setup state for the tenant", + }, + }, + required: ["setupState"], + }, + }, + }, + }, + responses: { + "200": { + description: "Tenant setup state updated successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + message: { type: "string", description: "Success message" }, + tenant: { + type: "object", + properties: { + id: { type: "string", description: "Tenant ID" }, + setupState: { + type: "string", + enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"], + description: "Current setup state", + }, + updatedAt: { + type: "string", + format: "date-time", + description: "Last update timestamp", + }, + }, + }, + }, + required: ["message", "tenant"], + }, + }, + }, + }, + "400": { + description: "Invalid input data", + 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 PUT: RequestHandler = async ({ params, request }) => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - const tenantId = params.id; - const body = await request.json(); + try { + const tenantId = params.id; + const body = await request.json(); - log.debug("Updating tenant setup state", { - tenantId, - setupState: body.setupState - }); + log.debug("Updating tenant setup state", { + tenantId, + setupState: body.setupState, + }); - if (!tenantId) { - return json({ error: "No tenant id given" }, { status: 400 }); - } + if (!tenantId) { + return json({ error: "No tenant id given" }, { status: 400 }); + } - const validation = setupStateSchema.safeParse(body); - if (!validation.success) { - return json({ error: "Invalid setup state" }, { status: 400 }); - } + const validation = setupStateSchema.safeParse(body); + if (!validation.success) { + return json({ error: "Invalid setup state" }, { status: 400 }); + } - const tenantService = await TenantAdminService.getTenantById(tenantId); - const updatedTenant = await tenantService.setSetupState(validation.data.setupState); + const tenantService = await TenantAdminService.getTenantById(tenantId); + const updatedTenant = await tenantService.setSetupState(validation.data.setupState); - log.debug("Tenant setup state updated successfully", { - tenantId, - setupState: validation.data.setupState - }); + log.debug("Tenant setup state updated successfully", { + tenantId, + setupState: validation.data.setupState, + }); - return json({ - message: "Tenant setup state updated successfully", - tenant: updatedTenant - }); - } catch (error) { - log.error("Error updating tenant setup state:", JSON.stringify(error || "?")); + return json({ + message: "Tenant setup state updated successfully", + tenant: updatedTenant, + }); + } catch (error) { + log.error("Error updating tenant setup state:", JSON.stringify(error || "?")); - if (error instanceof ValidationError) { - return json({ error: error.message }, { status: 400 }); - } + if (error instanceof ValidationError) { + return json({ error: error.message }, { status: 400 }); + } - if (error instanceof NotFoundError) { - return json({ error: "Tenant not found" }, { status: 404 }); - } + if (error instanceof NotFoundError) { + return json({ error: "Tenant not found" }, { status: 404 }); + } - return json({ error: "Internal server error" }, { status: 500 }); - } + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/config/defaults/+server.ts b/src/routes/api/tenants/config/defaults/+server.ts index bf2508e..8643c58 100644 --- a/src/routes/api/tenants/config/defaults/+server.ts +++ b/src/routes/api/tenants/config/defaults/+server.ts @@ -6,91 +6,91 @@ import logger from "$lib/logger"; // Register OpenAPI documentation registerOpenAPIRoute("/tenants/config/defaults", "GET", { - summary: "Get default tenant configuration", - description: "Returns the default configuration values for new tenants", - tags: ["Tenants", "Configuration"], - responses: { - "200": { - description: "Default configuration retrieved successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - brandColor: { - type: "string", - description: "Default brand color", - example: "#E11E15" - }, - defaultLanguage: { - type: "string", - description: "Default language code", - example: "DE" - }, - maxChannels: { - type: "number", - description: "Maximum channels (-1 for unlimited)", - example: -1 - }, - maxTeamMembers: { - type: "number", - description: "Maximum team members (-1 for unlimited)", - example: -1 - }, - autoDeleteDays: { - type: "number", - description: "Auto-delete data after days", - example: 30 - }, - requireEmail: { - type: "boolean", - description: "Require email for bookings", - example: true - }, - requirePhone: { - type: "boolean", - description: "Require phone for bookings", - example: false - } - }, - required: [ - "brandColor", - "defaultLanguage", - "maxChannels", - "maxTeamMembers", - "autoDeleteDays", - "requireEmail", - "requirePhone" - ] - } - } - } - }, - "500": { - description: "Internal server error", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Internal server error" } - } - } - } - } + summary: "Get default tenant configuration", + description: "Returns the default configuration values for new tenants", + tags: ["Tenants", "Configuration"], + responses: { + "200": { + description: "Default configuration retrieved successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + brandColor: { + type: "string", + description: "Default brand color", + example: "#E11E15", + }, + defaultLanguage: { + type: "string", + description: "Default language code", + example: "DE", + }, + maxChannels: { + type: "number", + description: "Maximum channels (-1 for unlimited)", + example: -1, + }, + maxTeamMembers: { + type: "number", + description: "Maximum team members (-1 for unlimited)", + example: -1, + }, + autoDeleteDays: { + type: "number", + description: "Auto-delete data after days", + example: 30, + }, + requireEmail: { + type: "boolean", + description: "Require email for bookings", + example: true, + }, + requirePhone: { + type: "boolean", + description: "Require phone for bookings", + example: false, + }, + }, + required: [ + "brandColor", + "defaultLanguage", + "maxChannels", + "maxTeamMembers", + "autoDeleteDays", + "requireEmail", + "requirePhone", + ], + }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Internal server error" }, + }, + }, + }, + }, }); export const GET: RequestHandler = async () => { - const log = logger.setContext("API"); + const log = logger.setContext("API"); - try { - log.debug("Getting default tenant configuration"); + try { + log.debug("Getting default tenant configuration"); - const defaults = TenantAdminService.getConfigDefaults(); + const defaults = TenantAdminService.getConfigDefaults(); - log.debug("Default configuration retrieved successfully"); + log.debug("Default configuration retrieved successfully"); - return json(defaults); - } catch (error) { - log.error("Error getting default configuration:", JSON.stringify(error || "?")); - return json({ error: "Internal server error" }, { status: 500 }); - } + return json(defaults); + } catch (error) { + log.error("Error getting default configuration:", JSON.stringify(error || "?")); + return json({ error: "Internal server error" }, { status: 500 }); + } }; diff --git a/src/routes/api/tenants/tenant-routes.test.ts b/src/routes/api/tenants/tenant-routes.test.ts index 27e8ff2..3441227 100644 --- a/src/routes/api/tenants/tenant-routes.test.ts +++ b/src/routes/api/tenants/tenant-routes.test.ts @@ -3,253 +3,253 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; // Mock dependencies before importing the actual modules vi.mock("$lib/server/services/tenant-admin-service", () => ({ - TenantAdminService: { - getTenantById: vi.fn(), - createTenant: vi.fn(), - getConfigDefaults: vi.fn() - } + TenantAdminService: { + getTenantById: vi.fn(), + createTenant: vi.fn(), + getConfigDefaults: vi.fn(), + }, })); vi.mock("$lib/server/openapi", () => ({ - registerOpenAPIRoute: vi.fn() + registerOpenAPIRoute: vi.fn(), })); vi.mock("@sveltejs/kit", () => ({ - json: vi.fn((data, options) => ({ - json: () => Promise.resolve(data), - data, - status: options?.status || 200 - })) + json: vi.fn((data, options) => ({ + json: () => Promise.resolve(data), + data, + status: options?.status || 200, + })), })); vi.mock("$lib/logger", () => ({ - default: { - setContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn() - })) - } + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + })), + }, })); vi.mock("$lib/server/utils/errors", () => ({ - ValidationError: class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ValidationError"; - } - }, - NotFoundError: class NotFoundError extends Error { - constructor(message: string) { - super(message); - this.name = "NotFoundError"; - } - } + ValidationError: class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + }, + NotFoundError: class NotFoundError extends Error { + constructor(message: string) { + super(message); + this.name = "NotFoundError"; + } + }, })); describe("Tenant API Routes", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - describe("PUT /api/tenants/123", () => { - it("should update tenant metadata successfully", async () => { - // Dynamic import to avoid module loading issues - const { PUT } = await import("./[id]/+server.js"); - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + describe("PUT /api/tenants/123", () => { + it("should update tenant metadata successfully", async () => { + // Dynamic import to avoid module loading issues + const { PUT } = await import("./[id]/+server.js"); + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const mockTenantService = { - updateTenantData: vi.fn().mockResolvedValue({ - id: "123", - shortName: "updated-tenant", - longName: "Updated Tenant Corp", - description: "Updated description", - updatedAt: new Date() - }) - }; + const mockTenantService = { + updateTenantData: vi.fn().mockResolvedValue({ + id: "123", + shortName: "updated-tenant", + longName: "Updated Tenant Corp", + description: "Updated description", + updatedAt: new Date(), + }), + }; - vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); + vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); - const mockRequest = { - json: () => - Promise.resolve({ - longName: "Updated Tenant Corp", - description: "Updated description" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + longName: "Updated Tenant Corp", + description: "Updated description", + }), + }; - const response = await PUT({ - params: { id: "123" }, - request: mockRequest as any - } as any); - const data = await response.json(); + const response = await PUT({ + params: { id: "123" }, + request: mockRequest as any, + } as any); + const data = await response.json(); - expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123"); - expect(mockTenantService.updateTenantData).toHaveBeenCalledWith({ - longName: "Updated Tenant Corp", - description: "Updated description" - }); + expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123"); + expect(mockTenantService.updateTenantData).toHaveBeenCalledWith({ + longName: "Updated Tenant Corp", + description: "Updated description", + }); - expect(data.message).toBe("Tenant metadata updated successfully"); - expect(data.tenant.longName).toBe("Updated Tenant Corp"); - }); + expect(data.message).toBe("Tenant metadata updated successfully"); + expect(data.tenant.longName).toBe("Updated Tenant Corp"); + }); - it("should handle missing tenant ID", async () => { - const { PUT } = await import("./[id]/+server.js"); + it("should handle missing tenant ID", async () => { + const { PUT } = await import("./[id]/+server.js"); - const mockRequest = { - json: () => - Promise.resolve({ - longName: "Updated Name" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + longName: "Updated Name", + }), + }; - const response = await PUT({ - params: { id: "" }, - request: mockRequest as any - } as any); - const data = await response.json(); + const response = await PUT({ + params: { id: "" }, + request: mockRequest as any, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "No tenant id given" - }); - expect(response.status).toBe(400); - }); - }); + expect(data).toEqual({ + error: "No tenant id given", + }); + expect(response.status).toBe(400); + }); + }); - describe("GET /api/tenants/123/config", () => { - it("should get tenant configuration successfully", async () => { - const { GET } = await import("./[id]/config/+server.js"); - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + describe("GET /api/tenants/123/config", () => { + it("should get tenant configuration successfully", async () => { + const { GET } = await import("./[id]/config/+server.js"); + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const mockConfig = { - brandColor: "#E11E15", - defaultLanguage: "DE", - maxChannels: 5, - requireEmail: true - }; + const mockConfig = { + brandColor: "#E11E15", + defaultLanguage: "DE", + maxChannels: 5, + requireEmail: true, + }; - const mockTenantService = { - configuration: mockConfig - }; + const mockTenantService = { + configuration: mockConfig, + }; - vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); + vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); - const response = await GET({ params: { id: "123" } } as any); - const data = await response.json(); + const response = await GET({ params: { id: "123" } } as any); + const data = await response.json(); - expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123"); - expect(data).toEqual(mockConfig); - }); + expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123"); + expect(data).toEqual(mockConfig); + }); - it("should handle missing tenant ID in GET", async () => { - const { GET } = await import("./[id]/config/+server.js"); + it("should handle missing tenant ID in GET", async () => { + const { GET } = await import("./[id]/config/+server.js"); - const response = await GET({ params: { id: "" } } as any); - const data = await response.json(); + const response = await GET({ params: { id: "" } } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "No tenant id given" - }); - expect(response.status).toBe(400); - }); - }); + expect(data).toEqual({ + error: "No tenant id given", + }); + expect(response.status).toBe(400); + }); + }); - describe("PUT /api/tenants/123/config", () => { - it("should update tenant configuration successfully", async () => { - const { PUT } = await import("./[id]/config/+server.js"); - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + describe("PUT /api/tenants/123/config", () => { + it("should update tenant configuration successfully", async () => { + const { PUT } = await import("./[id]/config/+server.js"); + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const mockTenantService = { - updateTenantConfig: vi.fn().mockResolvedValue([ - { key: "brandColor", value: "#FF0000" }, - { key: "maxChannels", value: 10 } - ]) - }; + const mockTenantService = { + updateTenantConfig: vi.fn().mockResolvedValue([ + { key: "brandColor", value: "#FF0000" }, + { key: "maxChannels", value: 10 }, + ]), + }; - vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); + vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); - const mockRequest = { - json: () => - Promise.resolve({ - brandColor: "#FF0000", - maxChannels: 10 - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + brandColor: "#FF0000", + maxChannels: 10, + }), + }; - const response = await PUT({ - params: { id: "123" }, - request: mockRequest as any - } as any); - const data = await response.json(); + const response = await PUT({ + params: { id: "123" }, + request: mockRequest as any, + } as any); + const data = await response.json(); - expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123"); - expect(mockTenantService.updateTenantConfig).toHaveBeenCalledWith({ - brandColor: "#FF0000", - maxChannels: 10 - }); + expect(TenantAdminService.getTenantById).toHaveBeenCalledWith("123"); + expect(mockTenantService.updateTenantConfig).toHaveBeenCalledWith({ + brandColor: "#FF0000", + maxChannels: 10, + }); - expect(data.message).toBe("Configuration updated successfully"); - expect(data.updatedKeys).toEqual(["brandColor", "maxChannels"]); - }); + expect(data.message).toBe("Configuration updated successfully"); + expect(data.updatedKeys).toEqual(["brandColor", "maxChannels"]); + }); - it("should handle validation errors", async () => { - const { PUT } = await import("./[id]/config/+server.js"); - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const { ValidationError } = await import("$lib/server/utils/errors"); + it("should handle validation errors", async () => { + const { PUT } = await import("./[id]/config/+server.js"); + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + const { ValidationError } = await import("$lib/server/utils/errors"); - const mockTenantService = { - updateTenantConfig: vi - .fn() - .mockRejectedValue(new ValidationError("Invalid configuration data")) - }; + const mockTenantService = { + updateTenantConfig: vi + .fn() + .mockRejectedValue(new ValidationError("Invalid configuration data")), + }; - vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); + vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any); - const mockRequest = { - json: () => - Promise.resolve({ - maxChannels: "invalid" // Should be number - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + maxChannels: "invalid", // Should be number + }), + }; - const response = await PUT({ - params: { id: "123" }, - request: mockRequest as any - } as any); - const data = await response.json(); + const response = await PUT({ + params: { id: "123" }, + request: mockRequest as any, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "Invalid configuration data" - }); - expect(response.status).toBe(400); - }); + expect(data).toEqual({ + error: "Invalid configuration data", + }); + expect(response.status).toBe(400); + }); - it("should handle tenant not found", async () => { - const { PUT } = await import("./[id]/config/+server.js"); - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const { NotFoundError } = await import("$lib/server/utils/errors"); + it("should handle tenant not found", async () => { + const { PUT } = await import("./[id]/config/+server.js"); + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + const { NotFoundError } = await import("$lib/server/utils/errors"); - vi.mocked(TenantAdminService.getTenantById).mockRejectedValue( - new NotFoundError("Tenant not found") - ); + vi.mocked(TenantAdminService.getTenantById).mockRejectedValue( + new NotFoundError("Tenant not found"), + ); - const mockRequest = { - json: () => - Promise.resolve({ - brandColor: "#FF0000" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + brandColor: "#FF0000", + }), + }; - const response = await PUT({ - params: { id: "non-existent-id" }, - request: mockRequest as any - } as any); - const data = await response.json(); + const response = await PUT({ + params: { id: "non-existent-id" }, + request: mockRequest as any, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "Tenant not found" - }); - expect(response.status).toBe(404); - }); - }); + expect(data).toEqual({ + error: "Tenant not found", + }); + expect(response.status).toBe(404); + }); + }); }); diff --git a/src/routes/api/tenants/tenants.test.ts b/src/routes/api/tenants/tenants.test.ts index 7c2473b..f52dcc7 100644 --- a/src/routes/api/tenants/tenants.test.ts +++ b/src/routes/api/tenants/tenants.test.ts @@ -5,320 +5,320 @@ import { GET } from "./config/defaults/+server.js"; // Mock the TenantAdminService vi.mock("$lib/server/services/tenant-admin-service", () => ({ - TenantAdminService: { - createTenant: vi.fn(), - getConfigDefaults: vi.fn() - } + TenantAdminService: { + createTenant: vi.fn(), + getConfigDefaults: vi.fn(), + }, })); // Mock the OpenAPI registration vi.mock("$lib/server/openapi", () => ({ - registerOpenAPIRoute: vi.fn() + registerOpenAPIRoute: vi.fn(), })); // Mock SvelteKit json helper vi.mock("@sveltejs/kit", () => ({ - json: vi.fn((data, options) => ({ - json: () => Promise.resolve(data), - data, - status: options?.status || 200 - })) + json: vi.fn((data, options) => ({ + json: () => Promise.resolve(data), + data, + status: options?.status || 200, + })), })); // Mock logger vi.mock("$lib/logger", () => ({ - default: { - setContext: vi.fn(() => ({ - debug: vi.fn(), - error: vi.fn() - })) - } + default: { + setContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + })), + }, })); // Mock ValidationError vi.mock("$lib/server/utils/errors", () => ({ - ValidationError: class ValidationError extends Error { - constructor(message: string) { - super(message); - this.name = "ValidationError"; - } - } + ValidationError: class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ValidationError"; + } + }, })); describe("/api/tenants", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); + beforeEach(() => { + vi.clearAllMocks(); + }); - describe("POST /api/tenants", () => { - it("should create a tenant successfully", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + describe("POST /api/tenants", () => { + it("should create a tenant successfully", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const mockTenantService = { - tenantId: "test-tenant-id" - }; + const mockTenantService = { + tenantId: "test-tenant-id", + }; - vi.mocked(TenantAdminService.createTenant).mockResolvedValue(mockTenantService as any); + vi.mocked(TenantAdminService.createTenant).mockResolvedValue(mockTenantService as any); - const mockRequest = { - json: () => - Promise.resolve({ - shortName: "test-tenant", - inviteAdmin: "admin@test.com" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + shortName: "test-tenant", + inviteAdmin: "admin@test.com", + }), + }; - const response = await POST({ - request: mockRequest, - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {} - } as any); - const data = await response.json(); + const response = await POST({ + request: mockRequest, + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + } as any); + const data = await response.json(); - expect(TenantAdminService.createTenant).toHaveBeenCalledWith({ - shortName: "test-tenant", - inviteAdmin: "admin@test.com" - }); + expect(TenantAdminService.createTenant).toHaveBeenCalledWith({ + shortName: "test-tenant", + inviteAdmin: "admin@test.com", + }); - expect(data).toEqual({ - message: "Tenant created successfully", - tenantId: "test-tenant-id", - shortName: "test-tenant" - }); - expect(response.status).toBe(201); - }); + expect(data).toEqual({ + message: "Tenant created successfully", + tenantId: "test-tenant-id", + shortName: "test-tenant", + }); + expect(response.status).toBe(201); + }); - it("should create a tenant without invite admin", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + it("should create a tenant without invite admin", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const mockTenantService = { - tenantId: "test-tenant-id-2" - }; + const mockTenantService = { + tenantId: "test-tenant-id-2", + }; - vi.mocked(TenantAdminService.createTenant).mockResolvedValue(mockTenantService as any); + vi.mocked(TenantAdminService.createTenant).mockResolvedValue(mockTenantService as any); - const mockRequest = { - json: () => - Promise.resolve({ - shortName: "test-tenant-2" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + shortName: "test-tenant-2", + }), + }; - const response = await POST({ - request: mockRequest, - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {} - } as any); - const data = await response.json(); + const response = await POST({ + request: mockRequest, + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + } as any); + const data = await response.json(); - expect(TenantAdminService.createTenant).toHaveBeenCalledWith({ - shortName: "test-tenant-2" - }); + expect(TenantAdminService.createTenant).toHaveBeenCalledWith({ + shortName: "test-tenant-2", + }); - expect(data).toEqual({ - message: "Tenant created successfully", - tenantId: "test-tenant-id-2", - shortName: "test-tenant-2" - }); - expect(response.status).toBe(201); - }); + expect(data).toEqual({ + message: "Tenant created successfully", + tenantId: "test-tenant-id-2", + shortName: "test-tenant-2", + }); + expect(response.status).toBe(201); + }); - it("should handle validation errors", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const { ValidationError } = await import("$lib/server/utils/errors"); + it("should handle validation errors", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + const { ValidationError } = await import("$lib/server/utils/errors"); - vi.mocked(TenantAdminService.createTenant).mockRejectedValue( - new ValidationError("Invalid tenant creation request") - ); + vi.mocked(TenantAdminService.createTenant).mockRejectedValue( + new ValidationError("Invalid tenant creation request"), + ); - const mockRequest = { - json: () => - Promise.resolve({ - shortName: "ab" // Too short - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + shortName: "ab", // Too short + }), + }; - const response = await POST({ - request: mockRequest, - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {} - } as any); - const data = await response.json(); + const response = await POST({ + request: mockRequest, + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "Invalid tenant creation request" - }); - expect(response.status).toBe(400); - }); + expect(data).toEqual({ + error: "Invalid tenant creation request", + }); + expect(response.status).toBe(400); + }); - it("should handle unique constraint violations", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + it("should handle unique constraint violations", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - vi.mocked(TenantAdminService.createTenant).mockRejectedValue( - new Error("unique constraint violation") - ); + vi.mocked(TenantAdminService.createTenant).mockRejectedValue( + new Error("unique constraint violation"), + ); - const mockRequest = { - json: () => - Promise.resolve({ - shortName: "existing-tenant" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + shortName: "existing-tenant", + }), + }; - const response = await POST({ - request: mockRequest, - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {} - } as any); - const data = await response.json(); + const response = await POST({ + request: mockRequest, + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "A tenant with this short name already exists" - }); - expect(response.status).toBe(409); - }); + expect(data).toEqual({ + error: "A tenant with this short name already exists", + }); + expect(response.status).toBe(409); + }); - it("should handle internal server errors", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + it("should handle internal server errors", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - vi.mocked(TenantAdminService.createTenant).mockRejectedValue( - new Error("Database connection failed") - ); + vi.mocked(TenantAdminService.createTenant).mockRejectedValue( + new Error("Database connection failed"), + ); - const mockRequest = { - json: () => - Promise.resolve({ - shortName: "test-tenant" - }) - }; + const mockRequest = { + json: () => + Promise.resolve({ + shortName: "test-tenant", + }), + }; - const response = await POST({ - request: mockRequest, - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {} - } as any); - const data = await response.json(); + const response = await POST({ + request: mockRequest, + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "Internal server error" - }); - expect(response.status).toBe(500); - }); - }); + expect(data).toEqual({ + error: "Internal server error", + }); + expect(response.status).toBe(500); + }); + }); - describe("GET /api/tenants/config/defaults", () => { - it("should return default configuration", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + describe("GET /api/tenants/config/defaults", () => { + it("should return default configuration", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - const mockDefaults = { - brandColor: "#E11E15", - defaultLanguage: "DE", - maxChannels: -1, - maxTeamMembers: -1, - autoDeleteDays: 30, - requireEmail: true, - requirePhone: false, - nextChannelColor: 0, - website: "", - imprint: "", - privacyStatement: "" - }; + const mockDefaults = { + brandColor: "#E11E15", + defaultLanguage: "DE", + maxChannels: -1, + maxTeamMembers: -1, + autoDeleteDays: 30, + requireEmail: true, + requirePhone: false, + nextChannelColor: 0, + website: "", + imprint: "", + privacyStatement: "", + }; - vi.mocked(TenantAdminService.getConfigDefaults).mockReturnValue(mockDefaults); + vi.mocked(TenantAdminService.getConfigDefaults).mockReturnValue(mockDefaults); - const response = await GET({ - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {}, - request: {} as any - } as any); - const data = await response.json(); + const response = await GET({ + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + request: {} as any, + } as any); + const data = await response.json(); - expect(TenantAdminService.getConfigDefaults).toHaveBeenCalled(); - expect(data).toEqual(mockDefaults); - expect(response.status).toBe(200); - }); + expect(TenantAdminService.getConfigDefaults).toHaveBeenCalled(); + expect(data).toEqual(mockDefaults); + expect(response.status).toBe(200); + }); - it("should handle errors when getting defaults", async () => { - const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); + it("should handle errors when getting defaults", async () => { + const { TenantAdminService } = await import("$lib/server/services/tenant-admin-service"); - vi.mocked(TenantAdminService.getConfigDefaults).mockImplementation(() => { - throw new Error("Configuration error"); - }); + vi.mocked(TenantAdminService.getConfigDefaults).mockImplementation(() => { + throw new Error("Configuration error"); + }); - const response = await GET({ - url: new URL("http://localhost"), - params: {}, - route: { id: "" }, - cookies: {} as any, - fetch: {} as any, - getClientAddress: () => "", - isDataRequest: false, - isSubRequest: false, - platform: undefined, - setHeaders: {} as any, - locals: {}, - request: {} as any - } as any); - const data = await response.json(); + const response = await GET({ + url: new URL("http://localhost"), + params: {}, + route: { id: "" }, + cookies: {} as any, + fetch: {} as any, + getClientAddress: () => "", + isDataRequest: false, + isSubRequest: false, + platform: undefined, + setHeaders: {} as any, + locals: {}, + request: {} as any, + } as any); + const data = await response.json(); - expect(data).toEqual({ - error: "Internal server error" - }); - expect(response.status).toBe(500); - }); - }); + expect(data).toEqual({ + error: "Internal server error", + }); + expect(response.status).toBe(500); + }); + }); }); diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index 5a94c40..b00eed1 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -8,113 +8,113 @@ const logger = new UniversalLogger().setContext("AuthHandle"); const PROTECTED_PATHS = ["/api/admin", "/api/tenant-admin", "/api/tenants", "/api/auth/register"]; const PUBLIC_PATHS = [ - "/", - "/api/auth/challenge", - "/api/auth/login", - "/api/auth/register", - "/api/auth/confirm", - "/api/auth/resend-confirmation", - "/api/health", - "/api/docs", - "/api/openapi.json", - "/api/env", - "/api/log", - "/api/admin/init", - "/api/admin/exists" + "/", + "/api/auth/challenge", + "/api/auth/login", + "/api/auth/register", + "/api/auth/confirm", + "/api/auth/resend-confirmation", + "/api/health", + "/api/docs", + "/api/openapi.json", + "/api/env", + "/api/log", + "/api/admin/init", + "/api/admin/exists", ]; const GLOBAL_ADMIN_PATHS = ["/api/admin", "/api/tenants"]; const ADMIN_PATHS = ["/api/tenant-admin"]; const PROTECTED_AUTH_PATHS = [ - "/api/auth/logout", - "/api/auth/refresh", - "/api/auth/session", - "/api/auth/sessions", - "/api/auth/passkeys", - "/api/auth/invite" + "/api/auth/logout", + "/api/auth/refresh", + "/api/auth/session", + "/api/auth/sessions", + "/api/auth/passkeys", + "/api/auth/invite", ]; export const apiAuthHandle: Handle = async ({ event, resolve }) => { - const { url } = event; - const path = url.pathname; + const { url } = event; + const path = url.pathname; - if (!path.startsWith("/api")) { - return resolve(event); - } + if (!path.startsWith("/api")) { + return resolve(event); + } - 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)); + 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 - if ( - isPublicPath && - !isProtectedAuthPath && - !isProtectedPath && - !isAdminPath && - !isGlobalAdminPath - ) { - return resolve(event); - } + // Allow public paths + if ( + isPublicPath && + !isProtectedAuthPath && + !isProtectedPath && + !isAdminPath && + !isGlobalAdminPath + ) { + return resolve(event); + } - // Require authentication for protected paths and protected auth paths - if (!isProtectedPath && !isProtectedAuthPath) { - return new Response( - JSON.stringify({ error: `Path ${path} not handled by auth. This is an error` }), - { - status: 400, - headers: { "Content-Type": "application/json" } - } - ); - } + // Require authentication for protected paths and protected auth paths + if (!isProtectedPath && !isProtectedAuthPath) { + return new Response( + JSON.stringify({ error: `Path ${path} not handled by auth. This is an error` }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } - const accessToken: string | null = getAccessToken(event); - if (!accessToken) { - logger.warn(`Authentication required for ${path}`); - return new Response(JSON.stringify({ error: "Authentication required" }), { - status: 401, - headers: { "Content-Type": "application/json" } - }); - } + const accessToken: string | null = getAccessToken(event); + if (!accessToken) { + logger.warn(`Authentication required for ${path}`); + return new Response(JSON.stringify({ error: "Authentication required" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } - // Verify access token with database session check - const sessionData = await SessionService.validateTokenWithDB(accessToken); - if (!sessionData) { - logger.warn(`Invalid or revoked access token for ${path}`); - return new Response(JSON.stringify({ error: "Invalid or expired access token" }), { - status: 401, - headers: { "Content-Type": "application/json" } - }); - } + // Verify access token with database session check + const sessionData = await SessionService.validateTokenWithDB(accessToken); + if (!sessionData) { + logger.warn(`Invalid or revoked access token for ${path}`); + return new Response(JSON.stringify({ error: "Invalid or expired access token" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } - // Add sessionId to the user object for easy access - event.locals.user = { - userId: sessionData.user.id, - exp: sessionData.exp.valueOf(), - ...sessionData.user, - sessionId: sessionData.sessionId - }; + // Add sessionId to the user object for easy access + event.locals.user = { + userId: sessionData.user.id, + exp: sessionData.exp.valueOf(), + ...sessionData.user, + sessionId: sessionData.sessionId, + }; - if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { - return new Response(JSON.stringify({ error: "Authentication failed" }), { - status: 403, - headers: { "Content-Type": "application/json" } - }); - } else if ( - isAdminPath && - !AuthorizationService.hasAnyRole(sessionData.user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]) - ) { - return new Response(JSON.stringify({ error: "Authentication failed" }), { - status: 403, - headers: { "Content-Type": "application/json" } - }); - } - // Protected auth paths require any authenticated user (no specific role required) - // The authentication check above is sufficient + if (isGlobalAdminPath && !AuthorizationService.hasRole(sessionData.user, "GLOBAL_ADMIN")) { + return new Response(JSON.stringify({ error: "Authentication failed" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } else if ( + isAdminPath && + !AuthorizationService.hasAnyRole(sessionData.user, ["GLOBAL_ADMIN", "TENANT_ADMIN"]) + ) { + return new Response(JSON.stringify({ error: "Authentication failed" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + // Protected auth paths require any authenticated user (no specific role required) + // The authentication check above is sufficient - logger.debug(`User authenticated: ${sessionData.user.email} for ${path}`); + logger.debug(`User authenticated: ${sessionData.user.email} for ${path}`); - return resolve(event); + return resolve(event); }; diff --git a/src/server-hooks/authGuard.ts b/src/server-hooks/authGuard.ts index 5489e89..76375d6 100644 --- a/src/server-hooks/authGuard.ts +++ b/src/server-hooks/authGuard.ts @@ -6,47 +6,47 @@ import { ROUTES } from "$lib/const/routes"; import { auth } from "$lib/stores/auth"; export const authGuard: Handle = async ({ event, resolve }) => { - const { url } = event; - const path = url.pathname; + const { url } = event; + const path = url.pathname; - // Do not handle api paths - if (path.startsWith("/api")) { - return resolve(event); - } + // Do not handle api paths + if (path.startsWith("/api")) { + return resolve(event); + } - // Only guard protected routes - const isDashboardRoute = path.startsWith(ROUTES.DASHBOARD.MAIN); - if (isDashboardRoute) { - // Check for accessToken - const accessToken: string | null = getAccessToken(event); - if (!accessToken) redirect(302, ROUTES.LOGIN); + // Only guard protected routes + const isDashboardRoute = path.startsWith(ROUTES.DASHBOARD.MAIN); + if (isDashboardRoute) { + // Check for accessToken + const accessToken: string | null = getAccessToken(event); + if (!accessToken) redirect(302, ROUTES.LOGIN); - // Verify access token with database session check - const sessionData = await SessionService.validateTokenWithDB(accessToken); - if (!sessionData) redirect(302, ROUTES.LOGIN); + // Verify access token with database session check + const sessionData = await SessionService.validateTokenWithDB(accessToken); + if (!sessionData) redirect(302, ROUTES.LOGIN); - // Set user in auth store for SSR - auth.setUser({ - id: sessionData.user.id, - email: sessionData.user.email, - name: sessionData.user.name, - role: sessionData.user.role, - tenantId: sessionData.user.tenantId - }); + // Set user in auth store for SSR + auth.setUser({ + id: sessionData.user.id, + email: sessionData.user.email, + name: sessionData.user.name, + role: sessionData.user.role, + tenantId: sessionData.user.tenantId, + }); - switch (true) { - case isDashboardRoute && - AuthorizationService.hasAnyRole(sessionData.user, [ - "GLOBAL_ADMIN", - "TENANT_ADMIN", - "STAFF" - ]): - return resolve(event); - default: - // Access not granted - redirect(302, ROUTES.LOGIN); - } - } + switch (true) { + case isDashboardRoute && + AuthorizationService.hasAnyRole(sessionData.user, [ + "GLOBAL_ADMIN", + "TENANT_ADMIN", + "STAFF", + ]): + return resolve(event); + default: + // Access not granted + redirect(302, ROUTES.LOGIN); + } + } - return resolve(event); + return resolve(event); }; diff --git a/src/server-hooks/corsHandle.ts b/src/server-hooks/corsHandle.ts index 6203e18..58d2a9f 100644 --- a/src/server-hooks/corsHandle.ts +++ b/src/server-hooks/corsHandle.ts @@ -9,19 +9,19 @@ import { type Handle } from "@sveltejs/kit"; * @returns {Promise} The response with applied headers and rate limiting */ export const corsHandle: Handle = async ({ event, resolve }) => { - const { request } = event; + const { request } = event; - if (request.method === "OPTIONS") { - return new Response(null, { - headers: { - "Access-Control-Allow-Origin": "*", // TODO: Should be limited to own server and registered webhooks etc. - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With", - "Access-Control-Max-Age": "86400" - } - }); - } + if (request.method === "OPTIONS") { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", // TODO: Should be limited to own server and registered webhooks etc. + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Requested-With", + "Access-Control-Max-Age": "86400", + }, + }); + } - const response = await resolve(event); - return response; + const response = await resolve(event); + return response; }; diff --git a/src/server-hooks/i18nHandle.ts b/src/server-hooks/i18nHandle.ts index 173db8a..8368a14 100644 --- a/src/server-hooks/i18nHandle.ts +++ b/src/server-hooks/i18nHandle.ts @@ -10,12 +10,12 @@ import { paraglideMiddleware } from "$i18n/server"; * @returns {Promise} The response with applied headers and rate limiting */ export const i18nHandle: Handle = ({ event, resolve }) => { - return paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { - event.request = localizedRequest; - return resolve(event, { - transformPageChunk: ({ html }) => { - return html.replace("%lang%", locale); - } - }); - }); + return paraglideMiddleware(event.request, ({ request: localizedRequest, locale }) => { + event.request = localizedRequest; + return resolve(event, { + transformPageChunk: ({ html }) => { + return html.replace("%lang%", locale); + }, + }); + }); }; diff --git a/src/server-hooks/loggingHandle.ts b/src/server-hooks/loggingHandle.ts index 6d163a6..e985892 100644 --- a/src/server-hooks/loggingHandle.ts +++ b/src/server-hooks/loggingHandle.ts @@ -10,20 +10,20 @@ import { logger } from "$lib/logger"; * @returns {Promise} The response with applied headers and rate limiting */ export const loggingHandle: Handle = async ({ event, resolve }) => { - if (!event.url.pathname.startsWith("/api")) { - return resolve(event); - } + if (!event.url.pathname.startsWith("/api")) { + return resolve(event); + } - const start = Date.now(); - const requestLogger = logger.setContext("REQUEST"); + const start = Date.now(); + const requestLogger = logger.setContext("REQUEST"); - const response = await resolve(event, { - preload: () => { - requestLogger.info(`Incoming ${event.request.method} ${event.url.pathname}`); - return true; - } - }); - const responseTime = Date.now() - start; - requestLogger.logRequest(event.request, responseTime, response.status); - return response; + const response = await resolve(event, { + preload: () => { + requestLogger.info(`Incoming ${event.request.method} ${event.url.pathname}`); + return true; + }, + }); + const responseTime = Date.now() - start; + requestLogger.logRequest(event.request, responseTime, response.status); + return response; }; diff --git a/src/server-hooks/rateLimitHandle.ts b/src/server-hooks/rateLimitHandle.ts index e8d7565..ea7044a 100644 --- a/src/server-hooks/rateLimitHandle.ts +++ b/src/server-hooks/rateLimitHandle.ts @@ -20,17 +20,17 @@ const RATE_LIMIT_MAX_REQUESTS = 10; * @returns {string} The client IP address or 'unknown' */ function getClientIP(request: Request): string { - const forwarded = request.headers.get("x-forwarded-for"); - if (forwarded) { - return forwarded.split(",")[0].trim(); - } + const forwarded = request.headers.get("x-forwarded-for"); + if (forwarded) { + return forwarded.split(",")[0].trim(); + } - const realIP = request.headers.get("x-real-ip"); - if (realIP) { - return realIP; - } + const realIP = request.headers.get("x-real-ip"); + if (realIP) { + return realIP; + } - return "unknown"; + return "unknown"; } /** @@ -43,35 +43,35 @@ function getClientIP(request: Request): string { * @returns {boolean} True if client is rate limited, false otherwise */ function isRateLimited(clientIP: string): boolean { - const now = Date.now(); - const key = clientIP; + const now = Date.now(); + const key = clientIP; - const record = rateLimitStore.get(key); + const record = rateLimitStore.get(key); - if (!record || now > record.resetTime) { - // Reset or create new record - rateLimitStore.set(key, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); - return false; - } + if (!record || now > record.resetTime) { + // Reset or create new record + rateLimitStore.set(key, { count: 1, resetTime: now + RATE_LIMIT_WINDOW }); + return false; + } - if (record.count >= RATE_LIMIT_MAX_REQUESTS) { - return true; - } + if (record.count >= RATE_LIMIT_MAX_REQUESTS) { + return true; + } - record.count++; - return false; + record.count++; + return false; } /** * Clean up limit store every minute */ setInterval(() => { - const now = Date.now(); - for (const [key, record] of rateLimitStore.entries()) { - if (now > record.resetTime) { - rateLimitStore.delete(key); - } - } + const now = Date.now(); + for (const [key, record] of rateLimitStore.entries()) { + if (now > record.resetTime) { + rateLimitStore.delete(key); + } + } }, 60000); /** @@ -83,20 +83,20 @@ setInterval(() => { * @returns {Promise} The response with applied headers and rate limiting */ export const rateLimitHandle: Handle = async ({ event, resolve }) => { - const { request } = event; - const clientIP = getClientIP(request); + const { request } = event; + const clientIP = getClientIP(request); - if (isRateLimited(clientIP)) { - return new Response("Too Many Requests", { - status: 429, - headers: { - "Retry-After": "1", - "Content-Type": "text/plain" - } - }); - } + if (isRateLimited(clientIP)) { + return new Response("Too Many Requests", { + status: 429, + headers: { + "Retry-After": "1", + "Content-Type": "text/plain", + }, + }); + } - const response = await resolve(event); + const response = await resolve(event); - return response; + return response; }; diff --git a/src/server-hooks/secHeaderHandle.ts b/src/server-hooks/secHeaderHandle.ts index 45a4ffd..35f9b11 100644 --- a/src/server-hooks/secHeaderHandle.ts +++ b/src/server-hooks/secHeaderHandle.ts @@ -9,45 +9,45 @@ import { type Handle } from "@sveltejs/kit"; * @returns {Promise} The response with applied headers and rate limiting */ export const secHeaderHandle: Handle = async ({ event, resolve }) => { - const response = await resolve(event); + const response = await resolve(event); - // TODO: These has to be rechecked and coordinated with caddy's configuration - response.headers.set("X-Frame-Options", "DENY"); - response.headers.set("X-Content-Type-Options", "nosniff"); - response.headers.set("X-XSS-Protection", "1; mode=block"); - response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); - response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + // TODO: These has to be rechecked and coordinated with caddy's configuration + response.headers.set("X-Frame-Options", "DENY"); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("X-XSS-Protection", "1; mode=block"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); - if (event.url.protocol === "https:") { - response.headers.set( - "Strict-Transport-Security", - "max-age=31536000; includeSubDomains; preload" - ); - } + if (event.url.protocol === "https:") { + response.headers.set( + "Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload", + ); + } - const cspDirectives = [ - "default-src 'self'", - "script-src 'self' 'unsafe-inline' https://unpkg.com", // Allow inline scripts and unpkg CDN (for Swagger) - "style-src 'self' 'unsafe-inline' https://unpkg.com", - "img-src 'self' data: https:", - "font-src 'self' data: https://unpkg.com", - "connect-src 'self'", - "media-src 'self'", - "object-src 'none'", - "base-uri 'self'", - "form-action 'self'", - "frame-ancestors 'none'", - "upgrade-insecure-requests" - ]; - response.headers.set("Content-Security-Policy", cspDirectives.join("; ")); + const cspDirectives = [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' https://unpkg.com", // Allow inline scripts and unpkg CDN (for Swagger) + "style-src 'self' 'unsafe-inline' https://unpkg.com", + "img-src 'self' data: https:", + "font-src 'self' data: https://unpkg.com", + "connect-src 'self'", + "media-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", + "upgrade-insecure-requests", + ]; + response.headers.set("Content-Security-Policy", cspDirectives.join("; ")); - if (event.url.pathname.startsWith("/api/")) { - response.headers.set("Access-Control-Allow-Origin", "*"); // TODO: Should be limited to own server and registered webhooks etc. - response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); - response.headers.set( - "Access-Control-Allow-Headers", - "Content-Type, Authorization, X-Requested-With" - ); - } - return response; + if (event.url.pathname.startsWith("/api/")) { + response.headers.set("Access-Control-Allow-Origin", "*"); // TODO: Should be limited to own server and registered webhooks etc. + response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); + response.headers.set( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, X-Requested-With", + ); + } + return response; }; diff --git a/src/server-hooks/startupHandle.ts b/src/server-hooks/startupHandle.ts index 957d578..d79340d 100644 --- a/src/server-hooks/startupHandle.ts +++ b/src/server-hooks/startupHandle.ts @@ -8,27 +8,27 @@ const logger = new UniversalLogger().setContext("StartupHandle"); let initializationPromise: Promise | null = null; export const startupHandle: Handle = async ({ event, resolve }) => { - // Initialize the application on first request - if (!initializationPromise) { - logger.info("Initializing application on first request"); - initializationPromise = StartupService.initialize(); - } + // Initialize the application on first request + if (!initializationPromise) { + logger.info("Initializing application on first request"); + initializationPromise = StartupService.initialize(); + } - // Wait for initialization to complete - try { - await initializationPromise; - } catch (error) { - logger.error("Application initialization failed", { error: String(error) }); - // Return a 503 Service Unavailable response - return new Response("Service temporarily unavailable. Please try again later.", { - status: 503, - headers: { - "Content-Type": "text/plain", - "Retry-After": "30" - } - }); - } + // Wait for initialization to complete + try { + await initializationPromise; + } catch (error) { + logger.error("Application initialization failed", { error: String(error) }); + // Return a 503 Service Unavailable response + return new Response("Service temporarily unavailable. Please try again later.", { + status: 503, + headers: { + "Content-Type": "text/plain", + "Retry-After": "30", + }, + }); + } - // Continue with the request - return resolve(event); + // Continue with the request + return resolve(event); }; diff --git a/src/server-hooks/utils/accessToken.ts b/src/server-hooks/utils/accessToken.ts index 0fcb0d8..8ff89ae 100644 --- a/src/server-hooks/utils/accessToken.ts +++ b/src/server-hooks/utils/accessToken.ts @@ -2,23 +2,23 @@ import type { RequestEvent } from "@sveltejs/kit"; const ACCESS_TOKEN_COOKIE_NAME = "access_token"; export const getAccessToken = ( - event: RequestEvent>, string | null> + event: RequestEvent>, string | null>, ): string | null => { - let accessToken: string | null = null; + let accessToken: string | null = null; - // Get access token from cookie - const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); - if (accessTokenCookie) { - accessToken = accessTokenCookie; - } + // Get access token from cookie + const accessTokenCookie = event.cookies.get(ACCESS_TOKEN_COOKIE_NAME); + if (accessTokenCookie) { + accessToken = accessTokenCookie; + } - // Fallback: check Authorization header - if (!accessToken) { - const authHeader = event.request.headers.get("authorization"); - if (authHeader?.startsWith("Bearer ")) { - accessToken = authHeader.substring(7); - } - } + // Fallback: check Authorization header + if (!accessToken) { + const authHeader = event.request.headers.get("authorization"); + if (authHeader?.startsWith("Bearer ")) { + accessToken = authHeader.substring(7); + } + } - return accessToken; + return accessToken; }; diff --git a/static/lib/argon2/argon2-bundled.min.js b/static/lib/argon2/argon2-bundled.min.js index d595d4b..c79ffb4 100644 --- a/static/lib/argon2/argon2-bundled.min.js +++ b/static/lib/argon2/argon2-bundled.min.js @@ -1,686 +1,686 @@ !(function (A, I) { - "object" == typeof exports && "object" == typeof module - ? (module.exports = I()) - : "function" == typeof define && define.amd - ? define([], I) - : "object" == typeof exports - ? (exports.argon2 = I()) - : (A.argon2 = I()); + "object" == typeof exports && "object" == typeof module + ? (module.exports = I()) + : "function" == typeof define && define.amd + ? define([], I) + : "object" == typeof exports + ? (exports.argon2 = I()) + : (A.argon2 = I()); })(this, function () { - return (() => { - var A, - I, - g = { - 773: (A, I, g) => { - var B, - Q = "undefined" != typeof self && void 0 !== self.Module ? self.Module : {}, - C = {}; - for (B in Q) Q.hasOwnProperty(B) && (C[B] = Q[B]); - var E, - i, - o, - D, - e = []; - (E = "object" == typeof window), - (i = "function" == typeof importScripts), - (o = - "object" == typeof process && - "object" == typeof process.versions && - "string" == typeof process.versions.node), - (D = !E && !o && !i); - var n, - t, - a, - r, - s, - y = ""; - o - ? ((y = i ? g(967).dirname(y) + "/" : "//"), - (n = function (A, I) { - return ( - r || (r = g(145)), - s || (s = g(967)), - (A = s.normalize(A)), - r.readFileSync(A, I ? null : "utf8") - ); - }), - (a = function (A) { - var I = n(A, !0); - return I.buffer || (I = new Uint8Array(I)), G(I.buffer), I; - }), - process.argv.length > 1 && process.argv[1].replace(/\\/g, "/"), - (e = process.argv.slice(2)), - (A.exports = Q), - process.on("uncaughtException", function (A) { - if (!(A instanceof V)) throw A; - }), - process.on("unhandledRejection", u), - (Q.inspect = function () { - return "[Emscripten Module object]"; - })) - : D - ? ("undefined" != typeof read && - (n = function (A) { - return read(A); - }), - (a = function (A) { - var I; - return "function" == typeof readbuffer - ? new Uint8Array(readbuffer(A)) - : (G("object" == typeof (I = read(A, "binary"))), I); - }), - "undefined" != typeof scriptArgs - ? (e = scriptArgs) - : void 0 !== arguments && (e = arguments), - "undefined" != typeof print && - ("undefined" == typeof console && (console = {}), - (console.log = print), - (console.warn = console.error = - "undefined" != typeof printErr ? printErr : print))) - : (E || i) && - (i - ? (y = self.location.href) - : "undefined" != typeof document && - document.currentScript && - (y = document.currentScript.src), - (y = 0 !== y.indexOf("blob:") ? y.substr(0, y.lastIndexOf("/") + 1) : ""), - (n = function (A) { - var I = new XMLHttpRequest(); - return I.open("GET", A, !1), I.send(null), I.responseText; - }), - i && - (a = function (A) { - var I = new XMLHttpRequest(); - return ( - I.open("GET", A, !1), - (I.responseType = "arraybuffer"), - I.send(null), - new Uint8Array(I.response) - ); - }), - (t = function (A, I, g) { - var B = new XMLHttpRequest(); - B.open("GET", A, !0), - (B.responseType = "arraybuffer"), - (B.onload = function () { - 200 == B.status || (0 == B.status && B.response) ? I(B.response) : g(); - }), - (B.onerror = g), - B.send(null); - })), - Q.print || console.log.bind(console); - var F, - c, - w = Q.printErr || console.warn.bind(console); - for (B in C) C.hasOwnProperty(B) && (Q[B] = C[B]); - (C = null), - Q.arguments && (e = Q.arguments), - Q.thisProgram && Q.thisProgram, - Q.quit && Q.quit, - Q.wasmBinary && (F = Q.wasmBinary), - Q.noExitRuntime, - "object" != typeof WebAssembly && u("no native wasm support detected"); - var h = !1; - function G(A, I) { - A || u("Assertion failed: " + I); - } - var N, - R, - f = "undefined" != typeof TextDecoder ? new TextDecoder("utf8") : void 0; - function U(A) { - (N = A), - (Q.HEAP8 = new Int8Array(A)), - (Q.HEAP16 = new Int16Array(A)), - (Q.HEAP32 = new Int32Array(A)), - (Q.HEAPU8 = R = new Uint8Array(A)), - (Q.HEAPU16 = new Uint16Array(A)), - (Q.HEAPU32 = new Uint32Array(A)), - (Q.HEAPF32 = new Float32Array(A)), - (Q.HEAPF64 = new Float64Array(A)); - } - Q.INITIAL_MEMORY; - var M, - Y = [], - S = [], - H = [], - d = 0, - k = null, - J = null; - function u(A) { - throw ( - (Q.onAbort && Q.onAbort(A), - w((A += "")), - (h = !0), - (A = "abort(" + A + "). Build with -s ASSERTIONS=1 for more info."), - new WebAssembly.RuntimeError(A)) - ); - } - function p(A) { - return A.startsWith("data:application/octet-stream;base64,"); - } - function L(A) { - return A.startsWith("file://"); - } - (Q.preloadedImages = {}), (Q.preloadedAudios = {}); - var l, - K = "argon2.wasm"; - function q(A) { - try { - if (A == K && F) return new Uint8Array(F); - if (a) return a(A); - throw "both async and sync fetching of the wasm failed"; - } catch (A) { - u(A); - } - } - function b(A) { - for (; A.length > 0; ) { - var I = A.shift(); - if ("function" != typeof I) { - var g = I.func; - "number" == typeof g - ? void 0 === I.arg - ? M.get(g)() - : M.get(g)(I.arg) - : g(void 0 === I.arg ? null : I.arg); - } else I(Q); - } - } - function x(A) { - try { - return c.grow((A - N.byteLength + 65535) >>> 16), U(c.buffer), 1; - } catch (A) {} - } - p(K) || ((l = K), (K = Q.locateFile ? Q.locateFile(l, y) : y + l)); - var m, - X = { - a: function (A, I, g) { - R.copyWithin(A, I, I + g); - }, - b: function (A) { - var I, - g = R.length, - B = 2147418112; - if ((A >>>= 0) > B) return !1; - for (var Q = 1; Q <= 4; Q *= 2) { - var C = g * (1 + 0.2 / Q); - if ( - ((C = Math.min(C, A + 100663296)), - x( - Math.min( - B, - ((I = Math.max(A, C)) % 65536 > 0 && (I += 65536 - (I % 65536)), I) - ) - )) - ) - return !0; - } - return !1; - } - }, - W = - ((function () { - var A = { a: X }; - function I(A, I) { - var g, - B = A.exports; - (Q.asm = B), - U((c = Q.asm.c).buffer), - (M = Q.asm.k), - (g = Q.asm.d), - S.unshift(g), - (function (A) { - if ( - (d--, - Q.monitorRunDependencies && Q.monitorRunDependencies(d), - 0 == d && (null !== k && (clearInterval(k), (k = null)), J)) - ) { - var I = J; - (J = null), I(); - } - })(); - } - function g(A) { - I(A.instance); - } - function B(I) { - return (function () { - if (!F && (E || i)) { - if ("function" == typeof fetch && !L(K)) - return fetch(K, { credentials: "same-origin" }) - .then(function (A) { - if (!A.ok) throw "failed to load wasm binary file at '" + K + "'"; - return A.arrayBuffer(); - }) - .catch(function () { - return q(K); - }); - if (t) - return new Promise(function (A, I) { - t( - K, - function (I) { - A(new Uint8Array(I)); - }, - I - ); - }); - } - return Promise.resolve().then(function () { - return q(K); - }); - })() - .then(function (I) { - return WebAssembly.instantiate(I, A); - }) - .then(I, function (A) { - w("failed to asynchronously prepare wasm: " + A), u(A); - }); - } - if ( - (d++, Q.monitorRunDependencies && Q.monitorRunDependencies(d), Q.instantiateWasm) - ) - try { - return Q.instantiateWasm(A, I); - } catch (A) { - return w("Module.instantiateWasm callback failed with error: " + A), !1; - } - F || - "function" != typeof WebAssembly.instantiateStreaming || - p(K) || - L(K) || - "function" != typeof fetch - ? B(g) - : fetch(K, { credentials: "same-origin" }).then(function (I) { - return WebAssembly.instantiateStreaming(I, A).then(g, function (A) { - return ( - w("wasm streaming compile failed: " + A), - w("falling back to ArrayBuffer instantiation"), - B(g) - ); - }); - }); - })(), - (Q.___wasm_call_ctors = function () { - return (Q.___wasm_call_ctors = Q.asm.d).apply(null, arguments); - }), - (Q._argon2_hash = function () { - return (Q._argon2_hash = Q.asm.e).apply(null, arguments); - }), - (Q._malloc = function () { - return (W = Q._malloc = Q.asm.f).apply(null, arguments); - })), - T = - ((Q._free = function () { - return (Q._free = Q.asm.g).apply(null, arguments); - }), - (Q._argon2_verify = function () { - return (Q._argon2_verify = Q.asm.h).apply(null, arguments); - }), - (Q._argon2_error_message = function () { - return (Q._argon2_error_message = Q.asm.i).apply(null, arguments); - }), - (Q._argon2_encodedlen = function () { - return (Q._argon2_encodedlen = Q.asm.j).apply(null, arguments); - }), - (Q._argon2_hash_ext = function () { - return (Q._argon2_hash_ext = Q.asm.l).apply(null, arguments); - }), - (Q._argon2_verify_ext = function () { - return (Q._argon2_verify_ext = Q.asm.m).apply(null, arguments); - }), - (Q.stackAlloc = function () { - return (T = Q.stackAlloc = Q.asm.n).apply(null, arguments); - })); - function V(A) { - (this.name = "ExitStatus"), - (this.message = "Program terminated with exit(" + A + ")"), - (this.status = A); - } - function j(A) { - function I() { - m || - ((m = !0), - (Q.calledRun = !0), - h || - (b(S), - Q.onRuntimeInitialized && Q.onRuntimeInitialized(), - (function () { - if (Q.postRun) - for ( - "function" == typeof Q.postRun && (Q.postRun = [Q.postRun]); - Q.postRun.length; + return (() => { + var A, + I, + g = { + 773: (A, I, g) => { + var B, + Q = "undefined" != typeof self && void 0 !== self.Module ? self.Module : {}, + C = {}; + for (B in Q) Q.hasOwnProperty(B) && (C[B] = Q[B]); + var E, + i, + o, + D, + e = []; + (E = "object" == typeof window), + (i = "function" == typeof importScripts), + (o = + "object" == typeof process && + "object" == typeof process.versions && + "string" == typeof process.versions.node), + (D = !E && !o && !i); + var n, + t, + a, + r, + s, + y = ""; + o + ? ((y = i ? g(967).dirname(y) + "/" : "//"), + (n = function (A, I) { + return ( + r || (r = g(145)), + s || (s = g(967)), + (A = s.normalize(A)), + r.readFileSync(A, I ? null : "utf8") + ); + }), + (a = function (A) { + var I = n(A, !0); + return I.buffer || (I = new Uint8Array(I)), G(I.buffer), I; + }), + process.argv.length > 1 && process.argv[1].replace(/\\/g, "/"), + (e = process.argv.slice(2)), + (A.exports = Q), + process.on("uncaughtException", function (A) { + if (!(A instanceof V)) throw A; + }), + process.on("unhandledRejection", u), + (Q.inspect = function () { + return "[Emscripten Module object]"; + })) + : D + ? ("undefined" != typeof read && + (n = function (A) { + return read(A); + }), + (a = function (A) { + var I; + return "function" == typeof readbuffer + ? new Uint8Array(readbuffer(A)) + : (G("object" == typeof (I = read(A, "binary"))), I); + }), + "undefined" != typeof scriptArgs + ? (e = scriptArgs) + : void 0 !== arguments && (e = arguments), + "undefined" != typeof print && + ("undefined" == typeof console && (console = {}), + (console.log = print), + (console.warn = console.error = + "undefined" != typeof printErr ? printErr : print))) + : (E || i) && + (i + ? (y = self.location.href) + : "undefined" != typeof document && + document.currentScript && + (y = document.currentScript.src), + (y = 0 !== y.indexOf("blob:") ? y.substr(0, y.lastIndexOf("/") + 1) : ""), + (n = function (A) { + var I = new XMLHttpRequest(); + return I.open("GET", A, !1), I.send(null), I.responseText; + }), + i && + (a = function (A) { + var I = new XMLHttpRequest(); + return ( + I.open("GET", A, !1), + (I.responseType = "arraybuffer"), + I.send(null), + new Uint8Array(I.response) + ); + }), + (t = function (A, I, g) { + var B = new XMLHttpRequest(); + B.open("GET", A, !0), + (B.responseType = "arraybuffer"), + (B.onload = function () { + 200 == B.status || (0 == B.status && B.response) ? I(B.response) : g(); + }), + (B.onerror = g), + B.send(null); + })), + Q.print || console.log.bind(console); + var F, + c, + w = Q.printErr || console.warn.bind(console); + for (B in C) C.hasOwnProperty(B) && (Q[B] = C[B]); + (C = null), + Q.arguments && (e = Q.arguments), + Q.thisProgram && Q.thisProgram, + Q.quit && Q.quit, + Q.wasmBinary && (F = Q.wasmBinary), + Q.noExitRuntime, + "object" != typeof WebAssembly && u("no native wasm support detected"); + var h = !1; + function G(A, I) { + A || u("Assertion failed: " + I); + } + var N, + R, + f = "undefined" != typeof TextDecoder ? new TextDecoder("utf8") : void 0; + function U(A) { + (N = A), + (Q.HEAP8 = new Int8Array(A)), + (Q.HEAP16 = new Int16Array(A)), + (Q.HEAP32 = new Int32Array(A)), + (Q.HEAPU8 = R = new Uint8Array(A)), + (Q.HEAPU16 = new Uint16Array(A)), + (Q.HEAPU32 = new Uint32Array(A)), + (Q.HEAPF32 = new Float32Array(A)), + (Q.HEAPF64 = new Float64Array(A)); + } + Q.INITIAL_MEMORY; + var M, + Y = [], + S = [], + H = [], + d = 0, + k = null, + J = null; + function u(A) { + throw ( + (Q.onAbort && Q.onAbort(A), + w((A += "")), + (h = !0), + (A = "abort(" + A + "). Build with -s ASSERTIONS=1 for more info."), + new WebAssembly.RuntimeError(A)) + ); + } + function p(A) { + return A.startsWith("data:application/octet-stream;base64,"); + } + function L(A) { + return A.startsWith("file://"); + } + (Q.preloadedImages = {}), (Q.preloadedAudios = {}); + var l, + K = "argon2.wasm"; + function q(A) { + try { + if (A == K && F) return new Uint8Array(F); + if (a) return a(A); + throw "both async and sync fetching of the wasm failed"; + } catch (A) { + u(A); + } + } + function b(A) { + for (; A.length > 0; ) { + var I = A.shift(); + if ("function" != typeof I) { + var g = I.func; + "number" == typeof g + ? void 0 === I.arg + ? M.get(g)() + : M.get(g)(I.arg) + : g(void 0 === I.arg ? null : I.arg); + } else I(Q); + } + } + function x(A) { + try { + return c.grow((A - N.byteLength + 65535) >>> 16), U(c.buffer), 1; + } catch (A) {} + } + p(K) || ((l = K), (K = Q.locateFile ? Q.locateFile(l, y) : y + l)); + var m, + X = { + a: function (A, I, g) { + R.copyWithin(A, I, I + g); + }, + b: function (A) { + var I, + g = R.length, + B = 2147418112; + if ((A >>>= 0) > B) return !1; + for (var Q = 1; Q <= 4; Q *= 2) { + var C = g * (1 + 0.2 / Q); + if ( + ((C = Math.min(C, A + 100663296)), + x( + Math.min( + B, + ((I = Math.max(A, C)) % 65536 > 0 && (I += 65536 - (I % 65536)), I), + ), + )) + ) + return !0; + } + return !1; + }, + }, + W = + ((function () { + var A = { a: X }; + function I(A, I) { + var g, + B = A.exports; + (Q.asm = B), + U((c = Q.asm.c).buffer), + (M = Q.asm.k), + (g = Q.asm.d), + S.unshift(g), + (function (A) { + if ( + (d--, + Q.monitorRunDependencies && Q.monitorRunDependencies(d), + 0 == d && (null !== k && (clearInterval(k), (k = null)), J)) + ) { + var I = J; + (J = null), I(); + } + })(); + } + function g(A) { + I(A.instance); + } + function B(I) { + return (function () { + if (!F && (E || i)) { + if ("function" == typeof fetch && !L(K)) + return fetch(K, { credentials: "same-origin" }) + .then(function (A) { + if (!A.ok) throw "failed to load wasm binary file at '" + K + "'"; + return A.arrayBuffer(); + }) + .catch(function () { + return q(K); + }); + if (t) + return new Promise(function (A, I) { + t( + K, + function (I) { + A(new Uint8Array(I)); + }, + I, + ); + }); + } + return Promise.resolve().then(function () { + return q(K); + }); + })() + .then(function (I) { + return WebAssembly.instantiate(I, A); + }) + .then(I, function (A) { + w("failed to asynchronously prepare wasm: " + A), u(A); + }); + } + if ( + (d++, Q.monitorRunDependencies && Q.monitorRunDependencies(d), Q.instantiateWasm) + ) + try { + return Q.instantiateWasm(A, I); + } catch (A) { + return w("Module.instantiateWasm callback failed with error: " + A), !1; + } + F || + "function" != typeof WebAssembly.instantiateStreaming || + p(K) || + L(K) || + "function" != typeof fetch + ? B(g) + : fetch(K, { credentials: "same-origin" }).then(function (I) { + return WebAssembly.instantiateStreaming(I, A).then(g, function (A) { + return ( + w("wasm streaming compile failed: " + A), + w("falling back to ArrayBuffer instantiation"), + B(g) + ); + }); + }); + })(), + (Q.___wasm_call_ctors = function () { + return (Q.___wasm_call_ctors = Q.asm.d).apply(null, arguments); + }), + (Q._argon2_hash = function () { + return (Q._argon2_hash = Q.asm.e).apply(null, arguments); + }), + (Q._malloc = function () { + return (W = Q._malloc = Q.asm.f).apply(null, arguments); + })), + T = + ((Q._free = function () { + return (Q._free = Q.asm.g).apply(null, arguments); + }), + (Q._argon2_verify = function () { + return (Q._argon2_verify = Q.asm.h).apply(null, arguments); + }), + (Q._argon2_error_message = function () { + return (Q._argon2_error_message = Q.asm.i).apply(null, arguments); + }), + (Q._argon2_encodedlen = function () { + return (Q._argon2_encodedlen = Q.asm.j).apply(null, arguments); + }), + (Q._argon2_hash_ext = function () { + return (Q._argon2_hash_ext = Q.asm.l).apply(null, arguments); + }), + (Q._argon2_verify_ext = function () { + return (Q._argon2_verify_ext = Q.asm.m).apply(null, arguments); + }), + (Q.stackAlloc = function () { + return (T = Q.stackAlloc = Q.asm.n).apply(null, arguments); + })); + function V(A) { + (this.name = "ExitStatus"), + (this.message = "Program terminated with exit(" + A + ")"), + (this.status = A); + } + function j(A) { + function I() { + m || + ((m = !0), + (Q.calledRun = !0), + h || + (b(S), + Q.onRuntimeInitialized && Q.onRuntimeInitialized(), + (function () { + if (Q.postRun) + for ( + "function" == typeof Q.postRun && (Q.postRun = [Q.postRun]); + Q.postRun.length; - ) - (A = Q.postRun.shift()), H.unshift(A); - var A; - b(H); - })())); - } - (A = A || e), - d > 0 || - ((function () { - if (Q.preRun) - for ( - "function" == typeof Q.preRun && (Q.preRun = [Q.preRun]); - Q.preRun.length; + ) + (A = Q.postRun.shift()), H.unshift(A); + var A; + b(H); + })())); + } + (A = A || e), + d > 0 || + ((function () { + if (Q.preRun) + for ( + "function" == typeof Q.preRun && (Q.preRun = [Q.preRun]); + Q.preRun.length; - ) - (A = Q.preRun.shift()), Y.unshift(A); - var A; - b(Y); - })(), - d > 0 || - (Q.setStatus - ? (Q.setStatus("Running..."), - setTimeout(function () { - setTimeout(function () { - Q.setStatus(""); - }, 1), - I(); - }, 1)) - : I())); - } - if ( - ((Q.allocate = function (A, I) { - var g; - return ( - (g = 1 == I ? T(A.length) : W(A.length)), - A.subarray || A.slice ? R.set(A, g) : R.set(new Uint8Array(A), g), - g - ); - }), - (Q.UTF8ToString = function (A, I) { - return A - ? (function (A, I, g) { - for (var B = I + g, Q = I; A[Q] && !(Q >= B); ) ++Q; - if (Q - I > 16 && A.subarray && f) return f.decode(A.subarray(I, Q)); - for (var C = ""; I < Q; ) { - var E = A[I++]; - if (128 & E) { - var i = 63 & A[I++]; - if (192 != (224 & E)) { - var o = 63 & A[I++]; - if ( - (E = - 224 == (240 & E) - ? ((15 & E) << 12) | (i << 6) | o - : ((7 & E) << 18) | (i << 12) | (o << 6) | (63 & A[I++])) < 65536 - ) - C += String.fromCharCode(E); - else { - var D = E - 65536; - C += String.fromCharCode(55296 | (D >> 10), 56320 | (1023 & D)); - } - } else C += String.fromCharCode(((31 & E) << 6) | i); - } else C += String.fromCharCode(E); - } - return C; - })(R, A, I) - : ""; - }), - (Q.ALLOC_NORMAL = 0), - (J = function A() { - m || j(), m || (J = A); - }), - (Q.run = j), - Q.preInit) - ) - for ( - "function" == typeof Q.preInit && (Q.preInit = [Q.preInit]); - Q.preInit.length > 0; + ) + (A = Q.preRun.shift()), Y.unshift(A); + var A; + b(Y); + })(), + d > 0 || + (Q.setStatus + ? (Q.setStatus("Running..."), + setTimeout(function () { + setTimeout(function () { + Q.setStatus(""); + }, 1), + I(); + }, 1)) + : I())); + } + if ( + ((Q.allocate = function (A, I) { + var g; + return ( + (g = 1 == I ? T(A.length) : W(A.length)), + A.subarray || A.slice ? R.set(A, g) : R.set(new Uint8Array(A), g), + g + ); + }), + (Q.UTF8ToString = function (A, I) { + return A + ? (function (A, I, g) { + for (var B = I + g, Q = I; A[Q] && !(Q >= B); ) ++Q; + if (Q - I > 16 && A.subarray && f) return f.decode(A.subarray(I, Q)); + for (var C = ""; I < Q; ) { + var E = A[I++]; + if (128 & E) { + var i = 63 & A[I++]; + if (192 != (224 & E)) { + var o = 63 & A[I++]; + if ( + (E = + 224 == (240 & E) + ? ((15 & E) << 12) | (i << 6) | o + : ((7 & E) << 18) | (i << 12) | (o << 6) | (63 & A[I++])) < 65536 + ) + C += String.fromCharCode(E); + else { + var D = E - 65536; + C += String.fromCharCode(55296 | (D >> 10), 56320 | (1023 & D)); + } + } else C += String.fromCharCode(((31 & E) << 6) | i); + } else C += String.fromCharCode(E); + } + return C; + })(R, A, I) + : ""; + }), + (Q.ALLOC_NORMAL = 0), + (J = function A() { + m || j(), m || (J = A); + }), + (Q.run = j), + Q.preInit) + ) + for ( + "function" == typeof Q.preInit && (Q.preInit = [Q.preInit]); + Q.preInit.length > 0; - ) - Q.preInit.pop()(); - j(), - (A.exports = Q), - (Q.unloadRuntime = function () { - "undefined" != typeof self && delete self.Module, - (Q = c = M = N = R = void 0), - delete A.exports; - }); - }, - 631: function (A, I, g) { - var B, Q; - "undefined" != typeof self && self, - void 0 === - (Q = - "function" == - typeof (B = function () { - const A = "undefined" != typeof self ? self : this, - I = { Argon2d: 0, Argon2i: 1, Argon2id: 2 }; - function B(I) { - if (B._promise) return B._promise; - if (B._module) return Promise.resolve(B._module); - let C; - return ( - (C = - A.process && A.process.versions && A.process.versions.node - ? Q().then( - (A) => - new Promise((I) => { - A.postRun = () => I(A); - }) - ) - : (A.loadArgon2WasmBinary - ? A.loadArgon2WasmBinary() - : Promise.resolve(g(721)).then((A) => - (function (A) { - const I = atob(A), - g = new Uint8Array(new ArrayBuffer(I.length)); - for (let A = 0; A < I.length; A++) g[A] = I.charCodeAt(A); - return g; - })(A) - ) - ).then((g) => - (function (I, g) { - return new Promise( - (B) => ( - (A.Module = { - wasmBinary: I, - wasmMemory: g, - postRun() { - B(Module); - } - }), - Q() - ) - ); - })( - g, - I - ? (function (A) { - const I = 1024, - g = 64 * I, - B = (1024 * I * 1024 * 2 - 64 * I) / g, - Q = Math.min( - Math.max(Math.ceil((A * I) / g), 256) + 256, - B - ); - return new WebAssembly.Memory({ initial: Q, maximum: B }); - })(I) - : void 0 - ) - )), - (B._promise = C), - C.then((A) => ((B._module = A), delete B._promise, A)) - ); - } - function Q() { - return A.loadArgon2WasmModule - ? A.loadArgon2WasmModule() - : Promise.resolve(g(773)); - } - function C(A, I) { - return A.allocate(I, "i8", A.ALLOC_NORMAL); - } - function E(A, I) { - return C(A, new Uint8Array([...I, 0])); - } - function i(A) { - if ("string" != typeof A) return A; - if ("function" == typeof TextEncoder) return new TextEncoder().encode(A); - if ("function" == typeof Buffer) return Buffer.from(A); - throw new Error("Don't know how to encode UTF8"); - } - return { - ArgonType: I, - hash: function (A) { - const g = A.mem || 1024; - return B(g).then((B) => { - const Q = A.time || 1, - o = A.parallelism || 1, - D = i(A.pass), - e = E(B, D), - n = D.length, - t = i(A.salt), - a = E(B, t), - r = t.length, - s = A.type || I.Argon2d, - y = B.allocate(new Array(A.hashLen || 24), "i8", B.ALLOC_NORMAL), - F = A.secret ? C(B, A.secret) : 0, - c = A.secret ? A.secret.byteLength : 0, - w = A.ad ? C(B, A.ad) : 0, - h = A.ad ? A.ad.byteLength : 0, - G = A.hashLen || 24, - N = B._argon2_encodedlen(Q, g, o, r, G, s), - R = B.allocate(new Array(N + 1), "i8", B.ALLOC_NORMAL); - let f, U, M; - try { - U = B._argon2_hash_ext( - Q, - g, - o, - e, - n, - a, - r, - y, - G, - R, - N, - s, - F, - c, - w, - h, - 19 - ); - } catch (A) { - f = A; - } - if (0 !== U || f) { - try { - f || (f = B.UTF8ToString(B._argon2_error_message(U))); - } catch (A) {} - M = { message: f, code: U }; - } else { - let A = ""; - const I = new Uint8Array(G); - for (let g = 0; g < G; g++) { - const Q = B.HEAP8[y + g]; - (I[g] = Q), (A += ("0" + (255 & Q).toString(16)).slice(-2)); - } - M = { hash: I, hashHex: A, encoded: B.UTF8ToString(R) }; - } - try { - B._free(e), - B._free(a), - B._free(y), - B._free(R), - w && B._free(w), - F && B._free(F); - } catch (A) {} - if (f) throw M; - return M; - }); - }, - verify: function (A) { - return B().then((g) => { - const B = i(A.pass), - Q = E(g, B), - o = B.length, - D = A.secret ? C(g, A.secret) : 0, - e = A.secret ? A.secret.byteLength : 0, - n = A.ad ? C(g, A.ad) : 0, - t = A.ad ? A.ad.byteLength : 0, - a = E(g, i(A.encoded)); - let r, - s, - y, - F = A.type; - if (void 0 === F) { - let g = A.encoded.split("$")[1]; - g && ((g = g.replace("a", "A")), (F = I[g] || I.Argon2d)); - } - try { - s = g._argon2_verify_ext(a, Q, o, D, e, n, t, F); - } catch (A) { - r = A; - } - if (s || r) { - try { - r || (r = g.UTF8ToString(g._argon2_error_message(s))); - } catch (A) {} - y = { message: r, code: s }; - } - try { - g._free(Q), g._free(a); - } catch (A) {} - if (r) throw y; - return y; - }); - }, - unloadRuntime: function () { - B._module && (B._module.unloadRuntime(), delete B._promise, delete B._module); - } - }; - }) - ? B.apply(I, []) - : B) || (A.exports = Q); - }, - 721: function (A, I) { - A.exports = - "AGFzbQEAAAABkwESYAN/f38Bf2ABfwF/YAJ/fwBgAn9/AX9gAX8AYAR/f39/AX9gA39/fwBgBH9/f38AYAJ/fgBgAn5/AX5gAn5+AX5gBX9/f39/AGAGf3x/f39/AX9gAABgCH9/f39/f39/AX9gEX9/f39/f39/f39/f39/f39/AX9gBn9/f39/fwF/YA1/f39/f39/f39/f39/AX8CDQIBYQFhAAABYQFiAAEDPDsJCgIAAAIEAQEAAQsGAQAHAAIBAwICAwIIBQECAwEHDQMBBgQGAQEFBQEAAAIEAAAIAQAODwQQAQURAwQFAXABAwMFBwEBgAL//wEGCQF/AUGQo8ACCwcxDAFjAgABZAAhAWUAOwFmAAkBZwAIAWgAOgFpADkBagA4AWsBAAFsADYBbQA1AW4AMwkIAQBBAQsCCzQKwbMBOwgAIAAgAa2KCx4AIAAgAXwgAEIBhkL+////H4MgAUL/////D4N+fAsXAEHwHCgCAEUgAEVyRQRAIAAgARAdCwuDBAEDfyACQYAETwRAIAAgASACEAAaIAAPCyAAIAJqIQMCQCAAIAFzQQNxRQRAAkAgAEEDcUUEQCAAIQIMAQsgAkEBSARAIAAhAgwBCyAAIQIDQCACIAEtAAA6AAAgAUEBaiEBIAJBAWoiAkEDcUUNASACIANJDQALCwJAIANBfHEiBEHAAEkNACACIARBQGoiBUsNAANAIAIgASgCADYCACACIAEoAgQ2AgQgAiABKAIINgIIIAIgASgCDDYCDCACIAEoAhA2AhAgAiABKAIUNgIUIAIgASgCGDYCGCACIAEoAhw2AhwgAiABKAIgNgIgIAIgASgCJDYCJCACIAEoAig2AiggAiABKAIsNgIsIAIgASgCMDYCMCACIAEoAjQ2AjQgAiABKAI4NgI4IAIgASgCPDYCPCABQUBrIQEgAkFAayICIAVNDQALCyACIARPDQEDQCACIAEoAgA2AgAgAUEEaiEBIAJBBGoiAiAESQ0ACwwBCyADQQRJBEAgACECDAELIAAgA0EEayIESwRAIAAhAgwBCyAAIQIDQCACIAEtAAA6AAAgAiABLQABOgABIAIgAS0AAjoAAiACIAEtAAM6AAMgAUEEaiEBIAJBBGoiAiAETQ0ACwsgAiADSQRAA0AgAiABLQAAOgAAIAFBAWohASACQQFqIgIgA0cNAAsLIAALzwEBA38CQCACRQ0AQX8hAyAARSABRXINACAAKQNQQgBSDQACQCAAKALgASIDIAJqQYEBSQ0AIABB4ABqIgUgA2ogAUGAASADayIEEAUaIABCgAEQGiAAIAUQGUEAIQMgAEEANgLgASABIARqIQEgAiAEayICQYEBSQ0AA0AgAEKAARAaIAAgARAZIAFBgAFqIQEgAkGAAWsiAkGAAUsNAAsgACgC4AEhAwsgACADakHgAGogASACEAUaIAAgACgC4AEgAmo2AuABQQAhAwsgAwsJACAAIAE2AAALpwwBB38CQCAARQ0AIABBCGsiAyAAQQRrKAIAIgFBeHEiAGohBQJAIAFBAXENACABQQNxRQ0BIAMgAygCACIBayIDQbAfKAIASQ0BIAAgAWohACADQbQfKAIARwRAIAFB/wFNBEAgAygCCCICIAFBA3YiBEEDdEHIH2pGGiACIAMoAgwiAUYEQEGgH0GgHygCAEF+IAR3cTYCAAwDCyACIAE2AgwgASACNgIIDAILIAMoAhghBgJAIAMgAygCDCIBRwRAIAMoAggiAiABNgIMIAEgAjYCCAwBCwJAIANBFGoiAigCACIEDQAgA0EQaiICKAIAIgQNAEEAIQEMAQsDQCACIQcgBCIBQRRqIgIoAgAiBA0AIAFBEGohAiABKAIQIgQNAAsgB0EANgIACyAGRQ0BAkAgAyADKAIcIgJBAnRB0CFqIgQoAgBGBEAgBCABNgIAIAENAUGkH0GkHygCAEF+IAJ3cTYCAAwDCyAGQRBBFCAGKAIQIANGG2ogATYCACABRQ0CCyABIAY2AhggAygCECICBEAgASACNgIQIAIgATYCGAsgAygCFCICRQ0BIAEgAjYCFCACIAE2AhgMAQsgBSgCBCIBQQNxQQNHDQBBqB8gADYCACAFIAFBfnE2AgQgAyAAQQFyNgIEIAAgA2ogADYCAA8LIAMgBU8NACAFKAIEIgFBAXFFDQACQCABQQJxRQRAIAVBuB8oAgBGBEBBuB8gAzYCAEGsH0GsHygCACAAaiIANgIAIAMgAEEBcjYCBCADQbQfKAIARw0DQagfQQA2AgBBtB9BADYCAA8LIAVBtB8oAgBGBEBBtB8gAzYCAEGoH0GoHygCACAAaiIANgIAIAMgAEEBcjYCBCAAIANqIAA2AgAPCyABQXhxIABqIQACQCABQf8BTQRAIAUoAggiAiABQQN2IgRBA3RByB9qRhogAiAFKAIMIgFGBEBBoB9BoB8oAgBBfiAEd3E2AgAMAgsgAiABNgIMIAEgAjYCCAwBCyAFKAIYIQYCQCAFIAUoAgwiAUcEQCAFKAIIIgJBsB8oAgBJGiACIAE2AgwgASACNgIIDAELAkAgBUEUaiICKAIAIgQNACAFQRBqIgIoAgAiBA0AQQAhAQwBCwNAIAIhByAEIgFBFGoiAigCACIEDQAgAUEQaiECIAEoAhAiBA0ACyAHQQA2AgALIAZFDQACQCAFIAUoAhwiAkECdEHQIWoiBCgCAEYEQCAEIAE2AgAgAQ0BQaQfQaQfKAIAQX4gAndxNgIADAILIAZBEEEUIAYoAhAgBUYbaiABNgIAIAFFDQELIAEgBjYCGCAFKAIQIgIEQCABIAI2AhAgAiABNgIYCyAFKAIUIgJFDQAgASACNgIUIAIgATYCGAsgAyAAQQFyNgIEIAAgA2ogADYCACADQbQfKAIARw0BQagfIAA2AgAPCyAFIAFBfnE2AgQgAyAAQQFyNgIEIAAgA2ogADYCAAsgAEH/AU0EQCAAQQN2IgFBA3RByB9qIQACf0GgHygCACICQQEgAXQiAXFFBEBBoB8gASACcjYCACAADAELIAAoAggLIQIgACADNgIIIAIgAzYCDCADIAA2AgwgAyACNgIIDwtBHyECIANCADcCECAAQf///wdNBEAgAEEIdiIBIAFBgP4/akEQdkEIcSIBdCICIAJBgOAfakEQdkEEcSICdCIEIARBgIAPakEQdkECcSIEdEEPdiABIAJyIARyayIBQQF0IAAgAUEVanZBAXFyQRxqIQILIAMgAjYCHCACQQJ0QdAhaiEBAkACQAJAQaQfKAIAIgRBASACdCIHcUUEQEGkHyAEIAdyNgIAIAEgAzYCACADIAE2AhgMAQsgAEEAQRkgAkEBdmsgAkEfRht0IQIgASgCACEBA0AgASIEKAIEQXhxIABGDQIgAkEddiEBIAJBAXQhAiAEIAFBBHFqIgdBEGooAgAiAQ0ACyAHIAM2AhAgAyAENgIYCyADIAM2AgwgAyADNgIIDAELIAQoAggiACADNgIMIAQgAzYCCCADQQA2AhggAyAENgIMIAMgADYCCAtBwB9BwB8oAgBBAWsiAEF/IAAbNgIACwuULQEMfyMAQRBrIgwkAAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAAQfQBTQRAQaAfKAIAIgVBECAAQQtqQXhxIABBC0kbIghBA3YiAnYiAUEDcQRAIAFBf3NBAXEgAmoiA0EDdCIBQdAfaigCACIEQQhqIQACQCAEKAIIIgIgAUHIH2oiAUYEQEGgHyAFQX4gA3dxNgIADAELIAIgATYCDCABIAI2AggLIAQgA0EDdCIBQQNyNgIEIAEgBGoiASABKAIEQQFyNgIEDA0LIAhBqB8oAgAiCk0NASABBEACQEECIAJ0IgBBACAAa3IgASACdHEiAEEAIABrcUEBayIAIABBDHZBEHEiAnYiAUEFdkEIcSIAIAJyIAEgAHYiAUECdkEEcSIAciABIAB2IgFBAXZBAnEiAHIgASAAdiIBQQF2QQFxIgByIAEgAHZqIgNBA3QiAEHQH2ooAgAiBCgCCCIBIABByB9qIgBGBEBBoB8gBUF+IAN3cSIFNgIADAELIAEgADYCDCAAIAE2AggLIARBCGohACAEIAhBA3I2AgQgBCAIaiICIANBA3QiASAIayIDQQFyNgIEIAEgBGogAzYCACAKBEAgCkEDdiIBQQN0QcgfaiEHQbQfKAIAIQQCfyAFQQEgAXQiAXFFBEBBoB8gASAFcjYCACAHDAELIAcoAggLIQEgByAENgIIIAEgBDYCDCAEIAc2AgwgBCABNgIIC0G0HyACNgIAQagfIAM2AgAMDQtBpB8oAgAiBkUNASAGQQAgBmtxQQFrIgAgAEEMdkEQcSICdiIBQQV2QQhxIgAgAnIgASAAdiIBQQJ2QQRxIgByIAEgAHYiAUEBdkECcSIAciABIAB2IgFBAXZBAXEiAHIgASAAdmpBAnRB0CFqKAIAIgEoAgRBeHEgCGshAyABIQIDQAJAIAIoAhAiAEUEQCACKAIUIgBFDQELIAAoAgRBeHEgCGsiAiADIAIgA0kiAhshAyAAIAEgAhshASAAIQIMAQsLIAEgCGoiCSABTQ0CIAEoAhghCyABIAEoAgwiBEcEQCABKAIIIgBBsB8oAgBJGiAAIAQ2AgwgBCAANgIIDAwLIAFBFGoiAigCACIARQRAIAEoAhAiAEUNBCABQRBqIQILA0AgAiEHIAAiBEEUaiICKAIAIgANACAEQRBqIQIgBCgCECIADQALIAdBADYCAAwLC0F/IQggAEG/f0sNACAAQQtqIgBBeHEhCEGkHygCACIJRQ0AQQAgCGshAwJAAkACQAJ/QQAgCEGAAkkNABpBHyAIQf///wdLDQAaIABBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCAIIABBFWp2QQFxckEcagsiBUECdEHQIWooAgAiAkUEQEEAIQAMAQtBACEAIAhBAEEZIAVBAXZrIAVBH0YbdCEBA0ACQCACKAIEQXhxIAhrIgcgA08NACACIQQgByIDDQBBACEDIAIhAAwDCyAAIAIoAhQiByAHIAIgAUEddkEEcWooAhAiAkYbIAAgBxshACABQQF0IQEgAg0ACwsgACAEckUEQEEAIQRBAiAFdCIAQQAgAGtyIAlxIgBFDQMgAEEAIABrcUEBayIAIABBDHZBEHEiAnYiAUEFdkEIcSIAIAJyIAEgAHYiAUECdkEEcSIAciABIAB2IgFBAXZBAnEiAHIgASAAdiIBQQF2QQFxIgByIAEgAHZqQQJ0QdAhaigCACEACyAARQ0BCwNAIAAoAgRBeHEgCGsiASADSSECIAEgAyACGyEDIAAgBCACGyEEIAAoAhAiAQR/IAEFIAAoAhQLIgANAAsLIARFDQAgA0GoHygCACAIa08NACAEIAhqIgYgBE0NASAEKAIYIQUgBCAEKAIMIgFHBEAgBCgCCCIAQbAfKAIASRogACABNgIMIAEgADYCCAwKCyAEQRRqIgIoAgAiAEUEQCAEKAIQIgBFDQQgBEEQaiECCwNAIAIhByAAIgFBFGoiAigCACIADQAgAUEQaiECIAEoAhAiAA0ACyAHQQA2AgAMCQsgCEGoHygCACICTQRAQbQfKAIAIQMCQCACIAhrIgFBEE8EQEGoHyABNgIAQbQfIAMgCGoiADYCACAAIAFBAXI2AgQgAiADaiABNgIAIAMgCEEDcjYCBAwBC0G0H0EANgIAQagfQQA2AgAgAyACQQNyNgIEIAIgA2oiACAAKAIEQQFyNgIECyADQQhqIQAMCwsgCEGsHygCACIGSQRAQawfIAYgCGsiATYCAEG4H0G4HygCACICIAhqIgA2AgAgACABQQFyNgIEIAIgCEEDcjYCBCACQQhqIQAMCwtBACEAIAhBL2oiCQJ/QfgiKAIABEBBgCMoAgAMAQtBhCNCfzcCAEH8IkKAoICAgIAENwIAQfgiIAxBDGpBcHFB2KrVqgVzNgIAQYwjQQA2AgBB3CJBADYCAEGAIAsiAWoiBUEAIAFrIgdxIgIgCE0NCkHYIigCACIEBEBB0CIoAgAiAyACaiIBIANNIAEgBEtyDQsLQdwiLQAAQQRxDQUCQAJAQbgfKAIAIgMEQEHgIiEAA0AgAyAAKAIAIgFPBEAgASAAKAIEaiADSw0DCyAAKAIIIgANAAsLQQAQDCIBQX9GDQYgAiEFQfwiKAIAIgNBAWsiACABcQRAIAIgAWsgACABakEAIANrcWohBQsgBSAITSAFQf7///8HS3INBkHYIigCACIEBEBB0CIoAgAiAyAFaiIAIANNIAAgBEtyDQcLIAUQDCIAIAFHDQEMCAsgBSAGayAHcSIFQf7///8HSw0FIAUQDCIBIAAoAgAgACgCBGpGDQQgASEACyAAQX9GIAhBMGogBU1yRQRAQYAjKAIAIgEgCSAFa2pBACABa3EiAUH+////B0sEQCAAIQEMCAsgARAMQX9HBEAgASAFaiEFIAAhAQwIC0EAIAVrEAwaDAULIAAiAUF/Rw0GDAQLAAtBACEEDAcLQQAhAQwFCyABQX9HDQILQdwiQdwiKAIAQQRyNgIACyACQf7///8HSw0BIAIQDCIBQX9GQQAQDCIAQX9GciAAIAFNcg0BIAAgAWsiBSAIQShqTQ0BC0HQIkHQIigCACAFaiIANgIAQdQiKAIAIABJBEBB1CIgADYCAAsCQAJAAkBBuB8oAgAiBwRAQeAiIQADQCABIAAoAgAiAyAAKAIEIgJqRg0CIAAoAggiAA0ACwwCC0GwHygCACIAQQAgACABTRtFBEBBsB8gATYCAAtBACEAQeQiIAU2AgBB4CIgATYCAEHAH0F/NgIAQcQfQfgiKAIANgIAQewiQQA2AgADQCAAQQN0IgNB0B9qIANByB9qIgI2AgAgA0HUH2ogAjYCACAAQQFqIgBBIEcNAAtBrB8gBUEoayIDQXggAWtBB3FBACABQQhqQQdxGyIAayICNgIAQbgfIAAgAWoiADYCACAAIAJBAXI2AgQgASADakEoNgIEQbwfQYgjKAIANgIADAILIAAtAAxBCHEgAyAHS3IgASAHTXINACAAIAIgBWo2AgRBuB8gB0F4IAdrQQdxQQAgB0EIakEHcRsiAGoiAjYCAEGsH0GsHygCACAFaiIBIABrIgA2AgAgAiAAQQFyNgIEIAEgB2pBKDYCBEG8H0GIIygCADYCAAwBC0GwHygCACABSwRAQbAfIAE2AgALIAEgBWohAkHgIiEAAkACQAJAAkACQAJAA0AgAiAAKAIARwRAIAAoAggiAA0BDAILCyAALQAMQQhxRQ0BC0HgIiEAA0AgByAAKAIAIgJPBEAgAiAAKAIEaiIEIAdLDQMLIAAoAgghAAwACwALIAAgATYCACAAIAAoAgQgBWo2AgQgAUF4IAFrQQdxQQAgAUEIakEHcRtqIgkgCEEDcjYCBCACQXggAmtBB3FBACACQQhqQQdxG2oiBSAIIAlqIgZrIQIgBSAHRgRAQbgfIAY2AgBBrB9BrB8oAgAgAmoiADYCACAGIABBAXI2AgQMAwsgBUG0HygCAEYEQEG0HyAGNgIAQagfQagfKAIAIAJqIgA2AgAgBiAAQQFyNgIEIAAgBmogADYCAAwDCyAFKAIEIgBBA3FBAUYEQCAAQXhxIQcCQCAAQf8BTQRAIAUoAggiAyAAQQN2IgBBA3RByB9qRhogAyAFKAIMIgFGBEBBoB9BoB8oAgBBfiAAd3E2AgAMAgsgAyABNgIMIAEgAzYCCAwBCyAFKAIYIQgCQCAFIAUoAgwiAUcEQCAFKAIIIgAgATYCDCABIAA2AggMAQsCQCAFQRRqIgAoAgAiAw0AIAVBEGoiACgCACIDDQBBACEBDAELA0AgACEEIAMiAUEUaiIAKAIAIgMNACABQRBqIQAgASgCECIDDQALIARBADYCAAsgCEUNAAJAIAUgBSgCHCIDQQJ0QdAhaiIAKAIARgRAIAAgATYCACABDQFBpB9BpB8oAgBBfiADd3E2AgAMAgsgCEEQQRQgCCgCECAFRhtqIAE2AgAgAUUNAQsgASAINgIYIAUoAhAiAARAIAEgADYCECAAIAE2AhgLIAUoAhQiAEUNACABIAA2AhQgACABNgIYCyAFIAdqIQUgAiAHaiECCyAFIAUoAgRBfnE2AgQgBiACQQFyNgIEIAIgBmogAjYCACACQf8BTQRAIAJBA3YiAEEDdEHIH2ohAgJ/QaAfKAIAIgFBASAAdCIAcUUEQEGgHyAAIAFyNgIAIAIMAQsgAigCCAshACACIAY2AgggACAGNgIMIAYgAjYCDCAGIAA2AggMAwtBHyEAIAJB////B00EQCACQQh2IgAgAEGA/j9qQRB2QQhxIgN0IgAgAEGA4B9qQRB2QQRxIgF0IgAgAEGAgA9qQRB2QQJxIgB0QQ92IAEgA3IgAHJrIgBBAXQgAiAAQRVqdkEBcXJBHGohAAsgBiAANgIcIAZCADcCECAAQQJ0QdAhaiEEAkBBpB8oAgAiA0EBIAB0IgFxRQRAQaQfIAEgA3I2AgAgBCAGNgIAIAYgBDYCGAwBCyACQQBBGSAAQQF2ayAAQR9GG3QhACAEKAIAIQEDQCABIgMoAgRBeHEgAkYNAyAAQR12IQEgAEEBdCEAIAMgAUEEcWoiBCgCECIBDQALIAQgBjYCECAGIAM2AhgLIAYgBjYCDCAGIAY2AggMAgtBrB8gBUEoayIDQXggAWtBB3FBACABQQhqQQdxGyIAayICNgIAQbgfIAAgAWoiADYCACAAIAJBAXI2AgQgASADakEoNgIEQbwfQYgjKAIANgIAIAcgBEEnIARrQQdxQQAgBEEna0EHcRtqQS9rIgAgACAHQRBqSRsiAkEbNgIEIAJB6CIpAgA3AhAgAkHgIikCADcCCEHoIiACQQhqNgIAQeQiIAU2AgBB4CIgATYCAEHsIkEANgIAIAJBGGohAANAIABBBzYCBCAAQQhqIQEgAEEEaiEAIAEgBEkNAAsgAiAHRg0DIAIgAigCBEF+cTYCBCAHIAIgB2siBEEBcjYCBCACIAQ2AgAgBEH/AU0EQCAEQQN2IgBBA3RByB9qIQICf0GgHygCACIBQQEgAHQiAHFFBEBBoB8gACABcjYCACACDAELIAIoAggLIQAgAiAHNgIIIAAgBzYCDCAHIAI2AgwgByAANgIIDAQLQR8hACAHQgA3AhAgBEH///8HTQRAIARBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCAEIABBFWp2QQFxckEcaiEACyAHIAA2AhwgAEECdEHQIWohAwJAQaQfKAIAIgJBASAAdCIBcUUEQEGkHyABIAJyNgIAIAMgBzYCACAHIAM2AhgMAQsgBEEAQRkgAEEBdmsgAEEfRht0IQAgAygCACEBA0AgASICKAIEQXhxIARGDQQgAEEddiEBIABBAXQhACACIAFBBHFqIgMoAhAiAQ0ACyADIAc2AhAgByACNgIYCyAHIAc2AgwgByAHNgIIDAMLIAMoAggiACAGNgIMIAMgBjYCCCAGQQA2AhggBiADNgIMIAYgADYCCAsgCUEIaiEADAULIAIoAggiACAHNgIMIAIgBzYCCCAHQQA2AhggByACNgIMIAcgADYCCAtBrB8oAgAiACAITQ0AQawfIAAgCGsiATYCAEG4H0G4HygCACICIAhqIgA2AgAgACABQQFyNgIEIAIgCEEDcjYCBCACQQhqIQAMAwtB3B5BMDYCAEEAIQAMAgsCQCAFRQ0AAkAgBCgCHCICQQJ0QdAhaiIAKAIAIARGBEAgACABNgIAIAENAUGkHyAJQX4gAndxIgk2AgAMAgsgBUEQQRQgBSgCECAERhtqIAE2AgAgAUUNAQsgASAFNgIYIAQoAhAiAARAIAEgADYCECAAIAE2AhgLIAQoAhQiAEUNACABIAA2AhQgACABNgIYCwJAIANBD00EQCAEIAMgCGoiAEEDcjYCBCAAIARqIgAgACgCBEEBcjYCBAwBCyAEIAhBA3I2AgQgBiADQQFyNgIEIAMgBmogAzYCACADQf8BTQRAIANBA3YiAEEDdEHIH2ohAgJ/QaAfKAIAIgFBASAAdCIAcUUEQEGgHyAAIAFyNgIAIAIMAQsgAigCCAshACACIAY2AgggACAGNgIMIAYgAjYCDCAGIAA2AggMAQtBHyEAIANB////B00EQCADQQh2IgAgAEGA/j9qQRB2QQhxIgJ0IgAgAEGA4B9qQRB2QQRxIgF0IgAgAEGAgA9qQRB2QQJxIgB0QQ92IAEgAnIgAHJrIgBBAXQgAyAAQRVqdkEBcXJBHGohAAsgBiAANgIcIAZCADcCECAAQQJ0QdAhaiECAkACQCAJQQEgAHQiAXFFBEBBpB8gASAJcjYCACACIAY2AgAgBiACNgIYDAELIANBAEEZIABBAXZrIABBH0YbdCEAIAIoAgAhCANAIAgiASgCBEF4cSADRg0CIABBHXYhAiAAQQF0IQAgASACQQRxaiICKAIQIggNAAsgAiAGNgIQIAYgATYCGAsgBiAGNgIMIAYgBjYCCAwBCyABKAIIIgAgBjYCDCABIAY2AgggBkEANgIYIAYgATYCDCAGIAA2AggLIARBCGohAAwBCwJAIAtFDQACQCABKAIcIgJBAnRB0CFqIgAoAgAgAUYEQCAAIAQ2AgAgBA0BQaQfIAZBfiACd3E2AgAMAgsgC0EQQRQgCygCECABRhtqIAQ2AgAgBEUNAQsgBCALNgIYIAEoAhAiAARAIAQgADYCECAAIAQ2AhgLIAEoAhQiAEUNACAEIAA2AhQgACAENgIYCwJAIANBD00EQCABIAMgCGoiAEEDcjYCBCAAIAFqIgAgACgCBEEBcjYCBAwBCyABIAhBA3I2AgQgCSADQQFyNgIEIAMgCWogAzYCACAKBEAgCkEDdiIAQQN0QcgfaiEEQbQfKAIAIQICf0EBIAB0IgAgBXFFBEBBoB8gACAFcjYCACAEDAELIAQoAggLIQAgBCACNgIIIAAgAjYCDCACIAQ2AgwgAiAANgIIC0G0HyAJNgIAQagfIAM2AgALIAFBCGohAAsgDEEQaiQAIAALfwEDfyAAIQECQCAAQQNxBEADQCABLQAARQ0CIAFBAWoiAUEDcQ0ACwsDQCABIgJBBGohASACKAIAIgNBf3MgA0GBgoQIa3FBgIGChHhxRQ0ACyADQf8BcUUEQCACIABrDwsDQCACLQABIQMgAkEBaiIBIQIgAw0ACwsgASAAawvyAgICfwF+AkAgAkUNACAAIAJqIgNBAWsgAToAACAAIAE6AAAgAkEDSQ0AIANBAmsgAToAACAAIAE6AAEgA0EDayABOgAAIAAgAToAAiACQQdJDQAgA0EEayABOgAAIAAgAToAAyACQQlJDQAgAEEAIABrQQNxIgRqIgMgAUH/AXFBgYKECGwiATYCACADIAIgBGtBfHEiBGoiAkEEayABNgIAIARBCUkNACADIAE2AgggAyABNgIEIAJBCGsgATYCACACQQxrIAE2AgAgBEEZSQ0AIAMgATYCGCADIAE2AhQgAyABNgIQIAMgATYCDCACQRBrIAE2AgAgAkEUayABNgIAIAJBGGsgATYCACACQRxrIAE2AgAgBCADQQRxQRhyIgRrIgJBIEkNACABrUKBgICAEH4hBSADIARqIQEDQCABIAU3AxggASAFNwMQIAEgBTcDCCABIAU3AwAgAUEgaiEBIAJBIGsiAkEfSw0ACwsgAAtPAQJ/QdgeKAIAIgEgAEEDakF8cSICaiEAAkAgAkEAIAAgAU0bDQAgAD8AQRB0SwRAIAAQAUUNAQtB2B4gADYCACABDwtB3B5BMDYCAEF/C20BAX8jAEGAAmsiBSQAIARBgMAEcSACIANMckUEQCAFIAFB/wFxIAIgA2siAkGAAiACQYACSSIBGxALGiABRQRAA0AgACAFQYACEA4gAkGAAmsiAkH/AUsNAAsLIAAgBSACEA4LIAVBgAJqJAALnQIBA38gAC0AAEEgcUUEQAJAIAEhBAJAIAIgACIBKAIQIgAEfyAABQJ/IAEiACABLQBKIgNBAWsgA3I6AEogASgCACIDQQhxBEAgACADQSByNgIAQX8MAQsgAEIANwIEIAAgACgCLCIDNgIcIAAgAzYCFCAAIAMgACgCMGo2AhBBAAsNASABKAIQCyABKAIUIgVrSwRAIAEgBCACIAEoAiQRAAAaDAILAn8gASwAS0F/SgRAIAIhAANAIAIgACIDRQ0CGiAEIANBAWsiAGotAABBCkcNAAsgASAEIAMgASgCJBEAACADSQ0CIAMgBGohBCABKAIUIQUgAiADawwBCyACCyEAIAUgBCAAEAUaIAEgASgCFCAAajYCFAsLCwsKACAAQTBrQQpJC2MBAn8gAkUEQEEADwsCfyAALQAAIgMEQANAAkACQCABLQAAIgRFDQAgAkEBayICRQ0AIAMgBEYNAQsgAwwDCyABQQFqIQEgAC0AASEDIABBAWohACADDQALC0EACyABLQAAawucDQIQfhB/IwBBgBBrIhQkACAUQYAIaiABEBcgFEGACGogABAWIBQgFEGACGoQFyADBEAgFCACEBYLQQAhAEEAIQEDQCAUQYAIaiABQQd0IgNBwAByaiIVKQMAIBRBgAhqIANB4AByaiIWKQMAIBRBgAhqIANqIhcpAwAgFEGACGogA0EgcmoiGCkDACIIEAMiBIVBIBACIgUQAyIGIAiFQRgQAiEIIAggBiAFIAQgCBADIgeFQRAQAiIKEAMiEYVBPxACIQggFEGACGogA0HIAHJqIhkpAwAgFEGACGogA0HoAHJqIhopAwAgFEGACGogA0EIcmoiGykDACAUQYAIaiADQShyaiIcKQMAIgQQAyIFhUEgEAIiBhADIgsgBIVBGBACIQQgBCALIAYgBSAEEAMiC4VBEBACIhIQAyIThUE/EAIhBCAUQYAIaiADQdAAcmoiHSkDACAUQYAIaiADQfAAcmoiHikDACAUQYAIaiADQRByaiIfKQMAIBRBgAhqIANBMHJqIiApAwAiBRADIgaFQSAQAiIMEAMiDSAFhUEYEAIhBSAFIA0gDCAGIAUQAyINhUEQEAIiDBADIg6FQT8QAiEFIBRBgAhqIANB2AByaiIhKQMAIBRBgAhqIANB+AByaiIiKQMAIBRBgAhqIANBGHJqIiMpAwAgFEGACGogA0E4cmoiAykDACIGEAMiD4VBIBACIgkQAyIQIAaFQRgQAiEGIAYgECAJIA8gBhADIg+FQRAQAiIJEAMiEIVBPxACIQYgFyAHIAQQAyIHIAQgDiAHIAmFQSAQAiIHEAMiDoVBGBACIgQQAyIJNwMAICIgByAJhUEQEAIiBzcDACAdIA4gBxADIgc3AwAgHCAEIAeFQT8QAjcDACAbIAsgBRADIgQgBSAQIAQgCoVBIBACIgQQAyIHhUEYEAIiBRADIgo3AwAgFiAEIAqFQRAQAiIENwMAICEgByAEEAMiBDcDACAgIAQgBYVBPxACNwMAIB8gDSAGEAMiBCAGIBEgBCAShUEgEAIiBBADIgWFQRgQAiIGEAMiBzcDACAaIAQgB4VBEBACIgQ3AwAgFSAFIAQQAyIENwMAIAMgBCAGhUE/EAI3AwAgIyAPIAgQAyIEIAggEyAEIAyFQSAQAiIEEAMiBYVBGBACIggQAyIGNwMAIB4gBCAGhUEQEAIiBDcDACAZIAUgBBADIgQ3AwAgGCAEIAiFQT8QAjcDACABQQFqIgFBCEcNAAsDQCAAQQR0IgMgFEGACGpqIgEiFUGABGopAwAgASkDgAYgASkDACABKQOAAiIIEAMiBIVBIBACIgUQAyIGIAiFQRgQAiEIIAggBiAFIAQgCBADIgeFQRAQAiIKEAMiEYVBPxACIQggASkDiAQgASkDiAYgFEGACGogA0EIcmoiAykDACABKQOIAiIEEAMiBYVBIBACIgYQAyILIASFQRgQAiEEIAQgCyAGIAUgBBADIguFQRAQAiISEAMiE4VBPxACIQQgASkDgAUgASkDgAcgASkDgAEgASkDgAMiBRADIgaFQSAQAiIMEAMiDSAFhUEYEAIhBSAFIA0gDCAGIAUQAyINhUEQEAIiDBADIg6FQT8QAiEFIAEpA4gFIAEpA4gHIAEpA4gBIAEpA4gDIgYQAyIPhUEgEAIiCRADIhAgBoVBGBACIQYgBiAQIAkgDyAGEAMiD4VBEBACIgkQAyIQhUE/EAIhBiABIAcgBBADIgcgBCAOIAcgCYVBIBACIgcQAyIOhUEYEAIiBBADIgk3AwAgASAHIAmFQRAQAiIHNwOIByABIA4gBxADIgc3A4AFIAEgBCAHhUE/EAI3A4gCIAMgCyAFEAMiBCAFIBAgBCAKhUEgEAIiBBADIgeFQRgQAiIFEAMiCjcDACABIAQgCoVBEBACIgQ3A4AGIAEgByAEEAMiBDcDiAUgASAEIAWFQT8QAjcDgAMgASANIAYQAyIEIAYgESAEIBKFQSAQAiIEEAMiBYVBGBACIgYQAyIHNwOAASABIAQgB4VBEBACIgQ3A4gGIBUgBSAEEAMiBDcDgAQgASAEIAaFQT8QAjcDiAMgASAPIAgQAyIEIAggEyAEIAyFQSAQAiIEEAMiBYVBGBACIggQAyIGNwOIASABIAQgBoVBEBACIgQ3A4AHIAEgBSAEEAMiBDcDiAQgASAEIAiFQT8QAjcDgAIgAEEBaiIAQQhHDQALIAIgFBAXIAIgFEGACGoQFiAUQYAQaiQAC8MBAQN/IwBBQGoiAyQAIANBAEHAABALIQRBfyEDAkAgAEUgAUVyDQAgACgC5AEgAksNACAAKQNQQgBSDQAgACAANQLgARAaIAAQJUEAIQMgAEHgAGoiAiAAKALgASIFakEAQYABIAVrEAsaIAAgAhAZA0AgBCADQQN0IgVqIAAgBWopAwAQMiADQQFqIgNBCEcNAAsgASAEIAAoAuQBEAUaIARBwAAQBCACQYABEAQgAEHAABAEQQAhAwsgBEFAayQAIAML1AMBBn8jAEEQayIEJAAgBCABNgIMIwBBoAFrIgMkACADQQhqQYAYQZABEAUaIAMgADYCNCADIAA2AhwgA0F+IABrIgJB/////wcgAkH/////B0kbIgU2AjggAyAAIAVqIgA2AiQgAyAANgIYIANBCGohACMAQdABayICJAAgAiABNgLMASACQaABakEAQSgQCxogAiACKALMATYCyAECQEEAIAJByAFqIAJB0ABqIAJBoAFqEBtBAEgNACAAKAJMQQBOIQYgACgCACEBIAAsAEpBAEwEQCAAIAFBX3E2AgALIAFBIHEhBwJ/IAAoAjAEQCAAIAJByAFqIAJB0ABqIAJBoAFqEBsMAQsgAEHQADYCMCAAIAJB0ABqNgIQIAAgAjYCHCAAIAI2AhQgACgCLCEBIAAgAjYCLCAAIAJByAFqIAJB0ABqIAJBoAFqEBsgAUUNABogAEEAQQAgACgCJBEAABogAEEANgIwIAAgATYCLCAAQQA2AhwgAEEANgIQIAAoAhQaIABBADYCFEEACxogACAAKAIAIAdyNgIAIAZFDQALIAJB0AFqJAAgBQRAIAMoAhwiACAAIAMoAhhGa0EAOgAACyADQaABaiQAIARBEGokAAs0AQF/QQEhAQJAIABBCkkNAEECIQEDQCAAQeQASQ0BIAFBAWohASAAQQpuIQAMAAsACyABC4UBAQd/AkAgAC0AACIGQTBrQf8BcUEJSw0AIAYhAgNAIAQhByADQZmz5swBSw0BIAJB/wFxQTBrIgIgA0EKbCIEQX9zSw0BIAIgBGohAyAAIAdBAWoiBGoiCC0AACICQTBrQf8BcUEKSQ0ACyAGQTBGQQAgBxsNACABIAM2AgAgCCEFCyAFCzEBA38DQCAAIAJBA3QiA2oiBCAEKQMAIAEgA2opAwCFNwMAIAJBAWoiAkGAAUcNAAsLDAAgACABQYAIEAUaC14BAn8jAEFAaiICJABBfyEDAkAgAEUNACABQQFrQcAATwRAIAAQNwwBCyACQQE6AAMgAkGAAjsAASACIAE6AAAgAkEEckEAQTwQCxogACACEDwhAwsgAkFAayQAIAMLpAoCA38RfiMAQYACayIDJAADQCACQQN0IgQgA0GAAWpqIAEgBGopAAA3AwAgAkEBaiICQRBHDQALIAMgAEHAABAFIQEgACkDWEL5wvibkaOz8NsAhSELIAApA1BC6/qG2r+19sEfhSEMIAApA0hCn9j52cKR2oKbf4UhDSAAKQNAQtGFmu/6z5SH0QCFIQ5C8e30+KWn/aelfyEPQqvw0/Sv7ry3PCESQrvOqqbY0Ouzu38hEEKIkvOd/8z5hOoAIQVBACEDIAEpAzghBiABKQMYIRQgASkDMCEHIAEpAxAhFSABKQMoIQggASkDCCERIAEpAyAhCSABKQMAIQoDQCAJIAUgDiABQYABaiADQQZ0IgJBwAhqKAIAQQN0aikDACAJIAp8fCIKhUEgEAIiDnwiE4VBGBACIQUgBSATIA4gAUGAAWogAkHECGooAgBBA3RqKQMAIAUgCnx8IgqFQRAQAiIOfCIThUE/EAIhCSAIIBAgDSABQYABaiACQcgIaigCAEEDdGopAwAgCCARfHwiEYVBIBACIg18IhCFQRgQAiEFIAUgECANIAFBgAFqIAJBzAhqKAIAQQN0aikDACAFIBF8fCIRhUEQEAIiDXwiEIVBPxACIQUgEiAMIAFBgAFqIAJB0AhqKAIAQQN0aikDACAHIBV8fCIIhUEgEAIiDHwiEiAHhUEYEAIhByAHIBIgDCABQYABaiACQdQIaigCAEEDdGopAwAgByAIfHwiFYVBEBACIgx8IgiFQT8QAiEHIA8gCyABQYABaiACQdgIaigCAEEDdGopAwAgBiAUfHwiEoVBIBACIgt8Ig8gBoVBGBACIQYgBiALIAFBgAFqIAJB3AhqKAIAQQN0aikDACAGIBJ8fCIUhUEQEAIiCyAPfCIPhUE/EAIhBiAFIAggCyABQYABaiACQeAIaigCAEEDdGopAwAgBSAKfHwiCoVBIBACIgt8IgiFQRgQAiEFIAUgCCALIAFBgAFqIAJB5AhqKAIAQQN0aikDACAFIAp8fCIKhUEQEAIiC3wiEoVBPxACIQggByAPIA4gAUGAAWogAkHoCGooAgBBA3RqKQMAIAcgEXx8Ig+FQSAQAiIOfCIRhUEYEAIhBSAFIBEgDiABQYABaiACQewIaigCAEEDdGopAwAgBSAPfHwiEYVBEBACIg58Ig+FQT8QAiEHIAYgDSABQYABaiACQfAIaigCAEEDdGopAwAgBiAVfHwiBYVBIBACIg0gE3wiE4VBGBACIQYgBiATIA0gAUGAAWogAkH0CGooAgBBA3RqKQMAIAUgBnx8IhWFQRAQAiINfCIFhUE/EAIhBiAJIBAgDCABQYABaiACQfgIaigCAEEDdGopAwAgCSAUfHwiEIVBIBACIgx8IhOFQRgQAiEJIAkgEyAMIAFBgAFqIAJB/AhqKAIAQQN0aikDACAJIBB8fCIUhUEQEAIiDHwiEIVBPxACIQkgA0EBaiIDQQxHDQALIAEgDjcDYCABIAk3AyAgASANNwNoIAEgCDcDKCABIBE3AwggASAQNwNIIAEgDDcDcCABIAc3AzAgASAVNwMQIAEgEjcDUCABIAs3A3ggASAGNwM4IAEgFDcDGCABIA83A1ggASAFNwNAIAEgCjcDACAAIAogACkDAIUgBYU3AwBBASECA0AgACACQQN0IgNqIgQgASADaiIDKQMAIAQpAwCFIANBQGspAwCFNwMAIAJBAWoiAkEIRw0ACyABQYACaiQACyYBAX4gACABIAApA0AiAXwiAjcDQCAAIAApA0ggASACVq18NwNIC6AUAhB/An4jAEHQAGsiBiQAIAZByg42AkwgBkE3aiETIAZBOGohEANAAkAgDkEASA0AQf////8HIA5rIARIBEBB3B5BPTYCAEF/IQ4MAQsgBCAOaiEOCyAGKAJMIgchBAJAAkACQAJAAkACQAJAAkAgBgJ/AkAgBy0AACIFBEADQAJAAkAgBUH/AXEiBUUEQCAEIQUMAQsgBUElRw0BIAQhBQNAIAQtAAFBJUcNASAGIARBAmoiCDYCTCAFQQFqIQUgBC0AAiELIAghBCALQSVGDQALCyAFIAdrIQQgAARAIAAgByAEEA4LIAQNDSAGKAJMLAABEA8hBSAGKAJMIQQgBUUNAyAELQACQSRHDQMgBCwAAUEwayEPQQEhESAEQQNqDAQLIAYgBEEBaiIINgJMIAQtAAEhBSAIIQQMAAsACyAOIQwgAA0IIBFFDQJBASEEA0AgAyAEQQJ0aigCACIABEAgAiAEQQN0aiAAIAEQJEEBIQwgBEEBaiIEQQpHDQEMCgsLQQEhDCAEQQpPDQgDQCADIARBAnRqKAIADQggBEEBaiIEQQpHDQALDAgLQX8hDyAEQQFqCyIENgJMQQAhCAJAIAQsAAAiDUEgayIFQR9LDQBBASAFdCIFQYnRBHFFDQADQAJAIAYgBEEBaiIINgJMIAQsAAEiDUEgayIEQSBPDQBBASAEdCIEQYnRBHFFDQAgBCAFciEFIAghBAwBCwsgCCEEIAUhCAsCQCANQSpGBEAgBgJ/AkAgBCwAARAPRQ0AIAYoAkwiBC0AAkEkRw0AIAQsAAFBAnQgA2pBwAFrQQo2AgAgBCwAAUEDdCACakGAA2soAgAhCkEBIREgBEEDagwBCyARDQhBACERQQAhCiAABEAgASABKAIAIgRBBGo2AgAgBCgCACEKCyAGKAJMQQFqCyIENgJMIApBf0oNAUEAIAprIQogCEGAwAByIQgMAQsgBkHMAGoQIyIKQQBIDQYgBigCTCEEC0F/IQkCQCAELQAAQS5HDQAgBC0AAUEqRgRAAkAgBCwAAhAPRQ0AIAYoAkwiBC0AA0EkRw0AIAQsAAJBAnQgA2pBwAFrQQo2AgAgBCwAAkEDdCACakGAA2soAgAhCSAGIARBBGoiBDYCTAwCCyARDQcgAAR/IAEgASgCACIEQQRqNgIAIAQoAgAFQQALIQkgBiAGKAJMQQJqIgQ2AkwMAQsgBiAEQQFqNgJMIAZBzABqECMhCSAGKAJMIQQLQQAhBQNAIAUhEkF/IQwgBCwAAEHBAGtBOUsNByAGIARBAWoiDTYCTCAELAAAIQUgDSEEIAUgEkE6bGpBzxhqLQAAIgVBAWtBCEkNAAsgBUETRg0CIAVFDQYgD0EATgRAIAMgD0ECdGogBTYCACAGIAIgD0EDdGopAwA3A0AMBAsgAA0BC0EAIQwMBQsgBkFAayAFIAEQJCAGKAJMIQ0MAgsgD0F/Sg0DC0EAIQQgAEUNBAsgCEH//3txIgsgCCAIQYDAAHEbIQVBACEMQcAOIQ8gECEIAkACQAJAAn8CQAJAAkACQAJ/AkACQAJAAkACQAJAAkAgDUEBaywAACIEQV9xIAQgBEEPcUEDRhsgBCASGyIEQdgAaw4hBBISEhISEhISDhIPBg4ODhIGEhISEgIFAxISCRIBEhIEAAsCQCAEQcEAaw4HDhILEg4ODgALIARB0wBGDQkMEQsgBikDQCEUQcAODAULQQAhBAJAAkACQAJAAkACQAJAIBJB/wFxDggAAQIDBBcFBhcLIAYoAkAgDjYCAAwWCyAGKAJAIA42AgAMFQsgBigCQCAOrDcDAAwUCyAGKAJAIA47AQAMEwsgBigCQCAOOgAADBILIAYoAkAgDjYCAAwRCyAGKAJAIA6sNwMADBALIAlBCCAJQQhLGyEJIAVBCHIhBUH4ACEECyAQIQcgBEEgcSELIAYpA0AiFFBFBEADQCAHQQFrIgcgFKdBD3FB4BxqLQAAIAtyOgAAIBRCD1YhDSAUQgSIIRQgDQ0ACwsgBUEIcUUgBikDQFByDQMgBEEEdkHADmohD0ECIQwMAwsgECEEIAYpA0AiFFBFBEADQCAEQQFrIgQgFKdBB3FBMHI6AAAgFEIHViEHIBRCA4ghFCAHDQALCyAEIQcgBUEIcUUNAiAJIBAgB2siBEEBaiAEIAlIGyEJDAILIAYpA0AiFEJ/VwRAIAZCACAUfSIUNwNAQQEhDEHADgwBCyAFQYAQcQRAQQEhDEHBDgwBC0HCDkHADiAFQQFxIgwbCyEPIBAhBAJAIBRCgICAgBBUBEAgFCEVDAELA0AgBEEBayIEIBQgFEIKgCIVQgp+fadBMHI6AAAgFEL/////nwFWIQcgFSEUIAcNAAsLIBWnIgcEQANAIARBAWsiBCAHIAdBCm4iC0EKbGtBMHI6AAAgB0EJSyENIAshByANDQALCyAEIQcLIAVB//97cSAFIAlBf0obIQUgBikDQCIUQgBSIAlyRQRAQQAhCSAQIQcMCgsgCSAUUCAQIAdraiIEIAQgCUgbIQkMCQsCfyAJIgRBAEchCAJAAkACQCAGKAJAIgVB4xYgBRsiByIFQQNxRSAERXINAANAIAUtAABFDQIgBEEBayIEQQBHIQggBUEBaiIFQQNxRQ0BIAQNAAsLIAhFDQELAkAgBS0AAEUgBEEESXINAANAIAUoAgAiCEF/cyAIQYGChAhrcUGAgYKEeHENASAFQQRqIQUgBEEEayIEQQNLDQALCyAERQ0AA0AgBSAFLQAARQ0CGiAFQQFqIQUgBEEBayIEDQALC0EACyIEIAcgCWogBBshCCALIQUgBCAHayAJIAQbIQkMCAsgCQRAIAYoAkAMAgtBACEEIABBICAKQQAgBRANDAILIAZBADYCDCAGIAYpA0A+AgggBiAGQQhqNgJAQX8hCSAGQQhqCyEIQQAhBAJAA0AgCCgCACIHRQ0BIAZBBGogBxAiIgdBAEgiCyAHIAkgBGtLckUEQCAIQQRqIQggCSAEIAdqIgRLDQEMAgsLQX8hDCALDQULIABBICAKIAQgBRANIARFBEBBACEEDAELQQAhCCAGKAJAIQ0DQCANKAIAIgdFDQEgBkEEaiAHECIiByAIaiIIIARKDQEgACAGQQRqIAcQDiANQQRqIQ0gBCAISw0ACwsgAEEgIAogBCAFQYDAAHMQDSAKIAQgBCAKSBshBAwFCyAAIAYrA0AgCiAJIAUgBEEAEQwAIQQMBAsgBiAGKQNAPAA3QQEhCSATIQcgCyEFDAILQX8hDAsgBkHQAGokACAMDwsgAEEgIAwgCCAHayILIAkgCSALSBsiCWoiCCAKIAggCkobIgQgCCAFEA0gACAPIAwQDiAAQTAgBCAIIAVBgIAEcxANIABBMCAJIAtBABANIAAgByALEA4gAEEgIAQgCCAFQYDAAHMQDQwACwALkwIBAn8gAEUEQEFnDwsgACgCAEUEQEF/DwsCQAJ/QX4gACgCBEEESQ0AGiAAKAIIRQRAQW4gACgCDA0BGgsgACgCFCEBIAAoAhBFDQFBeiABQQhJDQAaIAAoAhhFBEBBbCAAKAIcDQEaCyAAKAIgRQRAQWsgACgCJA0BGgtBciAAKAIsIgFBCEkNABpBcSABQYCAgAFLDQAaQXIgASAAKAIwIgJBA3RJDQAaIAAoAihFBEBBdA8LIAJFBEBBcA8LQW8gAkH///8HSw0AGiAAKAI0IgFFBEBBZA8LQWMgAUH///8HSw0AGiAAKAJAIQECQCAAKAI8BEAgAQ0BQWkPC0FoIAENARoLQQALDwtBbUF6IAEbCzgBAX8jAEEQayICJAAgAiAANgIMIAIgATYCCCACKAIMQQAgAigCCEH8FygCABEAABogAkEQaiQAC4MSAhN/An4jAEEwayIJJAACQCAAEBwiBA0AQWYhBCABQQJLDQAgACgCLCEDIAAoAjAhBCAAKAI4IQIgCUEANgIAIAkgAjYCBCAAKAIoIQIgCSAENgIYIAkgAjYCCCAJIARBA3QiAiADIAIgA0sbIARBAnQiAm4iAzYCECAJIANBAnQ2AhQgCSACIANsNgIMIAAoAjQhAyAJIAE2AiAgCSADNgIcIAMgBEsEQCAJIAQ2AhwLIwBB0ABrIgskAEFnIQQCQCAJIgFFIAAiA0VyDQAgASADNgIoIAMhBSABKAIMIQZBaiECAkAgASIERQ0AIAatQgqGIhVCIIinDQAgFachAgJAIAUoAjwiBQRAIAQgAiAFEQMAGiAEKAIAIQIMAQsgBCACEAkiAjYCAAtBAEFqIAIbIQILIAIiBA0AIAEoAiAhBSMAQYACayICJAAgA0UgCyIERXJFBEAgAkEQakHAABAYGiACQQxqIAMoAjAQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAgQQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAiwQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAigQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAjgQByACQRBqIAJBDGpBBBAGGiACQQxqIAUQByACQRBqIAJBDGpBBBAGGiACQQxqIAMoAgwQByACQRBqIAJBDGpBBBAGGgJAIAMoAggiBUUNACACQRBqIAUgAygCDBAGGiADLQBEQQFxRQ0AIAMoAgggAygCDBAdIANBADYCDAsgAkEMaiADKAIUEAcgAkEQaiACQQxqQQQQBhogAygCECIFBEAgAkEQaiAFIAMoAhQQBhoLIAJBDGogAygCHBAHIAJBEGogAkEMakEEEAYaAkAgAygCGCIFRQ0AIAJBEGogBSADKAIcEAYaIAMtAERBAnFFDQAgAygCGCADKAIcEB0gA0EANgIcCyACQQxqIAMoAiQQByACQRBqIAJBDGpBBBAGGiADKAIgIgUEQCACQRBqIAUgAygCJBAGGgsgAkEQaiAEQcAAEBIaCyACQYACaiQAIAtBQGtBCBAEQQAhAiMAQYAIayIDJAAgASgCGARAIARBxABqIQYgBEFAayEFA0AgBUEAEAcgBiACEAcgA0GACCAEQcgAECAgASgCACABKAIUIAJsQQp0aiADEC4gBUEBEAcgA0GACCAEQcgAECAgASgCACABKAIUIAJsQQp0akGACGogAxAuIAJBAWoiAiABKAIYSQ0ACwsgA0GACBAEIANBgAhqJAAgC0HIABAEQQAhBAsgC0HQAGokACAEDQBBZyEEAkAgCUUNACABKAIYRQ0AIwBBIGsiBSQAIAEiCygCCARAIAsoAhghBANAIAQhA0EAIQ8DQEEAIRBBACECIAMEQANAIAUgDzoAGCAFQQA2AhwgBSAFKQMYNwMIIAUgEjYCECAFIBA2AhQgBSAFKQMQNwMAIAUhBEEAIREjAEGAGGsiByQAAkAgCyIDRQ0AAkACQAJAAn8CfwJAAkACQCADKAIgQQFrDgICAQALIAQoAgAhCEEADAMLIAQoAgANA0EAIAQtAAgiDEECSQ0BGiAELQAIIghFQQF0IQwMBQsgBC0ACCEMIAQoAgALIQggBxAvIAdBgAhqEC8gByAIrTcDgAggBDUCBCEVIAcgDK1C/wGDNwOQCCAHIBU3A4gIIAcgAzUCDDcDmAggByADNQIINwOgCCAHIAM1AiA3A6gIQQELIREgCEUNAQsgBC0ACCEIQQAhDAwBCyAELQAIIghFQQF0IQwgCCARRXINACAHQYAQaiAHQYAIaiAHECZBAiEMQQAhCAsgDCADKAIQIgZPDQBBfyADKAIUIgJBAWsgAiAEKAIEbCAMaiAGIAhB/wFxbGoiCCACcBsgCGohBgNAIAhBAWsgBiAIIAJwQQFGGyEOAn8gEQRAIAxB/wBxIgJFBEAgB0GAEGogB0GACGogBxAmCyAHQYAQaiACQQN0agwBCyADKAIAIA5BCnRqCyECIAMoAhghCiACKQMAIRUgBCAMNgIMIAMhBiAVpyEUIBVCIIinIApwrSIVIBUgBDUCBCIVIAQtAAgbIAQoAgAbIhYgFVEhCgJ+IAQiAigCAEUEQCACLQAIIg1FBEAgAigCDEEBayEKQgAMAgsgBigCECANbCENIAIoAgwhAiAKBEAgAiANakEBayEKQgAMAgsgDSACRWshCkIADAELIAYoAhAhDSAGKAIUIRMCfyAKBEAgAigCDCATIA1Bf3NqagwBCyATIA1rIAIoAgxFawshCkIAIAItAAgiAkEDRg0AGiANIAJBAWpsrQshFSAVIApBAWutfCAKrSAUrSIVIBV+QiCIfkIgiH0gBjUCFIKnIQYgAygCACICIAMoAhQgFqdsQQp0aiAGQQp0aiEGIAIgCEEKdGohCgJAIAMoAgRBEEYEQCACIA5BCnRqIAYgCkEAEBEMAQsgAiAOQQp0aiECIAQoAgBFBEAgAiAGIApBABARDAELIAIgBiAKQQEQEQsgDEEBaiIMIAMoAhBPDQEgCEEBaiEIIA5BAWohBiADKAIUIQIMAAsACyAHQYAYaiQAIAsoAhgiBCECIBBBAWoiECAESQ0ACwsgAiEDIA9BAWoiD0EERw0ACyASQQFqIhIgCygCCEkNAAsLIAVBIGokAEEAIQQLIAQNACMAQYAQayIDJAAgAEUgCUVyRQRAIANBgAhqIAEoAgAgASgCFEEKdGpBgAhrEBcgASgCGEECTwRAQQEhBANAIANBgAhqIAEoAgAgASgCFCICIAIgBGxqQQp0akGACGsQFiAEQQFqIgQgASgCGEkNAAsLIAMiAkGACGohC0EAIQQDQCACIARBA3QiBWogBSALaikDABAyIARBAWoiBEGAAUcNAAsgACgCACAAKAIEIANBgAgQICADQYAIakGACBAEIANBgAgQBCABKAIAIgQgASgCDEEKdCIBEAQCQCAAKAJAIgAEQCAEIAEgABECAAwBCyAEEAgLCyADQYAQaiQAQQAhBAsgCUEwaiQAIAQLJwEBfwJAAkACQAJAIAAOAwABAgMLQdATDwtBixEPC0GeEyEBCyABC48DAQF/IwBBgANrIgQkACAEQQA2AowBIARBjAFqIAEQBwJAIAFBwABNBEAgBEGQAWogARAYQQBIDQEgBEGQAWogBEGMAWpBBBAGQQBIDQEgBEGQAWogAiADEAZBAEgNASAEQZABaiAAIAEQEhoMAQsgBEGQAWpBwAAQGEEASA0AIARBkAFqIARBjAFqQQQQBkEASA0AIARBkAFqIAIgAxAGQQBIDQAgBEGQAWogBEFAa0HAABASQQBIDQAgACAEKQNANwAAIAAgBCkDSDcACCAAIAQpA1g3ABggACAEKQNQNwAQIABBIGohACABQSBrIgJBwQBPBEADQCAEIARBQGtBwAAQBSIBQUBrQcAAIAEQMUEASA0CIAAgASkDQDcAACAAIAEpA0g3AAggACAEKQNYNwAYIAAgBCkDUDcAECAAQSBqIQAgAkEgayICQcAASw0ACwsgBCAEQUBrQcAAEAUiAUFAayACIAEQMUEASA0AIAAgAUFAayACEAUaCyAEQZABakHwARAEIARBgANqJAALAwABC5kCACAARQRAQQAPCwJ/AkAgAAR/IAFB/wBNDQECQEGgHigCACgCAEUEQCABQYB/cUGAvwNGDQMMAQsgAUH/D00EQCAAIAFBP3FBgAFyOgABIAAgAUEGdkHAAXI6AABBAgwECyABQYCwA09BACABQYBAcUGAwANHG0UEQCAAIAFBP3FBgAFyOgACIAAgAUEMdkHgAXI6AAAgACABQQZ2QT9xQYABcjoAAUEDDAQLIAFBgIAEa0H//z9NBEAgACABQT9xQYABcjoAAyAAIAFBEnZB8AFyOgAAIAAgAUEGdkE/cUGAAXI6AAIgACABQQx2QT9xQYABcjoAAUEEDAQLC0HcHkEZNgIAQX8FQQELDAELIAAgAToAAEEBCwtQAQN/AkAgACgCACwAABAPRQRADAELA0AgACgCACICLAAAIQMgACACQQFqNgIAIAEgA2pBMGshASACLAABEA9FDQEgAUEKbCEBDAALAAsgAQu7AgACQCABQRRLDQACQAJAAkACQAJAAkACQAJAAkACQCABQQlrDgoAAQIDBAUGBwgJCgsgAiACKAIAIgFBBGo2AgAgACABKAIANgIADwsgAiACKAIAIgFBBGo2AgAgACABNAIANwMADwsgAiACKAIAIgFB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AUHjFEEDEBANACABQQNqIANBDGoQFSIBRQ0AIAAgAygCDDYCKCABQecUQQMQEA0AIAFBA2ogA0EMahAVIgFFDQAgACADKAIMIgQ2AjAgACAENgI0IAEtAABBJEcNACADIAc2AgwgACgCECADQQxqIAFBAWoQLCIBRQ0AIAAgAygCDDYCFCABLQAAQSRHDQAgAyAGNgIMIAAoAgAgA0EMaiABQQFqECwiAUUNACAAIAMoAgw2AgQgAEEANgJEIABCADcCPCAAQgA3AhggAEIANwIgIAAQHCICDQBBYEEAIAEtAAAbIQILIANBEGokACACCykBAn8DQCAAIAJBA3QiA2ogASADaikAADcDACACQQFqIgJBgAFHDQALCwwAIABBAEGACBALGgtlAQJ/IAAgAhAeIgIEfyACBUFdQQACfyAAKAIAIQRBACECIAAoAgQiAAR/A0AgAyACIARqLQAAIAEgAmotAABzciEDIAJBAWoiAiAARw0ACyADQQFrQQh2QQFxQQFrBUEACwsbCwtdAQJ/IwBB8AFrIgMkAEF/IQQCQCACRSAARSABRXJyIAFBwABLcg0AIAMgARAYQQBIDQAgAyACQcAAEAZBAEgNACADIAAgARASIQQLIANB8AEQBCADQfABaiQAIAQLCQAgACABNwAACxAAIwAgAGtBcHEiACQAIAALMwEBfyAAKAIUIgMgASACIAAoAhAgA2siASABIAJLGyIBEAUaIAAgACgCFCABajYCFCACC9oBAQR/IwBB0ABrIggkAAJAIABFBEBBYCEADAELIAggABAKIgk2AgwgCCAJNgIcIAggCRAJIgo2AhggCCAJEAkiCzYCCEEAIQkCQAJAIApFIAtFcg0AIAggAjYCFCAIIAE2AhAgCEEIaiAAIAcQLSIADQEgCCgCCCEJIAggCCgCDBAJIgA2AgggAEUNACAIIAY2AiwgCCAFNgIoIAggBDYCJCAIIAM2AiAgCEEIaiAJIAcQMCEADAELQWohAAsgCCgCGBAIIAgoAggQCCAJEAgLIAhB0ABqJAAgAAuQAgEDfyMAQdAAayIRJABBfiETAkAgCEEESQ0AIAgQCSISRQRAQWohEwwBCyARQQA2AkwgEUIANwJEIBEgAjYCPCARIAI2AjggESABNgI0IBEgADYCMCARIA82AiwgESAONgIoIBEgDTYCJCAR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IGxvbmcgb3IgdG9vIHNob3J0AE1pc3NpbmcgYXJndW1lbnRzAFRvbyBtYW55IGxhbmVzAFRvbyBmZXcgbGFuZXMAVG9vIG1hbnkgdGhyZWFkcwBOb3QgZW5vdWdoIHRocmVhZHMATWVtb3J5IGFsbG9jYXRpb24gZXJyb3IATWVtb3J5IGNvc3QgaXMgdG9vIHNtYWxsAFRpbWUgY29zdCBpcyB0b28gc21hbGwAYXJnb24yaQBBcmdvbjJpAFRoZSBwYXNzd29yZCBkb2VzIG5vdCBtYXRjaCB0aGUgc3VwcGxpZWQgaGFzaABPdXRwdXQgcG9pbnRlciBtaXNtYXRjaABPdXRwdXQgaXMgdG9vIGxvbmcAU2FsdCBpcyB0b28gbG9uZwBTZWNyZXQgaXMgdG9vIGxvbmcAUGFzc3dvcmQgaXMgdG9vIGxvbmcAQXNzb2NpYXRlZCBkYXRhIGlzIHRvbyBsb25nAFRocmVhZGluZyBmYWlsdXJlAE1lbW9yeSBjb3N0IGlzIHRvbyBsYXJnZQBUaW1lIGNvc3QgaXMgdG9vIGxhcmdlAFVua25vd24gZXJyb3IgY29kZQBhcmdvbjJpZABBcmdvbjJpZABFbmNvZGluZyBmYWlsZWQARGVjb2RpbmcgZmFpbGVkAGFyZ29uMmQAQXJnb24yZABBcmdvbjJfQ29udGV4dCBjb250ZXh0IGlzIE5VTEwAT3V0cHV0IHBvaW50ZXIgaXMgTlVMTABUaGUgYWxsb2NhdGUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAVGhlIGZyZWUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAT0sAJHY9ACx0PQAscD0AJG09AFRoZXJlIGlzIG5vIHN1Y2ggdmVyc2lvbiBvZiBBcmdvbjIAU2FsdCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBzYWx0IGxlbmd0aCBpcyBub3QgMABTZWNyZXQgcG9pbnRlciBpcyBOVUxMLCBidXQgc2VjcmV0IGxlbmd0aCBpcyBub3QgMABQYXNzd29yZCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBwYXNzd29yZCBsZW5ndGggaXMgbm90IDAAQXNzb2NpYXRlZCBkYXRhIHBvaW50ZXIgaXMgTlVMTCwgYnV0IGFkIGxlbmd0aCBpcyBub3QgMAAobnVsbCkAAACbCAAAuwcAAEkJAADACQAAsAkAAPAHAAAfCAAAMAgAAMkIAABvCgAA4AkAABYKAAA7CgAAQwgAACsLAADBCgAAkgoAAPQKAAACCAAAEQgAAFsJAABbCAAAdAkAAHQIAAAFCQAAdAcAAC0JAACeBwAA9AgAAGIHAAAYCQAAiAcAAOEIAABOBwAA/wkAAFwKAAABAEGkGAsBAgBByxgLBf//////AEGQGQtBEQAKABEREQAAAAAFAAAAAAAACQAAAAALAAAAAAAAAAARAA8KERERAwoHAAEACQsLAAAJBgsAAAsABhEAAAAREREAQeEZCyELAAAAAAAAAAARAAoKERERAAoAAAIACQsAAAAJAAsAAAsAQZsaCwEMAEGnGgsVDAAAAAAMAAAAAAkMAAAAAAAMAAAMAEHVGgsBDgBB4RoLFQ0AAAAEDQAAAAAJDgAAAAAADgAADgBBjxsLARAAQZsbCx4PAAAAAA8AAAAACRAAAAAAABAAABAAABIAAAASEhIAQdIbCw4SAAAAEhISAAAAAAAACQBBgxwLAQsAQY8cCxUKAAAAAAoAAAAACQsAAAAAAAsAAAsAQb0cCwEMAEHJHAsnDAAAAAAMAAAAAAkMAAAAAAAMAAAMAAAwMTIzNDU2Nzg5QUJDREVGAEHwHAsBAQBBoB4LAogPAEHYHgsDkBFQ"; - }, - 145: () => {}, - 967: () => {} - }, - B = {}; - function Q(A) { - var I = B[A]; - if (void 0 !== I) return I.exports; - var C = (B[A] = { exports: {} }); - return g[A].call(C.exports, C, C.exports, Q), C.exports; - } - return ( - (I = Object.getPrototypeOf ? (A) => Object.getPrototypeOf(A) : (A) => A.__proto__), - (Q.t = function (g, B) { - if ((1 & B && (g = this(g)), 8 & B)) return g; - if ("object" == typeof g && g) { - if (4 & B && g.__esModule) return g; - if (16 & B && "function" == typeof g.then) return g; - } - var C = Object.create(null); - Q.r(C); - var E = {}; - A = A || [null, I({}), I([]), I(I)]; - for (var i = 2 & B && g; "object" == typeof i && !~A.indexOf(i); i = I(i)) - Object.getOwnPropertyNames(i).forEach((A) => (E[A] = () => g[A])); - return (E.default = () => g), Q.d(C, E), C; - }), - (Q.d = (A, I) => { - for (var g in I) - Q.o(I, g) && !Q.o(A, g) && Object.defineProperty(A, g, { enumerable: !0, get: I[g] }); - }), - (Q.o = (A, I) => Object.prototype.hasOwnProperty.call(A, I)), - (Q.r = (A) => { - "undefined" != typeof Symbol && - Symbol.toStringTag && - Object.defineProperty(A, Symbol.toStringTag, { value: "Module" }), - Object.defineProperty(A, "__esModule", { value: !0 }); - }), - Q(631) - ); - })(); + ) + Q.preInit.pop()(); + j(), + (A.exports = Q), + (Q.unloadRuntime = function () { + "undefined" != typeof self && delete self.Module, + (Q = c = M = N = R = void 0), + delete A.exports; + }); + }, + 631: function (A, I, g) { + var B, Q; + "undefined" != typeof self && self, + void 0 === + (Q = + "function" == + typeof (B = function () { + const A = "undefined" != typeof self ? self : this, + I = { Argon2d: 0, Argon2i: 1, Argon2id: 2 }; + function B(I) { + if (B._promise) return B._promise; + if (B._module) return Promise.resolve(B._module); + let C; + return ( + (C = + A.process && A.process.versions && A.process.versions.node + ? Q().then( + (A) => + new Promise((I) => { + A.postRun = () => I(A); + }), + ) + : (A.loadArgon2WasmBinary + ? A.loadArgon2WasmBinary() + : Promise.resolve(g(721)).then((A) => + (function (A) { + const I = atob(A), + g = new Uint8Array(new ArrayBuffer(I.length)); + for (let A = 0; A < I.length; A++) g[A] = I.charCodeAt(A); + return g; + })(A), + ) + ).then((g) => + (function (I, g) { + return new Promise( + (B) => ( + (A.Module = { + wasmBinary: I, + wasmMemory: g, + postRun() { + B(Module); + }, + }), + Q() + ), + ); + })( + g, + I + ? (function (A) { + const I = 1024, + g = 64 * I, + B = (1024 * I * 1024 * 2 - 64 * I) / g, + Q = Math.min( + Math.max(Math.ceil((A * I) / g), 256) + 256, + B, + ); + return new WebAssembly.Memory({ initial: Q, maximum: B }); + })(I) + : void 0, + ), + )), + (B._promise = C), + C.then((A) => ((B._module = A), delete B._promise, A)) + ); + } + function Q() { + return A.loadArgon2WasmModule + ? A.loadArgon2WasmModule() + : Promise.resolve(g(773)); + } + function C(A, I) { + return A.allocate(I, "i8", A.ALLOC_NORMAL); + } + function E(A, I) { + return C(A, new Uint8Array([...I, 0])); + } + function i(A) { + if ("string" != typeof A) return A; + if ("function" == typeof TextEncoder) return new TextEncoder().encode(A); + if ("function" == typeof Buffer) return Buffer.from(A); + throw new Error("Don't know how to encode UTF8"); + } + return { + ArgonType: I, + hash: function (A) { + const g = A.mem || 1024; + return B(g).then((B) => { + const Q = A.time || 1, + o = A.parallelism || 1, + D = i(A.pass), + e = E(B, D), + n = D.length, + t = i(A.salt), + a = E(B, t), + r = t.length, + s = A.type || I.Argon2d, + y = B.allocate(new Array(A.hashLen || 24), "i8", B.ALLOC_NORMAL), + F = A.secret ? C(B, A.secret) : 0, + c = A.secret ? A.secret.byteLength : 0, + w = A.ad ? C(B, A.ad) : 0, + h = A.ad ? A.ad.byteLength : 0, + G = A.hashLen || 24, + N = B._argon2_encodedlen(Q, g, o, r, G, s), + R = B.allocate(new Array(N + 1), "i8", B.ALLOC_NORMAL); + let f, U, M; + try { + U = B._argon2_hash_ext( + Q, + g, + o, + e, + n, + a, + r, + y, + G, + R, + N, + s, + F, + c, + w, + h, + 19, + ); + } catch (A) { + f = A; + } + if (0 !== U || f) { + try { + f || (f = B.UTF8ToString(B._argon2_error_message(U))); + } catch (A) {} + M = { message: f, code: U }; + } else { + let A = ""; + const I = new Uint8Array(G); + for (let g = 0; g < G; g++) { + const Q = B.HEAP8[y + g]; + (I[g] = Q), (A += ("0" + (255 & Q).toString(16)).slice(-2)); + } + M = { hash: I, hashHex: A, encoded: B.UTF8ToString(R) }; + } + try { + B._free(e), + B._free(a), + B._free(y), + B._free(R), + w && B._free(w), + F && B._free(F); + } catch (A) {} + if (f) throw M; + return M; + }); + }, + verify: function (A) { + return B().then((g) => { + const B = i(A.pass), + Q = E(g, B), + o = B.length, + D = A.secret ? C(g, A.secret) : 0, + e = A.secret ? A.secret.byteLength : 0, + n = A.ad ? C(g, A.ad) : 0, + t = A.ad ? A.ad.byteLength : 0, + a = E(g, i(A.encoded)); + let r, + s, + y, + F = A.type; + if (void 0 === F) { + let g = A.encoded.split("$")[1]; + g && ((g = g.replace("a", "A")), (F = I[g] || I.Argon2d)); + } + try { + s = g._argon2_verify_ext(a, Q, o, D, e, n, t, F); + } catch (A) { + r = A; + } + if (s || r) { + try { + r || (r = g.UTF8ToString(g._argon2_error_message(s))); + } catch (A) {} + y = { message: r, code: s }; + } + try { + g._free(Q), g._free(a); + } catch (A) {} + if (r) throw y; + return y; + }); + }, + unloadRuntime: function () { + B._module && (B._module.unloadRuntime(), delete B._promise, delete B._module); + }, + }; + }) + ? B.apply(I, []) + : B) || (A.exports = Q); + }, + 721: function (A, I) { + A.exports = + "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DwtBixEPC0GeEyEBCyABC48DAQF/IwBBgANrIgQkACAEQQA2AowBIARBjAFqIAEQBwJAIAFBwABNBEAgBEGQAWogARAYQQBIDQEgBEGQAWogBEGMAWpBBBAGQQBIDQEgBEGQAWogAiADEAZBAEgNASAEQZABaiAAIAEQEhoMAQsgBEGQAWpBwAAQGEEASA0AIARBkAFqIARBjAFqQQQQBkEASA0AIARBkAFqIAIgAxAGQQBIDQAgBEGQAWogBEFAa0HAABASQQBIDQAgACAEKQNANwAAIAAgBCkDSDcACCAAIAQpA1g3ABggACAEKQNQNwAQIABBIGohACABQSBrIgJBwQBPBEADQCAEIARBQGtBwAAQBSIBQUBrQcAAIAEQMUEASA0CIAAgASkDQDcAACAAIAEpA0g3AAggACAEKQNYNwAYIAAgBCkDUDcAECAAQSBqIQAgAkEgayICQcAASw0ACwsgBCAEQUBrQcAAEAUiAUFAayACIAEQMUEASA0AIAAgAUFAayACEAUaCyAEQZABakHwARAEIARBgANqJAALAwABC5kCACAARQRAQQAPCwJ/AkAgAAR/IAFB/wBNDQECQEGgHigCACgCAEUEQCABQYB/cUGAvwNGDQMMAQsgAUH/D00EQCAAIAFBP3FBgAFyOgABIAAgAUEGdkHAAXI6AABBAgwECyABQYCwA09BACABQYBAcUGAwANHG0UEQCAAIAFBP3FBgAFyOgACIAAgAUEMdkHgAXI6AAAgACABQQZ2QT9xQYABcjoAAUEDDAQLIAFBgIAEa0H//z9NBEAgACABQT9xQYABcjoAAyAAIAFBEnZB8AFyOgAAIAAgAUEGdkE/cUGAAXI6AAIgACABQQx2QT9xQYABcjoAAUEEDAQLC0HcHkEZNgIAQX8FQQELDAELIAAgAToAAEEBCwtQAQN/AkAgACgCACwAABAPRQRADAELA0AgACgCACICLAAAIQMgACACQQFqNgIAIAEgA2pBMGshASACLAABEA9FDQEgAUEKbCEBDAALAAsgAQu7AgACQCABQRRLDQACQAJAAkACQAJAAkACQAJAAkACQCABQQlrDgoAAQIDBAUGBwgJCgsgAiACKAIAIgFBBGo2AgAgACABKAIANgIADwsgAiACKAIAIgFBBGo2AgAgACABNAIANwMADwsgAiACKAIAIgFB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AUHjFEEDEBANACABQQNqIANBDGoQFSIBRQ0AIAAgAygCDDYCKCABQecUQQMQEA0AIAFBA2ogA0EMahAVIgFFDQAgACADKAIMIgQ2AjAgACAENgI0IAEtAABBJEcNACADIAc2AgwgACgCECADQQxqIAFBAWoQLCIBRQ0AIAAgAygCDDYCFCABLQAAQSRHDQAgAyAGNgIMIAAoAgAgA0EMaiABQQFqECwiAUUNACAAIAMoAgw2AgQgAEEANgJEIABCADcCPCAAQgA3AhggAEIANwIgIAAQHCICDQBBYEEAIAEtAAAbIQILIANBEGokACACCykBAn8DQCAAIAJBA3QiA2ogASADaikAADcDACACQQFqIgJBgAFHDQALCwwAIABBAEGACBALGgtlAQJ/IAAgAhAeIgIEfyACBUFdQQACfyAAKAIAIQRBACECIAAoAgQiAAR/A0AgAyACIARqLQAAIAEgAmotAABzciEDIAJBAWoiAiAARw0ACyADQQFrQQh2QQFxQQFrBUEACwsbCwtdAQJ/IwBB8AFrIgMkAEF/IQQCQCACRSAARSABRXJyIAFBwABLcg0AIAMgARAYQQBIDQAgAyACQcAAEAZBAEgNACADIAAgARASIQQLIANB8AEQBCADQfABaiQAIAQLCQAgACABNwAACxAAIwAgAGtBcHEiACQAIAALMwEBfyAAKAIUIgMgASACIAAoAhAgA2siASABIAJLGyIBEAUaIAAgACgCFCABajYCFCACC9oBAQR/IwBB0ABrIggkAAJAIABFBEBBYCEADAELIAggABAKIgk2AgwgCCAJNgIcIAggCRAJIgo2AhggCCAJEAkiCzYCCEEAIQkCQAJAIApFIAtFcg0AIAggAjYCFCAIIAE2AhAgCEEIaiAAIAcQLSIADQEgCCgCCCEJIAggCCgCDBAJIgA2AgggAEUNACAIIAY2AiwgCCAFNgIoIAggBDYCJCAIIAM2AiAgCEEIaiAJIAcQMCEADAELQWohAAsgCCgCGBAIIAgoAggQCCAJEAgLIAhB0ABqJAAgAAuQAgEDfyMAQdAAayIRJABBfiETAkAgCEEESQ0AIAgQCSISRQRAQWohEwwBCyARQQA2AkwgEUIANwJEIBEgAjYCPCARIAI2AjggESABNgI0IBEgADYCMCARIA82AiwgESAONgIoIBEgDTYCJCAR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IGxvbmcgb3IgdG9vIHNob3J0AE1pc3NpbmcgYXJndW1lbnRzAFRvbyBtYW55IGxhbmVzAFRvbyBmZXcgbGFuZXMAVG9vIG1hbnkgdGhyZWFkcwBOb3QgZW5vdWdoIHRocmVhZHMATWVtb3J5IGFsbG9jYXRpb24gZXJyb3IATWVtb3J5IGNvc3QgaXMgdG9vIHNtYWxsAFRpbWUgY29zdCBpcyB0b28gc21hbGwAYXJnb24yaQBBcmdvbjJpAFRoZSBwYXNzd29yZCBkb2VzIG5vdCBtYXRjaCB0aGUgc3VwcGxpZWQgaGFzaABPdXRwdXQgcG9pbnRlciBtaXNtYXRjaABPdXRwdXQgaXMgdG9vIGxvbmcAU2FsdCBpcyB0b28gbG9uZwBTZWNyZXQgaXMgdG9vIGxvbmcAUGFzc3dvcmQgaXMgdG9vIGxvbmcAQXNzb2NpYXRlZCBkYXRhIGlzIHRvbyBsb25nAFRocmVhZGluZyBmYWlsdXJlAE1lbW9yeSBjb3N0IGlzIHRvbyBsYXJnZQBUaW1lIGNvc3QgaXMgdG9vIGxhcmdlAFVua25vd24gZXJyb3IgY29kZQBhcmdvbjJpZABBcmdvbjJpZABFbmNvZGluZyBmYWlsZWQARGVjb2RpbmcgZmFpbGVkAGFyZ29uMmQAQXJnb24yZABBcmdvbjJfQ29udGV4dCBjb250ZXh0IGlzIE5VTEwAT3V0cHV0IHBvaW50ZXIgaXMgTlVMTABUaGUgYWxsb2NhdGUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAVGhlIGZyZWUgbWVtb3J5IGNhbGxiYWNrIGlzIE5VTEwAT0sAJHY9ACx0PQAscD0AJG09AFRoZXJlIGlzIG5vIHN1Y2ggdmVyc2lvbiBvZiBBcmdvbjIAU2FsdCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBzYWx0IGxlbmd0aCBpcyBub3QgMABTZWNyZXQgcG9pbnRlciBpcyBOVUxMLCBidXQgc2VjcmV0IGxlbmd0aCBpcyBub3QgMABQYXNzd29yZCBwb2ludGVyIGlzIE5VTEwsIGJ1dCBwYXNzd29yZCBsZW5ndGggaXMgbm90IDAAQXNzb2NpYXRlZCBkYXRhIHBvaW50ZXIgaXMgTlVMTCwgYnV0IGFkIGxlbmd0aCBpcyBub3QgMAAobnVsbCkAAACbCAAAuwcAAEkJAADACQAAsAkAAPAHAAAfCAAAMAgAAMkIAABvCgAA4AkAABYKAAA7CgAAQwgAACsLAADBCgAAkgoAAPQKAAACCAAAEQgAAFsJAABbCAAAdAkAAHQIAAAFCQAAdAcAAC0JAACeBwAA9AgAAGIHAAAYCQAAiAcAAOEIAABOBwAA/wkAAFwKAAABAEGkGAsBAgBByxgLBf//////AEGQGQtBEQAKABEREQAAAAAFAAAAAAAACQAAAAALAAAAAAAAAAARAA8KERERAwoHAAEACQsLAAAJBgsAAAsABhEAAAAREREAQeEZCyELAAAAAAAAAAARAAoKERERAAoAAAIACQsAAAAJAAsAAAsAQZsaCwEMAEGnGgsVDAAAAAAMAAAAAAkMAAAAAAAMAAAMAEHVGgsBDgBB4RoLFQ0AAAAEDQAAAAAJDgAAAAAADgAADgBBjxsLARAAQZsbCx4PAAAAAA8AAAAACRAAAAAAABAAABAAABIAAAASEhIAQdIbCw4SAAAAEhISAAAAAAAACQBBgxwLAQsAQY8cCxUKAAAAAAoAAAAACQsAAAAAAAsAAAsAQb0cCwEMAEHJHAsnDAAAAAAMAAAAAAkMAAAAAAAMAAAMAAAwMTIzNDU2Nzg5QUJDREVGAEHwHAsBAQBBoB4LAogPAEHYHgsDkBFQ"; + }, + 145: () => {}, + 967: () => {}, + }, + B = {}; + function Q(A) { + var I = B[A]; + if (void 0 !== I) return I.exports; + var C = (B[A] = { exports: {} }); + return g[A].call(C.exports, C, C.exports, Q), C.exports; + } + return ( + (I = Object.getPrototypeOf ? (A) => Object.getPrototypeOf(A) : (A) => A.__proto__), + (Q.t = function (g, B) { + if ((1 & B && (g = this(g)), 8 & B)) return g; + if ("object" == typeof g && g) { + if (4 & B && g.__esModule) return g; + if (16 & B && "function" == typeof g.then) return g; + } + var C = Object.create(null); + Q.r(C); + var E = {}; + A = A || [null, I({}), I([]), I(I)]; + for (var i = 2 & B && g; "object" == typeof i && !~A.indexOf(i); i = I(i)) + Object.getOwnPropertyNames(i).forEach((A) => (E[A] = () => g[A])); + return (E.default = () => g), Q.d(C, E), C; + }), + (Q.d = (A, I) => { + for (var g in I) + Q.o(I, g) && !Q.o(A, g) && Object.defineProperty(A, g, { enumerable: !0, get: I[g] }); + }), + (Q.o = (A, I) => Object.prototype.hasOwnProperty.call(A, I)), + (Q.r = (A) => { + "undefined" != typeof Symbol && + Symbol.toStringTag && + Object.defineProperty(A, Symbol.toStringTag, { value: "Module" }), + Object.defineProperty(A, "__esModule", { value: !0 }); + }), + Q(631) + ); + })(); }); diff --git a/static/lib/argon2/argon2.js b/static/lib/argon2/argon2.js index faf4499..1257892 100644 --- a/static/lib/argon2/argon2.js +++ b/static/lib/argon2/argon2.js @@ -3,14 +3,14 @@ var jsModule = Module; var moduleOverrides = {}; var key; for (key in Module) { - if (Module.hasOwnProperty(key)) { - moduleOverrides[key] = Module[key]; - } + if (Module.hasOwnProperty(key)) { + moduleOverrides[key] = Module[key]; + } } var arguments_ = []; var thisProgram = "./this.program"; var quit_ = function (status, toThrow) { - throw toThrow; + throw toThrow; }; var ENVIRONMENT_IS_WEB = false; var ENVIRONMENT_IS_WORKER = false; @@ -19,142 +19,142 @@ var ENVIRONMENT_IS_SHELL = false; ENVIRONMENT_IS_WEB = typeof window === "object"; ENVIRONMENT_IS_WORKER = typeof importScripts === "function"; ENVIRONMENT_IS_NODE = - typeof process === "object" && - typeof process.versions === "object" && - typeof process.versions.node === "string"; + typeof process === "object" && + typeof process.versions === "object" && + typeof process.versions.node === "string"; ENVIRONMENT_IS_SHELL = !ENVIRONMENT_IS_WEB && !ENVIRONMENT_IS_NODE && !ENVIRONMENT_IS_WORKER; var scriptDirectory = ""; function locateFile(path) { - if (Module["locateFile"]) { - return Module["locateFile"](path, scriptDirectory); - } - return scriptDirectory + path; + if (Module["locateFile"]) { + return Module["locateFile"](path, scriptDirectory); + } + return scriptDirectory + path; } var read_, readAsync, readBinary, setWindowTitle; var nodeFS; var nodePath; if (ENVIRONMENT_IS_NODE) { - if (ENVIRONMENT_IS_WORKER) { - scriptDirectory = require("path").dirname(scriptDirectory) + "/"; - } else { - scriptDirectory = __dirname + "/"; - } - read_ = function shell_read(filename, binary) { - if (!nodeFS) nodeFS = require("fs"); - if (!nodePath) nodePath = require("path"); - filename = nodePath["normalize"](filename); - return nodeFS["readFileSync"](filename, binary ? null : "utf8"); - }; - readBinary = function readBinary(filename) { - var ret = read_(filename, true); - if (!ret.buffer) { - ret = new Uint8Array(ret); - } - assert(ret.buffer); - return ret; - }; - if (process["argv"].length > 1) { - thisProgram = process["argv"][1].replace(/\\/g, "/"); - } - arguments_ = process["argv"].slice(2); - if (typeof module !== "undefined") { - module["exports"] = Module; - } - process["on"]("uncaughtException", function (ex) { - if (!(ex instanceof ExitStatus)) { - throw ex; - } - }); - process["on"]("unhandledRejection", abort); - quit_ = function (status) { - process["exit"](status); - }; - Module["inspect"] = function () { - return "[Emscripten Module object]"; - }; + if (ENVIRONMENT_IS_WORKER) { + scriptDirectory = require("path").dirname(scriptDirectory) + "/"; + } else { + scriptDirectory = __dirname + "/"; + } + read_ = function shell_read(filename, binary) { + if (!nodeFS) nodeFS = require("fs"); + if (!nodePath) nodePath = require("path"); + filename = nodePath["normalize"](filename); + return nodeFS["readFileSync"](filename, binary ? null : "utf8"); + }; + readBinary = function readBinary(filename) { + var ret = read_(filename, true); + if (!ret.buffer) { + ret = new Uint8Array(ret); + } + assert(ret.buffer); + return ret; + }; + if (process["argv"].length > 1) { + thisProgram = process["argv"][1].replace(/\\/g, "/"); + } + arguments_ = process["argv"].slice(2); + if (typeof module !== "undefined") { + module["exports"] = Module; + } + process["on"]("uncaughtException", function (ex) { + if (!(ex instanceof ExitStatus)) { + throw ex; + } + }); + process["on"]("unhandledRejection", abort); + quit_ = function (status) { + process["exit"](status); + }; + Module["inspect"] = function () { + return "[Emscripten Module object]"; + }; } else if (ENVIRONMENT_IS_SHELL) { - if (typeof read != "undefined") { - read_ = function shell_read(f) { - return read(f); - }; - } - readBinary = function readBinary(f) { - var data; - if (typeof readbuffer === "function") { - return new Uint8Array(readbuffer(f)); - } - data = read(f, "binary"); - assert(typeof data === "object"); - return data; - }; - if (typeof scriptArgs != "undefined") { - arguments_ = scriptArgs; - } else if (typeof arguments != "undefined") { - arguments_ = arguments; - } - if (typeof quit === "function") { - quit_ = function (status) { - quit(status); - }; - } - if (typeof print !== "undefined") { - if (typeof console === "undefined") console = {}; - console.log = print; - console.warn = console.error = typeof printErr !== "undefined" ? printErr : print; - } + if (typeof read != "undefined") { + read_ = function shell_read(f) { + return read(f); + }; + } + readBinary = function readBinary(f) { + var data; + if (typeof readbuffer === "function") { + return new Uint8Array(readbuffer(f)); + } + data = read(f, "binary"); + assert(typeof data === "object"); + return data; + }; + if (typeof scriptArgs != "undefined") { + arguments_ = scriptArgs; + } else if (typeof arguments != "undefined") { + arguments_ = arguments; + } + if (typeof quit === "function") { + quit_ = function (status) { + quit(status); + }; + } + if (typeof print !== "undefined") { + if (typeof console === "undefined") console = {}; + console.log = print; + console.warn = console.error = typeof printErr !== "undefined" ? printErr : print; + } } else if (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER) { - if (ENVIRONMENT_IS_WORKER) { - scriptDirectory = self.location.href; - } else if (typeof document !== "undefined" && document.currentScript) { - scriptDirectory = document.currentScript.src; - } - if (scriptDirectory.indexOf("blob:") !== 0) { - scriptDirectory = scriptDirectory.substr(0, scriptDirectory.lastIndexOf("/") + 1); - } else { - scriptDirectory = ""; - } - { - read_ = function (url) { - var xhr = new XMLHttpRequest(); - xhr.open("GET", url, false); - xhr.send(null); - return xhr.responseText; - }; - if (ENVIRONMENT_IS_WORKER) { - readBinary = function (url) { - var xhr = new XMLHttpRequest(); - xhr.open("GET", url, false); - xhr.responseType = "arraybuffer"; - xhr.send(null); - return new Uint8Array(xhr.response); - }; - } - readAsync = function (url, onload, onerror) { - var xhr = new XMLHttpRequest(); - xhr.open("GET", url, true); - xhr.responseType = "arraybuffer"; - xhr.onload = function () { - if (xhr.status == 200 || (xhr.status == 0 && xhr.response)) { - onload(xhr.response); - return; - } - onerror(); - }; - xhr.onerror = onerror; - xhr.send(null); - }; - } - setWindowTitle = function (title) { - document.title = title; - }; + if (ENVIRONMENT_IS_WORKER) { + scriptDirectory = self.location.href; + } else if (typeof document !== "undefined" && document.currentScript) { + scriptDirectory = document.currentScript.src; + } + if (scriptDirectory.indexOf("blob:") !== 0) { + scriptDirectory = scriptDirectory.substr(0, scriptDirectory.lastIndexOf("/") + 1); + } else { + scriptDirectory = ""; + } + { + read_ = function (url) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", url, false); + xhr.send(null); + return xhr.responseText; + }; + if (ENVIRONMENT_IS_WORKER) { + readBinary = function (url) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", url, false); + xhr.responseType = "arraybuffer"; + xhr.send(null); + return new Uint8Array(xhr.response); + }; + } + readAsync = function (url, onload, onerror) { + var xhr = new XMLHttpRequest(); + xhr.open("GET", url, true); + xhr.responseType = "arraybuffer"; + xhr.onload = function () { + if (xhr.status == 200 || (xhr.status == 0 && xhr.response)) { + onload(xhr.response); + return; + } + onerror(); + }; + xhr.onerror = onerror; + xhr.send(null); + }; + } + setWindowTitle = function (title) { + document.title = title; + }; } else { } var out = Module["print"] || console.log.bind(console); var err = Module["printErr"] || console.warn.bind(console); for (key in moduleOverrides) { - if (moduleOverrides.hasOwnProperty(key)) { - Module[key] = moduleOverrides[key]; - } + if (moduleOverrides.hasOwnProperty(key)) { + Module[key] = moduleOverrides[key]; + } } moduleOverrides = null; if (Module["arguments"]) arguments_ = Module["arguments"]; @@ -164,88 +164,88 @@ var wasmBinary; if (Module["wasmBinary"]) wasmBinary = Module["wasmBinary"]; var noExitRuntime = Module["noExitRuntime"] || true; if (typeof WebAssembly !== "object") { - abort("no native wasm support detected"); + abort("no native wasm support detected"); } var wasmMemory; var ABORT = false; var EXITSTATUS; function assert(condition, text) { - if (!condition) { - abort("Assertion failed: " + text); - } + if (!condition) { + abort("Assertion failed: " + text); + } } var ALLOC_NORMAL = 0; var ALLOC_STACK = 1; function allocate(slab, allocator) { - var ret; - if (allocator == ALLOC_STACK) { - ret = stackAlloc(slab.length); - } else { - ret = _malloc(slab.length); - } - if (slab.subarray || slab.slice) { - HEAPU8.set(slab, ret); - } else { - HEAPU8.set(new Uint8Array(slab), ret); - } - return ret; + var ret; + if (allocator == ALLOC_STACK) { + ret = stackAlloc(slab.length); + } else { + ret = _malloc(slab.length); + } + if (slab.subarray || slab.slice) { + HEAPU8.set(slab, ret); + } else { + HEAPU8.set(new Uint8Array(slab), ret); + } + return ret; } var UTF8Decoder = typeof TextDecoder !== "undefined" ? new TextDecoder("utf8") : undefined; function UTF8ArrayToString(heap, idx, maxBytesToRead) { - var endIdx = idx + maxBytesToRead; - var endPtr = idx; - while (heap[endPtr] && !(endPtr >= endIdx)) ++endPtr; - if (endPtr - idx > 16 && heap.subarray && UTF8Decoder) { - return UTF8Decoder.decode(heap.subarray(idx, endPtr)); - } else { - var str = ""; - while (idx < endPtr) { - var u0 = heap[idx++]; - if (!(u0 & 128)) { - str += String.fromCharCode(u0); - continue; - } - var u1 = heap[idx++] & 63; - if ((u0 & 224) == 192) { - str += String.fromCharCode(((u0 & 31) << 6) | u1); - continue; - } - var u2 = heap[idx++] & 63; - if ((u0 & 240) == 224) { - u0 = ((u0 & 15) << 12) | (u1 << 6) | u2; - } else { - u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heap[idx++] & 63); - } - if (u0 < 65536) { - str += String.fromCharCode(u0); - } else { - var ch = u0 - 65536; - str += String.fromCharCode(55296 | (ch >> 10), 56320 | (ch & 1023)); - } - } - } - return str; + var endIdx = idx + maxBytesToRead; + var endPtr = idx; + while (heap[endPtr] && !(endPtr >= endIdx)) ++endPtr; + if (endPtr - idx > 16 && heap.subarray && UTF8Decoder) { + return UTF8Decoder.decode(heap.subarray(idx, endPtr)); + } else { + var str = ""; + while (idx < endPtr) { + var u0 = heap[idx++]; + if (!(u0 & 128)) { + str += String.fromCharCode(u0); + continue; + } + var u1 = heap[idx++] & 63; + if ((u0 & 224) == 192) { + str += String.fromCharCode(((u0 & 31) << 6) | u1); + continue; + } + var u2 = heap[idx++] & 63; + if ((u0 & 240) == 224) { + u0 = ((u0 & 15) << 12) | (u1 << 6) | u2; + } else { + u0 = ((u0 & 7) << 18) | (u1 << 12) | (u2 << 6) | (heap[idx++] & 63); + } + if (u0 < 65536) { + str += String.fromCharCode(u0); + } else { + var ch = u0 - 65536; + str += String.fromCharCode(55296 | (ch >> 10), 56320 | (ch & 1023)); + } + } + } + return str; } function UTF8ToString(ptr, maxBytesToRead) { - return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : ""; + return ptr ? UTF8ArrayToString(HEAPU8, ptr, maxBytesToRead) : ""; } function alignUp(x, multiple) { - if (x % multiple > 0) { - x += multiple - (x % multiple); - } - return x; + if (x % multiple > 0) { + x += multiple - (x % multiple); + } + return x; } var buffer, HEAP8, HEAPU8, HEAP16, HEAPU16, HEAP32, HEAPU32, HEAPF32, HEAPF64; function updateGlobalBufferAndViews(buf) { - buffer = buf; - Module["HEAP8"] = HEAP8 = new Int8Array(buf); - Module["HEAP16"] = HEAP16 = new Int16Array(buf); - Module["HEAP32"] = HEAP32 = new Int32Array(buf); - Module["HEAPU8"] = HEAPU8 = new Uint8Array(buf); - Module["HEAPU16"] = HEAPU16 = new Uint16Array(buf); - Module["HEAPU32"] = HEAPU32 = new Uint32Array(buf); - Module["HEAPF32"] = HEAPF32 = new Float32Array(buf); - Module["HEAPF64"] = HEAPF64 = new Float64Array(buf); + buffer = buf; + Module["HEAP8"] = HEAP8 = new Int8Array(buf); + Module["HEAP16"] = HEAP16 = new Int16Array(buf); + Module["HEAP32"] = HEAP32 = new Int32Array(buf); + Module["HEAPU8"] = HEAPU8 = new Uint8Array(buf); + Module["HEAPU16"] = HEAPU16 = new Uint16Array(buf); + Module["HEAPU32"] = HEAPU32 = new Uint32Array(buf); + Module["HEAPF32"] = HEAPF32 = new Float32Array(buf); + Module["HEAPF64"] = HEAPF64 = new Float64Array(buf); } var INITIAL_MEMORY = Module["INITIAL_MEMORY"] || 16777216; var wasmTable; @@ -254,356 +254,356 @@ var __ATINIT__ = []; var __ATPOSTRUN__ = []; var runtimeInitialized = false; function preRun() { - if (Module["preRun"]) { - if (typeof Module["preRun"] == "function") Module["preRun"] = [Module["preRun"]]; - while (Module["preRun"].length) { - addOnPreRun(Module["preRun"].shift()); - } - } - callRuntimeCallbacks(__ATPRERUN__); + if (Module["preRun"]) { + if (typeof Module["preRun"] == "function") Module["preRun"] = [Module["preRun"]]; + while (Module["preRun"].length) { + addOnPreRun(Module["preRun"].shift()); + } + } + callRuntimeCallbacks(__ATPRERUN__); } function initRuntime() { - runtimeInitialized = true; - callRuntimeCallbacks(__ATINIT__); + runtimeInitialized = true; + callRuntimeCallbacks(__ATINIT__); } function postRun() { - if (Module["postRun"]) { - if (typeof Module["postRun"] == "function") Module["postRun"] = [Module["postRun"]]; - while (Module["postRun"].length) { - addOnPostRun(Module["postRun"].shift()); - } - } - callRuntimeCallbacks(__ATPOSTRUN__); + if (Module["postRun"]) { + if (typeof Module["postRun"] == "function") Module["postRun"] = [Module["postRun"]]; + while (Module["postRun"].length) { + addOnPostRun(Module["postRun"].shift()); + } + } + callRuntimeCallbacks(__ATPOSTRUN__); } function addOnPreRun(cb) { - __ATPRERUN__.unshift(cb); + __ATPRERUN__.unshift(cb); } function addOnInit(cb) { - __ATINIT__.unshift(cb); + __ATINIT__.unshift(cb); } function addOnPostRun(cb) { - __ATPOSTRUN__.unshift(cb); + __ATPOSTRUN__.unshift(cb); } var runDependencies = 0; var runDependencyWatcher = null; var dependenciesFulfilled = null; function addRunDependency(id) { - runDependencies++; - if (Module["monitorRunDependencies"]) { - Module["monitorRunDependencies"](runDependencies); - } + runDependencies++; + if (Module["monitorRunDependencies"]) { + Module["monitorRunDependencies"](runDependencies); + } } function removeRunDependency(id) { - runDependencies--; - if (Module["monitorRunDependencies"]) { - Module["monitorRunDependencies"](runDependencies); - } - if (runDependencies == 0) { - if (runDependencyWatcher !== null) { - clearInterval(runDependencyWatcher); - runDependencyWatcher = null; - } - if (dependenciesFulfilled) { - var callback = dependenciesFulfilled; - dependenciesFulfilled = null; - callback(); - } - } + runDependencies--; + if (Module["monitorRunDependencies"]) { + Module["monitorRunDependencies"](runDependencies); + } + if (runDependencies == 0) { + if (runDependencyWatcher !== null) { + clearInterval(runDependencyWatcher); + runDependencyWatcher = null; + } + if (dependenciesFulfilled) { + var callback = dependenciesFulfilled; + dependenciesFulfilled = null; + callback(); + } + } } Module["preloadedImages"] = {}; Module["preloadedAudios"] = {}; function abort(what) { - if (Module["onAbort"]) { - Module["onAbort"](what); - } - what += ""; - err(what); - ABORT = true; - EXITSTATUS = 1; - what = "abort(" + what + "). Build with -s ASSERTIONS=1 for more info."; - var e = new WebAssembly.RuntimeError(what); - throw e; + if (Module["onAbort"]) { + Module["onAbort"](what); + } + what += ""; + err(what); + ABORT = true; + EXITSTATUS = 1; + what = "abort(" + what + "). Build with -s ASSERTIONS=1 for more info."; + var e = new WebAssembly.RuntimeError(what); + throw e; } var dataURIPrefix = "data:application/octet-stream;base64,"; function isDataURI(filename) { - return filename.startsWith(dataURIPrefix); + return filename.startsWith(dataURIPrefix); } function isFileURI(filename) { - return filename.startsWith("file://"); + return filename.startsWith("file://"); } var wasmBinaryFile = "argon2.wasm"; if (!isDataURI(wasmBinaryFile)) { - wasmBinaryFile = locateFile(wasmBinaryFile); + wasmBinaryFile = locateFile(wasmBinaryFile); } function getBinary(file) { - try { - if (file == wasmBinaryFile && wasmBinary) { - return new Uint8Array(wasmBinary); - } - if (readBinary) { - return readBinary(file); - } else { - throw "both async and sync fetching of the wasm failed"; - } - } catch (err) { - abort(err); - } + try { + if (file == wasmBinaryFile && wasmBinary) { + return new Uint8Array(wasmBinary); + } + if (readBinary) { + return readBinary(file); + } else { + throw "both async and sync fetching of the wasm failed"; + } + } catch (err) { + abort(err); + } } function getBinaryPromise() { - if (!wasmBinary && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER)) { - if (typeof fetch === "function" && !isFileURI(wasmBinaryFile)) { - return fetch(wasmBinaryFile, { credentials: "same-origin" }) - .then(function (response) { - if (!response["ok"]) { - throw "failed to load wasm binary file at '" + wasmBinaryFile + "'"; - } - return response["arrayBuffer"](); - }) - .catch(function () { - return getBinary(wasmBinaryFile); - }); - } else { - if (readAsync) { - return new Promise(function (resolve, reject) { - readAsync( - wasmBinaryFile, - function (response) { - resolve(new Uint8Array(response)); - }, - reject - ); - }); - } - } - } - return Promise.resolve().then(function () { - return getBinary(wasmBinaryFile); - }); + if (!wasmBinary && (ENVIRONMENT_IS_WEB || ENVIRONMENT_IS_WORKER)) { + if (typeof fetch === "function" && !isFileURI(wasmBinaryFile)) { + return fetch(wasmBinaryFile, { credentials: "same-origin" }) + .then(function (response) { + if (!response["ok"]) { + throw "failed to load wasm binary file at '" + wasmBinaryFile + "'"; + } + return response["arrayBuffer"](); + }) + .catch(function () { + return getBinary(wasmBinaryFile); + }); + } else { + if (readAsync) { + return new Promise(function (resolve, reject) { + readAsync( + wasmBinaryFile, + function (response) { + resolve(new Uint8Array(response)); + }, + reject, + ); + }); + } + } + } + return Promise.resolve().then(function () { + return getBinary(wasmBinaryFile); + }); } function createWasm() { - var info = { a: asmLibraryArg }; - function receiveInstance(instance, module) { - var exports = instance.exports; - Module["asm"] = exports; - wasmMemory = Module["asm"]["c"]; - updateGlobalBufferAndViews(wasmMemory.buffer); - wasmTable = Module["asm"]["k"]; - addOnInit(Module["asm"]["d"]); - removeRunDependency("wasm-instantiate"); - } - addRunDependency("wasm-instantiate"); - function receiveInstantiationResult(result) { - receiveInstance(result["instance"]); - } - function instantiateArrayBuffer(receiver) { - return getBinaryPromise() - .then(function (binary) { - var result = WebAssembly.instantiate(binary, info); - return result; - }) - .then(receiver, function (reason) { - err("failed to asynchronously prepare wasm: " + reason); - abort(reason); - }); - } - function instantiateAsync() { - if ( - !wasmBinary && - typeof WebAssembly.instantiateStreaming === "function" && - !isDataURI(wasmBinaryFile) && - !isFileURI(wasmBinaryFile) && - typeof fetch === "function" - ) { - return fetch(wasmBinaryFile, { credentials: "same-origin" }).then(function (response) { - var result = WebAssembly.instantiateStreaming(response, info); - return result.then(receiveInstantiationResult, function (reason) { - err("wasm streaming compile failed: " + reason); - err("falling back to ArrayBuffer instantiation"); - return instantiateArrayBuffer(receiveInstantiationResult); - }); - }); - } else { - return instantiateArrayBuffer(receiveInstantiationResult); - } - } - if (Module["instantiateWasm"]) { - try { - var exports = Module["instantiateWasm"](info, receiveInstance); - return exports; - } catch (e) { - err("Module.instantiateWasm callback failed with error: " + e); - return false; - } - } - instantiateAsync(); - return {}; + var info = { a: asmLibraryArg }; + function receiveInstance(instance, module) { + var exports = instance.exports; + Module["asm"] = exports; + wasmMemory = Module["asm"]["c"]; + updateGlobalBufferAndViews(wasmMemory.buffer); + wasmTable = Module["asm"]["k"]; + addOnInit(Module["asm"]["d"]); + removeRunDependency("wasm-instantiate"); + } + addRunDependency("wasm-instantiate"); + function receiveInstantiationResult(result) { + receiveInstance(result["instance"]); + } + function instantiateArrayBuffer(receiver) { + return getBinaryPromise() + .then(function (binary) { + var result = WebAssembly.instantiate(binary, info); + return result; + }) + .then(receiver, function (reason) { + err("failed to asynchronously prepare wasm: " + reason); + abort(reason); + }); + } + function instantiateAsync() { + if ( + !wasmBinary && + typeof WebAssembly.instantiateStreaming === "function" && + !isDataURI(wasmBinaryFile) && + !isFileURI(wasmBinaryFile) && + typeof fetch === "function" + ) { + return fetch(wasmBinaryFile, { credentials: "same-origin" }).then(function (response) { + var result = WebAssembly.instantiateStreaming(response, info); + return result.then(receiveInstantiationResult, function (reason) { + err("wasm streaming compile failed: " + reason); + err("falling back to ArrayBuffer instantiation"); + return instantiateArrayBuffer(receiveInstantiationResult); + }); + }); + } else { + return instantiateArrayBuffer(receiveInstantiationResult); + } + } + if (Module["instantiateWasm"]) { + try { + var exports = Module["instantiateWasm"](info, receiveInstance); + return exports; + } catch (e) { + err("Module.instantiateWasm callback failed with error: " + e); + return false; + } + } + instantiateAsync(); + return {}; } function callRuntimeCallbacks(callbacks) { - while (callbacks.length > 0) { - var callback = callbacks.shift(); - if (typeof callback == "function") { - callback(Module); - continue; - } - var func = callback.func; - if (typeof func === "number") { - if (callback.arg === undefined) { - wasmTable.get(func)(); - } else { - wasmTable.get(func)(callback.arg); - } - } else { - func(callback.arg === undefined ? null : callback.arg); - } - } + while (callbacks.length > 0) { + var callback = callbacks.shift(); + if (typeof callback == "function") { + callback(Module); + continue; + } + var func = callback.func; + if (typeof func === "number") { + if (callback.arg === undefined) { + wasmTable.get(func)(); + } else { + wasmTable.get(func)(callback.arg); + } + } else { + func(callback.arg === undefined ? null : callback.arg); + } + } } function _emscripten_memcpy_big(dest, src, num) { - HEAPU8.copyWithin(dest, src, src + num); + HEAPU8.copyWithin(dest, src, src + num); } function emscripten_realloc_buffer(size) { - try { - wasmMemory.grow((size - buffer.byteLength + 65535) >>> 16); - updateGlobalBufferAndViews(wasmMemory.buffer); - return 1; - } catch (e) {} + try { + wasmMemory.grow((size - buffer.byteLength + 65535) >>> 16); + updateGlobalBufferAndViews(wasmMemory.buffer); + return 1; + } catch (e) {} } function _emscripten_resize_heap(requestedSize) { - var oldSize = HEAPU8.length; - requestedSize = requestedSize >>> 0; - var maxHeapSize = 2147418112; - if (requestedSize > maxHeapSize) { - return false; - } - for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { - var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); - overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296); - var newSize = Math.min(maxHeapSize, alignUp(Math.max(requestedSize, overGrownHeapSize), 65536)); - var replacement = emscripten_realloc_buffer(newSize); - if (replacement) { - return true; - } - } - return false; + var oldSize = HEAPU8.length; + requestedSize = requestedSize >>> 0; + var maxHeapSize = 2147418112; + if (requestedSize > maxHeapSize) { + return false; + } + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); + overGrownHeapSize = Math.min(overGrownHeapSize, requestedSize + 100663296); + var newSize = Math.min(maxHeapSize, alignUp(Math.max(requestedSize, overGrownHeapSize), 65536)); + var replacement = emscripten_realloc_buffer(newSize); + if (replacement) { + return true; + } + } + return false; } var asmLibraryArg = { a: _emscripten_memcpy_big, b: _emscripten_resize_heap }; var asm = createWasm(); var ___wasm_call_ctors = (Module["___wasm_call_ctors"] = function () { - return (___wasm_call_ctors = Module["___wasm_call_ctors"] = Module["asm"]["d"]).apply( - null, - arguments - ); + return (___wasm_call_ctors = Module["___wasm_call_ctors"] = Module["asm"]["d"]).apply( + null, + arguments, + ); }); var _argon2_hash = (Module["_argon2_hash"] = function () { - return (_argon2_hash = Module["_argon2_hash"] = Module["asm"]["e"]).apply(null, arguments); + return (_argon2_hash = Module["_argon2_hash"] = Module["asm"]["e"]).apply(null, arguments); }); var _malloc = (Module["_malloc"] = function () { - return (_malloc = Module["_malloc"] = Module["asm"]["f"]).apply(null, arguments); + return (_malloc = Module["_malloc"] = Module["asm"]["f"]).apply(null, arguments); }); var _free = (Module["_free"] = function () { - return (_free = Module["_free"] = Module["asm"]["g"]).apply(null, arguments); + return (_free = Module["_free"] = Module["asm"]["g"]).apply(null, arguments); }); var _argon2_verify = (Module["_argon2_verify"] = function () { - return (_argon2_verify = Module["_argon2_verify"] = Module["asm"]["h"]).apply(null, arguments); + return (_argon2_verify = Module["_argon2_verify"] = Module["asm"]["h"]).apply(null, arguments); }); var _argon2_error_message = (Module["_argon2_error_message"] = function () { - return (_argon2_error_message = Module["_argon2_error_message"] = Module["asm"]["i"]).apply( - null, - arguments - ); + return (_argon2_error_message = Module["_argon2_error_message"] = Module["asm"]["i"]).apply( + null, + arguments, + ); }); var _argon2_encodedlen = (Module["_argon2_encodedlen"] = function () { - return (_argon2_encodedlen = Module["_argon2_encodedlen"] = Module["asm"]["j"]).apply( - null, - arguments - ); + return (_argon2_encodedlen = Module["_argon2_encodedlen"] = Module["asm"]["j"]).apply( + null, + arguments, + ); }); var _argon2_hash_ext = (Module["_argon2_hash_ext"] = function () { - return (_argon2_hash_ext = Module["_argon2_hash_ext"] = Module["asm"]["l"]).apply( - null, - arguments - ); + return (_argon2_hash_ext = Module["_argon2_hash_ext"] = Module["asm"]["l"]).apply( + null, + arguments, + ); }); var _argon2_verify_ext = (Module["_argon2_verify_ext"] = function () { - return (_argon2_verify_ext = Module["_argon2_verify_ext"] = Module["asm"]["m"]).apply( - null, - arguments - ); + return (_argon2_verify_ext = Module["_argon2_verify_ext"] = Module["asm"]["m"]).apply( + null, + arguments, + ); }); var stackAlloc = (Module["stackAlloc"] = function () { - return (stackAlloc = Module["stackAlloc"] = Module["asm"]["n"]).apply(null, arguments); + return (stackAlloc = Module["stackAlloc"] = Module["asm"]["n"]).apply(null, arguments); }); Module["allocate"] = allocate; Module["UTF8ToString"] = UTF8ToString; Module["ALLOC_NORMAL"] = ALLOC_NORMAL; var calledRun; function ExitStatus(status) { - this.name = "ExitStatus"; - this.message = "Program terminated with exit(" + status + ")"; - this.status = status; + this.name = "ExitStatus"; + this.message = "Program terminated with exit(" + status + ")"; + this.status = status; } dependenciesFulfilled = function runCaller() { - if (!calledRun) run(); - if (!calledRun) dependenciesFulfilled = runCaller; + if (!calledRun) run(); + if (!calledRun) dependenciesFulfilled = runCaller; }; function run(args) { - args = args || arguments_; - if (runDependencies > 0) { - return; - } - preRun(); - if (runDependencies > 0) { - return; - } - function doRun() { - if (calledRun) return; - calledRun = true; - Module["calledRun"] = true; - if (ABORT) return; - initRuntime(); - if (Module["onRuntimeInitialized"]) Module["onRuntimeInitialized"](); - postRun(); - } - if (Module["setStatus"]) { - Module["setStatus"]("Running..."); - setTimeout(function () { - setTimeout(function () { - Module["setStatus"](""); - }, 1); - doRun(); - }, 1); - } else { - doRun(); - } + args = args || arguments_; + if (runDependencies > 0) { + return; + } + preRun(); + if (runDependencies > 0) { + return; + } + function doRun() { + if (calledRun) return; + calledRun = true; + Module["calledRun"] = true; + if (ABORT) return; + initRuntime(); + if (Module["onRuntimeInitialized"]) Module["onRuntimeInitialized"](); + postRun(); + } + if (Module["setStatus"]) { + Module["setStatus"]("Running..."); + setTimeout(function () { + setTimeout(function () { + Module["setStatus"](""); + }, 1); + doRun(); + }, 1); + } else { + doRun(); + } } Module["run"] = run; if (Module["preInit"]) { - if (typeof Module["preInit"] == "function") Module["preInit"] = [Module["preInit"]]; - while (Module["preInit"].length > 0) { - Module["preInit"].pop()(); - } + if (typeof Module["preInit"] == "function") Module["preInit"] = [Module["preInit"]]; + while (Module["preInit"].length > 0) { + Module["preInit"].pop()(); + } } run(); if (typeof module !== "undefined") module.exports = Module; Module.unloadRuntime = function () { - if (typeof self !== "undefined") { - delete self.Module; - } - Module = - jsModule = - wasmMemory = - wasmTable = - asm = - buffer = - HEAP8 = - HEAPU8 = - HEAP16 = - HEAPU16 = - HEAP32 = - HEAPU32 = - HEAPF32 = - HEAPF64 = - undefined; - if (typeof module !== "undefined") { - delete module.exports; - } + if (typeof self !== "undefined") { + delete self.Module; + } + Module = + jsModule = + wasmMemory = + wasmTable = + asm = + buffer = + HEAP8 = + HEAPU8 = + HEAP16 = + HEAPU16 = + HEAP32 = + HEAPU32 = + HEAPF32 = + HEAPF64 = + undefined; + if (typeof module !== "undefined") { + delete module.exports; + } }; diff --git a/svelte.config.js b/svelte.config.js index 0508e90..b5d398f 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -4,19 +4,19 @@ import path from "path"; /** @type {import('@sveltejs/kit').Config} */ const config = { - // Consult https://svelte.dev/docs/kit/integrations - // for more information about preprocessors - preprocess: vitePreprocess(), + // Consult https://svelte.dev/docs/kit/integrations + // for more information about preprocessors + preprocess: vitePreprocess(), - kit: { - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. - adapter: adapter(), - alias: { - $i18n: path.resolve("./src/i18n") - } - } + kit: { + // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. + // If your environment is not supported, or you settled on a specific environment, switch out the adapter. + // See https://svelte.dev/docs/kit/adapters for more information about adapters. + adapter: adapter(), + alias: { + $i18n: path.resolve("./src/i18n"), + }, + }, }; export default config; diff --git a/tenant-migrations/meta/0000_snapshot.json b/tenant-migrations/meta/0000_snapshot.json index 2e0b101..d5508b6 100644 --- a/tenant-migrations/meta/0000_snapshot.json +++ b/tenant-migrations/meta/0000_snapshot.json @@ -1,463 +1,463 @@ { - "id": "ae9961fd-c495-4680-98ed-595599978298", - "prevId": "00000000-0000-0000-0000-000000000000", - "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 - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "logo": { - "name": "logo", - "type": "bytea", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "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()" - }, - "client_id": { - "name": "client_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "channel_id": { - "name": "channel_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "appointment_date": { - "name": "appointment_date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "expiry_date": { - "name": "expiry_date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "appointment_status", - "typeSchema": "public", - "primaryKey": false, - "notNull": true, - "default": "'NEW'" - } - }, - "indexes": {}, - "foreignKeys": { - "appointment_client_id_client_id_fk": { - "name": "appointment_client_id_client_id_fk", - "tableFrom": "appointment", - "tableTo": "client", - "columnsFrom": ["client_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.channel": { - "name": "channel", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "color": { - "name": "color", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "description": { - "name": "description", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "language": { - "name": "language", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "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.slotTemplate": { - "name": "slotTemplate", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true, - "default": "gen_random_uuid()" - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "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": { - "name": "staff", - "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 - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "position": { - "name": "position", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "language": { - "name": "language", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "staff_hash_key_unique": { - "name": "staff_hash_key_unique", - "nullsNotDistinct": false, - "columns": ["hash_key"] - } - }, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": { - "public.appointment_status": { - "name": "appointment_status", - "schema": "public", - "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] - }, - "public.channel_type": { - "name": "channel_type", - "schema": "public", - "values": ["ROOM", "MACHINE", "PERSONNEL"] - } - }, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } + "id": "ae9961fd-c495-4680-98ed-595599978298", + "prevId": "00000000-0000-0000-0000-000000000000", + "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 + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo": { + "name": "logo", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "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()" + }, + "client_id": { + "name": "client_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_client_id_client_id_fk": { + "name": "appointment_client_id_client_id_fk", + "tableFrom": "appointment", + "tableTo": "client", + "columnsFrom": ["client_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.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "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.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "name": "staff", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "staff_hash_key_unique": { + "name": "staff_hash_key_unique", + "nullsNotDistinct": false, + "columns": ["hash_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] + }, + "public.channel_type": { + "name": "channel_type", + "schema": "public", + "values": ["ROOM", "MACHINE", "PERSONNEL"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } } diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 6d46a05..6a15a06 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -1,13 +1,13 @@ { - "version": "7", - "dialect": "postgresql", - "entries": [ - { - "idx": 0, - "version": "7", - "when": 1752671302514, - "tag": "0000_neat_nemesis", - "breakpoints": true - } - ] + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1752671302514, + "tag": "0000_neat_nemesis", + "breakpoints": true + } + ] } diff --git a/tsconfig.json b/tsconfig.json index 0b2d886..f4d0a0e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,19 +1,19 @@ { - "extends": "./.svelte-kit/tsconfig.json", - "compilerOptions": { - "allowJs": true, - "checkJs": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true, - "sourceMap": true, - "strict": true, - "moduleResolution": "bundler" - } - // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias - // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files - // - // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes - // from the referenced tsconfig.json - TypeScript does not merge them in + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in } diff --git a/vite.config.ts b/vite.config.ts index 93fd900..08e92a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,34 +5,34 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; export default defineConfig({ - plugins: [ - tailwindcss(), - sveltekit(), - paraglideVitePlugin({ project: "./project.inlang", outdir: "./src/i18n" }) - ], - test: { - projects: [ - { - extends: "./vite.config.ts", - plugins: [svelteTesting()], - test: { - name: "client", - environment: "jsdom", - clearMocks: true, - include: ["src/**/*.svelte.{test,spec}.{js,ts}"], - exclude: ["src/lib/server/**"], - setupFiles: ["./vitest-setup-client.ts"] - } - }, - { - extends: "./vite.config.ts", - test: { - name: "server", - environment: "node", - include: ["src/**/*.{test,spec}.{js,ts}"], - exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"] - } - } - ] - } + plugins: [ + tailwindcss(), + sveltekit(), + paraglideVitePlugin({ project: "./project.inlang", outdir: "./src/i18n" }), + ], + test: { + projects: [ + { + extends: "./vite.config.ts", + plugins: [svelteTesting()], + test: { + name: "client", + environment: "jsdom", + clearMocks: true, + include: ["src/**/*.svelte.{test,spec}.{js,ts}"], + exclude: ["src/lib/server/**"], + setupFiles: ["./vitest-setup-client.ts"], + }, + }, + { + extends: "./vite.config.ts", + test: { + name: "server", + environment: "node", + include: ["src/**/*.{test,spec}.{js,ts}"], + exclude: ["src/**/*.svelte.{test,spec}.{js,ts}"], + }, + }, + ], + }, }); diff --git a/vitest-setup-client.ts b/vitest-setup-client.ts index ecda271..6ff8def 100644 --- a/vitest-setup-client.ts +++ b/vitest-setup-client.ts @@ -3,16 +3,16 @@ import { vi } from "vitest"; // required for svelte5 + jsdom as jsdom does not support matchMedia Object.defineProperty(window, "matchMedia", { - writable: true, - enumerable: true, - value: vi.fn().mockImplementation((query) => ({ - matches: false, - media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), - dispatchEvent: vi.fn() - })) + writable: true, + enumerable: true, + value: vi.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), }); // add more mocks here if you need them