mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Merge pull request #280 from open-reception/273-bug-tenantdomain-is-interpreted-inconsistently-confirm-invite-urls-are-malformed
Unify confirmation emails, fixing issue #273
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "user" DROP COLUMN "token";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP COLUMN "token_valid_until";
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages";
|
||||
import { setLocale } from "$i18n/runtime";
|
||||
import type { SupportedLocale } from "$lib/const/locales";
|
||||
import { type SelectTenant } from "$lib/server/db/central-schema";
|
||||
import type { SelectUserEmail } from "$lib/server/email/email-service";
|
||||
import EmailButton from "./components/EmailButton.svelte";
|
||||
import EmailLayout from "./components/EmailLayout.svelte";
|
||||
import EmailText from "./components/EmailText.svelte";
|
||||
|
||||
let {
|
||||
locale,
|
||||
user,
|
||||
tenant,
|
||||
confirmUrl,
|
||||
expirationMinutes,
|
||||
}: {
|
||||
locale: SupportedLocale;
|
||||
user: SelectUserEmail;
|
||||
tenant: SelectTenant;
|
||||
confirmUrl: string;
|
||||
expirationMinutes: number;
|
||||
} = $props();
|
||||
|
||||
$effect(() => {
|
||||
setLocale(locale);
|
||||
});
|
||||
</script>
|
||||
|
||||
<EmailLayout>
|
||||
<EmailText variant="md">{m["emails.greeting"]({ name: user.name }, { locale })}</EmailText>
|
||||
<EmailText variant="md">
|
||||
{m["emails.userInvite.introduction"]({ tenant: tenant.longName }, { locale })}
|
||||
</EmailText>
|
||||
<EmailButton href={confirmUrl} {locale}>
|
||||
{m["emails.userInvite.action"]({}, { locale })}
|
||||
</EmailButton>
|
||||
<EmailText variant="md">
|
||||
{m["emails.userInvite.hint"]({ expirationMinutes }, { locale })}
|
||||
</EmailText>
|
||||
<EmailText variant="md" color="text-light">
|
||||
{m["emails.userInvite.reason"]({}, { locale })}
|
||||
</EmailText>
|
||||
</EmailLayout>
|
||||
@@ -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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void>}
|
||||
*/
|
||||
export async function sendUserInviteEmail(
|
||||
userEmail: string,
|
||||
userName: string,
|
||||
tenant: SelectTenant,
|
||||
role: "TENANT_ADMIN" | "STAFF",
|
||||
registrationUrl: string,
|
||||
language: Language = "en",
|
||||
): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import type { PostgresJsQueryResultHKT } from "drizzle-orm/postgres-js";
|
||||
import { AppointmentService } from "./appointment-service";
|
||||
|
||||
export type InsertUser = InferInsertModel<typeof centralSchema.user>;
|
||||
export type InsertUserInvite = InferInsertModel<typeof centralSchema.userInvite>;
|
||||
export type InsertUserPasskey = InferInsertModel<typeof centralSchema.userPasskey>;
|
||||
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<void> {
|
||||
static async resendConfirmationEmail(email: string, requestUrl: URL): Promise<void> {
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -198,12 +198,12 @@
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
<Text style="xs" color="medium" class="mt-1 ml-1 leading-none">
|
||||
{m["login.or"]()}
|
||||
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
|
||||
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
|
||||
{m["login.usePasskey"]()}
|
||||
</Button>
|
||||
</Form.Description>
|
||||
</Text>
|
||||
</Form.Field>
|
||||
{/if}
|
||||
{#if $formData.type === "passkey"}
|
||||
@@ -240,7 +240,7 @@
|
||||
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
|
||||
<Text style="xs" color="medium" class="mt-1 ml-1 leading-none">
|
||||
{m["login.or"]()}
|
||||
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
|
||||
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
|
||||
{m["login.usePassphrase"]()}
|
||||
</Button>.
|
||||
</Text>
|
||||
|
||||
@@ -174,13 +174,13 @@
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
<Text style="xs" color="medium">
|
||||
{m["form.passphraseRequirements"]()}
|
||||
{m["login.or"]()}
|
||||
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
|
||||
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
|
||||
{m["login.usePasskey"]()}
|
||||
</Button>.
|
||||
</Form.Description>
|
||||
</Text>
|
||||
</Form.Field>
|
||||
{/if}
|
||||
{#if $formData.type === "passkey"}
|
||||
@@ -211,9 +211,9 @@
|
||||
</Form.Field>
|
||||
<Label class="mb-2">{m["form.passkey"]()}</Label>
|
||||
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
|
||||
<Text style="md" color="medium">
|
||||
<Text style="xs" color="medium">
|
||||
{m["login.or"]()}
|
||||
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
|
||||
<Button variant="link" size="xs" onclick={onToggle} class="text-inherit">
|
||||
{m["login.usePassphrase"]()}
|
||||
</Button>.
|
||||
</Text>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user