diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..bd9bbdb --- /dev/null +++ b/docs/database.md @@ -0,0 +1,9 @@ +# Database + +## Generating migrations + +1. Make your changes to the respective drizzle schema +2. Fix issues in code +3. To create migrations + - for the central database run `npm run db:generate` + - for tenant databases run `npm run db:tenant:generate` diff --git a/migrations/0004_mixed_caretaker.sql b/migrations/0004_mixed_caretaker.sql new file mode 100644 index 0000000..96441fb --- /dev/null +++ b/migrations/0004_mixed_caretaker.sql @@ -0,0 +1,10 @@ +ALTER TABLE "tenant" +ALTER COLUMN "description" +SET DATA TYPE json +USING CASE + WHEN description IS NULL THEN NULL + WHEN description::text = '' THEN NULL + ELSE description::json +END;--> statement-breakpoint +ALTER TABLE "tenant" RENAME COLUMN "description" TO "descriptions";--> statement-breakpoint +ALTER TABLE "tenant" ADD COLUMN "languages" json NOT NULL; \ No newline at end of file diff --git a/migrations/meta/0004_snapshot.json b/migrations/meta/0004_snapshot.json new file mode 100644 index 0000000..3377249 --- /dev/null +++ b/migrations/meta/0004_snapshot.json @@ -0,0 +1,773 @@ +{ + "id": "805d1d9a-094d-4a4d-a998-2f74ca7dde8d", + "prevId": "a1a8c8d8-96ed-4dfa-9fa4-047ff0a02d7e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.tenant": { + "name": "tenant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "short_name": { + "name": "short_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "long_name": { + "name": "long_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "languages": { + "name": "languages", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "varchar(100000)", + "primaryKey": false, + "notNull": false + }, + "database_url": { + "name": "database_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_state": { + "name": "setup_state", + "type": "setup_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NEW'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_database_url_idx": { + "name": "tenant_database_url_idx", + "columns": [ + { + "expression": "database_url", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenant_short_name_unique": { + "name": "tenant_short_name_unique", + "nullsNotDistinct": false, + "columns": ["short_name"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_config": { + "name": "tenant_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "config_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "tenant_config_tenant_name_idx": { + "name": "tenant_config_tenant_name_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_config_tenant_id_tenant_id_fk": { + "name": "tenant_config_tenant_id_tenant_id_fk", + "tableFrom": "tenant_config", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'STAFF'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "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 + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + } + }, + "indexes": { + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_tenant_id_tenant_id_fk": { + "name": "user_tenant_id_tenant_id_fk", + "tableFrom": "user", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_invite": { + "name": "user_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "invite_code": { + "name": "invite_code", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'de'" + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_user_id": { + "name": "created_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_invite_code_idx": { + "name": "user_invite_code_idx", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_email_idx": { + "name": "user_invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_invite_tenant_idx": { + "name": "user_invite_tenant_idx", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_invite_tenant_id_tenant_id_fk": { + "name": "user_invite_tenant_id_tenant_id_fk", + "tableFrom": "user_invite", + "tableTo": "tenant", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_invited_by_user_id_fk": { + "name": "user_invite_invited_by_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_invite_created_user_id_user_id_fk": { + "name": "user_invite_created_user_id_user_id_fk", + "tableFrom": "user_invite", + "tableTo": "user", + "columnsFrom": ["created_user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_invite_invite_code_unique": { + "name": "user_invite_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["invite_code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_passkey": { + "name": "user_passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_passkey_user_idx": { + "name": "user_passkey_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_passkey_user_id_user_id_fk": { + "name": "user_passkey_user_id_user_id_fk", + "tableFrom": "user_passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_session": { + "name": "user_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "user_session_user_idx": { + "name": "user_session_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_session_token_idx": { + "name": "user_session_token_idx", + "columns": [ + { + "expression": "session_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_session_user_id_user_id_fk": { + "name": "user_session_user_id_user_id_fk", + "tableFrom": "user_session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_session_session_token_unique": { + "name": "user_session_session_token_unique", + "nullsNotDistinct": false, + "columns": ["session_token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.config_type": { + "name": "config_type", + "schema": "public", + "values": ["BOOLEAN", "NUMBER", "STRING"] + }, + "public.setup_state": { + "name": "setup_state", + "schema": "public", + "values": ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 47805d6..e1076c4 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1758555636975, "tag": "0003_strange_lord_hawal", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1759144205727, + "tag": "0004_mixed_caretaker", + "breakpoints": true } ] } diff --git a/package-lock.json b/package-lock.json index 623149c..f8d6e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "@types/dotenv": "^6.1.1", "@types/node": "^24", "@types/nodemailer": "^6.4.17", - "bits-ui": "^2.8.13", + "bits-ui": "^2.11.4", "clsx": "^2.1.1", "drizzle-kit": "^0.31.1", "eslint": "^9.29.0", @@ -3487,17 +3487,17 @@ "license": "MIT" }, "node_modules/bits-ui": { - "version": "2.8.13", - "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.8.13.tgz", - "integrity": "sha512-YC3DiatZzREdbTZR8IuxbQeYg+/MNcyQeuS6Hycl+ufoG7eiV66qICx210plFER9B776FEYSHXSYuQdlvWODpw==", + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.11.4.tgz", + "integrity": "sha512-OlVBJhNUMDHbIAf8oDAyPchIrU8b1S5NAMm6enMZSKx5HKcf/QPI485/BL1r4EPlv4O3m45e59hBRCETtYFdxg==", "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", - "runed": "^0.29.1", - "svelte-toolbelt": "^0.9.3", + "runed": "^0.31.1", + "svelte-toolbelt": "^0.10.4", "tabbable": "^6.2.0" }, "engines": { @@ -3512,9 +3512,9 @@ } }, "node_modules/bits-ui/node_modules/runed": { - "version": "0.29.2", - "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz", - "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==", + "version": "0.31.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.31.1.tgz", + "integrity": "sha512-v3czcTnO+EJjiPvD4dwIqfTdHLZ8oH0zJheKqAHh9QMViY7Qb29UlAMRpX7ZtHh7AFqV60KmfxaJ9QMy+L1igQ==", "dev": true, "funding": [ "https://github.com/sponsors/huntabyte", @@ -3529,9 +3529,9 @@ } }, "node_modules/bits-ui/node_modules/svelte-toolbelt": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.9.3.tgz", - "integrity": "sha512-HCSWxCtVmv+c6g1ACb8LTwHVbDqLKJvHpo6J8TaqwUme2hj9ATJCpjCPNISR1OCq2Q4U1KT41if9ON0isINQZw==", + "version": "0.10.5", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.5.tgz", + "integrity": "sha512-8e+eWTgxw1aiLxhDE8Rb1X6AoLitqpJz+WhAul2W7W58C8KoLoJQf1TgQdFPBiCPJ0Jg5y0Zi1uyua9em4VS0w==", "dev": true, "funding": [ "https://github.com/sponsors/huntabyte" @@ -3549,6 +3549,23 @@ "svelte": "^5.30.2" } }, + "node_modules/bits-ui/node_modules/svelte-toolbelt/node_modules/runed": { + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.29.2.tgz", + "integrity": "sha512-0cq6cA6sYGZwl/FvVqjx9YN+1xEBu9sDDyuWdDW1yWX7JF2wmvmVKfH+hVCZs+csW+P3ARH92MjI3H9QTagOQA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "esm-env": "^1.0.0" + }, + "peerDependencies": { + "svelte": "^5.7.0" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", diff --git a/package.json b/package.json index 40aaf2c..8bfd870 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "db:studio": "drizzle-kit studio", "db:generate": "drizzle-kit generate", "db:tenant:generate": "drizzle-kit generate --config=drizzle.tenant.config.ts", + "db:drop": "docker volume ls -q | grep '^open-reception_' | xargs -r docker volume rm", "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", @@ -33,7 +34,8 @@ "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" + "i18n:compile": "npx @inlang/paraglide-js compile --project ./project.inlang --outdir ./src/i18n", + "db:clean": "npm run docker:dev:down && npm run db:drop && npm run docker:dev:up" }, "devDependencies": { "@eslint/compat": "^1.3.0", @@ -52,7 +54,7 @@ "@types/dotenv": "^6.1.1", "@types/node": "^24", "@types/nodemailer": "^6.4.17", - "bits-ui": "^2.8.13", + "bits-ui": "^2.11.4", "clsx": "^2.1.1", "drizzle-kit": "^0.31.1", "eslint": "^9.29.0", diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json index 1185faa..470f01e 100644 --- a/project.inlang/messages/de.json +++ b/project.inlang/messages/de.json @@ -275,5 +275,9 @@ "error": "Konnte Akteur:in nicht aktualisieren.", "action": "Übernehmen" } + }, + "locales": { + "en": "Englisch", + "de": "Deutsch" } } diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json index 3f78965..d615a54 100644 --- a/project.inlang/messages/en.json +++ b/project.inlang/messages/en.json @@ -284,5 +284,9 @@ "error": "Could not update agent.", "action": "Edit Agent" } + }, + "locales": { + "en": "English", + "de": "German" } } 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 183ed6f..0eb1705 100644 --- a/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte +++ b/src/lib/components/layouts/sidebar-layout/components/tenant-switcher.svelte @@ -18,12 +18,8 @@ const sidebar = useSidebar(); const maxTenantsToShow = 5; - let activeTenantId = $auth.user?.tenantId; + let activeTenantId = $derived($auth.user?.tenantId); let activeTenant = $derived($tenants.tenants.find((t) => t.id === activeTenantId)); - - $effect(() => { - activeTenantId = $auth.user?.tenantId; - }); @@ -86,9 +82,9 @@ side={sidebar.isMobile ? "bottom" : "right"} sideOffset={4} > - {m["nav.tenants"]()} + + {m["nav.tenants"]()} + {#each $tenants?.tenants.slice(0, maxTenantsToShow) as tenant (tenant.id)} tenants.setCurrentTenant(tenant.id)} diff --git a/src/lib/components/ui/language-tabs/index.ts b/src/lib/components/ui/language-tabs/index.ts new file mode 100644 index 0000000..c013ed1 --- /dev/null +++ b/src/lib/components/ui/language-tabs/index.ts @@ -0,0 +1,3 @@ +import LanguageTabs from "./language-tabs.svelte"; + +export { LanguageTabs }; diff --git a/src/lib/components/ui/language-tabs/language-tabs.svelte b/src/lib/components/ui/language-tabs/language-tabs.svelte new file mode 100644 index 0000000..2113c60 --- /dev/null +++ b/src/lib/components/ui/language-tabs/language-tabs.svelte @@ -0,0 +1,36 @@ + + +{#if tenantLanguages.length > 1} + + + {#each tenantLanguages as language (language.key)} + {language.label} + {/each} + + {#each tenantLanguages as language (language.key)} + + {@render children({ locale: language.key })} + + {/each} + +{:else if tenantLanguages.length === 1} + {@render children({ locale: tenantLanguages[0].key })} +{/if} + diff --git a/src/lib/components/ui/tabs/index.ts b/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..4c728b6 --- /dev/null +++ b/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,16 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/src/lib/components/ui/tabs/tabs-content.svelte b/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..c313e09 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-list.svelte b/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..82d79a2 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,16 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-trigger.svelte b/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..b084077 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,20 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs.svelte b/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..cdea1c7 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/lib/const/locales.ts b/src/lib/const/locales.ts new file mode 100644 index 0000000..47fb5de --- /dev/null +++ b/src/lib/const/locales.ts @@ -0,0 +1,8 @@ +import { m } from "$i18n/messages"; + +export const supportedLocales = ["en", "de"] as const; + +export const translatedLocales = { + en: m["locales.en"](), + de: m["locales.de"](), +}; diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index e957c76..888a9bf 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -10,6 +10,7 @@ import { integer, index, varchar, + json, } from "drizzle-orm/pg-core"; /** @@ -44,8 +45,10 @@ export const tenant = pgTable( 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"), + /** Optional descriptions of the organization */ + descriptions: json("descriptions").$type<{ [key: string]: string }>().notNull(), + /** Active languages for this tenant (array of language codes) */ + languages: json("languages").$type().notNull(), /** Organization logo as binary data (PNG, JPEG, GIF, or WEBP) */ logo: varchar("logo", { length: 100_000 }), /** Database connection string for this tenant's isolated database */ diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index fcab839..26454b5 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -38,9 +38,9 @@ export const agent = pgTable("agent", { /** Agent's display name */ name: text("name").notNull(), /** Optional description of the agent's role or specialties */ - description: text("description"), + descriptions: json("descriptions").$type<{ [key: string]: string }>().notNull(), /** Optional logo/profile image for the agent (PNG, JPEG, GIF, or WEBP) */ - image: varchar("image", { length: 100_000 }), + image: varchar("image", { length: 250_000 }), }); /** @@ -54,15 +54,13 @@ 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(), + names: json("names").$type<{ [key: string]: string }>().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(), + descriptions: json("descriptions").$type<{ [key: string]: string }>().notNull(), /** Whether channel is publicly bookable or requires internal access */ isPublic: boolean("is_public"), /** Whether appointments must be explicitly confirmed by staff */ diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts index 51eacc1..f832228 100644 --- a/src/lib/server/email/__tests__/email-system.test.ts +++ b/src/lib/server/email/__tests__/email-system.test.ts @@ -107,7 +107,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -137,7 +138,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -167,7 +169,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -197,7 +200,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -252,7 +256,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -315,7 +320,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -354,7 +360,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -387,7 +394,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -420,7 +428,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -472,7 +481,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, @@ -515,7 +525,8 @@ describe("Email System", () => { id: "tenant-1", shortName: "test", longName: "Test Organization", - description: null, + descriptions: { en: "Tenant Description" }, + languages: ["en"], logo: null, databaseUrl: "postgresql://test", setupState: "FIRST_CHANNEL_CREATED" as const, 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 03dd3c0..27eaba3 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -29,7 +29,8 @@ describe("generateBaseUrl", () => { logo: null, createdAt: new Date(), updatedAt: new Date(), - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", }; @@ -51,7 +52,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "test", longName: "Test Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -69,7 +71,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -87,7 +90,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -125,7 +129,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -143,7 +148,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -161,7 +167,8 @@ describe("generateBaseUrl", () => { id: "tenant-2", shortName: "new-tenant", longName: "New Tenant Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -179,7 +186,8 @@ describe("generateBaseUrl", () => { id: "tenant-2", shortName: "new-tenant", longName: "New Tenant Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -197,7 +205,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "tenant", longName: "Tenant Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -215,7 +224,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -233,7 +243,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "", // Empty shortName longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -254,7 +265,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -273,7 +285,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, @@ -292,7 +305,8 @@ describe("generateBaseUrl", () => { id: "tenant-1", shortName: "acme", longName: "ACME Corp", - description: "", + descriptions: { en: "" }, + languages: ["en"], databaseUrl: "", setupState: "NEW", logo: null, 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 0c9d601..6286c57 100644 --- a/src/lib/server/email/__tests__/tenant-admin-invite.test.ts +++ b/src/lib/server/email/__tests__/tenant-admin-invite.test.ts @@ -6,7 +6,8 @@ describe("sendTenantAdminInviteEmail", () => { id: "test-tenant-id", shortName: "testcorp", longName: "Test Corporation GmbH", - description: "A test corporation", + descriptions: { en: "A test corporation" }, + languages: ["en"], databaseUrl: "postgresql://test", setupState: "NEW" as const, createdAt: new Date(), diff --git a/src/lib/server/services/__tests__/agent-service.test.ts b/src/lib/server/services/__tests__/agent-service.test.ts index 03c8a05..7aed5f9 100644 --- a/src/lib/server/services/__tests__/agent-service.test.ts +++ b/src/lib/server/services/__tests__/agent-service.test.ts @@ -58,8 +58,9 @@ const mockDb = { const mockAgent = { id: "agent-123", name: "Test Agent", - description: "Test description", - logo: Buffer.from("test"), + descriptions: ["Test description"], + languages: ["en"], + image: Buffer.from("test"), }; describe("AgentService", () => { @@ -100,7 +101,8 @@ describe("AgentService", () => { const request = { name: "Test Agent", - description: "Test description", + descriptions: { en: "Test description" }, + languages: ["en"], image: "test", }; @@ -110,7 +112,7 @@ describe("AgentService", () => { expect(mockDb.insert).toHaveBeenCalled(); expect(insertChain.values).toHaveBeenCalledWith({ name: "Test Agent", - description: "Test description", + descriptions: { en: "Test description" }, image: "test", }); }); @@ -118,7 +120,7 @@ describe("AgentService", () => { it("should handle validation error for invalid name", async () => { const request = { name: "", - description: "Test description", + description: ["Test description"], }; await expect(service.createAgent(request)).rejects.toThrow(ValidationError); diff --git a/src/lib/server/services/__tests__/channel-service.test.ts b/src/lib/server/services/__tests__/channel-service.test.ts index bd37f06..ed82f55 100644 --- a/src/lib/server/services/__tests__/channel-service.test.ts +++ b/src/lib/server/services/__tests__/channel-service.test.ts @@ -45,10 +45,9 @@ import { getTenantDb } from "../../db"; // Mock data with valid UUIDs const mockChannel = { id: "550e8400-e29b-41d4-a716-446655440000", - names: ["Test Channel"], + names: { en: "Test Channel" }, color: "#FF0000", - descriptions: ["Test description"], - languages: ["de"], + descriptions: { en: "Test description" }, isPublic: true, requiresConfirmation: false, pause: false, @@ -57,7 +56,7 @@ const mockChannel = { const mockAgent = { id: "550e8400-e29b-41d4-a716-446655440001", name: "Test Agent", - description: "Test description", + descriptions: { en: "Test description" }, image: null, }; @@ -118,8 +117,7 @@ describe("ChannelService", () => { // Use minimal valid request that matches schema exactly const request = { - names: ["Test Channel"], - languages: ["de"], + names: { en: "Test Channel" }, }; const result = await service.createChannel(request as any); @@ -138,8 +136,7 @@ describe("ChannelService", () => { mockDb.transaction.mockResolvedValue(expectedResult); const request = { - names: ["Test Channel"], - languages: ["de"], + names: { en: "Test Channel" }, agentIds: ["550e8400-e29b-41d4-a716-446655440001"], slotTemplates: [ { @@ -159,8 +156,7 @@ describe("ChannelService", () => { it("should handle validation error for invalid name", async () => { const request = { - names: [""], - languages: ["de"], + names: { en: "" }, agentIds: [], slotTemplates: [], }; @@ -170,8 +166,7 @@ describe("ChannelService", () => { it("should handle validation error for invalid time format", async () => { const request = { - names: ["Test Channel"], - languages: ["de"], + names: { en: "Test Channel" }, agentIds: [], slotTemplates: [ { @@ -190,8 +185,7 @@ describe("ChannelService", () => { mockDb.transaction.mockRejectedValue(new Error("Transaction failed")); const request = { - names: ["Test Channel"], - languages: ["de"], + names: { en: "Test Channel" }, agentIds: [], slotTemplates: [], }; @@ -210,7 +204,7 @@ describe("ChannelService", () => { it("should update channel successfully", async () => { const expectedResult = { ...mockChannel, - names: ["Updated Channel"], + names: { en: "Updated Channel" }, agents: [], slotTemplates: [], }; @@ -218,7 +212,7 @@ describe("ChannelService", () => { mockDb.transaction.mockResolvedValue(expectedResult); const updateData = { - names: ["Updated Channel"], + names: { en: "Updated Channel" }, }; const result = await service.updateChannel( @@ -231,7 +225,7 @@ describe("ChannelService", () => { }); it("should handle validation error for invalid name", async () => { - const updateData = { names: [""], languages: ["de"] }; + const updateData = { names: { en: "" } }; await expect( service.updateChannel("550e8400-e29b-41d4-a716-446655440000", updateData), @@ -241,7 +235,7 @@ describe("ChannelService", () => { it("should handle transaction error", async () => { mockDb.transaction.mockRejectedValue(new Error("Transaction failed")); - const updateData = { names: ["Updated Channel"], languages: ["de"] }; + const updateData = { names: { en: "Updated Channel" } }; await expect( service.updateChannel("550e8400-e29b-41d4-a716-446655440000", updateData), 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 ae9d94a..2662b43 100644 --- a/src/lib/server/services/__tests__/tenant-admin-service.test.ts +++ b/src/lib/server/services/__tests__/tenant-admin-service.test.ts @@ -67,6 +67,8 @@ describe("TenantAdminService", () => { const newTenant = { shortName: "test-clinic", longName: "", + descriptions: { en: "" }, + languages: ["en"], description: "A test clinic", }; @@ -118,8 +120,7 @@ describe("TenantAdminService", () => { it("should rollback tenant creation if database initialization fails", async () => { const newTenant = { shortName: "test-clinic", - longName: "", - description: "A test clinic", + descriptions: "A test clinic", }; const mockCreatedTenant = { @@ -237,7 +238,7 @@ describe("TenantAdminService", () => { const tenantId = "tenant-123"; const updateData = { longName: "Updated Clinic Name", - description: "Updated description", + description: ["Updated description"], logo: "logo data", }; diff --git a/src/lib/server/services/agent-service.ts b/src/lib/server/services/agent-service.ts index 790bf82..0f37f14 100644 --- a/src/lib/server/services/agent-service.ts +++ b/src/lib/server/services/agent-service.ts @@ -6,17 +6,20 @@ import { eq, and, between, or, lte, gte, ne } from "drizzle-orm"; import logger from "$lib/logger"; import z from "zod/v4"; import { ValidationError, NotFoundError, ConflictError } from "../utils/errors"; +import { supportedLocales } from "$lib/const/locales"; const agentCreationSchema = z.object({ name: z.string().min(1).max(100), - description: z.string().optional(), + descriptions: z.partialRecord(z.enum(supportedLocales), z.string().min(1)).optional(), image: z.string().optional().nullable(), + languages: z.array(z.string()).optional(), }); const agentUpdateSchema = z.object({ name: z.string().min(1).max(100).optional(), - description: z.string().optional(), + descriptions: z.partialRecord(z.enum(supportedLocales), z.string().min(1)).optional(), image: z.string().optional().nullable(), + languages: z.array(z.string()).optional(), }); const absenceCreationSchema = z.object({ @@ -96,7 +99,7 @@ export class AgentService { .insert(tenantSchema.agent) .values({ name: request.name, - description: request.description, + descriptions: request.descriptions ?? {}, image: request.image, }) .returning(); @@ -295,7 +298,7 @@ export class AgentService { .select({ id: tenantSchema.agent.id, name: tenantSchema.agent.name, - description: tenantSchema.agent.description, + descriptions: tenantSchema.agent.descriptions, image: tenantSchema.agent.image, }) .from(tenantSchema.agent) diff --git a/src/lib/server/services/channel-service.ts b/src/lib/server/services/channel-service.ts index 996e6f9..d8ee1ad 100644 --- a/src/lib/server/services/channel-service.ts +++ b/src/lib/server/services/channel-service.ts @@ -7,6 +7,7 @@ import logger from "$lib/logger"; import z from "zod/v4"; import { ValidationError, NotFoundError } from "../utils/errors"; import { TenantConfig } from "../db/tenant-config"; +import { supportedLocales } from "$lib/const/locales"; const CHANNEL_COLORS = ["#FF0000", "#00FF00", "#0000FF"] as const; const NEXT_COLOR_KEY = "nextChannelColor"; @@ -19,56 +20,31 @@ const slotTemplateSchema = z.object({ 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", - }); +const channelCreationSchema = z.object({ + names: z.partialRecord(z.enum(supportedLocales), z.string().min(1).max(100)), + color: z.string().optional(), + descriptions: z.partialRecord(z.enum(supportedLocales), z.string().min(1)).optional(), + isPublic: z.boolean().optional(), + requiresConfirmation: z.boolean().optional(), + agentIds: z.array(z.string().uuid()).optional().default([]), + slotTemplates: z.array(slotTemplateSchema).optional().default([]), +}); -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", - }); +const channelUpdateSchema = z.object({ + names: z.partialRecord(z.enum(supportedLocales), z.string().min(1).max(100)), + color: z.string().optional(), + descriptions: z.partialRecord(z.enum(supportedLocales), z.string().min(1)).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(), +}); export type ChannelCreationRequest = z.infer; export type ChannelUpdateRequest = z.infer; @@ -133,7 +109,6 @@ export class ChannelService { log.debug("Creating new channel", { tenantId: this.tenantId, names: request.names, - languages: request.languages, agentCount: request.agentIds?.length || 0, slotTemplateCount: request.slotTemplates?.length || 0, }); @@ -149,8 +124,7 @@ export class ChannelService { .values({ names: request.names, color: request.color, - descriptions: request.descriptions || [], - languages: request.languages, + descriptions: request.descriptions || {}, isPublic: request.isPublic, requiresConfirmation: request.requiresConfirmation, }) @@ -218,7 +192,6 @@ export class ChannelService { tenantId: this.tenantId, channelId: result.id, names: result.names, - languages: result.languages, agentCount: result.agents.length, slotTemplateCount: result.slotTemplates.length, }); @@ -266,7 +239,6 @@ export class ChannelService { names: updateData.names, color: updateData.color, descriptions: updateData.descriptions, - languages: updateData.languages, isPublic: updateData.isPublic, requiresConfirmation: updateData.requiresConfirmation, }; @@ -338,7 +310,7 @@ export class ChannelService { .select({ id: tenantSchema.agent.id, name: tenantSchema.agent.name, - description: tenantSchema.agent.description, + descriptions: tenantSchema.agent.descriptions, image: tenantSchema.agent.image, }) .from(tenantSchema.agent) @@ -515,7 +487,7 @@ export class ChannelService { .select({ id: tenantSchema.agent.id, name: tenantSchema.agent.name, - description: tenantSchema.agent.description, + descriptions: tenantSchema.agent.descriptions, image: tenantSchema.agent.image, }) .from(tenantSchema.agent) diff --git a/src/lib/server/services/tenant-admin-service.ts b/src/lib/server/services/tenant-admin-service.ts index 0ccc6b3..4f215b0 100644 --- a/src/lib/server/services/tenant-admin-service.ts +++ b/src/lib/server/services/tenant-admin-service.ts @@ -98,7 +98,13 @@ export class TenantAdminService { const urlParts = env.DATABASE_URL?.split("/") ?? []; urlParts.pop(); - const newTenant: InsertTenant = { ...request, longName: "", databaseUrl: "" }; + const newTenant: InsertTenant = { + ...request, + longName: "", + databaseUrl: "", + descriptions: { en: "" }, + languages: ["en"], + }; newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName; try { @@ -231,10 +237,12 @@ export class TenantAdminService { } /** - * Update tenant data (longName, shortName, description, logo) + * Update tenant data (longName, shortName, descriptions, languages, logo) */ async updateTenantData( - updateData: Partial>, + updateData: Partial< + Pick + >, ) { const log = logger.setContext("TenantAdminService"); log.debug("Updating tenant data", { diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts index 937d370..ccbe81b 100644 --- a/src/lib/server/services/user-service.ts +++ b/src/lib/server/services/user-service.ts @@ -41,7 +41,8 @@ function createSystemTenant(): SelectTenant { id: "system", shortName: "open-reception", longName: "Open Reception", - description: "Secure appointment booking platform", + descriptions: { en: "Secure appointment booking platform" }, + languages: ["en"], logo: null, databaseUrl: "", setupState: "FIRST_CHANNEL_CREATED", diff --git a/src/lib/stores/auth.ts b/src/lib/stores/auth.ts index 31f5575..41e25f1 100644 --- a/src/lib/stores/auth.ts +++ b/src/lib/stores/auth.ts @@ -52,6 +52,14 @@ function createAuthStore() { unsubscribe(); return authState!.isAuthenticated; }, + getTenant: () => { + let authState: AuthState; + const unsubscribe = store.subscribe((state) => { + authState = state; + }); + unsubscribe(); + return authState!.user?.tenantId || null; + }, }; } diff --git a/src/lib/stores/tenants.ts b/src/lib/stores/tenants.ts index 445dff6..f2a3a3b 100644 --- a/src/lib/stores/tenants.ts +++ b/src/lib/stores/tenants.ts @@ -5,6 +5,8 @@ import { toast } from "svelte-sonner"; import { writable } from "svelte/store"; import { auth } from "./auth"; import { m } from "$i18n/messages"; +import { goto } from "$app/navigation"; +import { ROUTES } from "$lib/const/routes"; const log = logger.setContext("TenantsStore"); @@ -26,8 +28,9 @@ const createTenantsStore = () => { init: (tenants: TTenant[]) => { store.set({ tenants, isLoading: false, currentTenant: null }); }, - setCurrentTenant: (tenantId: string | null, updateApi: boolean = true) => { - if (updateApi) { + setCurrentTenant: (tenantId: string | null) => { + const curTenant = auth.getTenant(); + if (tenantId !== curTenant) { const success = changeTenantUsingApi(tenantId); if (!success) { log.error("Failed to change tenant via API", { tenantId }); @@ -35,12 +38,14 @@ const createTenantsStore = () => { return; } } - store.update((state) => { const currentTenant = state.tenants.find((t) => t.id === tenantId) || null; auth.setTenantId(tenantId); return { ...state, currentTenant }; }); + if (tenantId !== curTenant) { + goto(ROUTES.DASHBOARD.MAIN); + } }, reload: async () => { store.update((state) => { diff --git a/src/lib/types/tenant.ts b/src/lib/types/tenant.ts index 42ef0aa..9c74674 100644 --- a/src/lib/types/tenant.ts +++ b/src/lib/types/tenant.ts @@ -1,3 +1,3 @@ import type { SelectTenant } from "$lib/server/db/central-schema"; -export type TTenant = Pick; +export type TTenant = Pick; diff --git a/src/routes/(pages)/dashboard/+layout.svelte b/src/routes/(pages)/dashboard/+layout.svelte index 92e457a..0c6385c 100644 --- a/src/routes/(pages)/dashboard/+layout.svelte +++ b/src/routes/(pages)/dashboard/+layout.svelte @@ -40,7 +40,7 @@ if (data?.tenants) { tenants.init(await data.tenants); if ($auth.user?.tenantId) { - tenants.setCurrentTenant($auth.user.tenantId, false); + tenants.setCurrentTenant($auth.user.tenantId); } } }; diff --git a/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte b/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte index 6635fd0..596161b 100644 --- a/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte +++ b/src/routes/(pages)/dashboard/agents/(components)/add-agent-form/add-agent-form.svelte @@ -2,30 +2,44 @@ import { m } from "$i18n/messages.js"; import * as Form from "$lib/components/ui/form"; import { Input } from "$lib/components/ui/input"; - import { toast } from "svelte-sonner"; - import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms"; - import { zodClient } from "sveltekit-superforms/adapters"; - import ItemIcon from "@lucide/svelte/icons/user-star"; - import { formSchema, type FormSchema } from "."; - import { Textarea } from "$lib/components/ui/textarea"; import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob"; + import { LanguageTabs } from "$lib/components/ui/language-tabs"; + import { Textarea } from "$lib/components/ui/textarea"; + import ItemIcon from "@lucide/svelte/icons/user-star"; + import { toast } from "svelte-sonner"; + import { superForm } from "sveltekit-superforms"; + import { zodClient } from "sveltekit-superforms/adapters"; + import { formSchema } from "."; + import { tenants } from "$lib/stores/tenants"; + import { get } from "svelte/store"; - let { data, done }: { done: () => void; data: { form: SuperValidated> } } = - $props(); + let { done }: { done: () => void } = $props(); - const form = superForm(data.form, { - validators: zodClient(formSchema), - onResult: async (event) => { - if (event.result.type === "success") { - toast.success(m["agents.add.success"]()); - done(); - } else if (event.result.type === "failure") { - toast.error(m["agents.add.errors.unknown"]()); - } - isSubmitting = false; + const tenantLocales = get(tenants).currentTenant?.languages ?? []; + const form = superForm( + { + name: "", + descriptions: tenantLocales.reduce( + (acc, locale) => ({ ...acc, [locale]: "" }), + {} as { [key: string]: string }, + ), + image: "", }, - onSubmit: () => (isSubmitting = true), - }); + { + dataType: "json", + validators: zodClient(formSchema), + onResult: async (event) => { + if (event.result.type === "success") { + toast.success(m["agents.add.success"]()); + done(); + } else if (event.result.type === "failure") { + toast.error(m["agents.add.errors.unknown"]()); + } + isSubmitting = false; + }, + onSubmit: () => (isSubmitting = true), + }, + ); let isSubmitting = $state(false); @@ -42,16 +56,20 @@ - - - {#snippet children({ props })} - {m["agents.add.fields.description.title"]()} -