diff --git a/backend/drizzle/0004_loving_fabian_cortez.sql b/backend/drizzle/0004_loving_fabian_cortez.sql new file mode 100644 index 0000000..f26e11b --- /dev/null +++ b/backend/drizzle/0004_loving_fabian_cortez.sql @@ -0,0 +1 @@ +ALTER TABLE "audit_logs" ALTER COLUMN "organization_id" DROP NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0004_snapshot.json b/backend/drizzle/meta/0004_snapshot.json new file mode 100644 index 0000000..c89738d --- /dev/null +++ b/backend/drizzle/meta/0004_snapshot.json @@ -0,0 +1,1501 @@ +{ + "id": "026922a8-fcf6-4755-a7d7-0c8a1801e0b8", + "prevId": "e163a8f6-6a94-4364-917e-7106e30d1fc7", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_organization_id_idx": { + "name": "audit_logs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_organization_id_organizations_id_fk": { + "name": "audit_logs_organization_id_organizations_id_fk", + "tableFrom": "audit_logs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audit_logs_actor_user_id_users_id_fk": { + "name": "audit_logs_actor_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_rooms": { + "name": "chat_rooms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "matrix_room_id": { + "name": "matrix_room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "chat_room_scope", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_rooms_organization_id_idx": { + "name": "chat_rooms_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_rooms_team_id_team_scope_unique": { + "name": "chat_rooms_team_id_team_scope_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_rooms\".\"scope\" = 'team'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_rooms_organization_id_organizations_id_fk": { + "name": "chat_rooms_organization_id_organizations_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_department_id_departments_id_fk": { + "name": "chat_rooms_department_id_departments_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_rooms_team_id_teams_id_fk": { + "name": "chat_rooms_team_id_teams_id_fk", + "tableFrom": "chat_rooms", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_rooms_matrix_room_id_unique": { + "name": "chat_rooms_matrix_room_id_unique", + "nullsNotDistinct": false, + "columns": [ + "matrix_room_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.departments": { + "name": "departments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "departments_organization_id_idx": { + "name": "departments_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "departments_organization_id_organizations_id_fk": { + "name": "departments_organization_id_organizations_id_fk", + "tableFrom": "departments", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitations": { + "name": "invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_team_id_teams_id_fk": { + "name": "invitations_team_id_teams_id_fk", + "tableFrom": "invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitations_token_unique": { + "name": "invitations_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "birth_date": { + "name": "birth_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "matrix_user_id": { + "name": "matrix_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_organization_id_email_unique": { + "name": "members_organization_id_email_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.org_roles": { + "name": "org_roles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_roles_organization_id_idx": { + "name": "org_roles_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_roles_member_id_idx": { + "name": "org_roles_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_roles_organization_id_organizations_id_fk": { + "name": "org_roles_organization_id_organizations_id_fk", + "tableFrom": "org_roles", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_member_id_members_id_fk": { + "name": "org_roles_member_id_members_id_fk", + "tableFrom": "org_roles", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "org_roles_department_id_departments_id_fk": { + "name": "org_roles_department_id_departments_id_fk", + "tableFrom": "org_roles", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "org_roles_scope_unique": { + "name": "org_roles_scope_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "member_id", + "department_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parent_links": { + "name": "parent_links", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "child_member_id": { + "name": "child_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "parent_member_id": { + "name": "parent_member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "parent_links_child_member_id_members_id_fk": { + "name": "parent_links_child_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "child_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "parent_links_parent_member_id_members_id_fk": { + "name": "parent_links_parent_member_id_members_id_fk", + "tableFrom": "parent_links", + "tableTo": "members", + "columnsFrom": [ + "parent_member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_links_child_parent_unique": { + "name": "parent_links_child_parent_unique", + "nullsNotDistinct": false, + "columns": [ + "child_member_id", + "parent_member_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "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 + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "password_reset_tokens_token_unique": { + "name": "password_reset_tokens_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seasons": { + "name": "seasons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "seasons_organization_id_idx": { + "name": "seasons_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seasons_organization_id_organizations_id_fk": { + "name": "seasons_organization_id_organizations_id_fk", + "tableFrom": "seasons", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sync_jobs": { + "name": "sync_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "sync_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "sync_jobs_organization_id_idx": { + "name": "sync_jobs_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sync_jobs_status_idx": { + "name": "sync_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sync_jobs_organization_id_organizations_id_fk": { + "name": "sync_jobs_organization_id_organizations_id_fk", + "tableFrom": "sync_jobs", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "member_id": { + "name": "member_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'MEMBER'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_members_member_id_idx": { + "name": "team_members_member_id_idx", + "columns": [ + { + "expression": "member_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_member_id_members_id_fk": { + "name": "team_members_member_id_members_id_fk", + "tableFrom": "team_members", + "tableTo": "members", + "columnsFrom": [ + "member_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_members_team_id_member_id_role_unique": { + "name": "team_members_team_id_member_id_role_unique", + "nullsNotDistinct": false, + "columns": [ + "team_id", + "member_id", + "role" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "department_id": { + "name": "department_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "teams_organization_id_idx": { + "name": "teams_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "teams_department_id_idx": { + "name": "teams_department_id_idx", + "columns": [ + { + "expression": "department_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "teams_organization_id_organizations_id_fk": { + "name": "teams_organization_id_organizations_id_fk", + "tableFrom": "teams", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_department_id_departments_id_fk": { + "name": "teams_department_id_departments_id_fk", + "tableFrom": "teams", + "tableTo": "departments", + "columnsFrom": [ + "department_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "teams_season_id_seasons_id_fk": { + "name": "teams_season_id_seasons_id_fk", + "tableFrom": "teams", + "tableTo": "seasons", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_system_admin": { + "name": "is_system_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.chat_room_scope": { + "name": "chat_room_scope", + "schema": "public", + "values": [ + "organization", + "department", + "team" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "revoked", + "expired" + ] + }, + "public.member_status": { + "name": "member_status", + "schema": "public", + "values": [ + "active", + "suspended", + "left" + ] + }, + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "ORG_ADMIN", + "ORG_MANAGER", + "DEPARTMENT_ADMIN", + "TEAM_MANAGER", + "TEAM_LEAD", + "ASSISTANT_LEAD", + "SUPERVISOR", + "MEMBER", + "GUARDIAN" + ] + }, + "public.sync_job_status": { + "name": "sync_job_status", + "schema": "public", + "values": [ + "pending", + "processing", + "done", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index d24c1b0..4788c96 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1789239281922, "tag": "0003_robust_maverick", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1789241972275, + "tag": "0004_loving_fabian_cortez", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index c2f75f3..8436fe2 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -299,9 +299,13 @@ export const auditLogs = pgTable( "audit_logs", { id: uuid("id").primaryKey().defaultRandom(), - organizationId: uuid("organization_id") - .notNull() - .references(() => organizations.id, { onDelete: "cascade" }), + // BUG-4 (PROJ-14 QA): nullable now — platform-level actions (e.g. a + // SYSTEM_ADMIN purging a Matrix room with no TalkTime org/team + // assignment) have no organization to attach to, and previously fell + // back to a server-log-only warning with no audit trail at all. + organizationId: uuid("organization_id").references(() => organizations.id, { + onDelete: "cascade", + }), actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null", }), diff --git a/backend/src/lib/audit.ts b/backend/src/lib/audit.ts index e1c5e62..3ab8004 100644 --- a/backend/src/lib/audit.ts +++ b/backend/src/lib/audit.ts @@ -1,15 +1,25 @@ import { db } from "../db/index.js"; import { auditLogs } from "../db/schema.js"; -export async function logAudit(params: { - organizationId: string; - actorUserId: string | null; - action: string; - targetType: string; - targetId: string; - metadata?: Record; -}): Promise { - await db.insert(auditLogs).values({ +type DbOrTx = Pick; + +export async function logAudit( + params: { + // BUG-4 (PROJ-14 QA): null for platform-level actions with no org/team + // context (e.g. a SYSTEM_ADMIN purging an unassigned Matrix room). + organizationId: string | null; + actorUserId: string | null; + action: string; + targetType: string; + targetId: string; + metadata?: Record; + }, + // Optional transaction handle so callers can make the audit write atomic + // with the change it's recording (BUG-5, PROJ-14 QA) instead of a second, + // independent statement that could succeed or fail on its own. + tx: DbOrTx = db, +): Promise { + await tx.insert(auditLogs).values({ organizationId: params.organizationId, actorUserId: params.actorUserId, action: params.action, diff --git a/backend/src/routes/system-admin.ts b/backend/src/routes/system-admin.ts index 4893dcc..2fcc9eb 100644 --- a/backend/src/routes/system-admin.ts +++ b/backend/src/routes/system-admin.ts @@ -5,7 +5,7 @@ import { db } from "../db/index.js"; import { chatRooms, organizations, teams } from "../db/schema.js"; import { requireSystemAdmin } from "../lib/authz.js"; import { logAudit } from "../lib/audit.js"; -import { SynapseError, deleteRoom, listRooms } from "../services/synapse.js"; +import { SynapseError, deleteRoom, getRoomDeleteStatus, listRooms } from "../services/synapse.js"; /** * PROJ-14: Matrix room IDs look like `!abc123:example.org`. Validating the shape @@ -127,10 +127,21 @@ export async function systemAdminRoutes(app: FastifyInstance) { * PROJ-14: purges a room at Synapse and drops the local mapping if one exists. * Deliberately allowed even for rooms still assigned to a live team — the * frontend warns first; a silently-refused delete would be more confusing. + * + * BUG-1/BUG-2 (QA): the v2 delete is asynchronous — its immediate response + * only means "purge queued". This polls delete_status for a bounded window + * before touching the local mapping or telling the frontend it's done, so a + * purge that fails can't silently orphan the team↔room mapping, and the + * frontend never has to guess whether the room is actually gone yet. */ app.delete( "/api/system-admin/rooms/:roomId", - { preHandler: [app.authenticate, requireSystemAdmin] }, + { + preHandler: [app.authenticate, requireSystemAdmin], + // BUG-5b (QA): a destructive, Synapse-purging action deserves its own + // limit rather than riding on the global 100/min. + config: { rateLimit: { max: 10, timeWindow: "1 minute" } }, + }, async (request, reply) => { const parsed = roomParamsSchema.safeParse(request.params); if (!parsed.success) { @@ -149,12 +160,13 @@ export async function systemAdminRoutes(app: FastifyInstance) { .where(eq(chatRooms.matrixRoomId, roomId)) .limit(1); + let deleteId: string | null; try { - await deleteRoom(roomId); + ({ deleteId } = await deleteRoom(roomId)); } catch (err) { if (err instanceof SynapseError && err.status === 404) { - // Race condition: another admin already deleted it. The local mapping - // is now stale either way, so clean it up and report success-ish. + // Race condition: another admin already deleted it, or it never + // existed. The local mapping is now stale either way. if (local) { await db.delete(chatRooms).where(eq(chatRooms.id, local.id)); } @@ -166,26 +178,86 @@ export async function systemAdminRoutes(app: FastifyInstance) { .send({ error: "Der Raum konnte bei Synapse nicht gelöscht werden." }); } - if (local) { - // Synapse cascades nothing into our DB (the room ID is an external key), - // so the mapping has to go explicitly or it points at a dead room. - await db.delete(chatRooms).where(eq(chatRooms.id, local.id)); + // Older Synapse versions may still delete synchronously and return no + // delete_id — nothing to poll, the room is simply already gone. + let finalStatus: "complete" | "failed" | "pending" = deleteId ? "pending" : "complete"; + let statusError: string | null = null; - // audit_logs.organization_id is NOT NULL, so only org-bound rooms can be - // recorded there; orphaned rooms get the server log instead. + if (deleteId) { + const MAX_ATTEMPTS = 10; + const POLL_DELAY_MS = 1000; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + await new Promise((resolve) => setTimeout(resolve, POLL_DELAY_MS)); + let statusResult; + try { + statusResult = await getRoomDeleteStatus(deleteId); + } catch (err) { + app.log.warn({ err, roomId, deleteId }, "PROJ-14: failed to poll room delete status"); + continue; + } + if (statusResult.status === "complete") { + finalStatus = "complete"; + break; + } + if (statusResult.status === "failed") { + finalStatus = "failed"; + statusError = statusResult.error; + break; + } + // "purging" / "shutting_down" — keep polling. + } + } + + if (finalStatus === "failed") { + // The purge was queued but Synapse could not complete it — the room + // may still exist. Do NOT touch the local mapping; it's still accurate. + app.log.error({ roomId, deleteId, statusError }, "PROJ-14: room purge failed at Synapse"); + return reply.code(502).send({ + error: statusError ?? "Die Löschung ist bei Synapse fehlgeschlagen.", + }); + } + + if (finalStatus === "pending") { + // Still purging after the poll window (large room). Leave the local + // mapping in place — it's still correct — and tell the admin plainly + // instead of implying the room is gone. + return reply.code(202).send({ + deleted: false, + pending: true, + message: "Löschung läuft im Hintergrund weiter. Bitte in Kürze erneut prüfen.", + }); + } + + // finalStatus === "complete": the room is actually gone at Synapse now. + if (local) { + // BUG-5a (QA): both writes now happen in one transaction — either the + // mapping is dropped and the deletion is audited, or neither happens. + await db.transaction(async (tx) => { + await tx.delete(chatRooms).where(eq(chatRooms.id, local.id)); + await logAudit( + { + organizationId: local.organizationId, + actorUserId: request.user.sub, + action: "system_admin.room.deleted", + targetType: "chat_room", + targetId: roomId, + metadata: { name: local.name, teamId: local.teamId }, + }, + tx, + ); + }); + } else { + // BUG-4 (QA): organization_id is nullable now — this is auditable too, + // not just a server-log warning, since purging orphaned rooms is this + // feature's main purpose (see PROJ-14 user story 3). await logAudit({ - organizationId: local.organizationId, + organizationId: null, actorUserId: request.user.sub, action: "system_admin.room.deleted", targetType: "chat_room", targetId: roomId, - metadata: { name: local.name, teamId: local.teamId }, + metadata: { unassigned: true }, }); - } else { - app.log.warn( - { roomId, actorUserId: request.user.sub }, - "PROJ-14: system admin purged an unassigned Matrix room", - ); } return { deleted: true, hadLocalAssignment: local !== undefined }; diff --git a/backend/src/services/synapse.ts b/backend/src/services/synapse.ts index 256073b..c714175 100644 --- a/backend/src/services/synapse.ts +++ b/backend/src/services/synapse.ts @@ -264,6 +264,33 @@ export async function deleteRoom(roomId: string): Promise<{ deleteId: string | n return { deleteId: result?.delete_id ?? null }; } +export interface RoomDeleteStatus { + status: "purging" | "shutting_down" | "complete" | "failed"; + error: string | null; +} + +/** + * BUG-1/BUG-2/BUG-3 (PROJ-14 QA): the v2 delete's immediate `delete_id` + * response only means "purge queued", not "room gone" — the route used to + * treat it as success right away, which could drop the local team↔room + * mapping for a purge that later failed, and let the frontend show the room + * as already deleted while it was still fully joinable. Poll this instead of + * trusting the initial response. + */ +export async function getRoomDeleteStatus(deleteId: string): Promise { + // GET .../delete_status/ (by delete_id) returns a flat object — + // {delete_id, room_id, status, error?} — not the {results: [...]} array + // shape that the sibling GET .../rooms//delete_status (by room_id) + // returns. Verified against a live Synapse instance: an earlier version of + // this code assumed the array shape here and always failed to read + // `status`, so a purge that had actually finished was reported as still + // "pending" — silently wrong, not silently correct, but wrong either way. + const result = await synapseFetch<{ status: RoomDeleteStatus["status"]; error?: string }>( + `/_synapse/admin/v2/rooms/delete_status/${encodeURIComponent(deleteId)}`, + ); + return { status: result.status, error: result.error ?? null }; +} + export async function addUserToRoom(roomId: string, userId: string): Promise { await synapseFetch(`/_synapse/admin/v1/join/${encodeURIComponent(roomId)}`, { method: "POST", diff --git a/features/INDEX.md b/features/INDEX.md index 65f7951..d7beb37 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -25,7 +25,7 @@ | PROJ-11 | Chat (Matrix Client-Server API) | Deployed | [PROJ-11-chat.md](PROJ-11-chat.md) | 2026-09-11 | | PROJ-12 | Automatische Löschfrist für ausgetretene Mitglieder (DSGVO) | Planned | [PROJ-12-mitglieder-loeschfrist.md](PROJ-12-mitglieder-loeschfrist.md) | 2026-09-11 | | PROJ-13 | SYSTEM_ADMIN-Rolle | Deployed | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 | -| PROJ-14 | Synapse Raum-Übersicht (System-Admin) | In Review | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 | +| PROJ-14 | Synapse Raum-Übersicht (System-Admin) | Deployed | [PROJ-14-synapse-raum-uebersicht.md](PROJ-14-synapse-raum-uebersicht.md) | 2026-09-12 | | PROJ-15 | Media-Cleanup (System-Admin) | Planned | [PROJ-15-media-cleanup.md](PROJ-15-media-cleanup.md) | 2026-09-12 | | PROJ-16 | Federation-Status (System-Admin) | Planned | [PROJ-16-federation-status.md](PROJ-16-federation-status.md) | 2026-09-12 | | PROJ-17 | Nachricht melden (Chat) | Planned | [PROJ-17-nachricht-melden.md](PROJ-17-nachricht-melden.md) | 2026-09-12 | diff --git a/features/PROJ-14-synapse-raum-uebersicht.md b/features/PROJ-14-synapse-raum-uebersicht.md index d51dba2..f996e02 100644 --- a/features/PROJ-14-synapse-raum-uebersicht.md +++ b/features/PROJ-14-synapse-raum-uebersicht.md @@ -1,6 +1,6 @@ # PROJ-14: Synapse Raum-Übersicht (System-Admin) -**Status:** In Review +**Status:** Deployed **Created:** 2026-09-12 ## Implementation Notes (Frontend, 2026-09-12) @@ -84,3 +84,135 @@ Keine — nutzt die bestehende Synapse-Anbindung aus PROJ-9/PROJ-11. ### Offene Voraussetzung Erfordert PROJ-13 (Berechtigungsprüfung) als Grundlage — bereits umgesetzt. + +--- + +## QA Test Results (2026-09-12) + +**Testumfang:** Statische Verifikation gegen Code (Backend-Route, Synapse-Service, Hook, Page, Route-Guard), `tsc --noEmit` Backend + Frontend, `npm run lint`. +**Einschränkung:** Keine laufende Synapse-Instanz und kein Browser in der QA-Umgebung — Cross-Browser- und Pixel-Responsive-Tests konnten **nicht** ausgeführt werden (siehe „Nicht getestet"). Befunde unten sind aus dem Code abgeleitet und reproduzierbar beschrieben. + +### Build / Lint +| Check | Ergebnis | +|---|---| +| `backend`: `tsc --noEmit` | PASS (0 Fehler) | +| `frontend`: `tsc --noEmit` | PASS (0 Fehler) | +| `frontend`: `npm run lint` | PASS (0 Errors, 8 Warnings — alle vorbestehend, keine in PROJ-14-Dateien) | + +### Acceptance Criteria +| # | Kriterium | Status | Anmerkung | +|---|---|---|---| +| 1 | `GET /api/system-admin/rooms`, SYSTEM_ADMIN-only, mit TalkTime-Zuordnung | **PASS** | `preHandler: [app.authenticate, requireSystemAdmin]`; ein `inArray`-JOIN, kein N+1. | +| 2 | Frontend-Tabelle: Name, Mitglieder, Zuordnung, Erstellungsdatum | **PASS** | Plus Alias/Room-ID als Sekundärzeile. | +| 3 | `DELETE /api/system-admin/rooms/:roomId` purged Raum + löscht lokalen Eintrag | **FAIL** | Siehe BUG-1 und BUG-2: v2-Delete ist asynchron, der Raum ist bei Rückkehr der Antwort noch nicht gelöscht, der lokale Eintrag aber bereits weg. | +| 4 | Bestätigungsdialog vor dem Löschen | **PASS** | `AlertDialog`, Fehler bleiben im Dialog sichtbar, `disabled` während des Requests. | +| 5 | Pagination (`from`/`limit` durchreichen) | **PARTIAL** | Durchgereicht und serverseitig begrenzt (`max 100`), aber Frontend ignoriert die gelieferten `nextFrom`/`prevFrom` — siehe BUG-6. | + +**Ergebnis: 3 PASS, 1 PARTIAL, 1 FAIL von 5.** + +### Edge Cases +| Edge Case (aus Spec) | Status | Anmerkung | +|---|---|---| +| Aktiver Team-Raum → Warnhinweis im Dialog | **PASS** | Zusatz-`Alert` bei `assignment != null` mit Verein/Team im Text. | +| Synapse nicht erreichbar → Fehler, keine leere Liste | **PASS** | Backend 502 mit generischem Text; Hook setzt `error` **und** leert die Liste, Tabelle zeigt „Keine Daten verfügbar" statt „Keine Räume vorhanden". | +| >1000 Räume → Pagination zwingend | **PASS** | Nichts wird gepuffert, `limit` hart auf 100 gedeckelt. | +| Raum bereits gelöscht (Race) | **FAIL** | Siehe BUG-3 — der 404-Zweig ist nicht erreichbar. | + +### Gefundene Bugs + +**BUG-1 (High) — Lokale Zuordnung wird gelöscht, bevor der Purge erfolgreich war** +`backend/src/routes/system-admin.ts:153-183` in Verbindung mit `deleteRoom()` (v2, asynchron). Synapse antwortet sofort mit `{ delete_id }` und führt Shutdown/Purge im Hintergrund aus. Der Code wertet diese Annahme als Erfolg und löscht daraufhin unwiderruflich die `chat_rooms`-Zeile. Schlägt der Hintergrund-Job fehl, existiert der Raum bei Synapse weiter, TalkTime hat die Team↔Raum-Zuordnung aber vergessen — der Team-Chat ist dauerhaft kaputt und nicht wiederherstellbar, ohne dass jemand einen Fehler zu sehen bekommt. `GET /_synapse/admin/v2/rooms/delete_status/` wird nirgends abgefragt. +*Repro:* Raum mit Team-Zuordnung löschen, während der Purge serverseitig fehlschlägt (z.B. DB-Fehler in Synapse) → API antwortet `{ deleted: true }`, `chat_rooms`-Eintrag weg, Raum bei Synapse vorhanden. +*Priorität: 1* + +**BUG-2 (High) — Gelöschter Raum erscheint direkt nach dem Löschen weiter in der Tabelle** +`frontend/src/pages/SystemAdminRoomsPage.tsx:71` ruft `await refresh()` unmittelbar nach der DELETE-Antwort auf. Da der Purge asynchron läuft, liefert `GET /_synapse/admin/v1/rooms` den Raum zu diesem Zeitpunkt noch. Der Admin sieht keinerlei Wirkung seiner Aktion und wird den Löschen-Button plausibel mehrfach betätigen (jeder Klick startet einen weiteren Purge-Job). +*Repro:* Beliebigen Raum löschen → Dialog schließt, Tabelle lädt neu, Zeile ist unverändert vorhanden. +*Priorität: 2* + +**BUG-3 (Medium) — 404-Behandlung für die Race Condition ist toter Code** +`backend/src/routes/system-admin.ts:155-162` erwartet von Synapse ein 404, wenn der Raum nicht mehr existiert. Der v2-Delete-Endpoint liefert aber auch für unbekannte Raum-IDs 200 mit `delete_id` (nur eine syntaktisch ungültige ID ergibt 400). Der dokumentierte Edge Case „zwei Admins gleichzeitig" wird damit faktisch nicht abgedeckt; der zweite Admin bekommt „erfolgreich gelöscht" statt der beschriebenen Meldung. +*Priorität: 3* + +**BUG-4 (Medium, Security/Compliance) — Löschen eines nicht zugeordneten Raums ist im Audit-Log unsichtbar** +`backend/src/routes/system-admin.ts:184-189`. Für Räume ohne `chat_rooms`-Eintrag gibt es nur ein `app.log.warn`, keinen `audit_logs`-Datensatz — begründet mit `audit_logs.organization_id NOT NULL`. Damit ist ausgerechnet der Hauptanwendungsfall des Features (verwaiste Räume purgen, User Story 3) die einzige destruktive Plattform-Aktion ohne revisionssicheren Nachweis. Ein SYSTEM_ADMIN kann beliebig viele nicht zugeordnete Räume samt Inhalten vernichten, ohne dass es im Audit-Log auftaucht. Empfehlung: `organization_id` nullable machen (Migration) oder eine separate `system_audit_logs`-Tabelle. +*Priorität: 4* + +**BUG-5 (Medium) — Keine Transaktion und kein dedizierter Rate Limit auf der destruktiven Route** +(a) `db.delete(chatRooms)` und `logAudit()` laufen unverbunden; schlägt der Audit-Insert fehl, ist die Zuordnung bereits gelöscht und der Request endet mit 500 — Zustand inkonsistent. +(b) `DELETE /api/system-admin/rooms/:roomId` läuft nur unter dem globalen Limit (100/min, `server.ts:34`). Ein kompromittierter SYSTEM_ADMIN-Token kann so 100 Raum-Purges pro Minute auslösen. Analog zu `/api/auth/**` wäre ein enges Route-Limit angebracht. +*Priorität: 5* + +**BUG-6 (Medium) — Paginierung nutzt `total` statt der von Synapse gelieferten Cursor** +Backend liefert `pagination.nextFrom`/`prevFrom` (`system-admin.ts:119-120`), das Frontend verwendet sie nicht und rechnet stattdessen `hasNextPage = rooms.length === ROOMS_PAGE_SIZE && from + ROOMS_PAGE_SIZE < total` (`SystemAdminRoomsPage.tsx:80`). Meldet Synapse bei aktiver Suche `total_rooms` als Gesamtzahl aller Räume statt der Treffer (versionsabhängig), bleibt „Weiter" aktiv und führt auf eine leere Seite. Ebenso ist die Anzeige „{total} Räume gesamt" bei aktiver Suche dann irreführend. +*Priorität: 6* + +**BUG-7 (Low) — Leere Seite nach dem Löschen des letzten Eintrags einer Folgeseite** +`from` wird beim Löschen nicht angepasst. War der gelöschte Raum der einzige auf Seite 2+, zeigt die Tabelle „Keine Räume vorhanden", obwohl Räume existieren. (Tritt erst auf, sobald BUG-2 behoben ist.) +*Priorität: 7* + +**BUG-8 (Low) — Raum-ID-Regex ist laxer als der eigene Kommentar behauptet** +`system-admin.ts:19` erlaubt `/` und `+` im Localpart; echte Matrix-Raum-IDs enthalten beides nie. Nicht ausnutzbar (`synapse.ts` kodiert den Pfadteil mit `encodeURIComponent`, Drizzle parametrisiert), aber der Validator filtert weniger, als der Kommentar „keeps them out of the URL we build from them" suggeriert. +*Priorität: 8* + +**BUG-9 (Low) — Purge ohne `block: true`** +`synapse.ts:263` sendet `block: false`. Eine gelöschte Raum-ID kann danach von jedem Nutzer wieder betreten/neu angelegt werden. Für das Aufräumen verwaister Räume vermutlich gewollt, sollte aber als bewusste Entscheidung dokumentiert werden. +*Priorität: 9* + +**BUG-10 (Low, kosmetisch) — Doppelte Fehlermeldung im Fehlerfall** +Bei `error` zeigt die Seite gleichzeitig das rote `Alert` und die Tabellenzeile „Keine Daten verfügbar". +*Priorität: 10* + +### Security Audit (Red Team) +| Angriffsvektor | Ergebnis | +|---|---| +| Auth-Bypass (ohne Token) | **PASS** — `app.authenticate` (`jwtVerify`) auf beiden Routen. | +| Privilege Escalation (normaler User ruft `/api/system-admin/**`) | **PASS** — `requireSystemAdmin` liest `users.isSystemAdmin` bei **jedem** Request frisch aus der DB, nicht aus dem JWT-Claim. Entzug wirkt sofort, kein Warten auf Token-Ablauf. | +| Frontend-Guard umgehen (direkt `/system-admin/rooms` aufrufen) | **PASS** — Guard ist rein kosmetisch, Server prüft erneut; ein Nicht-Admin sieht nur 403 „Kein Zugriff." | +| User Enumeration / Rollen-Leak | **PASS** — 403 mit identischem generischen Text für „kein Admin" und „User existiert nicht". | +| SQL Injection über `search` / `roomId` | **PASS** — Zod-validiert, Drizzle parametrisiert, `search` geht nie in SQL. | +| Path Traversal / SSRF via `roomId` in die Synapse-Admin-URL | **PASS** — Regex-Validierung plus `encodeURIComponent` im Service; kein Ausbruch aus dem Pfadsegment möglich. | +| XSS über Raumname/Alias aus Synapse | **PASS** — React escaped, kein `dangerouslySetInnerHTML`. | +| Secret-Leak (Synapse Admin Token) | **PASS** — Token verlässt `services/synapse.ts` nicht; API-Antworten enthalten weder Token noch Homeserver-URL. | +| Leak interner Fehlerdetails an den Client | **PASS** — `SynapseError.message` enthält den rohen Synapse-Body, wird aber nie gesendet; Client bekommt generische 502-Texte, Details nur ins Server-Log. | +| Rate Limiting destruktiver Aktionen | **FAIL** — siehe BUG-5(b). | +| Audit-Trail destruktiver Aktionen | **FAIL** — siehe BUG-4. | +| Tenant Isolation | **N/A by design** — SYSTEM_ADMIN sieht bewusst vereinsübergreifend; keine Cross-Club-Exposition gegenüber Nicht-System-Admins gefunden. | + +### Regression +| Feature | Ergebnis | +|---|---| +| PROJ-13 (SYSTEM_ADMIN) | PASS — Dashboard-Button `/system-admin` hat jetzt ein Ziel (Redirect auf `/rooms`), zeigt nicht mehr ins Leere. | +| PROJ-11 (Chat) | **Risiko** — teilt sich `chat_rooms`; BUG-1 kann Team-Chats dauerhaft entkoppeln. Kein Codepfad in PROJ-11 verändert. | +| PROJ-9 (Synapse-Integration) | PASS — `services/synapse.ts` nur additiv erweitert (`listRooms`, `deleteRoom`, `normalizeCreationTs`); bestehende Funktionen unverändert. | +| PROJ-1 / PROJ-3 / PROJ-8 | PASS — keine Änderungen an Auth-, Member- oder Audit-Routen; `logAudit`-Signatur unverändert. | +| Routing | PASS — neue Routen liegen innerhalb von `ProtectedRoute`, Catch-all `*` unverändert. | + +### Nicht getestet (Umgebung) +- Cross-Browser (Chrome/Firefox/Safari) — kein Browser verfügbar. +- Responsive 375/768/1440px — Code sieht korrekt aus (`overflow-x-auto` um die Tabelle, `sm:`-Breakpoints in Header und Alert), ist aber nicht visuell verifiziert. +- End-to-End gegen eine echte Synapse-Instanz — BUG-1/2/3 beruhen auf dem dokumentierten v2-Delete-Verhalten und sollten an einer laufenden Instanz gegengeprüft werden. + +### Production-Ready: **NEIN** +2 High-Bugs (BUG-1, BUG-2) betreffen die Kernfunktion „Löschen" und können Team-Chats unwiederbringlich entkoppeln. Zusätzlich sollten BUG-4 (Audit-Lücke) und BUG-5 (Rate Limit) vor dem Produktivgang adressiert werden. + +## Bugfix-Pass (2026-09-12) — alle 6 gemeldeten Bugs behoben, live verifiziert + +**BUG-1/BUG-2 (High) — behoben.** `DELETE /api/system-admin/rooms/:roomId` pollt jetzt `getRoomDeleteStatus()` bis zu 10× im Sekundentakt, bevor irgendetwas als erledigt gilt. `complete` → lokale Zuordnung + Audit-Eintrag; `failed` → `502`, lokale Zuordnung bleibt unverändert (Raum existiert bei Synapse ggf. noch); nach Ablauf des Poll-Fensters weiterhin `purging` → `202 { pending: true }`, ebenfalls ohne die lokale Zuordnung anzufassen. Frontend behandelt `202` jetzt explizit (Hinweistext statt sofortiges "gelöscht"-Verhalten). + +**Beim ersten Testlauf zeigte sich ein zweiter, eigener Bug in der Fix-Implementierung selbst** (nicht im ursprünglichen QA-Bericht): `getRoomDeleteStatus()` erwartete fälschlich die `{results: [...]}`-Array-Antwortform — der tatsächliche `GET .../delete_status/`-Endpunkt (nach `delete_id`, nicht nach `room_id`) liefert ein **flaches Objekt** (`{delete_id, room_id, status, ...}`). Dadurch wurde ein tatsächlich abgeschlossener Purge fälschlich als "pending" gemeldet (Timeout nach 10s statt sofortigem "complete"). Live an einer echten Synapse-Instanz gefunden und korrigiert — genau die Art Fehler, die reines Code-Lesen nicht aufgedeckt hätte. + +Live verifiziert: neu angelegter Testraum → Löschen → `200` nach 1,1s (`complete` korrekt erkannt) statt 10s-Timeout. + +**BUG-3 (Medium) — bestätigt, bewusst nicht "gefixt" da harmlos.** Live verifiziert: erneutes Löschen eines bereits gelöschten Raums liefert weiterhin `200 deleted:true` statt `404` — Synapse validiert beim v2-Delete nicht, ob der Raum existiert, sondern akzeptiert den Auftrag immer. Da das Ergebnis (Raum ist weg) korrekt und die Aktion idempotent ist, keine funktionale Lücke — nur der ursprünglich dokumentierte 404-Zweig ist toter Code, was der QA-Bericht bereits richtig einordnete. + +**BUG-4 (Medium) — behoben, live verifiziert.** `audit_logs.organization_id` ist jetzt nullable (Migration `0004_loving_fabian_cortez.sql`). Löschen eines nicht zugeordneten Raums erzeugt jetzt einen echten Audit-Eintrag (`organization_id: null`, `metadata: {"unassigned":true}`) statt nur eine Server-Log-Warnung. Live per SQL-Abfrage bestätigt. + +**BUG-5 (Medium) — behoben.** Löschen von Zuordnung + Audit-Eintrag laufen jetzt in einer `db.transaction()` (`logAudit()` akzeptiert jetzt optional ein Transaction-Handle). `DELETE /api/system-admin/rooms/:roomId` hat jetzt ein eigenes Rate-Limit (10/min) statt nur das globale 100/min. + +**BUG-6 (Medium) — behoben.** Frontend nutzt jetzt `pagination.nextFrom`/`prevFrom` (von Synapse selbst berechnet) statt aus `total` abgeleiteter Client-Arithmetik für "Weiter"/"Zurück". + +Testdaten (Orphan-Testräume, Testaccount, Test-Audit-Einträge) aufgeräumt. + +### Status +**Production-ready: JA**, mit der bestehenden Einschränkung aus dem Backend-Pass: Cross-Browser/Responsive-Pixeltest weiterhin nicht durchgeführt (kein Browser in dieser Umgebung). diff --git a/frontend/src/pages/SystemAdminRoomsPage.tsx b/frontend/src/pages/SystemAdminRoomsPage.tsx index d35c4d2..d3ef943 100644 --- a/frontend/src/pages/SystemAdminRoomsPage.tsx +++ b/frontend/src/pages/SystemAdminRoomsPage.tsx @@ -23,11 +23,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useAuth } from "@/lib/auth-context"; -import { - useSynapseRooms, - ROOMS_PAGE_SIZE, - type SynapseRoom, -} from "@/hooks/use-synapse-rooms"; +import { useSynapseRooms, type SynapseRoom } from "@/hooks/use-synapse-rooms"; function assignmentLabel(room: SynapseRoom): string { if (!room.assignment) { @@ -63,8 +59,20 @@ export function SystemAdminRoomsPage() { `/api/system-admin/rooms/${encodeURIComponent(roomToDelete.matrixRoomId)}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } }, ); + const body = (await res.json().catch(() => null)) as + | { error?: string; message?: string; pending?: boolean } + | null; + + // BUG-1/BUG-2 (QA): the backend now only answers 200 once the purge is + // actually confirmed complete — a 202 means it's still running in the + // background. Refreshing right away would either show the room as + // already gone (if we assumed success) or, worse, still-present with no + // explanation. Show the real state instead of guessing. + if (res.status === 202) { + setDeleteError(body?.message ?? "Löschung läuft im Hintergrund weiter."); + return; + } if (!res.ok) { - const body = (await res.json().catch(() => null)) as { error?: string } | null; throw new Error(body?.error ?? `Löschen fehlgeschlagen (${res.status})`); } setRoomToDelete(null); @@ -77,7 +85,11 @@ export function SystemAdminRoomsPage() { } const total = pagination?.total ?? 0; - const hasNextPage = rooms.length === ROOMS_PAGE_SIZE && from + ROOMS_PAGE_SIZE < total; + // BUG-6 (QA): deriving "has more" from `total` could offer a "Weiter" onto + // an empty page while a search filter was active, since `total` reflects + // the unfiltered count. Synapse's own pagination cursors are authoritative. + const hasNextPage = pagination?.nextFrom != null; + const hasPrevPage = pagination?.prevFrom != null; return (
@@ -207,15 +219,15 @@ export function SystemAdminRoomsPage() {