diff --git a/migrations/0012_polite_impossible_man.sql b/migrations/0012_polite_impossible_man.sql
new file mode 100644
index 0000000..cdc5224
--- /dev/null
+++ b/migrations/0012_polite_impossible_man.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint
+ALTER TABLE "user" DROP COLUMN "token_valid_until";
\ No newline at end of file
diff --git a/migrations/0013_abnormal_vengeance.sql b/migrations/0013_abnormal_vengeance.sql
new file mode 100644
index 0000000..7082cd0
--- /dev/null
+++ b/migrations/0013_abnormal_vengeance.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "user_invite" ALTER COLUMN "tenant_id" DROP NOT NULL;--> statement-breakpoint
+ALTER TABLE "user_invite" ALTER COLUMN "invited_by" DROP NOT NULL;
\ No newline at end of file
diff --git a/migrations/meta/0012_snapshot.json b/migrations/meta/0012_snapshot.json
new file mode 100644
index 0000000..71f68e3
--- /dev/null
+++ b/migrations/meta/0012_snapshot.json
@@ -0,0 +1,853 @@
+{
+ "id": "f0ff401a-6299-471e-9812-80c678b9d47e",
+ "prevId": "4ab7df30-6490-4189-93b7-e1b25b0c8e90",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.challenge_throttle": {
+ "name": "challenge_throttle",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "challenge_throttle_tenant_id_tenant_id_fk": {
+ "name": "challenge_throttle_tenant_id_tenant_id_fk",
+ "tableFrom": "challenge_throttle",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "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'"
+ },
+ "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": {}
+ }
+}
diff --git a/migrations/meta/0013_snapshot.json b/migrations/meta/0013_snapshot.json
new file mode 100644
index 0000000..7daf77c
--- /dev/null
+++ b/migrations/meta/0013_snapshot.json
@@ -0,0 +1,853 @@
+{
+ "id": "8da6b050-6e61-46c0-8480-8b0c2ac62e55",
+ "prevId": "f0ff401a-6299-471e-9812-80c678b9d47e",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.challenge_throttle": {
+ "name": "challenge_throttle",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "challenge_throttle_tenant_id_tenant_id_fk": {
+ "name": "challenge_throttle_tenant_id_tenant_id_fk",
+ "tableFrom": "challenge_throttle",
+ "tableTo": "tenant",
+ "columnsFrom": ["tenant_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "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'"
+ },
+ "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": false
+ },
+ "invited_by": {
+ "name": "invited_by",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {}
+ }
+}
diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json
index 1276900..e2fbc68 100644
--- a/migrations/meta/_journal.json
+++ b/migrations/meta/_journal.json
@@ -85,6 +85,20 @@
"when": 1779177151355,
"tag": "0011_nebulous_stephen_strange",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1780157869249,
+ "tag": "0012_polite_impossible_man",
+ "breakpoints": true
+ },
+ {
+ "idx": 13,
+ "version": "7",
+ "when": 1780252077820,
+ "tag": "0013_abnormal_vengeance",
+ "breakpoints": true
}
]
}
diff --git a/package.json b/package.json
index 2ae834a..caaec02 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "open-reception",
"private": true,
- "version": "1.0.9",
+ "version": "1.1.0-rc-1",
"type": "module",
"description": "End-to-end encrypted appointment booking platform",
"scripts": {
@@ -28,8 +28,11 @@
"docker:dev:clean": "docker compose -f docker-compose.dev.yml down -v --remove-orphans",
"docker:build": "docker buildx build --platform linux/amd64,linux/arm64 -t openreception/open-reception:latest .",
"docker:build:tag": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version",
+ "docker:build:tag:prerelease": "docker tag openreception/open-reception:latest openreception/open-reception:$npm_package_version",
"docker:push": "docker push openreception/open-reception:$npm_package_version && docker push openreception/open-reception:latest",
+ "docker:push:prerelease": "docker push openreception/open-reception:$npm_package_version",
"docker:build-and-push": "npm run docker:build && npm run docker:build:tag && npm run docker:push",
+ "docker:build-and-push-prerelease": "npm run docker:build && npm run docker:build:tag:prerelease && npm run docker:push:prerelease",
"docker:prod:up": "docker compose -f docker-compose.prod.yml up -d",
"docker:prod:down": "docker compose -f docker-compose.prod.yml down",
"docker:prod:logs": "docker compose -f docker-compose.prod.yml logs -f",
diff --git a/project.inlang/messages/de.json b/project.inlang/messages/de.json
index e6d8abe..fe6026f 100644
--- a/project.inlang/messages/de.json
+++ b/project.inlang/messages/de.json
@@ -1045,13 +1045,6 @@
"introduction": "Sie haben erfolgreich Ihre PIN beim Terminbuchungsportal von {tenant} geändert und können sich ab sofort mit Ihrer PIN anmelden.",
"action": "Anmelden",
"reason": "Sie erhalten diese E-Mail, weil jemand für die PIN für Ihr Konto geändert hat."
- },
- "userInvite": {
- "subject": "E-Mail Adresse bestätigen",
- "introduction": "willkommen beim Terminbuchungsportal von {tenant}. Bitte bestätige Deine E-Mail Adresse.",
- "action": "E-Mail Adresse bestätigen",
- "hint": "Dieser Link ist nur {expirationMinutes} Minuten gültig und kann nur einmal verwendet werden.",
- "reason": "Du erhältst diese E-Mail, weil jemand für Deine E-Mail Adresse ein Konto registriert hat."
}
},
"notifications": {
diff --git a/project.inlang/messages/en.json b/project.inlang/messages/en.json
index d0f8f1e..fe36b48 100644
--- a/project.inlang/messages/en.json
+++ b/project.inlang/messages/en.json
@@ -1043,24 +1043,17 @@
"hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
"reason": "You are receiving this email because someone registered an account with your e-mail address."
},
- "pinReset": {
- "subject": "PIN successfully changed",
- "introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.",
- "action": "Login",
- "reason": "You are receiving this email because someone changed the PIN-Code for your account."
- },
"notification": {
"subject": "Activity in your appointment booking platform",
"introduction": "There is new activity in your appointment booking platform. Please log in to view details.",
"action": "Open Dashboard",
"reason": "You are receiving this email because you have notifications enabled for a channel."
},
- "userInvite": {
- "subject": "Confirm your E-Mail Address",
- "introduction": "welcome our appointment booking platform. Please confirm your e-mail address.",
- "action": "Confirm E-Mail Address",
- "hint": "This link is only valid for {expirationMinutes} minutes and can only be used once.",
- "reason": "You are receiving this email because someone registered an account with your e-mail address."
+ "pinReset": {
+ "subject": "PIN successfully changed",
+ "introduction": "you've successfully changed your PIN on the {tenant} appointment booking platform. You can now log-in with your new PIN.",
+ "action": "Login",
+ "reason": "You are receiving this email because someone changed the PIN-Code for your account."
}
},
"notifications": {
diff --git a/src/lib/emails/UserInvite.svelte b/src/lib/emails/UserInvite.svelte
deleted file mode 100644
index 307aed1..0000000
--- a/src/lib/emails/UserInvite.svelte
+++ /dev/null
@@ -1,44 +0,0 @@
-
-
-
- {m["emails.greeting"]({ name: user.name }, { locale })}
-
- {m["emails.userInvite.introduction"]({ tenant: tenant.longName }, { locale })}
-
-
- {m["emails.userInvite.action"]({}, { locale })}
-
-
- {m["emails.userInvite.hint"]({ expirationMinutes }, { locale })}
-
-
- {m["emails.userInvite.reason"]({}, { locale })}
-
-
diff --git a/src/lib/server/auth/__tests__/jwt-utils.test.ts b/src/lib/server/auth/__tests__/jwt-utils.test.ts
index d774da9..99d63a7 100644
--- a/src/lib/server/auth/__tests__/jwt-utils.test.ts
+++ b/src/lib/server/auth/__tests__/jwt-utils.test.ts
@@ -20,8 +20,6 @@ const mockUser: SelectUser = {
lastLoginAt: new Date(),
isActive: true,
confirmationState: "ACCESS_GRANTED" as const,
- token: null,
- tokenValidUntil: null,
passphraseHash: null,
recoveryPassphrase: null,
language: "de",
diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts
index 6361698..3de5e5d 100644
--- a/src/lib/server/db/central-schema.ts
+++ b/src/lib/server/db/central-schema.ts
@@ -122,8 +122,6 @@ export const user = pgTable(
lastLoginAt: timestamp("last_login_at"),
isActive: boolean("is_active").default(true),
confirmationState: confirmationStateEnum("confirmation_state").default("INVITED"),
- token: text("token"),
- tokenValidUntil: timestamp("token_valid_until"),
/** Hashed passphrase for password authentication (optional, alternative to WebAuthn) */
passphraseHash: text("passphrase_hash"),
/** Recovery passphrase for WebAuthn-only users (stored in plain text, shown only once) */
@@ -206,13 +204,9 @@ export const userInvite = pgTable(
/** Role to assign to user when they register */
role: userRoleEnum("role").notNull(),
/** Tenant the user is being invited to */
- tenantId: uuid("tenant_id")
- .notNull()
- .references(() => tenant.id, { onDelete: "cascade" }),
+ tenantId: uuid("tenant_id").references(() => tenant.id, { onDelete: "cascade" }),
/** User who sent the invitation */
- invitedBy: uuid("invited_by")
- .notNull()
- .references(() => user.id, { onDelete: "cascade" }),
+ invitedBy: uuid("invited_by").references(() => user.id, { onDelete: "cascade" }),
/** Language preference for the invitation */
language: text("language").notNull().default("de"),
/** Whether the invitation has been used */
diff --git a/src/lib/server/email/__tests__/email-system.test.ts b/src/lib/server/email/__tests__/email-system.test.ts
index 1f7b0e3..d5d2fac 100644
--- a/src/lib/server/email/__tests__/email-system.test.ts
+++ b/src/lib/server/email/__tests__/email-system.test.ts
@@ -467,7 +467,13 @@ describe("Email System", () => {
const confirmationCode = "ABC123";
const expirationMinutes = 15;
- await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
+ await sendConfirmationEmail(
+ staffUser,
+ mockTenant,
+ confirmationCode,
+ expirationMinutes,
+ new URL("https://example.com"),
+ );
expect(mockSendMail).toHaveBeenCalled();
});
@@ -506,7 +512,13 @@ describe("Email System", () => {
const confirmationCode = "XYZ789";
const expirationMinutes = 10;
- await sendConfirmationEmail(staffUser, mockTenant, confirmationCode, expirationMinutes);
+ await sendConfirmationEmail(
+ staffUser,
+ mockTenant,
+ confirmationCode,
+ expirationMinutes,
+ new URL("https://example.com"),
+ );
expect(mockSendMail).toHaveBeenCalled();
});
diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts
index a34d581..869a452 100644
--- a/src/lib/server/email/__tests__/generate-base-url.test.ts
+++ b/src/lib/server/email/__tests__/generate-base-url.test.ts
@@ -1,6 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { generateBaseUrl } from "../email-service";
-import type { SelectTenant } from "$lib/server/db/central-schema";
// Mock NODE_ENV
const mockEnv = vi.hoisted(() => ({
@@ -22,365 +21,16 @@ describe("generateBaseUrl", () => {
vi.clearAllMocks();
});
- describe("Development/Local Environment", () => {
- it("should return localhost URL regardless of tenant", () => {
- const requestUrl = new URL("http://localhost:5173");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- logo: null,
- createdAt: new Date(),
- updatedAt: new Date(),
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant.example.com",
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("http://localhost:5173");
- });
-
- it("should return localhost URL for null tenant", () => {
- const requestUrl = new URL("http://localhost:3000");
-
- const result = generateBaseUrl(requestUrl, null);
- expect(result).toBe("http://localhost:3000");
- });
-
- it("should preserve port for localhost", () => {
- const requestUrl = new URL("http://localhost:8080");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "test",
- longName: "Test Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant.example.com",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("http://localhost:8080");
- });
-
- it("should handle 127.x.x.x addresses", () => {
- const requestUrl = new URL("http://127.0.0.1:3000");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant.example.com",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("http://127.0.0.1:3000");
- });
-
- it("should handle 192.168.x.x addresses", () => {
- const requestUrl = new URL("http://192.168.1.100:8080");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant.example.com",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("http://192.168.1.100:8080");
- });
+ it("should return localhost with port", () => {
+ const requestUrl = new URL("http://localhost:5173");
+ const result = generateBaseUrl(requestUrl);
+ expect(result).toBe("http://localhost:5173");
});
- describe("Production Environment", () => {
- beforeEach(() => {
- mockEnv.NODE_ENV = "production";
- });
+ it("should return tenant domain", () => {
+ const requestUrl = new URL("https://example.com");
- it("should return main domain for null tenant", () => {
- const requestUrl = new URL("https://example.com");
-
- const result = generateBaseUrl(requestUrl, null);
- expect(result).toBe("https://example.com");
- });
-
- it("should return main domain with port for null tenant", () => {
- const requestUrl = new URL("https://example.com:8443");
-
- const result = generateBaseUrl(requestUrl, null);
- expect(result).toBe("https://example.com:8443");
- });
-
- it("should create subdomain URL for tenant on main domain", () => {
- const requestUrl = new URL("https://example.com");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- domain: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://acme.example.com");
- });
-
- it("should create subdomain URL with port", () => {
- const requestUrl = new URL("https://example.com:8443");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "acme",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://acme.example.com:8443");
- });
-
- it("should replace existing subdomain with tenant shortName", () => {
- const requestUrl = new URL("https://old-tenant.example.com");
- const tenant: SelectTenant = {
- id: "tenant-2",
- shortName: "new-tenant",
- longName: "New Tenant Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "new-tenant",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://new-tenant.old-tenant.example.com");
- });
-
- it("should replace existing subdomain with port", () => {
- const requestUrl = new URL("https://old-tenant.example.com:8443");
- const tenant: SelectTenant = {
- id: "tenant-2",
- shortName: "new-tenant",
- longName: "New Tenant Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "new-tenant",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://new-tenant.old-tenant.example.com:8443");
- });
-
- it("should handle complex subdomains (keep last two parts)", () => {
- const requestUrl = new URL("https://admin.api.example.com");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "tenant",
- longName: "Tenant Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://tenant.admin.api.example.com");
- });
-
- it("should handle http protocol", () => {
- const requestUrl = new URL("http://example.com");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "acme",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("http://acme.example.com");
- });
-
- it("should return main domain when tenant has no shortName", () => {
- const requestUrl = new URL("https://example.com");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "", // Empty shortName
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://example.com");
- });
- });
-
- describe("Edge Cases", () => {
- it("should handle single domain names in production", () => {
- mockEnv.NODE_ENV = "production";
- const requestUrl = new URL("https://app");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "acme",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://acme.app");
- });
-
- it("should handle localhost in production (still treated as development)", () => {
- mockEnv.NODE_ENV = "production";
- const requestUrl = new URL("https://localhost:8443");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant.example.com",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://localhost:8443");
- });
-
- it("should handle IP addresses as development", () => {
- mockEnv.NODE_ENV = "production";
- const requestUrl = new URL("https://192.168.1.100:8443");
- const tenant: SelectTenant = {
- id: "tenant-1",
- shortName: "acme",
- longName: "ACME Corp",
- descriptions: { en: "" },
- languages: ["en"],
- defaultLanguage: "en",
- databaseUrl: "",
- setupState: "SETTINGS",
- logo: null,
- links: { website: "", imprint: "", privacyStatement: "" },
- domain: "tenant.example.com",
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const result = generateBaseUrl(requestUrl, tenant);
- expect(result).toBe("https://192.168.1.100:8443");
- });
-
- it("should handle default HTTP port (80)", () => {
- mockEnv.NODE_ENV = "production";
- const requestUrl = new URL("http://example.com:80");
-
- // URL constructor should handle port 80 correctly
- const result = generateBaseUrl(requestUrl, null);
- // Default HTTP port shouldn't be included in URL
- expect(result).toBe("http://example.com");
- });
-
- it("should handle default HTTPS port (443)", () => {
- mockEnv.NODE_ENV = "production";
- const requestUrl = new URL("https://example.com:443");
-
- // URL constructor should handle port 443 correctly
- const result = generateBaseUrl(requestUrl, null);
- // Default HTTPS port shouldn't be included in URL
- expect(result).toBe("https://example.com");
- });
+ const result = generateBaseUrl(requestUrl);
+ expect(result).toBe("https://example.com");
});
});
diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts
index 66c577e..69e6d13 100644
--- a/src/lib/server/email/email-service.ts
+++ b/src/lib/server/email/email-service.ts
@@ -22,7 +22,6 @@ import { AgentService } from "../services/agent-service";
import { TenantService } from "../db/tenant-service";
import Confirmation from "$lib/emails/Confirmation.svelte";
import PinReset from "$lib/emails/PinReset.svelte";
-import UserInvite from "$lib/emails/UserInvite.svelte";
import { dev } from "$app/environment";
import Notification from "$lib/emails/Notification.svelte";
@@ -148,7 +147,7 @@ export async function sendPinResetEmail(
locale,
user,
tenant,
- loginUrl: generateBaseUrl(requestUrl, tenant) ?? "http://localhost:5173",
+ loginUrl: generateBaseUrl(requestUrl),
},
});
const html = renderOutputToHtml(emailRender);
@@ -400,23 +399,10 @@ export async function sendAppointmentUpdatedEmail(
* @param {SelectTenant | null} tenant - Tenant information (null for global admin)
* @returns {string} The appropriate base URL
*/
-export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): string {
- const protocol = requestUrl.protocol;
- const port = requestUrl.port ? `:${requestUrl.port}` : "";
- const hostname = requestUrl.hostname;
-
- // In development, always use the original hostname regardless of tenant
- if (hostname === "localhost" || hostname.startsWith("127.") || hostname.startsWith("192.168.")) {
- return `${protocol}//${hostname}${port}`;
- }
-
- // In production, handle tenant subdomains.
- // Exclude the system tenant when determining if we should use the tenant's domain for the URL, as the system tenant does not have a domain and should use the main domain.
- if (tenant?.domain && tenant.id !== "system") {
- return `${protocol}//${tenant.domain}.${hostname}${port}`;
- }
-
- // For global admin or no tenant, use main domain
+export function generateBaseUrl(url: URL): string {
+ const protocol = url.protocol;
+ const port = url.port ? `:${url.port}` : "";
+ const hostname = url.hostname;
return `${protocol}//${hostname}${port}`;
}
@@ -435,10 +421,10 @@ export async function sendConfirmationEmail(
tenant: SelectTenant,
confirmationCode: string,
expirationMinutes: number = 15,
- requestUrl?: URL,
+ requestUrl: URL,
): Promise {
// Generate appropriate base URL if request URL is provided
- const baseUrl = requestUrl ? generateBaseUrl(requestUrl, tenant) : "http://localhost:5173";
+ const baseUrl = generateBaseUrl(requestUrl);
const confirmUrl = `${baseUrl}/confirm/${confirmationCode}`;
const recipient = user;
// Generate email
@@ -460,50 +446,6 @@ export async function sendConfirmationEmail(
await sendEmail(recipient as any, subject, html, text, tenant.longName);
}
-/**
- * Send user invitation email for existing tenant
- * @param {string} userEmail - Email address of the invited user
- * @param {string} userName - Name of the invited user
- * @param {SelectTenant} tenant - Tenant information for branding
- * @param {string} role - Role to assign to the user (TENANT_ADMIN or STAFF) - for logging only
- * @param {string} registrationUrl - URL for user to register (contains secure invite code)
- * @param {Language} [language="en"] - Email language
- * @throws {Error} When email sending fails
- * @returns {Promise}
- */
-export async function sendUserInviteEmail(
- userEmail: string,
- userName: string,
- tenant: SelectTenant,
- role: "TENANT_ADMIN" | "STAFF",
- registrationUrl: string,
- language: Language = "en",
-): Promise {
- const { recipient, locale } = await getRecipient({ email: userEmail, name: userName, language });
-
- // Generate email
- const subject = m["emails.userInvite.subject"](
- {
- tenant: tenant.longName,
- },
- { locale },
- );
- const emailRender = render(UserInvite, {
- props: {
- locale,
- user: recipient as SelectUserEmail,
- tenant,
- confirmUrl: registrationUrl,
- expirationMinutes: 30,
- },
- });
- const html = renderOutputToHtml(emailRender);
- const text = htmlToText(html);
-
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- await sendEmail(recipient as any, subject, html, text, tenant.longName);
-}
-
/**
* Send appointment cancellation email
* @param {SelectClient | SelectUser} user - Database user object or client data
diff --git a/src/lib/server/services/__tests__/user-service.test.ts b/src/lib/server/services/__tests__/user-service.test.ts
index 70aee52..7518dbe 100644
--- a/src/lib/server/services/__tests__/user-service.test.ts
+++ b/src/lib/server/services/__tests__/user-service.test.ts
@@ -10,6 +10,7 @@ vi.mock("../../db", () => ({
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
+ limit: vi.fn(),
},
}));
@@ -89,16 +90,24 @@ describe("UserService", () => {
name: "Test Admin",
email: "test@example.com",
language: "de" as const,
+ role: "GLOBAL_ADMIN" as const,
+ };
+
+ const mockCreatedAdminInvite = {
+ id: "invite-123",
+ tenantId: "018f-a1b2-c3d4-a5f6-789abcdef019",
+ inviteCode: "018f-a1b2-c3d4-e5f6-789abcdef012",
+ used: false,
+ expiresAt: new Date("2024-01-01T12:10:00Z"),
};
const mockCreatedAdmin = {
id: "018f-a1b2-c3d4-e5f6-789abcdef012",
name: "Test Admin",
email: "test@example.com",
- token: "018f-a1b2-c3d4-e5f6-789abcdef012",
- tokenValidUntil: new Date("2024-01-01T12:10:00Z"),
- confirmationState: "INVITED" as const,
- isActive: false,
+ confirmationState: "ACCESS_GRANTED" as const,
+ role: "GLOBAL_ADMIN" as const,
+ isActive: true,
};
const mockInsertBuilder = {
@@ -106,17 +115,25 @@ describe("UserService", () => {
returning: vi.fn().mockResolvedValue([mockCreatedAdmin]),
};
- mockCentralDb.insert.mockReturnValue(mockInsertBuilder);
+ const mockInviteInsertBuilder = {
+ values: vi.fn().mockReturnThis(),
+ returning: vi.fn().mockResolvedValue([mockCreatedAdminInvite]),
+ };
- const result = await UserService.createUser(adminData);
+ mockCentralDb.insert
+ .mockReturnValueOnce(mockInviteInsertBuilder)
+ .mockReturnValueOnce(mockInsertBuilder);
+
+ const result = await UserService.createUser(adminData, new URL("http://localhost:5173"));
expect(mockCentralDb.insert).toHaveBeenCalled();
expect(mockInsertBuilder.values).toHaveBeenCalledWith({
...adminData,
- token: "018f-a1b2-c3d4-e5f6-789abcdef012",
- tokenValidUntil: expect.any(Date),
- confirmationState: "INVITED" as const,
- isActive: false,
+ confirmationState: "ACCESS_GRANTED" as const,
+ isActive: true,
+ role: "GLOBAL_ADMIN" as const,
+ tenantId: undefined,
+ recoveryPassphrase: expect.any(String),
});
expect(result).toEqual(mockCreatedAdmin);
});
@@ -141,12 +158,12 @@ describe("UserService", () => {
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
- await UserService.resendConfirmationEmail(email);
+ await UserService.resendConfirmationEmail(email, new URL("http://localhost:5173"));
expect(mockCentralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
- token: "018f-a1b2-c3d4-e5f6-789abcdef012",
- tokenValidUntil: expect.any(Date),
+ inviteCode: "018f-a1b2-c3d4-e5f6-789abcdef012",
+ expiresAt: expect.any(Date),
});
expect(mockUpdateBuilder.where).toHaveBeenCalled();
expect(mockUpdateBuilder.returning).toHaveBeenCalled();
@@ -163,7 +180,9 @@ describe("UserService", () => {
mockCentralDb.update.mockReturnValue(mockUpdateBuilder);
- await expect(UserService.resendConfirmationEmail(email)).rejects.toThrow(NotFoundError);
+ await expect(
+ UserService.resendConfirmationEmail(email, new URL("http://localhost:5173")),
+ ).rejects.toThrow(NotFoundError);
});
});
@@ -178,6 +197,20 @@ describe("UserService", () => {
limit: vi.fn().mockResolvedValue([{ id: "user-123", recoveryPassphrase: "recovery-123" }]),
};
+ const mockUserInviteBuilder = {
+ from: vi.fn().mockReturnThis(),
+ where: vi.fn().mockReturnThis(),
+ limit: vi.fn().mockResolvedValue([
+ {
+ id: "invite-123",
+ tenantId: "tenant-123",
+ inviteCode: token,
+ used: false,
+ expiresAt: new Date("2024-01-01T12:10:00Z"),
+ },
+ ]),
+ };
+
const mockCountSelectBuilder = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockResolvedValue([{ count: 1 }]),
@@ -195,6 +228,7 @@ describe("UserService", () => {
// First call for user lookup, second call for tenant admin count, third for total count
mockCentralDb.select
+ .mockReturnValueOnce(mockUserInviteBuilder)
.mockReturnValueOnce(mockSelectBuilder)
.mockReturnValueOnce(mockCountSelectBuilder)
.mockReturnValueOnce(mockTotalCountSelectBuilder);
@@ -202,7 +236,7 @@ describe("UserService", () => {
const result = await UserService.confirm(token);
- expect(mockCentralDb.select).toHaveBeenCalledTimes(3);
+ expect(mockCentralDb.select).toHaveBeenCalledTimes(4);
expect(mockCentralDb.update).toHaveBeenCalled();
expect(mockUpdateBuilder.set).toHaveBeenCalledWith({
confirmationState: "ACCESS_GRANTED" as const,
diff --git a/src/lib/server/services/invite-service.ts b/src/lib/server/services/invite-service.ts
index 909d062..efd12ce 100644
--- a/src/lib/server/services/invite-service.ts
+++ b/src/lib/server/services/invite-service.ts
@@ -33,7 +33,7 @@ export class InviteService {
tenantId,
invitedBy,
language,
- expiresAt: sql`timezone('utc', now()) + interval '30 minutes'`,
+ expiresAt: sql`timezone('utc', now()) + interval '10 minutes'`,
used: false,
};
diff --git a/src/lib/server/services/user-service.ts b/src/lib/server/services/user-service.ts
index fbcfdcf..2f34b1f 100644
--- a/src/lib/server/services/user-service.ts
+++ b/src/lib/server/services/user-service.ts
@@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
import { AppointmentService } from "./appointment-service";
export type InsertUser = InferInsertModel;
+export type InsertUserInvite = InferInsertModel;
export type InsertUserPasskey = InferInsertModel;
export type UserTransaction = PgTransaction<
PostgresJsQueryResultHKT,
@@ -45,10 +46,8 @@ const userCreationSchema = z.object({
name: z.string().min(5),
email: z.email(),
role: z.enum(["GLOBAL_ADMIN", "TENANT_ADMIN", "STAFF"]).optional(),
- tenantId: z.string().uuid().optional(),
+ tenantId: z.uuid().optional(),
passphrase: z.string().min(12).optional(),
- token: z.uuidv7().optional(),
- tokenValidUntil: z.date().optional(),
language: z.enum(["de", "en"]).optional().default("de"),
confirmationState: z.enum(["INVITED", "CONFIRMED", "ACCESS_GRANTED"]).optional(),
// Note: passphraseHash and recoveryPassphrase are handled internally, not via user input
@@ -99,7 +98,7 @@ export class UserService {
/**
* Create a new user
*/
- static async createUser(userData: UserCreation, requestUrl?: URL) {
+ static async createUser(userData: UserCreation, requestUrl: URL) {
const log = logger.setContext("UserService");
log.debug("Creating new user account", {
email: userData.email,
@@ -122,8 +121,14 @@ export class UserService {
throw new ValidationError("Passphrase must be at least 12 characters long");
}
- userData.token = uuidv7();
- userData.tokenValidUntil = addMinutes(new Date(), 10);
+ const userInviteForDb: InsertUserInvite = {
+ email: userData.email,
+ name: userData.name,
+ role: userData.role!,
+ tenantId: userData.tenantId!,
+ expiresAt: addMinutes(new Date(), 10),
+ inviteCode: uuidv7(),
+ };
// Prepare user data for database
const userDataForDb: InsertUser = {
@@ -131,8 +136,6 @@ export class UserService {
email: userData.email,
role: userData.role,
tenantId: userData.tenantId,
- token: userData.token,
- tokenValidUntil: userData.tokenValidUntil,
language: userData.language || "de",
confirmationState: userData.confirmationState || "INVITED",
isActive: false,
@@ -162,43 +165,49 @@ export class UserService {
}
try {
- const result = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
+ const [inviteResult] = await centralDb
+ .insert(centralSchema.userInvite)
+ .values(userInviteForDb)
+ .returning();
+ const [insertedUser] = await centralDb
+ .insert(centralSchema.user)
+ .values(userDataForDb)
+ .returning();
log.debug("User account created successfully", {
- userId: result[0].id,
- email: result[0].email,
- tokenValidUntil: result[0].tokenValidUntil,
- hasPassphrase: !!result[0].passphraseHash,
- hasRecoveryPassphrase: !!result[0].recoveryPassphrase,
+ userId: insertedUser.id,
+ email: insertedUser.email,
+ hasPassphrase: !!insertedUser.passphraseHash,
+ hasRecoveryPassphrase: !!insertedUser.recoveryPassphrase,
});
// Send confirmation email to user (token is used as confirmation code)
try {
- if (result[0].email && result[0].token) {
- const tenant = await getTenantForUser(result[0]);
+ if (insertedUser?.email && inviteResult?.inviteCode) {
+ const tenant = await getTenantForUser(insertedUser);
await sendConfirmationEmail(
- result[0],
+ insertedUser,
tenant,
- result[0].token,
+ inviteResult.inviteCode,
10, // 10 minutes expiration to match tokenValidUntil
requestUrl,
);
log.debug("Confirmation email sent successfully", {
- userId: result[0].id,
- email: result[0].email,
- tenantId: result[0].tenantId,
+ userId: insertedUser.id,
+ email: insertedUser.email,
+ tenantId: insertedUser.tenantId,
});
}
} catch (emailError) {
log.warn("Failed to send confirmation email", {
- userId: result[0].id,
- email: result[0].email,
+ userId: insertedUser?.id,
+ email: insertedUser?.email,
error: String(emailError),
});
// Don't throw - user creation succeeded, email is just a bonus
}
- return result[0];
+ return insertedUser;
} catch (error) {
log.error("Failed to create user account", { email: userData.email, error: String(error) });
throw error;
@@ -208,9 +217,9 @@ export class UserService {
/**
* Resend the confirmation email for a user
* @param email - Email of the user to confirm
- * @param requestUrl - Optional request URL for generating correct baseUrl
+ * @param requestUrl - request URL for generating correct baseUrl
*/
- static async resendConfirmationEmail(email: string, requestUrl?: URL): Promise {
+ static async resendConfirmationEmail(email: string, requestUrl: URL): Promise {
const log = logger.setContext("UserService");
log.debug("Resending confirmation email", { email });
@@ -219,9 +228,9 @@ export class UserService {
try {
const result = await centralDb
- .update(centralSchema.user)
- .set({ token, tokenValidUntil })
- .where(eq(centralSchema.user.email, email))
+ .update(centralSchema.userInvite)
+ .set({ inviteCode: token, expiresAt: tokenValidUntil })
+ .where(eq(centralSchema.userInvite.email, email))
.returning();
if (result.length !== 1) {
@@ -294,7 +303,32 @@ export class UserService {
}
| undefined = undefined;
- const userData = await centralDb
+ const [matchingInvite] = await centralDb
+ .select({
+ id: centralSchema.userInvite.id,
+ email: centralSchema.userInvite.email,
+ tenantId: centralSchema.userInvite.tenantId,
+ role: centralSchema.userInvite.role,
+ name: centralSchema.userInvite.name,
+ language: centralSchema.userInvite.language,
+ })
+ .from(centralSchema.userInvite)
+ .where(
+ and(
+ eq(centralSchema.userInvite.inviteCode, linkToken),
+ gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
+ ),
+ )
+ .limit(1);
+
+ if (!matchingInvite) {
+ log.warn("User confirmation failed: Invalid or expired invite code", {
+ token: linkToken.substring(0, 8) + "...",
+ });
+ throw new NotFoundError("Invalid or expired invite code");
+ }
+
+ const [userData] = await centralDb
.select({
id: centralSchema.user.id,
recoveryPassphrase: centralSchema.user.recoveryPassphrase,
@@ -305,64 +339,38 @@ export class UserService {
.from(centralSchema.user)
.where(
and(
- eq(centralSchema.user.token, linkToken),
- gt(centralSchema.user.tokenValidUntil, sql`timezone('utc', now())`),
+ eq(centralSchema.user.email, matchingInvite.email),
+ matchingInvite.tenantId
+ ? eq(centralSchema.user.tenantId, matchingInvite.tenantId)
+ : undefined,
),
)
.limit(1);
- if (userData.length === 0) {
- const inviteData = await centralDb
- .select({
- id: centralSchema.userInvite.id,
- tenantId: centralSchema.userInvite.tenantId,
- role: centralSchema.userInvite.role,
- email: centralSchema.userInvite.email,
- name: centralSchema.userInvite.name,
- language: centralSchema.userInvite.language,
- })
- .from(centralSchema.userInvite)
- .where(
- and(
- eq(centralSchema.userInvite.inviteCode, linkToken),
- gt(centralSchema.userInvite.expiresAt, sql`timezone('utc', now())`),
- ),
- )
- .limit(1);
+ if (!userData) {
+ resultData = { ...matchingInvite, recoveryPassphrase: null };
+ const userDataForDb: InsertUser = {
+ name: resultData.name!,
+ email: resultData.email,
+ role: resultData.role,
+ tenantId: resultData.tenantId,
+ language: resultData.language || "de",
+ confirmationState: "CONFIRMED",
+ isActive: true,
+ };
+ const retVal = await centralDb.insert(centralSchema.user).values(userDataForDb).returning();
+ resultData.id = retVal[0].id;
- if (inviteData.length === 0) {
- log.warn("User confirmation failed: Invalid or expired token", {
- token: linkToken.substring(0, 8) + "...",
- });
- throw new NotFoundError("Invalid or timed-out token");
- } else {
- resultData = { ...inviteData[0], recoveryPassphrase: null };
- const userDataForDb: InsertUser = {
- name: resultData.name!,
- email: resultData.email,
- role: resultData.role,
- tenantId: resultData.tenantId,
- language: resultData.language || "de",
- confirmationState: "CONFIRMED",
- isActive: true,
- };
- const retVal = await centralDb
- .insert(centralSchema.user)
- .values(userDataForDb)
- .returning();
- resultData.id = retVal[0].id;
+ await InviteService.markInviteAsUsed(linkToken, resultData.id);
+ log.debug("Invitation marked as used", {
+ inviteCode: linkToken,
+ userId: resultData.id,
+ });
- await InviteService.markInviteAsUsed(linkToken, resultData.id);
- log.debug("Invitation marked as used", {
- inviteCode: linkToken,
- userId: resultData.id,
- });
-
- const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
- adminService.validateSetupState();
- }
+ const adminService = await TenantAdminService.getTenantById(resultData.tenantId!);
+ adminService.validateSetupState();
} else {
- resultData = userData[0];
+ resultData = userData;
}
// Check if this is the first tenant admin for the tenant
diff --git a/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts b/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts
deleted file mode 100644
index bc111c6..0000000
--- a/src/routes/(local-only)/[test=dev]/e-mail-templates/user-invite/+server.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import UserInvite from "$lib/emails/UserInvite.svelte";
-import { renderOutputToHtml } from "$lib/emails/utils";
-import type { SelectTenant } from "$lib/server/db/central-schema";
-import type { RequestHandler } from "@sveltejs/kit";
-import { render } from "svelte/server";
-
-export const GET: RequestHandler = async () => {
- const emailRender = render(UserInvite, {
- props: {
- locale: "en",
- user: {
- email: "max.mustermann@example.com",
- name: "Max Mustermann",
- language: "en",
- },
- tenant: { longName: "Praxis Dr. Jane Doe" } as SelectTenant,
- confirmUrl: "https://open-reception.org/confirm/abc123",
- expirationMinutes: 30,
- },
- });
- const html = renderOutputToHtml(emailRender);
- return new Response(html, {
- headers: {
- "Content-Type": "text/html",
- },
- });
-};
diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte
index 5d28a28..7dd9d53 100644
--- a/src/routes/(pages)/login/login-form.svelte
+++ b/src/routes/(pages)/login/login-form.svelte
@@ -198,12 +198,12 @@
{/snippet}
-
+
{m["login.or"]()}
-
+
{/if}
{#if $formData.type === "passkey"}
@@ -240,7 +240,7 @@
{m["login.or"]()}
-
+
{m["login.usePassphrase"]()}
.
diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte
index fba9cdb..98eb554 100644
--- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte
+++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte
@@ -174,13 +174,13 @@
{/snippet}
-
+
{m["form.passphraseRequirements"]()}
{m["login.or"]()}
-
+
{m["login.usePasskey"]()}
.
-
+
{/if}
{#if $formData.type === "passkey"}
@@ -211,9 +211,9 @@
-
+
{m["login.or"]()}
-
+
{m["login.usePassphrase"]()}
.
diff --git a/src/routes/api/auth/invite/+server.ts b/src/routes/api/auth/invite/+server.ts
index fb018c3..54cc190 100644
--- a/src/routes/api/auth/invite/+server.ts
+++ b/src/routes/api/auth/invite/+server.ts
@@ -1,6 +1,6 @@
import { dev } from "$app/environment";
import { UniversalLogger } from "$lib/logger";
-import { sendUserInviteEmail } from "$lib/server/email/email-service";
+import { sendConfirmationEmail } from "$lib/server/email/email-service";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import { InviteService } from "$lib/server/services/invite-service";
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
@@ -128,7 +128,7 @@ registerOpenAPIRoute("/auth/invite", "POST", {
},
});
-export const POST: RequestHandler = async ({ request, locals }) => {
+export const POST: RequestHandler = async ({ request, locals, url }) => {
try {
// Verify user is authenticated
if (!locals.user) {
@@ -200,17 +200,20 @@ export const POST: RequestHandler = async ({ request, locals }) => {
language,
);
- // Generate registration URL with secure invite code
- const baseUrl = dev ? "http://localhost:5173" : `https://${tenant.domain}`;
- const registrationUrl = `${baseUrl}/confirm/${invitation.inviteCode}`;
-
// Send invitation email
- await sendUserInviteEmail(email, name, tenant, role, registrationUrl, language);
+ await sendConfirmationEmail(
+ { id: invitation.id, email, language, name },
+ tenant,
+ invitation.inviteCode,
+ 10, // 10 minutes expiration to match tokenValidUntil
+ dev ? url : new URL(`https://${tenant.domain}`),
+ );
logger.debug("User invitation sent successfully", {
invitedEmail: email,
tenantId,
role,
+ language,
});
return json({
diff --git a/src/routes/api/auth/invite/__tests__/invite.test.ts b/src/routes/api/auth/invite/__tests__/invite.test.ts
index 6ecfd0a..1cd3748 100644
--- a/src/routes/api/auth/invite/__tests__/invite.test.ts
+++ b/src/routes/api/auth/invite/__tests__/invite.test.ts
@@ -17,7 +17,7 @@ vi.mock("$lib/server/services/invite-service", () => ({
}));
vi.mock("$lib/server/email/email-service", () => ({
- sendUserInviteEmail: vi.fn(),
+ sendConfirmationEmail: vi.fn(),
}));
vi.mock("$env/dynamic/private", () => ({
@@ -28,7 +28,7 @@ vi.mock("$env/dynamic/private", () => ({
import { TenantAdminService } from "$lib/server/services/tenant-admin-service";
import { InviteService } from "$lib/server/services/invite-service";
-import { sendUserInviteEmail } from "$lib/server/email/email-service";
+import { sendConfirmationEmail } from "$lib/server/email/email-service";
import { mockCookies } from "$lib/tests/const";
describe("POST /api/auth/invite", () => {
@@ -88,7 +88,7 @@ describe("POST /api/auth/invite", () => {
vi.mocked(TenantAdminService.getTenantById).mockResolvedValue(mockTenantService as any);
vi.mocked(InviteService.hasPendingInvite).mockResolvedValue(false);
vi.mocked(InviteService.createInvite).mockResolvedValue(mockInvitation as any);
- vi.mocked(sendUserInviteEmail).mockResolvedValue();
+ vi.mocked(sendConfirmationEmail).mockResolvedValue();
});
it("should reject unauthenticated requests", async () => {
@@ -142,7 +142,7 @@ describe("POST /api/auth/invite", () => {
"admin-id",
"en",
);
- expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalled();
+ expect(vi.mocked(sendConfirmationEmail)).toHaveBeenCalled();
});
it("should allow tenant admin to invite to their own tenant", async () => {
@@ -176,13 +176,22 @@ describe("POST /api/auth/invite", () => {
"admin-id",
"en",
);
- expect(vi.mocked(sendUserInviteEmail)).toHaveBeenCalledWith(
- "user@example.com",
- "Test User",
- mockTenant,
- "STAFF",
- expect.stringContaining("invite-code-123"),
- "en",
+ expect(vi.mocked(sendConfirmationEmail)).toHaveBeenCalledWith(
+ { email: "user@example.com", id: "invite-123", language: "en", name: "Test User" },
+ {
+ createdAt: expect.any(Date),
+ databaseUrl: "postgresql://test",
+ description: "A test corporation",
+ id: "12345678-1234-4234-8234-123456789012",
+ longName: "Test Corporation GmbH",
+ logo: null,
+ setupState: "NEW",
+ shortName: "testcorp",
+ updatedAt: expect.any(Date),
+ },
+ "invite-code-123",
+ 10,
+ expect.any(URL),
);
});
@@ -209,7 +218,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
- expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
+ expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should reject staff members from inviting", async () => {
@@ -235,7 +244,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(403);
expect(data.error).toBe("Insufficient permissions");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
- expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
+ expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should validate request data", async () => {
@@ -261,7 +270,7 @@ describe("POST /api/auth/invite", () => {
expect(response.status).toBe(400);
expect(data.error).toBe("Invalid request data");
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
- expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
+ expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
it("should reject if user already has pending invitation", async () => {
@@ -294,6 +303,6 @@ describe("POST /api/auth/invite", () => {
"12345678-1234-4234-8234-123456789012",
);
expect(vi.mocked(InviteService.createInvite)).not.toHaveBeenCalled();
- expect(vi.mocked(sendUserInviteEmail)).not.toHaveBeenCalled();
+ expect(vi.mocked(sendConfirmationEmail)).not.toHaveBeenCalled();
});
});
diff --git a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts
index db25a0a..904823e 100644
--- a/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts
+++ b/src/routes/api/tenants/[id]/appointments/[appointmentId]/+server.ts
@@ -1,11 +1,142 @@
import { json } from "@sveltejs/kit";
import { AppointmentService } from "$lib/server/services/appointment-service";
-import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors";
+import {
+ BackendError,
+ InternalError,
+ logError,
+ NotFoundError,
+ ValidationError,
+} from "$lib/server/utils/errors";
import type { RequestHandler } from "@sveltejs/kit";
import { registerOpenAPIRoute } from "$lib/server/openapi";
import logger from "$lib/logger";
import { checkPermission } from "$lib/server/utils/permissions";
+// Register OpenAPI documentation for GET
+registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "GET", {
+ summary: "Get appointment by ID",
+ description:
+ "Retrieves a specific appointment by its ID. Accessible to dashboard users. Required for notification previews. Only returns a subset of appointment data.",
+ tags: ["Appointments"],
+ parameters: [
+ {
+ name: "id",
+ in: "path",
+ required: true,
+ schema: { type: "string", format: "uuid" },
+ description: "Tenant ID",
+ },
+ {
+ name: "appointmentId",
+ in: "path",
+ required: true,
+ schema: { type: "string", format: "uuid" },
+ description: "Appointment ID",
+ },
+ ],
+ responses: {
+ "200": {
+ description: "Appointment retrieved successfully",
+ content: {
+ "application/json": {
+ schema: {
+ type: "object",
+ properties: {
+ appointment: {
+ type: "object",
+ properties: {
+ id: { type: "string", format: "uuid", description: "Appointment ID" },
+ tunnelId: { type: "string", format: "uuid", description: "Client tunnel ID" },
+ channelId: { type: "string", format: "uuid", description: "Channel ID" },
+ appointmentDate: {
+ type: "string",
+ format: "date-time",
+ description: "Appointment date and time",
+ },
+ expiryDate: {
+ type: "string",
+ format: "date",
+ description: "Data expiry date (nullable)",
+ },
+ status: {
+ type: "string",
+ enum: ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"],
+ description: "Appointment status",
+ },
+ encryptedPayload: {
+ type: "string",
+ description: "Encrypted appointment data (nullable)",
+ },
+ iv: {
+ type: "string",
+ description: "Initialization vector for encryption (nullable)",
+ },
+ authTag: {
+ type: "string",
+ description: "Authentication tag for encryption (nullable)",
+ },
+ createdAt: {
+ type: "string",
+ format: "date-time",
+ description: "Creation timestamp (nullable)",
+ },
+ updatedAt: {
+ type: "string",
+ format: "date-time",
+ description: "Last update timestamp (nullable)",
+ },
+ },
+ required: ["id", "tunnelId", "channelId", "appointmentDate", "status"],
+ },
+ },
+ required: ["appointment"],
+ },
+ },
+ },
+ },
+ "400": {
+ description: "Invalid input data",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/Error" },
+ },
+ },
+ },
+ "401": {
+ description: "Authentication required",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/Error" },
+ },
+ },
+ },
+ "403": {
+ description: "Insufficient permissions",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/Error" },
+ },
+ },
+ },
+ "404": {
+ description: "Appointment not found",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/Error" },
+ },
+ },
+ },
+ "500": {
+ description: "Internal server error",
+ content: {
+ "application/json": {
+ schema: { $ref: "#/components/schemas/Error" },
+ },
+ },
+ },
+ },
+});
+
// Register OpenAPI documentation for DELETE
registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
summary: "Delete appointment",
@@ -85,6 +216,58 @@ registerOpenAPIRoute("/tenants/{id}/appointments/{appointmentId}", "DELETE", {
},
});
+export const GET: RequestHandler = async ({ params, locals }) => {
+ const log = logger.setContext("API");
+
+ try {
+ const tenantId = params.id;
+ const appointmentId = params.appointmentId;
+
+ if (!tenantId || !appointmentId) {
+ throw new ValidationError("Tenant ID and appointment ID are required");
+ }
+
+ checkPermission(locals, tenantId, true);
+
+ log.debug("Getting appointment by ID", {
+ tenantId,
+ appointmentId,
+ requestedBy: locals.user?.id,
+ });
+
+ const appointmentService = await AppointmentService.forTenant(tenantId);
+ const appointment = await appointmentService.getAppointmentById(appointmentId);
+
+ if (!appointment) {
+ throw new NotFoundError("Appointment not found");
+ }
+
+ log.debug("Appointment retrieved successfully", {
+ tenantId,
+ appointmentId,
+ requestedBy: locals.user?.id,
+ });
+
+ // Stripping data, because we only us this for notification previews
+ return json({
+ appointment: {
+ id: appointment.id,
+ appointmentDate: appointment.appointmentDate,
+ channelId: appointment.channelId,
+ agentId: appointment.agentId,
+ },
+ });
+ } catch (error) {
+ logError(log)("Error getting appointment", error, locals.user?.id, params.id);
+
+ if (error instanceof BackendError) {
+ return error.toJson();
+ }
+
+ return new InternalError().toJson();
+ }
+};
+
export const DELETE: RequestHandler = async ({ params, locals }) => {
const log = logger.setContext("API");
diff --git a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts
index 1249503..b4536ff 100644
--- a/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts
+++ b/src/routes/api/tenants/[id]/appointments/add-to-tunnel/+server.ts
@@ -381,7 +381,9 @@ export const POST: RequestHandler = async ({ request, params }) => {
(await notificationService).createNotification({
type: "APPOINTMENT_REQUESTED",
channelId: validatedData.channelId,
- metaData: { appointmentId: result.id },
+ metaData: {
+ appointmentId: result.id,
+ },
});
}
if (validatedData.clientEmail) {