mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Merge pull request #120 from open-reception/feat/custom-domains
Support Custom Domains
This commit is contained in:
@@ -1,7 +1,44 @@
|
|||||||
# Production Caddyfile for appointment booking application
|
# Production Caddyfile for appointment booking application
|
||||||
# Replace your-domain.com with your actual domain
|
# Replace your-domain.com with your actual domain
|
||||||
|
{
|
||||||
|
on_demand_tls {
|
||||||
|
ask http://app:3000/api/public/domains
|
||||||
|
burst 5
|
||||||
|
interval 60s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
your-domain.com {
|
:443 {
|
||||||
|
reverse_proxy app:3000
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
header {
|
||||||
|
-Server
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
X-XSS-Protection "1; mode=block"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'"
|
||||||
|
}
|
||||||
|
|
||||||
|
encode gzip
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /data/access.log {
|
||||||
|
roll_size 100mb
|
||||||
|
roll_keep 10
|
||||||
|
}
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
|
||||||
|
tls {
|
||||||
|
on_demand
|
||||||
|
protocols tls1.2 tls1.3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
your-admin-domain.com {
|
||||||
reverse_proxy app:3000
|
reverse_proxy app:3000
|
||||||
|
|
||||||
# Security headers
|
# Security headers
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE "tenant" ADD COLUMN "domain" text;--> statement-breakpoint
|
||||||
|
UPDATE "tenant" SET "domain" = CONCAT("short_name", '.open-reception.example.com');
|
||||||
|
ALTER TABLE "tenant" ALTER COLUMN "domain" SET NOT NULL;
|
||||||
|
ALTER TABLE "tenant" ADD CONSTRAINT "tenant_domain_unique" UNIQUE("domain");
|
||||||
@@ -0,0 +1,849 @@
|
|||||||
|
{
|
||||||
|
"id": "ef7f86cc-6f1f-45cc-833b-0645ede95732",
|
||||||
|
"prevId": "945e895d-77bd-4acb-a4d3-20f652924edf",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.challenge_throttle": {
|
||||||
|
"name": "challenge_throttle",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"failed_attempts": {
|
||||||
|
"name": "failed_attempts",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"last_attempt_at": {
|
||||||
|
"name": "last_attempt_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"reset_at": {
|
||||||
|
"name": "reset_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"defaultLanguage": {
|
||||||
|
"name": "defaultLanguage",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"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": "'SETTINGS'"
|
||||||
|
},
|
||||||
|
"links": {
|
||||||
|
"name": "links",
|
||||||
|
"type": "json",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'{}'::json"
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"name": "domain",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tenant_database_url_idx": {
|
||||||
|
"name": "tenant_database_url_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "database_url",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"tenant_short_name_unique": {
|
||||||
|
"name": "tenant_short_name_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["short_name"]
|
||||||
|
},
|
||||||
|
"tenant_domain_unique": {
|
||||||
|
"name": "tenant_domain_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["domain"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.tenant_config": {
|
||||||
|
"name": "tenant_config",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"name": "tenant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"name": "type",
|
||||||
|
"type": "config_type",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"tenant_config_tenant_name_idx": {
|
||||||
|
"name": "tenant_config_tenant_name_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "tenant_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expression": "name",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"tenant_config_tenant_id_tenant_id_fk": {
|
||||||
|
"name": "tenant_config_tenant_id_tenant_id_fk",
|
||||||
|
"tableFrom": "tenant_config",
|
||||||
|
"tableTo": "tenant",
|
||||||
|
"columnsFrom": ["tenant_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "user_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'STAFF'"
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"name": "tenant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"confirmation_state": {
|
||||||
|
"name": "confirmation_state",
|
||||||
|
"type": "confirmation_state",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "'INVITED'"
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"token_valid_until": {
|
||||||
|
"name": "token_valid_until",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"passphrase_hash": {
|
||||||
|
"name": "passphrase_hash",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"recovery_passphrase": {
|
||||||
|
"name": "recovery_passphrase",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'de'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_email_idx": {
|
||||||
|
"name": "user_email_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_tenant_id_tenant_id_fk": {
|
||||||
|
"name": "user_tenant_id_tenant_id_fk",
|
||||||
|
"tableFrom": "user",
|
||||||
|
"tableTo": "tenant",
|
||||||
|
"columnsFrom": ["tenant_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["email"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_invite": {
|
||||||
|
"name": "user_invite",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"invite_code": {
|
||||||
|
"name": "invite_code",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "user_role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"tenant_id": {
|
||||||
|
"name": "tenant_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"invited_by": {
|
||||||
|
"name": "invited_by",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'de'"
|
||||||
|
},
|
||||||
|
"used": {
|
||||||
|
"name": "used",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"used_at": {
|
||||||
|
"name": "used_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_user_id": {
|
||||||
|
"name": "created_user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_invite_code_idx": {
|
||||||
|
"name": "user_invite_code_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "invite_code",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"user_invite_email_idx": {
|
||||||
|
"name": "user_invite_email_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "email",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"user_invite_tenant_idx": {
|
||||||
|
"name": "user_invite_tenant_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "tenant_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_invite_tenant_id_tenant_id_fk": {
|
||||||
|
"name": "user_invite_tenant_id_tenant_id_fk",
|
||||||
|
"tableFrom": "user_invite",
|
||||||
|
"tableTo": "tenant",
|
||||||
|
"columnsFrom": ["tenant_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_invite_invited_by_user_id_fk": {
|
||||||
|
"name": "user_invite_invited_by_user_id_fk",
|
||||||
|
"tableFrom": "user_invite",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["invited_by"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"user_invite_created_user_id_user_id_fk": {
|
||||||
|
"name": "user_invite_created_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_invite",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["created_user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "no action",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_invite_invite_code_unique": {
|
||||||
|
"name": "user_invite_invite_code_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["invite_code"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_passkey": {
|
||||||
|
"name": "user_passkey",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"public_key": {
|
||||||
|
"name": "public_key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"counter": {
|
||||||
|
"name": "counter",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"device_name": {
|
||||||
|
"name": "device_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_passkey_user_idx": {
|
||||||
|
"name": "user_passkey_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_passkey_user_id_user_id_fk": {
|
||||||
|
"name": "user_passkey_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_passkey",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user_session": {
|
||||||
|
"name": "user_session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "uuid",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"session_token": {
|
||||||
|
"name": "session_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"passkey_id": {
|
||||||
|
"name": "passkey_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"last_used_at": {
|
||||||
|
"name": "last_used_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_session_user_idx": {
|
||||||
|
"name": "user_session_user_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "user_id",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": false,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
},
|
||||||
|
"user_session_token_idx": {
|
||||||
|
"name": "user_session_token_idx",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"expression": "session_token",
|
||||||
|
"isExpression": false,
|
||||||
|
"asc": true,
|
||||||
|
"nulls": "last"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isUnique": true,
|
||||||
|
"concurrently": false,
|
||||||
|
"method": "btree",
|
||||||
|
"with": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_session_user_id_user_id_fk": {
|
||||||
|
"name": "user_session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "user_session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": ["user_id"],
|
||||||
|
"columnsTo": ["id"],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_session_session_token_unique": {
|
||||||
|
"name": "user_session_session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": ["session_token"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.config_type": {
|
||||||
|
"name": "config_type",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["BOOLEAN", "NUMBER", "STRING"]
|
||||||
|
},
|
||||||
|
"public.confirmation_state": {
|
||||||
|
"name": "confirmation_state",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["INVITED", "CONFIRMED", "ACCESS_GRANTED"]
|
||||||
|
},
|
||||||
|
"public.setup_state": {
|
||||||
|
"name": "setup_state",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["SETTINGS", "AGENTS", "CHANNELS", "STAFF", "READY"]
|
||||||
|
},
|
||||||
|
"public.user_role": {
|
||||||
|
"name": "user_role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": ["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,6 +71,13 @@
|
|||||||
"when": 1768324950524,
|
"when": 1768324950524,
|
||||||
"tag": "0009_bizarre_saracen",
|
"tag": "0009_bizarre_saracen",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1769196961847,
|
||||||
|
"tag": "0010_rich_stardust",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"passkey": "Passkey",
|
"passkey": "Passkey",
|
||||||
"salutation": "Anrede",
|
"salutation": "Anrede",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"domain": "Domain",
|
||||||
"phone": "Telefonnummer",
|
"phone": "Telefonnummer",
|
||||||
"pin": "PIN",
|
"pin": "PIN",
|
||||||
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager."
|
"pinHint": "Speichere Deine PIN an einem sicheren Ort, z.B. in einem Passwort-Manager."
|
||||||
@@ -164,12 +165,17 @@
|
|||||||
"title": "Mandant hinzufügen",
|
"title": "Mandant hinzufügen",
|
||||||
"description": "Erstelle eine Seite für jemanden, der Termine sammeln möchte.",
|
"description": "Erstelle eine Seite für jemanden, der Termine sammeln möchte.",
|
||||||
"name": {
|
"name": {
|
||||||
"description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden. Wird als URL verwendet {domain}",
|
"description": "Muss einmalig auf diesem Server sein. Kann niemals geändert werden.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"urlFormat": "Nur Kleinbuchstaben, Zahlen und Bindestriche sind erlaubt",
|
|
||||||
"startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden"
|
"startEndDash": "Darf nicht mit einem Bindestrich beginnen oder enden"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"domain": {
|
||||||
|
"description": "Muss einmalig auf diesem Server sein.",
|
||||||
|
"errors": {
|
||||||
|
"urlFormat": "Format der Domain ist ungültig. Bitte kein https:// einfügen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Tenant-Admin einladen"
|
"title": "Tenant-Admin einladen"
|
||||||
},
|
},
|
||||||
@@ -210,7 +216,7 @@
|
|||||||
},
|
},
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Mandant bearbeiten",
|
"title": "Mandant bearbeiten",
|
||||||
"description": "Aktualisiere den Namen des Mandanten.",
|
"description": "Aktualisiere die Domain des Mandanten.",
|
||||||
"success": "Mandant aktualisiert",
|
"success": "Mandant aktualisiert",
|
||||||
"error": "Mandant konnte nicht aktualisiert werden.",
|
"error": "Mandant konnte nicht aktualisiert werden.",
|
||||||
"action": "Name ändern"
|
"action": "Name ändern"
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
"passkey": "Passkey",
|
"passkey": "Passkey",
|
||||||
"salutation": "Salutation",
|
"salutation": "Salutation",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"domain": "Domain",
|
||||||
"phone": "Phone Number",
|
"phone": "Phone Number",
|
||||||
"pin": "PIN",
|
"pin": "PIN",
|
||||||
"pinHint": "Save your PIN in a secure place, like a password-manager."
|
"pinHint": "Save your PIN in a secure place, like a password-manager."
|
||||||
@@ -172,12 +173,17 @@
|
|||||||
"title": "Add Tenant",
|
"title": "Add Tenant",
|
||||||
"description": "Create a site for someone to start collecting appointments.",
|
"description": "Create a site for someone to start collecting appointments.",
|
||||||
"name": {
|
"name": {
|
||||||
"description": "Must be unique on this server. Cannot be changed. Used as a subdomain {domain}",
|
"description": "Must be unique on this server. Cannot be changed.",
|
||||||
"errors": {
|
"errors": {
|
||||||
"urlFormat": "Only letters, numbers, and hyphens are allowed",
|
|
||||||
"startEndDash": "Must not start or end with a dash"
|
"startEndDash": "Must not start or end with a dash"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"domain": {
|
||||||
|
"description": "Must be unique on this server.",
|
||||||
|
"errors": {
|
||||||
|
"urlFormat": "Not a valid domain format. Note: Do not include https://"
|
||||||
|
}
|
||||||
|
},
|
||||||
"invite": {
|
"invite": {
|
||||||
"title": "Invite a tenant admin"
|
"title": "Invite a tenant admin"
|
||||||
},
|
},
|
||||||
@@ -218,7 +224,7 @@
|
|||||||
"failedToSwitch": "Failed to switch tenant",
|
"failedToSwitch": "Failed to switch tenant",
|
||||||
"edit": {
|
"edit": {
|
||||||
"title": "Edit Tenant",
|
"title": "Edit Tenant",
|
||||||
"description": "Update the tenant's short name.",
|
"description": "Update the tenant's domain.",
|
||||||
"success": "Tenant updated",
|
"success": "Tenant updated",
|
||||||
"error": "Could not update tenant.",
|
"error": "Could not update tenant.",
|
||||||
"action": "Edit Tenant"
|
"action": "Edit Tenant"
|
||||||
|
|||||||
@@ -32,7 +32,9 @@
|
|||||||
};
|
};
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
setLocale(locale);
|
$effect(() => {
|
||||||
|
setLocale(locale);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EmailLayout>
|
<EmailLayout>
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export const ERRORS = {
|
|||||||
MISSING_TENANT_OR_AGENT_ID: "Missing tenant or agent ID",
|
MISSING_TENANT_OR_AGENT_ID: "Missing tenant or agent ID",
|
||||||
NONE_FOUND: "No Tenant found on this instance",
|
NONE_FOUND: "No Tenant found on this instance",
|
||||||
},
|
},
|
||||||
|
DOMAINS: {
|
||||||
|
QUERY_MISSING: "Domain query missing",
|
||||||
|
NOT_FOUND: "Domain not found",
|
||||||
|
},
|
||||||
CHANNELS: {
|
CHANNELS: {
|
||||||
NOT_FOUND: "Channel not found",
|
NOT_FOUND: "Channel not found",
|
||||||
OPEN_APPOINTMENTS_CONFLICT:
|
OPEN_APPOINTMENTS_CONFLICT:
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ export const tenant = pgTable(
|
|||||||
.$type<{ imprint?: string; privacyStatement?: string; website?: string }>()
|
.$type<{ imprint?: string; privacyStatement?: string; website?: string }>()
|
||||||
.notNull()
|
.notNull()
|
||||||
.default({}),
|
.default({}),
|
||||||
|
/** Domain for the tenant */
|
||||||
|
domain: text("domain").notNull().unique(),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -146,6 +147,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -179,6 +181,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -212,6 +215,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -270,6 +274,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -336,6 +341,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -378,6 +384,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -414,6 +421,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -450,6 +458,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -488,6 +497,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
@@ -517,6 +527,7 @@ describe("Email System", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "postgresql://test",
|
databaseUrl: "postgresql://test",
|
||||||
setupState: "STAFF" as const,
|
setupState: "STAFF" as const,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ describe("generateBaseUrl", () => {
|
|||||||
databaseUrl: "",
|
databaseUrl: "",
|
||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = generateBaseUrl(requestUrl, tenant);
|
const result = generateBaseUrl(requestUrl, tenant);
|
||||||
@@ -64,6 +65,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -85,6 +87,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -106,6 +109,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -147,6 +151,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -168,6 +173,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -189,6 +195,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -210,6 +217,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -231,6 +239,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -252,6 +261,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -273,6 +283,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -297,6 +308,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -319,6 +331,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
@@ -341,6 +354,7 @@ describe("generateBaseUrl", () => {
|
|||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe("TenantAdminService", () => {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
description: "A test clinic",
|
description: "A test clinic",
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockCreatedTenant = {
|
const mockCreatedTenant = {
|
||||||
@@ -142,6 +143,7 @@ describe("TenantAdminService", () => {
|
|||||||
it("should rollback tenant creation if database initialization fails", async () => {
|
it("should rollback tenant creation if database initialization fails", async () => {
|
||||||
const newTenant = {
|
const newTenant = {
|
||||||
shortName: "test-clinic",
|
shortName: "test-clinic",
|
||||||
|
domain: "tenant.example.com",
|
||||||
descriptions: "A test clinic",
|
descriptions: "A test clinic",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { TenantConfig } from "../db/tenant-config";
|
|||||||
import { TenantMigrationService } from "./tenant-migration-service";
|
import { TenantMigrationService } from "./tenant-migration-service";
|
||||||
|
|
||||||
import { env } from "$env/dynamic/private";
|
import { env } from "$env/dynamic/private";
|
||||||
import { eq, and, not, count, or } from "drizzle-orm";
|
|
||||||
import logger from "$lib/logger";
|
import logger from "$lib/logger";
|
||||||
|
import { and, count, eq, ne, not, or } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ValidationError, NotFoundError, ConflictError } from "../utils/errors";
|
import { ConflictError, NotFoundError, ValidationError } from "../utils/errors";
|
||||||
import { redactDbUrl } from "../utils/url";
|
import { redactDbUrl } from "../utils/url";
|
||||||
|
|
||||||
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
if (!env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
|
||||||
@@ -19,6 +19,12 @@ const tenantCreationSchema = z.object({
|
|||||||
.min(4)
|
.min(4)
|
||||||
.max(15)
|
.max(15)
|
||||||
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
|
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/),
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)*[a-z0-9]+(-[a-z0-9]+)*$/, "Invalid domain format"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
|
export type TenantCreationRequest = z.infer<typeof tenantCreationSchema>;
|
||||||
@@ -61,16 +67,23 @@ export class TenantAdminService {
|
|||||||
|
|
||||||
log.debug("Creating new tenant", {
|
log.debug("Creating new tenant", {
|
||||||
shortName: request.shortName,
|
shortName: request.shortName,
|
||||||
|
domain: request.domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if tenant can be created without any duplications. Do not create tenant if:
|
// Check if tenant can be created without any duplications. Do not create tenant if:
|
||||||
// - short name already exists
|
// - short name already exists
|
||||||
|
// - domain already exists
|
||||||
const tenantExists = await centralDb
|
const tenantExists = await centralDb
|
||||||
.select()
|
.select()
|
||||||
.from(centralSchema.tenant)
|
.from(centralSchema.tenant)
|
||||||
.where(eq(centralSchema.tenant.shortName, request.shortName));
|
.where(
|
||||||
|
or(
|
||||||
|
eq(centralSchema.tenant.shortName, request.shortName),
|
||||||
|
eq(centralSchema.tenant.domain, request.domain),
|
||||||
|
),
|
||||||
|
);
|
||||||
if (tenantExists.length > 0) {
|
if (tenantExists.length > 0) {
|
||||||
throw new ConflictError("Tenant with shortname already exists");
|
throw new ConflictError("Tenant with shortname or domain already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
const configuration = TenantAdminService.getConfigDefaults();
|
const configuration = TenantAdminService.getConfigDefaults();
|
||||||
@@ -90,6 +103,7 @@ export class TenantAdminService {
|
|||||||
imprint: "",
|
imprint: "",
|
||||||
privacyStatement: "",
|
privacyStatement: "",
|
||||||
},
|
},
|
||||||
|
domain: request.domain,
|
||||||
};
|
};
|
||||||
newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName;
|
newTenant.databaseUrl = urlParts.join("/") + "/" + newTenant.shortName;
|
||||||
|
|
||||||
@@ -199,7 +213,10 @@ export class TenantAdminService {
|
|||||||
*/
|
*/
|
||||||
async updateTenantData(
|
async updateTenantData(
|
||||||
updateData: Partial<
|
updateData: Partial<
|
||||||
Pick<InsertTenant, "longName" | "shortName" | "descriptions" | "languages" | "logo">
|
Pick<
|
||||||
|
InsertTenant,
|
||||||
|
"longName" | "shortName" | "descriptions" | "languages" | "logo" | "domain"
|
||||||
|
>
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
const log = logger.setContext("TenantAdminService");
|
const log = logger.setContext("TenantAdminService");
|
||||||
@@ -212,6 +229,22 @@ export class TenantAdminService {
|
|||||||
throw new ValidationError("Shortname cannot be changed");
|
throw new ValidationError("Shortname cannot be changed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if domain is already in use
|
||||||
|
if (updateData.domain) {
|
||||||
|
const domainExists = await centralDb
|
||||||
|
.select()
|
||||||
|
.from(centralSchema.tenant)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(centralSchema.tenant.domain, updateData.domain),
|
||||||
|
ne(centralSchema.tenant.id, this.tenantId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (domainExists.length > 0) {
|
||||||
|
throw new ConflictError("Tenant with shortname or domain already exists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await centralDb
|
const result = await centralDb
|
||||||
.update(centralSchema.tenant)
|
.update(centralSchema.tenant)
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function createSystemTenant(): SelectTenant {
|
|||||||
defaultLanguage: "en",
|
defaultLanguage: "en",
|
||||||
logo: null,
|
logo: null,
|
||||||
links: { website: "", imprint: "", privacyStatement: "" },
|
links: { website: "", imprint: "", privacyStatement: "" },
|
||||||
|
domain: "tenant.example.com",
|
||||||
databaseUrl: "",
|
databaseUrl: "",
|
||||||
setupState: "SETTINGS",
|
setupState: "SETTINGS",
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { SelectTenant } from "$lib/server/db/central-schema";
|
import type { SelectTenant } from "$lib/server/db/central-schema";
|
||||||
|
|
||||||
export type TTenant = Pick<SelectTenant, "id" | "shortName" | "languages" | "setupState"> & {
|
export type TTenant = Pick<
|
||||||
|
SelectTenant,
|
||||||
|
"id" | "shortName" | "languages" | "setupState" | "domain"
|
||||||
|
> & {
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+12
@@ -59,6 +59,18 @@
|
|||||||
</Form.Description>
|
</Form.Description>
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
</Form.Field>
|
</Form.Field>
|
||||||
|
<Form.Field {form} name="domain">
|
||||||
|
<Form.Control>
|
||||||
|
{#snippet children({ props })}
|
||||||
|
<Form.Label>{m["form.domain"]()}</Form.Label>
|
||||||
|
<Input {...props} bind:value={$formData.domain} type="text" autocomplete="off" />
|
||||||
|
{/snippet}
|
||||||
|
</Form.Control>
|
||||||
|
<Form.Description>
|
||||||
|
{m["tenants.add.domain.description"]()}
|
||||||
|
</Form.Description>
|
||||||
|
<Form.FieldErrors />
|
||||||
|
</Form.Field>
|
||||||
<Form.Field {form} name="inviteAdmin">
|
<Form.Field {form} name="inviteAdmin">
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ export const formSchema = z.object({
|
|||||||
.string()
|
.string()
|
||||||
.min(4, m["form.errors.shortname"]())
|
.min(4, m["form.errors.shortname"]())
|
||||||
.max(15, m["form.errors.shortname"]())
|
.max(15, m["form.errors.shortname"]())
|
||||||
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, {
|
|
||||||
message: m["tenants.add.name.errors.urlFormat"](),
|
|
||||||
})
|
|
||||||
.refine((val) => !val.startsWith("-") && !val.endsWith("-"), {
|
.refine((val) => !val.startsWith("-") && !val.endsWith("-"), {
|
||||||
message: m["tenants.add.name.errors.startEndDash"](),
|
message: m["tenants.add.name.errors.startEndDash"](),
|
||||||
}),
|
}),
|
||||||
|
domain: z
|
||||||
|
.string()
|
||||||
|
.min(1)
|
||||||
|
.max(253)
|
||||||
|
.toLowerCase()
|
||||||
|
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, m["tenants.add.domain.errors.urlFormat"]()),
|
||||||
inviteAdmin: z.boolean(),
|
inviteAdmin: z.boolean(),
|
||||||
email: z.string().email(m["form.errors.email"]()).optional().or(z.literal("")),
|
email: z.string().email(m["form.errors.email"]()).optional().or(z.literal("")),
|
||||||
});
|
});
|
||||||
|
|||||||
+6
-8
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
const form = superForm(
|
const form = superForm(
|
||||||
{ id: entity.id, shortName: entity.shortName },
|
{ id: entity.id, domain: entity.domain },
|
||||||
{
|
{
|
||||||
validators: zodClient(formSchema),
|
validators: zodClient(formSchema),
|
||||||
onResult: async (event) => {
|
onResult: async (event) => {
|
||||||
@@ -34,19 +34,17 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Form.Root {enhance} action="?/edit">
|
<Form.Root {enhance} action="?/edit">
|
||||||
<Form.Field {form} name="shortName">
|
<Form.Field {form} name="domain">
|
||||||
<Form.Control>
|
<Form.Control>
|
||||||
{#snippet children({ props })}
|
{#snippet children({ props })}
|
||||||
<Form.Label>{m["form.name"]()}</Form.Label>
|
<Form.Label>{m["form.domain"]()}</Form.Label>
|
||||||
<Input {...props} bind:value={$formData.shortName} type="text" autocomplete="off" />
|
<Input {...props} bind:value={$formData.domain} type="text" autocomplete="off" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Form.Control>
|
</Form.Control>
|
||||||
<Form.Description>
|
<Form.Description>
|
||||||
{m["tenants.add.name.description"]({
|
{m["tenants.add.domain.description"]({
|
||||||
domain:
|
domain:
|
||||||
$formData.shortName.length < 2
|
$formData.domain.length < 2 ? "" : `${$formData.domain}.${window.location.hostname}`,
|
||||||
? ""
|
|
||||||
: `${$formData.shortName}.${window.location.hostname}`,
|
|
||||||
})}
|
})}
|
||||||
</Form.Description>
|
</Form.Description>
|
||||||
<Form.FieldErrors />
|
<Form.FieldErrors />
|
||||||
|
|||||||
@@ -2,16 +2,12 @@ import { m } from "$i18n/messages";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const formSchema = z.object({
|
export const formSchema = z.object({
|
||||||
shortName: z
|
domain: z
|
||||||
.string()
|
.string()
|
||||||
.min(4, m["form.errors.shortname"]())
|
.min(1)
|
||||||
.max(15, m["form.errors.shortname"]())
|
.max(253)
|
||||||
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, {
|
.toLowerCase()
|
||||||
message: m["tenants.add.name.errors.urlFormat"](),
|
.regex(/^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/, m["tenants.add.domain.errors.urlFormat"]()),
|
||||||
})
|
|
||||||
.refine((val) => !val.startsWith("-") && !val.endsWith("-"), {
|
|
||||||
message: m["tenants.add.name.errors.startEndDash"](),
|
|
||||||
}),
|
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export const actions: Actions = {
|
|||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
shortName: form.data.shortName,
|
shortName: form.data.shortName,
|
||||||
|
domain: form.data.domain,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@ export const actions: Actions = {
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
body: JSON.stringify({ shortName: form.data.shortName }),
|
body: JSON.stringify({ domain: form.data.domain }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resp.status < 400) {
|
if (resp.status < 400) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { ROUTES } from "$lib/const/routes";
|
import { ROUTES } from "$lib/const/routes";
|
||||||
import { tenants as tenantsStore } from "$lib/stores/tenants";
|
import { tenants as tenantsStore } from "$lib/stores/tenants";
|
||||||
import { type TTenant } from "$lib/types/tenant";
|
import { type TTenant } from "$lib/types/tenant";
|
||||||
// import EditIcon from "@lucide/svelte/icons/pencil";
|
import EditIcon from "@lucide/svelte/icons/pencil";
|
||||||
import SelectIcon from "@lucide/svelte/icons/plug-zap";
|
import SelectIcon from "@lucide/svelte/icons/plug-zap";
|
||||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||||
import UnknownItemIcon from "@lucide/svelte/icons/landmark";
|
import UnknownItemIcon from "@lucide/svelte/icons/landmark";
|
||||||
@@ -74,24 +74,19 @@
|
|||||||
<ListItem
|
<ListItem
|
||||||
image={item.logo || UnknownItemIcon}
|
image={item.logo || UnknownItemIcon}
|
||||||
title={item.shortName}
|
title={item.shortName}
|
||||||
description={`${item.shortName}.${window.location.hostname}`}
|
description={item.domain}
|
||||||
descriptionOnClick={() =>
|
descriptionOnClick={() =>
|
||||||
window.open(
|
window.open(`https://${item.domain}`, "_blank", "noopener,noreferrer")}
|
||||||
`https://${item.shortName}.${window.location.hostname}`,
|
|
||||||
"_blank",
|
|
||||||
"noopener,noreferrer",
|
|
||||||
)}
|
|
||||||
actions={[
|
actions={[
|
||||||
// Editiing will we re-introduced, when we support custom domains
|
{
|
||||||
// {
|
type: "action",
|
||||||
// type: "action",
|
icon: EditIcon,
|
||||||
// icon: EditIcon,
|
label: m["edit"](),
|
||||||
// label: m["edit"](),
|
onClick: () => {
|
||||||
// onClick: () => {
|
curItem = item;
|
||||||
// curItem = item;
|
openDialog("edit");
|
||||||
// openDialog("edit");
|
},
|
||||||
// },
|
},
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
type: "action",
|
type: "action",
|
||||||
icon: SelectIcon,
|
icon: SelectIcon,
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
|||||||
const log = logger.setContext("API");
|
const log = logger.setContext("API");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tenantId = await getTenantIdByDomain(url.hostname);
|
const tenantId = await getTenantIdByDomain(url.hostname, true);
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return new NotFoundError(ERRORS.TENANTS.NONE_FOUND).toJson();
|
return new NotFoundError(ERRORS.TENANTS.NONE_FOUND).toJson();
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
|||||||
const log = logger.setContext("API");
|
const log = logger.setContext("API");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tenantId = await getTenantIdByDomain(url.hostname);
|
const tenantId = await getTenantIdByDomain(url.hostname, true);
|
||||||
|
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return new NotFoundError(ERRORS.TENANTS.NONE_FOUND).toJson();
|
return new NotFoundError(ERRORS.TENANTS.NONE_FOUND).toJson();
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const GET: RequestHandler = async ({ locals, url, params }) => {
|
|||||||
const log = logger.setContext("API");
|
const log = logger.setContext("API");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tenantId = await getTenantIdByDomain(url.hostname);
|
const tenantId = await getTenantIdByDomain(url.hostname, true);
|
||||||
if (!tenantId) {
|
if (!tenantId) {
|
||||||
return new NotFoundError(ERRORS.TENANTS.NOT_FOUND).toJson();
|
return new NotFoundError(ERRORS.TENANTS.NOT_FOUND).toJson();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { ERRORS } from "$lib/errors";
|
||||||
|
import logger from "$lib/logger";
|
||||||
|
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||||
|
import { InternalError, logError, NotFoundError } from "$lib/server/utils/errors";
|
||||||
|
import type { RequestHandler } from "@sveltejs/kit";
|
||||||
|
import { json } from "@sveltejs/kit";
|
||||||
|
import { getTenantIdByDomain } from "../utils";
|
||||||
|
|
||||||
|
// Register OpenAPI documentation
|
||||||
|
registerOpenAPIRoute("/public/domains", "GET", {
|
||||||
|
summary: "Get public tenant data",
|
||||||
|
description: "Checks if a domain belongs to this server",
|
||||||
|
tags: ["Tenants"],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "Retrieved data successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
tbd: { type: "string", description: "TBD" },
|
||||||
|
},
|
||||||
|
required: ["tbd"],
|
||||||
|
},
|
||||||
|
example: {
|
||||||
|
tbd: "tbd",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
description: "The request does not contain the proper format",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
example: { error: "Malformatted request" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
description: "Domain not found on this instance",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
example: { error: "Domain not found on this instance" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
description: "Internal server error",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
example: { error: "Internal server error" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GET: RequestHandler = async ({ url }) => {
|
||||||
|
const log = logger.setContext("API");
|
||||||
|
|
||||||
|
const domain = url.searchParams.get("domain");
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return new NotFoundError(ERRORS.DOMAINS.QUERY_MISSING).toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tenantId = await getTenantIdByDomain(domain);
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return new NotFoundError(ERRORS.DOMAINS.NOT_FOUND).toJson();
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({});
|
||||||
|
} catch (error) {
|
||||||
|
logError(log)(`Error verifying domain ${domain}`, error);
|
||||||
|
return new InternalError().toJson();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,8 +3,11 @@ import { db } from "$lib/server/db";
|
|||||||
import { tenant } from "$lib/server/db/central-schema";
|
import { tenant } from "$lib/server/db/central-schema";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export const getTenantIdByDomain = async (domain: string): Promise<string | null> => {
|
export const getTenantIdByDomain = async (
|
||||||
if (dev) {
|
domain: string,
|
||||||
|
chooseFirstTenantLocally?: boolean,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
if (dev && chooseFirstTenantLocally) {
|
||||||
// Get the first tenant ID in development
|
// Get the first tenant ID in development
|
||||||
const tenants = await db
|
const tenants = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -17,13 +20,18 @@ export const getTenantIdByDomain = async (domain: string): Promise<string | null
|
|||||||
} else {
|
} else {
|
||||||
// Get tenant ID by domain in production
|
// Get tenant ID by domain in production
|
||||||
const shortName = domain.replace("https://", "").split(".")[0];
|
const shortName = domain.replace("https://", "").split(".")[0];
|
||||||
const tenants = await db
|
try {
|
||||||
.select({
|
const tenants = await db
|
||||||
id: tenant.id,
|
.select({
|
||||||
})
|
id: tenant.id,
|
||||||
.from(tenant)
|
})
|
||||||
.where(eq(tenant.shortName, shortName))
|
.from(tenant)
|
||||||
.limit(1);
|
.where(eq(tenant.shortName, shortName))
|
||||||
return tenants[0].id || null;
|
.limit(1);
|
||||||
|
return tenants[0]?.id || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.log("error", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ registerOpenAPIRoute("/tenants", "POST", {
|
|||||||
description: "Short name for the tenant (4-15 characters)",
|
description: "Short name for the tenant (4-15 characters)",
|
||||||
example: "acme-corp",
|
example: "acme-corp",
|
||||||
},
|
},
|
||||||
|
domain: {
|
||||||
|
type: "string",
|
||||||
|
format: "uri",
|
||||||
|
description: "Domain for the tenant",
|
||||||
|
example: "https://acme-corp.example.com",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
required: ["shortName"],
|
required: ["shortName", "domain"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -48,8 +54,9 @@ registerOpenAPIRoute("/tenants", "POST", {
|
|||||||
properties: {
|
properties: {
|
||||||
id: { type: "string", description: "Generated tenant ID" },
|
id: { type: "string", description: "Generated tenant ID" },
|
||||||
shortName: { type: "string", description: "Tenant short name" },
|
shortName: { type: "string", description: "Tenant short name" },
|
||||||
|
domain: { type: "string", format: "uri", description: "Tenant domain" },
|
||||||
},
|
},
|
||||||
required: ["id", "shortName"],
|
required: ["id", "shortName", "domain"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
required: ["message", "tenant"],
|
required: ["message", "tenant"],
|
||||||
@@ -107,6 +114,7 @@ registerOpenAPIRoute("/tenants", "GET", {
|
|||||||
shortName: { type: "string", description: "Tenant short name" },
|
shortName: { type: "string", description: "Tenant short name" },
|
||||||
longName: { type: "string", description: "Tenant long name" },
|
longName: { type: "string", description: "Tenant long name" },
|
||||||
logo: { type: "string", format: "uri", description: "URL of the tenant logo" },
|
logo: { type: "string", format: "uri", description: "URL of the tenant logo" },
|
||||||
|
domain: { type: "string", format: "uri", description: "Tenant domain" },
|
||||||
setupState: {
|
setupState: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"],
|
enum: ["NEW", "SETTINGS_CREATED", "AGENTS_SET_UP", "FIRST_CHANNEL_CREATED"],
|
||||||
@@ -165,6 +173,7 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
|||||||
|
|
||||||
const tenantService = await TenantAdminService.createTenant({
|
const tenantService = await TenantAdminService.createTenant({
|
||||||
shortName: body.shortName,
|
shortName: body.shortName,
|
||||||
|
domain: body.domain,
|
||||||
});
|
});
|
||||||
|
|
||||||
log.debug("Tenant created successfully", {
|
log.debug("Tenant created successfully", {
|
||||||
@@ -216,6 +225,7 @@ export const GET: RequestHandler = async ({ locals }) => {
|
|||||||
const tenants = await db
|
const tenants = await db
|
||||||
.select({
|
.select({
|
||||||
id: tenant.id,
|
id: tenant.id,
|
||||||
|
domain: tenant.domain,
|
||||||
shortName: tenant.shortName,
|
shortName: tenant.shortName,
|
||||||
languages: tenant.languages,
|
languages: tenant.languages,
|
||||||
setupState: tenant.setupState,
|
setupState: tenant.setupState,
|
||||||
|
|||||||
Reference in New Issue
Block a user