Merge pull request #120 from open-reception/feat/custom-domains

Support Custom Domains
This commit is contained in:
Karl Ludwig Weise
2026-01-28 21:38:06 +01:00
committed by GitHub
27 changed files with 1154 additions and 67 deletions
+38 -1
View File
@@ -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
+4
View File
@@ -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");
+849
View File
@@ -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": {}
}
}
+7
View File
@@ -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
} }
] ]
} }
+9 -3
View File
@@ -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"
+9 -3
View File
@@ -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"
+3 -1
View File
@@ -32,7 +32,9 @@
}; };
} = $props(); } = $props();
setLocale(locale); $effect(() => {
setLocale(locale);
});
</script> </script>
<EmailLayout> <EmailLayout>
+4
View File
@@ -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:
+2
View File
@@ -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)
+1
View File
@@ -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(),
+4 -1
View File
@@ -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;
}; };
@@ -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("")),
}); });
@@ -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,
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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();
} }
+83
View File
@@ -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();
}
};
+18 -10
View File
@@ -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;
}
} }
}; };
+12 -2
View File
@@ -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,