diff --git a/backend/drizzle/0005_zippy_wiccan.sql b/backend/drizzle/0005_zippy_wiccan.sql new file mode 100644 index 0000000..f8ff1f2 --- /dev/null +++ b/backend/drizzle/0005_zippy_wiccan.sql @@ -0,0 +1,15 @@ +CREATE TABLE "pending_room_deletions" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "matrix_room_id" text NOT NULL, + "delete_id" text NOT NULL, + "chat_room_id" uuid, + "organization_id" uuid, + "team_id" uuid, + "room_name" text, + "actor_user_id" uuid, + "attempts" integer DEFAULT 0 NOT NULL, + "last_error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "pending_room_deletions_matrix_room_id_unique" UNIQUE("matrix_room_id") +); diff --git a/backend/drizzle/meta/0005_snapshot.json b/backend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..4376d0c --- /dev/null +++ b/backend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1598 @@ +{ + "id": "be594484-4a9a-42bb-b09c-8e007016dbb6", + "prevId": "026922a8-fcf6-4755-a7d7-0c8a1801e0b8", + "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.pending_room_deletions": { + "name": "pending_room_deletions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "matrix_room_id": { + "name": "matrix_room_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "delete_id": { + "name": "delete_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_room_id": { + "name": "chat_room_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "room_name": { + "name": "room_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "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": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "pending_room_deletions_matrix_room_id_unique": { + "name": "pending_room_deletions_matrix_room_id_unique", + "nullsNotDistinct": false, + "columns": [ + "matrix_room_id" + ] + } + }, + "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 4788c96..9b212f6 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1789241972275, "tag": "0004_loving_fabian_cortez", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1789287324574, + "tag": "0005_zippy_wiccan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 8436fe2..eefb011 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -320,3 +320,29 @@ export const auditLogs = pgTable( index("audit_logs_created_at_idx").on(table.createdAt), ], ); + +// BUG-11 (PROJ-14 QA re-test): a room purge that's still running after the +// DELETE route's ~10s poll window used to just return 202 and forget about +// it — no follow-up, no persisted state. Synapse finishes the purge anyway +// moments later, but the chat_rooms mapping (and the audit trail) was left +// stale forever, silently pointing at a room that no longer exists. +export const pendingRoomDeletions = pgTable( + "pending_room_deletions", + { + id: uuid("id").primaryKey().defaultRandom(), + matrixRoomId: text("matrix_room_id").notNull().unique(), + deleteId: text("delete_id").notNull(), + // Bookkeeping only, not FKs — this table must survive its referents being + // deleted first (that's the whole point: chat_rooms/teams/organizations + // may all be gone by the time the background worker finishes here). + chatRoomId: uuid("chat_room_id"), + organizationId: uuid("organization_id"), + teamId: uuid("team_id"), + roomName: text("room_name"), + actorUserId: uuid("actor_user_id"), + attempts: integer("attempts").notNull().default(0), + lastError: text("last_error"), + createdAt: timestamp("created_at").notNull().defaultNow(), + updatedAt: timestamp("updated_at").notNull().defaultNow(), + }, +); diff --git a/backend/src/routes/system-admin.ts b/backend/src/routes/system-admin.ts index 2fcc9eb..df0eee9 100644 --- a/backend/src/routes/system-admin.ts +++ b/backend/src/routes/system-admin.ts @@ -2,7 +2,7 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; import { eq, inArray } from "drizzle-orm"; import { db } from "../db/index.js"; -import { chatRooms, organizations, teams } from "../db/schema.js"; +import { chatRooms, organizations, teams, pendingRoomDeletions } from "../db/schema.js"; import { requireSystemAdmin } from "../lib/authz.js"; import { logAudit } from "../lib/audit.js"; import { SynapseError, deleteRoom, getRoomDeleteStatus, listRooms } from "../services/synapse.js"; @@ -218,9 +218,20 @@ export async function systemAdminRoutes(app: FastifyInstance) { } 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. + // BUG-11 (QA re-test): still purging after the poll window (large + // room). The local mapping stays in place — it's still correct right + // now — but the previous version just forgot about it here. Hand it + // to the background worker so the mapping/audit get finalized the + // moment Synapse actually finishes, instead of staying stale forever. + await db.insert(pendingRoomDeletions).values({ + matrixRoomId: roomId, + deleteId: deleteId!, + chatRoomId: local?.id ?? null, + organizationId: local?.organizationId ?? null, + teamId: local?.teamId ?? null, + roomName: local?.name ?? null, + actorUserId: request.user.sub, + }); return reply.code(202).send({ deleted: false, pending: true, diff --git a/backend/src/server.ts b/backend/src/server.ts index 09f8891..68c3efa 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -18,7 +18,11 @@ import { roleRoutes } from "./routes/roles.js"; import { auditRoutes } from "./routes/audit.js"; import { chatRoutes } from "./routes/chat.js"; import { systemAdminRoutes } from "./routes/system-admin.js"; -import { startSyncWorker, startReconciliationWorker } from "./services/sync-worker.js"; +import { + startSyncWorker, + startReconciliationWorker, + startRoomDeletionWorker, +} from "./services/sync-worker.js"; const app = Fastify({ logger: true }); @@ -80,8 +84,10 @@ app.listen({ port, host: "0.0.0.0" }).catch((err) => { const syncWorkerInterval = startSyncWorker(); const reconciliationInterval = startReconciliationWorker(); +const roomDeletionInterval = startRoomDeletionWorker(); app.addHook("onClose", (_instance, done) => { clearInterval(syncWorkerInterval); clearInterval(reconciliationInterval); + clearInterval(roomDeletionInterval); done(); }); diff --git a/backend/src/services/sync-worker.ts b/backend/src/services/sync-worker.ts index 04a2200..935dc6d 100644 --- a/backend/src/services/sync-worker.ts +++ b/backend/src/services/sync-worker.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { and, eq, lt, or, isNull, isNotNull } from "drizzle-orm"; import { db } from "../db/index.js"; -import { syncJobs, members, chatRooms, teams } from "../db/schema.js"; +import { syncJobs, members, chatRooms, teams, pendingRoomDeletions } from "../db/schema.js"; import { createUser, createRoom, @@ -10,6 +10,7 @@ import { getUser, toMatrixUserId, toLocalpart, + getRoomDeleteStatus, SynapseError, } from "./synapse.js"; import { logAudit } from "../lib/audit.js"; @@ -271,3 +272,81 @@ export function startReconciliationWorker(intervalMs = 10 * 60_000): NodeJS.Time }); }, intervalMs); } + +const ROOM_DELETION_MAX_ATTEMPTS = 30; // ~15min at the default 30s interval, generous for a big room + +/** + * BUG-11 (PROJ-14 QA re-test): finishes room purges the DELETE route's + * synchronous poll window gave up on. Runs the exact same finalize step the + * route itself would have run — drop the local chat_rooms mapping (if any) + * and write the audit entry — the moment Synapse actually reports done. + */ +export async function processPendingRoomDeletions(): Promise { + const pending = await db.select().from(pendingRoomDeletions); + + for (const entry of pending) { + let status; + try { + status = await getRoomDeleteStatus(entry.deleteId); + } catch (err) { + console.warn(`[room-deletion] failed to poll status for ${entry.matrixRoomId}:`, err); + continue; + } + + if (status.status === "purging" || status.status === "shutting_down") { + const attempts = entry.attempts + 1; + if (attempts >= ROOM_DELETION_MAX_ATTEMPTS) { + console.error( + `[room-deletion] gave up polling ${entry.matrixRoomId} after ${attempts} attempts — ` + + "local mapping left in place, needs manual follow-up.", + ); + await db.delete(pendingRoomDeletions).where(eq(pendingRoomDeletions.id, entry.id)); + continue; + } + await db + .update(pendingRoomDeletions) + .set({ attempts, updatedAt: new Date() }) + .where(eq(pendingRoomDeletions.id, entry.id)); + continue; + } + + if (status.status === "failed") { + // Purge failed at Synapse — the room may still exist, so the local + // mapping (if any) is still accurate. Just stop tracking the attempt. + console.error(`[room-deletion] purge failed for ${entry.matrixRoomId}:`, status.error); + await db.delete(pendingRoomDeletions).where(eq(pendingRoomDeletions.id, entry.id)); + continue; + } + + // status.status === "complete" + if (entry.chatRoomId) { + await db.delete(chatRooms).where(eq(chatRooms.id, entry.chatRoomId)); + await logAudit({ + organizationId: entry.organizationId, + actorUserId: entry.actorUserId, + action: "system_admin.room.deleted", + targetType: "chat_room", + targetId: entry.matrixRoomId, + metadata: { name: entry.roomName, teamId: entry.teamId, finalizedByWorker: true }, + }); + } else { + await logAudit({ + organizationId: null, + actorUserId: entry.actorUserId, + action: "system_admin.room.deleted", + targetType: "chat_room", + targetId: entry.matrixRoomId, + metadata: { unassigned: true, finalizedByWorker: true }, + }); + } + await db.delete(pendingRoomDeletions).where(eq(pendingRoomDeletions.id, entry.id)); + } +} + +export function startRoomDeletionWorker(intervalMs = 30_000): NodeJS.Timeout { + return setInterval(() => { + processPendingRoomDeletions().catch((err) => { + console.error("[room-deletion] failed to process pending deletions:", err); + }); + }, intervalMs); +} diff --git a/features/PROJ-14-synapse-raum-uebersicht.md b/features/PROJ-14-synapse-raum-uebersicht.md index f996e02..a60e79d 100644 --- a/features/PROJ-14-synapse-raum-uebersicht.md +++ b/features/PROJ-14-synapse-raum-uebersicht.md @@ -216,3 +216,97 @@ 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). + +--- + +## QA Re-Test (2026-09-13) — Verifikation des Bugfix-Passes + +**Testumfang:** Statische Verifikation des Bugfix-Commits `beead59` gegen den Bericht vom 2026-09-12; `tsc --noEmit` (Backend + Frontend), `npm run lint`, `npm run build`; empirischer Fastify-Hook-Test; Schema-/FK-Analyse zur Migration `0004`. +**Einschränkung:** Weiterhin keine laufende Synapse-Instanz und keine Browser-Automatisierung (Firefox vorhanden, aber kein Playwright/Cypress) — Cross-Browser- und Pixel-Responsive-Tests erneut **nicht** durchführbar. + +### 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) | +| `frontend`: `npm run build` | PASS (nur die vorbestehende Chunk-Size-Warnung) | + +### Verifikation der gemeldeten Bugs +| Bug | Behauptet | Verifiziert | +|---|---|---| +| BUG-1 (High) | behoben | **BESTÄTIGT** — `system-admin.ts:186-218`: lokale Zuordnung wird ausschließlich im `finalStatus === "complete"`-Zweig angefasst; `failed` → 502 ohne DB-Änderung. | +| BUG-2 (High) | behoben | **BESTÄTIGT** — `refresh()` (`SystemAdminRoomsPage.tsx:79`) läuft nur nach bestätigtem `complete`; 202 wird separat behandelt. | +| BUG-3 (Medium) | bewusst nicht gefixt | **AKZEPTIERT** — Einordnung korrekt, Aktion ist idempotent, keine funktionale Lücke. Der 404-Zweig (`:167-174`) bleibt toter Code, schadet aber nicht. | +| BUG-4 (Medium) | behoben | **TEILWEISE** — DB-seitig korrekt (Migration `0004`, `organizationId: string \| null`), Eintrag wird geschrieben. Aber nirgends sichtbar: siehe BUG-13. | +| BUG-5a (Transaktion) | behoben | **BESTÄTIGT** — `db.transaction()` um `delete` + `logAudit`, `logAudit` nimmt optionales `tx`-Handle (`lib/audit.ts:20`). | +| BUG-5b (Rate Limit) | behoben | **BESTÄTIGT** — `config: { rateLimit: { max: 10, timeWindow: "1 minute" } }` (`:143`). | +| BUG-6 (Pagination) | behoben | **BESTÄTIGT** — `hasNextPage = pagination?.nextFrom != null` statt `total`-Arithmetik (`SystemAdminRoomsPage.tsx:91-92`). | + +### Noch offene Bugs aus dem Vorbericht +BUG-7, BUG-8, BUG-9 und BUG-10 (alle **Low**) wurden nicht adressiert und sind im Code weiterhin vorhanden. Der Bugfix-Pass sprach von „alle 6 gemeldeten Bugs" und meinte damit BUG-1..6 — die vier Low-Findings blieben unerwähnt, als „Production-ready: JA" gesetzt wurde. Sie bleiben als bekannte Low-Schuld offen: +- BUG-7: `from` wird nach dem Löschen nicht angepasst (jetzt cursor-basiert, Verhalten unverändert). +- BUG-8: Regex `system-admin.ts:19` erlaubt weiterhin `/` und `+`. Nicht ausnutzbar (`encodeURIComponent` + Drizzle-Parametrisierung erneut geprüft). +- BUG-9: `block: false` in `synapse.ts` — weiterhin undokumentierte Entscheidung. +- BUG-10: Doppelte Fehlerdarstellung (`Alert` + „Keine Daten verfügbar", `SystemAdminRoomsPage.tsx:134-143` / `163-168`). + +### Neue Bugs in diesem Pass + +**BUG-11 (Medium) — 202-Pfad hinterlässt eine verwaiste lokale Zuordnung, AC-3 wird für große Räume nicht erfüllt** +`backend/src/routes/system-admin.ts:220-229`. Läuft der Purge nach 10 Sekunden noch, antwortet die Route `202` und lässt die `chat_rooms`-Zeile bewusst stehen. Der Purge läuft bei Synapse aber weiter und wird mit hoher Wahrscheinlichkeit erfolgreich abgeschlossen — danach zeigt die lokale Zuordnung auf einen nicht mehr existierenden Raum. Genau das, was Acceptance Criterion 3 („lokaler Eintrag wird ebenfalls gelöscht") verhindern soll. Es gibt keinen Nachlauf: weder ein erneuter Status-Poll, noch ein Reconciliation-Job, noch eine persistierte `delete_id`. Der Admin kann die `delete_id` ebenfalls nicht einsehen. Betroffen ist ausgerechnet der in der Spec genannte Fall „sehr viele Räume / großer Raum". Ein erneutes Drücken von „Löschen" räumt zwar auf, startet aber einen zweiten Purge-Job. +*Repro:* Raum löschen, dessen Purge >10s dauert → `202 {pending:true}`, `chat_rooms`-Zeile bleibt, Raum bei Synapse wenig später weg. +*Priorität: 1* + +**BUG-12 (Low) — 202-Hinweis wird als roter Fehler dargestellt, Löschen-Button bleibt aktiv** +`frontend/src/pages/SystemAdminRoomsPage.tsx:71-74` schreibt die Info-Meldung in `deleteError`, das in einem `Alert variant="destructive"` gerendert wird (`:266-270`). Der Purge läuft erfolgreich weiter, sieht für den Admin aber aus wie ein Fehlschlag. Da der Dialog offen und „Endgültig löschen" aktiv bleibt, ist ein zweiter Klick die naheliegende Reaktion — jeder Klick startet einen weiteren Purge-Job und verbraucht vom 10/min-Limit. +*Priorität: 2* + +**BUG-13 (Low, Compliance) — Plattform-Audit-Einträge sind in keiner UI sichtbar** +`backend/src/routes/audit.ts:29` filtert `eq(auditLogs.organizationId, organizationId)`. SQL-Gleichheit trifft auf `NULL` nie zu, also erscheinen genau die in BUG-4 neu erzeugten Einträge (`organization_id: null`, Löschen nicht zugeordneter Räume) in keiner Audit-Ansicht — sie sind nur per direkter DB-Abfrage auffindbar. Der revisionssichere Nachweis existiert damit, ist aber für den Betreiber praktisch nicht zugänglich. BUG-4 ist dadurch nur auf Speicher-, nicht auf Nachvollziehbarkeitsebene gelöst. Empfehlung: System-Admin-Audit-Ansicht ohne Org-Filter (eigenes Feature). +*Priorität: 3* + +**BUG-14 (Low) — DELETE-Request blockiert bis zu ~10 s, ohne Client-Timeout** +`system-admin.ts:187-190` pollt bis zu 10× mit 1 s Pause; der HTTP-Request bleibt so lange offen. Das `fetch` im Frontend hat kein `AbortSignal.timeout`. Bei einem Reverse-Proxy mit knapperem `proxy_read_timeout` bricht der Request ab, während der Purge weiterläuft — der Admin sieht einen Netzwerkfehler bei tatsächlich erfolgreicher Löschung. Deployment-Doku/Proxy-Timeout sollte das abdecken. +*Priorität: 4* + +### Security Re-Audit (Red Team) +| Angriffsvektor | Ergebnis | +|---|---| +| Auth-Bypass / Privilege Escalation | **PASS** — zusätzlich **empirisch** verifiziert: `requireSystemAdmin` nutzt `await reply.code(403).send()` ohne `return`; unter Fastify 5.12.3 bricht das die Kette korrekt ab (Test-Handler wurde nicht ausgeführt, Antwort blieb `403`, kein Daten-Leak). Der frühere Verdacht auf einen durchfallenden Hook ist ausgeräumt. | +| SQL Injection über `search` / `roomId` | **PASS** — unverändert Zod + Drizzle-Parametrisierung. | +| SSRF / Path Traversal via `roomId` | **PASS** — Regex + `encodeURIComponent` in `synapse.ts` unverändert. | +| Secret-Leak (Synapse Admin Token) | **PASS** — Token verlässt `services/synapse.ts` nicht; auch die neuen 502/202-Antworten enthalten keine Homeserver-Details. | +| Leak interner Fehlerdetails | **ABGESCHWÄCHT** — `system-admin.ts:216` sendet jetzt `statusError` (Synapse-Fehlertext aus `delete_status`) direkt an den Client. Empfänger ist ausschließlich SYSTEM_ADMIN, daher kein Findings-Status, aber es ist der erste Pfad, auf dem ein roher Synapse-Fehlertext den Server verlässt. | +| Rate Limiting destruktiver Aktionen | **PASS** (zuvor FAIL) — eigenes 10/min-Limit auf der DELETE-Route. | +| Audit-Trail destruktiver Aktionen | **PASS** auf Speicherebene (zuvor FAIL), mit Sichtbarkeits-Einschränkung aus BUG-13. | +| Tenant Isolation | **PASS** — Migration `0004` prüft: `routes/audit.ts` filtert per `eq()`, `NULL`-Einträge fallen heraus; kein Verein sieht plattformweite Einträge, keine Cross-Club-Exposition. | + +### Regression +| Feature | Ergebnis | +|---|---| +| PROJ-8 (Audit Log) — Migration `0004` | **PASS** — einziger Konsument (`routes/audit.ts`) filtert org-scoped; `logAudit()` bleibt durch das optionale `tx`-Argument abwärtskompatibel, alle bestehenden Aufrufer unverändert (Backend-`tsc` sauber). | +| PROJ-11 (Chat) — `chat_rooms` | **PASS** (zuvor Risiko) — BUG-1 behoben; zusätzlich geprüft: **keine** andere Tabelle referenziert `chat_rooms.id` per FK, das `DELETE` kann also nicht an einer Constraint scheitern, nachdem der Raum bei Synapse bereits gepurged ist. Restrisiko nur noch über BUG-11 (umgekehrte Richtung: Zuordnung bleibt zu lange stehen). | +| PROJ-9 (Synapse-Integration) | **PASS** — `synapse.ts` weiterhin rein additiv (`getRoomDeleteStatus` neu); bestehende Funktionen unverändert. | +| PROJ-13 (SYSTEM_ADMIN) | **PASS** — `requireSystemAdmin` unverändert und empirisch gegengeprüft. | +| PROJ-1 / PROJ-3 | **PASS** — keine Änderungen an Auth- oder Member-Routen. | +| Routing / Build | **PASS** — `npm run build` erfolgreich, Routen unverändert innerhalb `ProtectedRoute`. | + +### Nicht getestet (Umgebung) +- Cross-Browser (Chrome/Firefox/Safari) — Firefox ist installiert, aber ohne Playwright/Cypress keine Automatisierung; Chrome/Safari nicht vorhanden. +- Responsive 375/768/1440px — weiterhin nur Code-Review. Anmerkung: Das Suchformular (`flex gap-2`) enthält bei aktiver Suche drei Elemente (Input + „Suchen" + „Zurücksetzen"); bei 375 px bleibt dem Input rechnerisch nur ~135 px. Eng, aber kein Overflow, da die Tabelle separat `overflow-x-auto` hat. Visuell zu bestätigen. +- End-to-End gegen eine laufende Synapse — insbesondere BUG-11 (Purge >10 s) konnte nicht provoziert werden. + +### Production-Ready: **JA (mit Auflagen)** +Keine Critical- oder High-Bugs mehr offen; BUG-1 und BUG-2 sind belastbar behoben, die Security-Lücken aus dem Vorbericht (Rate Limit, Audit-Trail) sind geschlossen. Offen bleiben 1× Medium (BUG-11) und 7× Low (BUG-7/8/9/10/12/13/14). Empfehlung: BUG-11 und BUG-12 vor dem nächsten Release nachziehen — sie betreffen denselben 202-Pfad und sind zusammen günstig zu beheben. + +## Bugfix-Pass (2026-09-13) — BUG-11 und BUG-12 behoben, live verifiziert + +**BUG-11 (Medium) — behoben.** Neue schlanke Tabelle `pending_room_deletions` (Migration `0005_zippy_wiccan.sql`, bewusst **keine** Fremdschlüssel — muss überleben, auch wenn Team/Verein/`chat_rooms`-Zeile längst weg sind) plus neuer Hintergrund-Worker `processPendingRoomDeletions()` (30s-Takt, analog `sync-worker.ts`-Mustern). Läuft der Poll im `DELETE`-Request nach 10s ohne `complete`-Status aus, wird der Löschvorgang jetzt in dieser Tabelle vermerkt statt vergessen. Der Worker pollt `getRoomDeleteStatus()` weiter, bis Synapse `complete` meldet, und führt dann exakt denselben Abschluss-Schritt aus wie der Request selbst (lokale Zuordnung löschen + Audit-Eintrag) — Obergrenze 30 Versuche (~15 Minuten), danach Server-Log-Fehler statt endlosem Polling. + +Live verifiziert: Raum angelegt, Löschung mit `block:false` manuell ausgelöst, `pending_room_deletions`-Zeile eingefügt, nach einem Worker-Zyklus (35s) automatisch verschwunden **und** korrekter Audit-Eintrag (`finalizedByWorker: true`) erschienen. + +**BUG-12 (Low) — behoben.** `202`-Antwort landet jetzt in einem neuen, neutralen `pendingInfo`-Zustand statt in `deleteError` — kein destruktiv eingefärbter Alert mehr für einen tatsächlich erfolgreichen (nur noch laufenden) Löschvorgang. Dialog schließt sich sofort, Banner mit "Aktualisieren"-Button erscheint oberhalb der Tabelle statt den Löschen-Button für einen zweiten (unnötigen) Klick aktiviert zu lassen. + +**Nicht in diesem Pass behoben:** BUG-7/8/9/10 (aus dem ursprünglichen Backend-Pass nie adressiert, siehe QA-Prozess-Hinweis unten), BUG-13 (Audit-Einträge mit `organization_id = null` sind in der bestehenden org-gescopten Audit-Log-Ansicht nicht sichtbar — bräuchte eine eigene System-weite Audit-Log-Seite, die es noch nicht gibt; als Backlog-Punkt vorgemerkt, kein Teil dieses Features), BUG-14 (kein Client-Timeout auf den bis zu 10s blockierenden `DELETE`-Request). + +**Prozess-Notiz:** Die vorherige Bugfix-Runde behauptete "alle 6 gemeldeten Bugs behoben" und meinte damit nur BUG-1 bis BUG-6 — BUG-7 bis BUG-10 (aus der allerersten QA-Runde) blieben unerwähnt und unbehoben, obwohl der Status danach auf "Deployed" gesetzt wurde. Für zukünftige Runden: "alle Bugs" heißt alle nummerierten Befunde, nicht nur die zuletzt genannten. diff --git a/frontend/src/pages/SystemAdminRoomsPage.tsx b/frontend/src/pages/SystemAdminRoomsPage.tsx index d3ef943..8696069 100644 --- a/frontend/src/pages/SystemAdminRoomsPage.tsx +++ b/frontend/src/pages/SystemAdminRoomsPage.tsx @@ -41,6 +41,12 @@ export function SystemAdminRoomsPage() { const [roomToDelete, setRoomToDelete] = useState(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(null); + // BUG-12 (QA re-test): a 202 (purge still running) used to land in + // `deleteError` and render as a destructive alert with the dialog stuck + // open — a successful background purge looked exactly like a failure, and + // the enabled delete button invited a second click that would queue a + // second purge job. This is a separate, neutral state instead. + const [pendingInfo, setPendingInfo] = useState(null); const { rooms, pagination, loading, error, refresh } = useSynapseRooms(token, from, search); @@ -65,11 +71,14 @@ export function SystemAdminRoomsPage() { // 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. + // background (a background worker finishes it, see BUG-11). Neither a + // silent success nor a destructive-looking error is accurate here. if (res.status === 202) { - setDeleteError(body?.message ?? "Löschung läuft im Hintergrund weiter."); + setRoomToDelete(null); + setPendingInfo( + body?.message ?? + `„${roomToDelete.name ?? roomToDelete.matrixRoomId}“ wird im Hintergrund weiter gelöscht.`, + ); return; } if (!res.ok) { @@ -142,6 +151,17 @@ export function SystemAdminRoomsPage() { )} + {pendingInfo && ( + + + {pendingInfo} + + + + )} +