Make descriptions multilingual (#89)

* Make descriptions multilingual

* Format and test fixes

* Update src/lib/server/db/tenant-schema.ts

* Fix tenant-switcher to update instantly

* Fix sql migrations for description

* Add languages to tenant table

* Fix tenant schema

* Use objects for translated fields

* Fix format

* Fix lint

* Allow longer descriptions for channels

* Field Localizations are based on tenant locales not supportedLocales

* Fix agent image list preview

* Improve language tab keyboard navigation

* Fix falsy redirect after store init

---------

Co-authored-by: Karl Ludwig Weise <office@ludwigweise.de>
Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
Hendrik
2025-10-01 18:43:01 +02:00
committed by GitHub
co-authored by Karl Ludwig Weise Karl Ludwig Weise
parent d2cc794aec
commit 5965fada38
49 changed files with 1737 additions and 309 deletions
+9
View File
@@ -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`
+10
View File
@@ -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;
+773
View File
@@ -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": {}
}
}
+7
View File
@@ -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
}
]
}
+29 -12
View File
@@ -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",
+4 -2
View File
@@ -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",
+4
View File
@@ -275,5 +275,9 @@
"error": "Konnte Akteur:in nicht aktualisieren.",
"action": "Übernehmen"
}
},
"locales": {
"en": "Englisch",
"de": "Deutsch"
}
}
+4
View File
@@ -284,5 +284,9 @@
"error": "Could not update agent.",
"action": "Edit Agent"
}
},
"locales": {
"en": "English",
"de": "German"
}
}
@@ -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;
});
</script>
<Sidebar.Menu>
@@ -86,9 +82,9 @@
side={sidebar.isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenu.Label class="text-muted-foreground text-xs"
>{m["nav.tenants"]()}</DropdownMenu.Label
>
<DropdownMenu.Label class="text-muted-foreground text-xs">
{m["nav.tenants"]()}
</DropdownMenu.Label>
{#each $tenants?.tenants.slice(0, maxTenantsToShow) as tenant (tenant.id)}
<DropdownMenu.Item
onSelect={() => tenants.setCurrentTenant(tenant.id)}
@@ -0,0 +1,3 @@
import LanguageTabs from "./language-tabs.svelte";
export { LanguageTabs };
@@ -0,0 +1,36 @@
<script lang="ts">
import * as Tabs from "$lib/components/ui/tabs";
import { translatedLocales } from "$lib/const/locales";
import { tenants } from "$lib/stores/tenants";
import type { Snippet } from "svelte";
import { Separator } from "../separator";
const tenantLanguages = ($tenants.currentTenant?.languages || []).map((it) => ({
key: it,
label: translatedLocales[it as keyof typeof translatedLocales],
}));
type Props = {
children: Snippet<[{ locale: string }]>;
};
let { children }: Props = $props();
</script>
{#if tenantLanguages.length > 1}
<Tabs.Root value={tenantLanguages[0]?.key}>
<Tabs.List>
{#each tenantLanguages as language (language.key)}
<Tabs.Trigger value={language.key}>{language.label}</Tabs.Trigger>
{/each}
</Tabs.List>
{#each tenantLanguages as language (language.key)}
<Tabs.Content value={language.key}>
{@render children({ locale: language.key })}
</Tabs.Content>
{/each}
</Tabs.Root>
{:else if tenantLanguages.length === 1}
{@render children({ locale: tenantLanguages[0].key })}
{/if}
<Separator class="mb-3" />
+16
View File
@@ -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,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.ContentProps = $props();
</script>
<TabsPrimitive.Content
bind:ref
data-slot="tabs-content"
class={cn("flex-1 outline-none", className)}
{...restProps}
/>
@@ -0,0 +1,16 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let { ref = $bindable(null), class: className, ...restProps }: TabsPrimitive.ListProps = $props();
</script>
<TabsPrimitive.List
bind:ref
data-slot="tabs-list"
class={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className,
)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: TabsPrimitive.TriggerProps = $props();
</script>
<TabsPrimitive.Trigger
bind:ref
data-slot="tabs-trigger"
class={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...restProps}
/>
+19
View File
@@ -0,0 +1,19 @@
<script lang="ts">
import { Tabs as TabsPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
value = $bindable(""),
class: className,
...restProps
}: TabsPrimitive.RootProps = $props();
</script>
<TabsPrimitive.Root
bind:ref
bind:value
data-slot="tabs"
class={cn("flex flex-col gap-2", className)}
{...restProps}
/>
+8
View File
@@ -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"](),
};
+5 -2
View File
@@ -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<string[]>().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 */
+4 -6
View File
@@ -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<string[]>().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<string[]>(),
/** Active languages for this channel (array of language codes) */
languages: json("languages").$type<string[]>().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 */
@@ -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,
@@ -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,
@@ -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(),
@@ -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);
@@ -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),
@@ -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",
};
+7 -4
View File
@@ -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)
+28 -56
View File
@@ -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<typeof channelCreationSchema>;
export type ChannelUpdateRequest = z.infer<typeof channelUpdateSchema>;
@@ -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)
@@ -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<Pick<InsertTenant, "longName" | "shortName" | "description" | "logo">>,
updateData: Partial<
Pick<InsertTenant, "longName" | "shortName" | "descriptions" | "languages" | "logo">
>,
) {
const log = logger.setContext("TenantAdminService");
log.debug("Updating tenant data", {
+2 -1
View File
@@ -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",
+8
View File
@@ -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;
},
};
}
+8 -3
View File
@@ -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) => {
+1 -1
View File
@@ -1,3 +1,3 @@
import type { SelectTenant } from "$lib/server/db/central-schema";
export type TTenant = Pick<SelectTenant, "id" | "shortName" | "longName" | "setupState">;
export type TTenant = Pick<SelectTenant, "id" | "shortName" | "languages" | "setupState">;
+1 -1
View File
@@ -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);
}
}
};
@@ -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<Infer<FormSchema>> } } =
$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 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="description">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["agents.add.fields.description.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.description} maxlength={200} />
{/snippet}
</Form.Control>
<Form.Description>{m["agents.add.fields.description.description"]()}</Form.Description>
<Form.FieldErrors />
</Form.Field>
<LanguageTabs>
{#snippet children({ locale })}
<Form.Field {form} name={`descriptions.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["agents.add.fields.description.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={200} />
{/snippet}
</Form.Control>
<Form.Description>{m["agents.add.fields.description.description"]()}</Form.Description>
<Form.FieldErrors />
</Form.Field>
{/snippet}
</LanguageTabs>
<Form.Field {form} name="image">
<Form.Control>
{#snippet children({ props })}
@@ -6,7 +6,7 @@ export const formSchema = z.object({
.string()
.min(2, m["agents.add.fields.name.errors.length"]())
.max(50, m["agents.add.fields.name.errors.length"]()),
description: z.string().optional().or(z.literal("")),
descriptions: z.record(z.string(), z.string()).optional(),
image: z.string().optional().or(z.literal("")),
});
@@ -2,25 +2,33 @@
import { m } from "$i18n/messages.js";
import * as Form from "$lib/components/ui/form";
import { Input } from "$lib/components/ui/input";
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 type { TAgent } from "$lib/types/agent";
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 { Textarea } from "$lib/components/ui/textarea";
import { InputCroppedImageBlob } from "$lib/components/ui/input-cropped-image-blob";
import ItemIcon from "@lucide/svelte/icons/user-star";
import { tenants } from "$lib/stores/tenants";
import { get } from "svelte/store";
let { entity, done }: { entity: TAgent; done: () => void } = $props();
const tenantLocales = get(tenants).currentTenant?.languages ?? [];
const form = superForm(
{
id: entity.id,
name: entity.name,
description: entity.description,
descriptions: tenantLocales.reduce(
(acc, locale) => ({ ...acc, [locale]: entity.descriptions[locale] ?? "" }),
{} as { [key: string]: string },
),
image: entity.image ?? "",
},
{
dataType: "json",
validators: zodClient(formSchema),
onResult: async (event) => {
if (event.result.type === "success") {
@@ -50,16 +58,20 @@
</Form.Control>
<Form.FieldErrors />
</Form.Field>
<Form.Field {form} name="description">
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["agents.add.fields.description.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.description} maxlength={200} />
{/snippet}
</Form.Control>
<Form.Description>{m["agents.add.fields.description.description"]()}</Form.Description>
<Form.FieldErrors />
</Form.Field>
<LanguageTabs>
{#snippet children({ locale })}
<Form.Field {form} name={`descriptions.${locale}`}>
<Form.Control>
{#snippet children({ props })}
<Form.Label>{m["agents.add.fields.description.title"]()}</Form.Label>
<Textarea {...props} bind:value={$formData.descriptions[locale]} maxlength={200} />
{/snippet}
</Form.Control>
<Form.Description>{m["agents.add.fields.description.description"]()}</Form.Description>
<Form.FieldErrors />
</Form.Field>
{/snippet}
</LanguageTabs>
<Form.Field {form} name="image">
<Form.Control>
{#snippet children({ props })}
@@ -7,7 +7,7 @@ export const formSchema = z.object({
.string()
.min(2, m["agents.add.fields.name.errors.length"]())
.max(50, m["agents.add.fields.name.errors.length"]()),
description: z.string().optional().or(z.literal("")),
descriptions: z.record(z.string(), z.string()).optional(),
image: z.string().optional().or(z.literal("")),
});
@@ -39,7 +39,6 @@ export const load = async (event) => {
});
return {
form: await superValidate(zod(addFormSchema)),
streamed: {
list,
},
@@ -71,7 +70,7 @@ export const actions: Actions = {
credentials: "same-origin",
body: JSON.stringify({
name: form.data.name,
description: form.data.description,
descriptions: form.data.descriptions,
image: form.data.image,
}),
});
@@ -118,7 +117,7 @@ export const actions: Actions = {
credentials: "same-origin",
body: JSON.stringify({
name: form.data.name,
description: form.data.description,
descriptions: form.data.descriptions,
image: form.data.image ?? null,
}),
},
@@ -57,7 +57,6 @@
<PlusIcon /> {m["agents.add.title"]()}
{/snippet}
<AddAgentForm
{data}
done={() => {
invalidate(ROUTES.DASHBOARD.AGENTS);
closeDialog("add");
@@ -69,7 +68,7 @@
<List>
{#each items as item (item.id)}
<ListItem
image={item.image ?? UnknownItemIcon}
image={item.image || UnknownItemIcon}
title={item.name}
actions={[
{
@@ -94,7 +94,7 @@
type: "action",
icon: SelectIcon,
label: m["select"](),
onClick: () => tenantsStore.setCurrentTenant(item.id, true),
onClick: () => tenantsStore.setCurrentTenant(item.id),
},
{
type: "divider",
+3 -3
View File
@@ -32,13 +32,13 @@ export const POST: RequestHandler = async ({ request, locals, cookies }) => {
// Verify user is authenticated and is global admin
checkPermission(locals, null, true);
const body = await request.json();
const validation = tenantSwitchSchema.safeParse(body);
if (!locals.user?.sessionId) {
throw new ValidationError(ERRORS.SECURITY.SESSION_MISSING);
}
const body = await request.json();
const validation = tenantSwitchSchema.safeParse(body);
if (!validation.success) {
logger.warn("Invalid tenant switch request", {
userId: locals.user?.id,
+1 -1
View File
@@ -211,7 +211,7 @@ export const GET: RequestHandler = async ({ locals }) => {
.select({
id: tenant.id,
shortName: tenant.shortName,
longName: tenant.longName,
languages: tenant.languages,
setupState: tenant.setupState,
logo: tenant.logo,
})
+11 -11
View File
@@ -36,15 +36,15 @@ registerOpenAPIRoute("/tenants/{id}/agents", "POST", {
description: "Agent name",
example: "Support Agent",
},
description: {
type: "string",
description: "Agent description",
example: "Handles customer support requests",
descriptions: {
type: "object",
description: "Localized agent descriptions. Keys are locale codes.",
additionalProperties: true,
example: { en: "Support Agent", de: "Service-Mitarbeiterin" },
},
logo: {
image: {
type: "string",
format: "byte",
description: "Base64 encoded agent logo",
description: "Base64 encoded agent image",
},
},
required: ["name"],
@@ -66,8 +66,8 @@ registerOpenAPIRoute("/tenants/{id}/agents", "POST", {
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" },
descriptions: { type: "object", description: "Localized Agent descriptions" },
image: { type: "string", description: "Agent image" },
},
required: ["id", "name"],
},
@@ -150,8 +150,8 @@ registerOpenAPIRoute("/tenants/{id}/agents", "GET", {
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" },
descriptions: { type: "object", description: "Localized Agent descriptions" },
image: { type: "string", description: "Agent image" },
},
required: ["id", "name"],
},
@@ -48,8 +48,8 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "GET", {
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" },
descriptions: { type: "object", description: "Localized Agent descriptions" },
image: { type: "string", description: "Agent image" },
},
required: ["id", "name"],
},
@@ -130,15 +130,15 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "PUT", {
description: "Agent name",
example: "Updated Support Agent",
},
description: {
type: "string",
description: "Agent description",
example: "Updated description for customer support",
descriptions: {
type: "object",
description: "Localized agent descriptions. Keys are locale codes.",
additionalProperties: true,
example: { en: "Support Agent", de: "Service-Mitarbeiterin" },
},
logo: {
image: {
type: "string",
format: "byte",
description: "Base64 encoded agent logo",
description: "Base64 encoded agent image",
},
},
},
@@ -159,8 +159,8 @@ registerOpenAPIRoute("/tenants/{id}/agents/{agentId}", "PUT", {
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" },
descriptions: { type: "string", description: "Localized Agent descriptions" },
image: { type: "string", description: "Agent image" },
},
required: ["id", "name"],
},
+20 -42
View File
@@ -30,10 +30,10 @@ registerOpenAPIRoute("/tenants/{id}/channels", "POST", {
type: "object",
properties: {
names: {
type: "array",
items: { type: "string", minLength: 1, maxLength: 100 },
description: "Channel names (one per language)",
example: ["Support", "Unterstützung"],
type: "object",
additionalProperties: true,
description: "Localized channel names. Keys are locale codes.",
example: { en: "Support", de: "Unterstützung" },
},
color: {
type: "string",
@@ -41,16 +41,10 @@ registerOpenAPIRoute("/tenants/{id}/channels", "POST", {
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"],
type: "object",
additionalProperties: true,
description: "Localized channel descriptions. Keys are locale codes.",
example: { en: "Some description", de: "Eine Beschreibung" },
},
isPublic: {
type: "boolean",
@@ -107,17 +101,11 @@ registerOpenAPIRoute("/tenants/{id}/channels", "POST", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Channel ID" },
names: { type: "array", items: { type: "string" }, description: "Channel names" },
names: { type: "object", description: "Localized 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",
type: "object",
description: "Localized Channel descriptions",
},
isPublic: { type: "boolean", description: "Public visibility" },
requiresConfirmation: { type: "boolean", description: "Requires confirmation" },
@@ -128,8 +116,8 @@ registerOpenAPIRoute("/tenants/{id}/channels", "POST", {
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
description: { type: "string" },
logo: { type: "string", format: "byte" },
descriptions: { type: "object" },
image: { type: "string" },
},
},
},
@@ -147,7 +135,7 @@ registerOpenAPIRoute("/tenants/{id}/channels", "POST", {
},
},
},
required: ["id", "names", "languages", "agents", "slotTemplates"],
required: ["id", "names", "agents", "slotTemplates"],
},
},
required: ["message", "channel"],
@@ -227,21 +215,11 @@ registerOpenAPIRoute("/tenants/{id}/channels", "GET", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Channel ID" },
names: {
type: "array",
items: { type: "string" },
description: "Channel names",
},
names: { type: "object", description: "Localized 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",
type: "object",
description: "Localized Channel descriptions",
},
isPublic: { type: "boolean", description: "Public visibility" },
requiresConfirmation: { type: "boolean", description: "Requires confirmation" },
@@ -252,8 +230,8 @@ registerOpenAPIRoute("/tenants/{id}/channels", "GET", {
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
description: { type: "string" },
logo: { type: "string", format: "byte" },
descriptions: { type: "object" },
image: { type: "string" },
},
},
},
@@ -271,7 +249,7 @@ registerOpenAPIRoute("/tenants/{id}/channels", "GET", {
},
},
},
required: ["id", "names", "languages", "agents", "slotTemplates"],
required: ["id", "names", "agents", "slotTemplates"],
},
},
},
@@ -47,17 +47,11 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "GET", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Channel ID" },
names: { type: "array", items: { type: "string" }, description: "Channel names" },
names: { type: "object", description: "Localized 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",
type: "object",
description: "Localized Channel descriptions",
},
isPublic: { type: "boolean", description: "Public visibility" },
requiresConfirmation: { type: "boolean", description: "Requires confirmation" },
@@ -68,8 +62,8 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "GET", {
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
description: { type: "string" },
logo: { type: "string", format: "byte" },
descriptions: { type: "object" },
image: { type: "string" },
},
},
},
@@ -87,7 +81,7 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "GET", {
},
},
},
required: ["id", "names", "languages", "agents", "slotTemplates"],
required: ["id", "names", "agents", "slotTemplates"],
},
},
required: ["channel"],
@@ -160,10 +154,10 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "PUT", {
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"],
type: "object",
additionalProperties: true,
description: "Localized channel names. Keys are locale codes.",
example: { en: "Support", de: "Unterstützung" },
},
color: {
type: "string",
@@ -171,16 +165,10 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "PUT", {
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"],
type: "object",
additionalProperties: true,
description: "Localized channel descriptions. Keys are locale codes.",
example: { en: "Some description", de: "Eine Beschreibung" },
},
isPublic: {
type: "boolean",
@@ -241,17 +229,11 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "PUT", {
type: "object",
properties: {
id: { type: "string", format: "uuid", description: "Channel ID" },
names: { type: "array", items: { type: "string" }, description: "Channel names" },
names: { type: "object", description: "Localized 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",
type: "object",
description: "Localized Channel descriptions",
},
isPublic: { type: "boolean", description: "Public visibility" },
requiresConfirmation: { type: "boolean", description: "Requires confirmation" },
@@ -262,8 +244,8 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "PUT", {
properties: {
id: { type: "string", format: "uuid" },
name: { type: "string" },
description: { type: "string" },
logo: { type: "string", format: "byte" },
descriptions: { type: "object" },
image: { type: "string" },
},
},
},
@@ -281,7 +263,7 @@ registerOpenAPIRoute("/tenants/{id}/channels/{channelId}", "PUT", {
},
},
},
required: ["id", "names", "languages", "agents", "slotTemplates"],
required: ["id", "names", "agents", "slotTemplates"],
},
},
required: ["message", "channel"],
+3
View File
@@ -0,0 +1,3 @@
ALTER TABLE "agent" RENAME COLUMN "description" TO "descriptions";--> statement-breakpoint
ALTER TABLE "channel" ALTER COLUMN "descriptions" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "channel" DROP COLUMN "languages";
+452
View File
@@ -0,0 +1,452 @@
{
"id": "71a71648-32b1-483e-b395-949a63f93f7d",
"prevId": "b4773ec9-f77f-40ec-8da0-b6f3a4498ca7",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.agent": {
"name": "agent",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "varchar(250000)",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.agent_absence": {
"name": "agent_absence",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"start_date": {
"name": "start_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"end_date": {
"name": "end_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"absence_type": {
"name": "absence_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"agent_absence_agent_id_agent_id_fk": {
"name": "agent_absence_agent_id_agent_id_fk",
"tableFrom": "agent_absence",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.appointment": {
"name": "appointment",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"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
},
"status": {
"name": "status",
"type": "appointment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'NEW'"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"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()"
},
"names": {
"name": "names",
"type": "json",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"paused": {
"name": "paused",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"descriptions": {
"name": "descriptions",
"type": "json",
"primaryKey": false,
"notNull": true
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"requires_confirmation": {
"name": "requires_confirmation",
"type": "boolean",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_agent": {
"name": "channel_agent",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"agent_id": {
"name": "agent_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_agent_channel_id_channel_id_fk": {
"name": "channel_agent_channel_id_channel_id_fk",
"tableFrom": "channel_agent",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_agent_agent_id_agent_id_fk": {
"name": "channel_agent_agent_id_agent_id_fk",
"tableFrom": "channel_agent",
"tableTo": "agent",
"columnsFrom": ["agent_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.channel_slot_template": {
"name": "channel_slot_template",
"schema": "",
"columns": {
"channel_id": {
"name": "channel_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"slot_template_id": {
"name": "slot_template_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"channel_slot_template_channel_id_channel_id_fk": {
"name": "channel_slot_template_channel_id_channel_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "channel",
"columnsFrom": ["channel_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
},
"channel_slot_template_slot_template_id_slotTemplate_id_fk": {
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
"tableFrom": "channel_slot_template",
"tableTo": "slotTemplate",
"columnsFrom": ["slot_template_id"],
"columnsTo": ["id"],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.client": {
"name": "client",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"hash_key": {
"name": "hash_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"public_key": {
"name": "public_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"private_key_share": {
"name": "private_key_share",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"language": {
"name": "language",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"client_hash_key_unique": {
"name": "client_hash_key_unique",
"nullsNotDistinct": false,
"columns": ["hash_key"]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slotTemplate": {
"name": "slotTemplate",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"weekdays": {
"name": "weekdays",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"from": {
"name": "from",
"type": "time",
"primaryKey": false,
"notNull": true
},
"to": {
"name": "to",
"type": "time",
"primaryKey": false,
"notNull": true
},
"duration": {
"name": "duration",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.appointment_status": {
"name": "appointment_status",
"schema": "public",
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -22,6 +22,13 @@
"when": 1758555647097,
"tag": "0002_typical_the_twelve",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1759144219626,
"tag": "0003_big_boomerang",
"breakpoints": true
}
]
}