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"]()} - . 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"]()} - . - + {/if} {#if $formData.type === "passkey"} @@ -211,9 +211,9 @@ - + {m["login.or"]()} - . 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) {