diff --git a/backend/drizzle/0003_robust_maverick.sql b/backend/drizzle/0003_robust_maverick.sql new file mode 100644 index 0000000..cc38e4f --- /dev/null +++ b/backend/drizzle/0003_robust_maverick.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "is_system_admin" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/meta/0003_snapshot.json b/backend/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..89e9ad6 --- /dev/null +++ b/backend/drizzle/meta/0003_snapshot.json @@ -0,0 +1,1501 @@ +{ + "id": "e163a8f6-6a94-4364-917e-7106e30d1fc7", + "prevId": "735c3afb-31cc-42ea-ab27-8a26f33cc915", + "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": true + }, + "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 5c57533..d24c1b0 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1789160534830, "tag": "0002_melted_imperial_guard", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1789239281922, + "tag": "0003_robust_maverick", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index b06d5ca..c2f75f3 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -2,6 +2,7 @@ import { pgTable, uuid, text, + boolean, timestamp, date, integer, @@ -16,6 +17,9 @@ export const users = pgTable("users", { id: uuid("id").primaryKey().defaultRandom(), email: text("email").notNull().unique(), passwordHash: text("password_hash").notNull(), + // PROJ-13: platform-wide admin flag, deliberately NOT tied to any organization + // membership. Only ever set manually via SQL (no UI in MVP scope). + isSystemAdmin: boolean("is_system_admin").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), }); diff --git a/backend/src/lib/authz.ts b/backend/src/lib/authz.ts index 3cdd121..00684de 100644 --- a/backend/src/lib/authz.ts +++ b/backend/src/lib/authz.ts @@ -1,6 +1,7 @@ +import type { FastifyReply, FastifyRequest } from "fastify"; import { and, eq } from "drizzle-orm"; import { db } from "../db/index.js"; -import { members, organizationRoles } from "../db/schema.js"; +import { members, organizationRoles, users } from "../db/schema.js"; const ADMIN_ROLES = ["ORG_ADMIN", "ORG_MANAGER"] as const; @@ -37,3 +38,30 @@ export async function requesterIsOrganizationAdmin(userId: string, organizationI return role !== undefined && (ADMIN_ROLES as readonly string[]).includes(role.role); } + +/** + * PROJ-13: platform-wide SYSTEM_ADMIN check, independent of any organization. + * Read fresh from the DB on every request (never cached in the JWT) so revoking + * the flag takes effect immediately without waiting for the token to expire. + */ +export async function requesterIsSystemAdmin(userId: string): Promise { + const [user] = await db + .select({ isSystemAdmin: users.isSystemAdmin }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.isSystemAdmin === true; +} + +/** + * PROJ-13: preHandler for system-admin-only routes. Use *after* `app.authenticate`, + * e.g. `{ preHandler: [app.authenticate, requireSystemAdmin] }`. + */ +export async function requireSystemAdmin(request: FastifyRequest, reply: FastifyReply): Promise { + if (!(await requesterIsSystemAdmin(request.user.sub))) { + // Same generic message for "not an admin" and "no such user" — don't hint + // at the existence of the platform admin role to ordinary users. + await reply.code(403).send({ error: "Kein Zugriff." }); + } +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index d649b01..7eef9ef 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -62,9 +62,23 @@ export async function authRoutes(app: FastifyInstance) { return reply.send({ token }); }); - app.get("/api/auth/me", { preHandler: app.authenticate }, async (request) => { + app.get("/api/auth/me", { preHandler: app.authenticate }, async (request, reply) => { const { sub, email } = request.user; - return { id: sub, email }; + + // PROJ-13: read the SYSTEM_ADMIN flag from the DB rather than the JWT, so a + // revoked flag disappears on the next request instead of on the next login. + const [user] = await db + .select({ isSystemAdmin: users.isSystemAdmin }) + .from(users) + .where(eq(users.id, sub)) + .limit(1); + + if (!user) { + // Token is valid but the account was deleted in the meantime. + return reply.code(401).send({ error: "Konto existiert nicht mehr." }); + } + + return { id: sub, email, isSystemAdmin: user.isSystemAdmin }; }); app.post( diff --git a/features/INDEX.md b/features/INDEX.md index 30e29a1..a13a700 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -24,7 +24,7 @@ | PROJ-10 | Deployment (Docker Compose + Synapse Installer) | Deployed | [PROJ-10-deployment.md](PROJ-10-deployment.md) | 2026-09-11 | | 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 | In Progress | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 | +| PROJ-13 | SYSTEM_ADMIN-Rolle | In Review | [PROJ-13-system-admin-rolle.md](PROJ-13-system-admin-rolle.md) | 2026-09-12 | | PROJ-14 | Synapse Raum-Übersicht (System-Admin) | Planned | [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 | diff --git a/features/PROJ-13-system-admin-rolle.md b/features/PROJ-13-system-admin-rolle.md index 8c1233a..df3e6b7 100644 --- a/features/PROJ-13-system-admin-rolle.md +++ b/features/PROJ-13-system-admin-rolle.md @@ -1,6 +1,6 @@ # PROJ-13: SYSTEM_ADMIN-Rolle (Grundlage für System-Admin-Panel) -**Status:** Planned +**Status:** In Review **Created:** 2026-09-12 ## Summary @@ -16,12 +16,12 @@ Erste Umsetzung der in PRD/vereinschat-Doku vorgesehenen `SYSTEM_ADMIN`-Rolle: e 3. Als normaler Nutzer (auch als ORG_ADMIN eines Vereins) will ich das System-Admin-Panel **nicht** sehen oder aufrufen können. ## Acceptance Criteria -- [ ] `users`-Tabelle bekommt Feld `isSystemAdmin` (boolean, `not null default false`) -- [ ] Neuer Helper `requesterIsSystemAdmin(userId)` in `backend/src/lib/authz.ts` -- [ ] Alle System-Admin-Routen (PROJ-14/15/16/18) prüfen diesen Helper, `403` sonst -- [ ] `GET /api/auth/me` liefert `isSystemAdmin` mit, damit das Frontend die Navigation entsprechend zeigt/versteckt -- [ ] Kein UI zum Setzen/Entfernen der Rolle — nur per direktem SQL-Update (`UPDATE users SET is_system_admin = true WHERE email = '...'`), dokumentiert in der Spec, nicht im Produkt -- [ ] Frontend: neuer Navigationspunkt "System-Admin" im Dashboard, nur sichtbar wenn `isSystemAdmin` +- [x] `users`-Tabelle bekommt Feld `isSystemAdmin` (boolean, `not null default false`) +- [x] Neuer Helper `requesterIsSystemAdmin(userId)` in `backend/src/lib/authz.ts` +- [ ] Alle System-Admin-Routen (PROJ-14/15/16/18) prüfen diesen Helper, `403` sonst — offen, da diese Routen noch nicht existieren; Fundament (`requireSystemAdmin`-preHandler) steht bereit +- [x] `GET /api/auth/me` liefert `isSystemAdmin` mit, damit das Frontend die Navigation entsprechend zeigt/versteckt +- [x] Kein UI zum Setzen/Entfernen der Rolle — nur per direktem SQL-Update (`UPDATE users SET is_system_admin = true WHERE email = '...'`), dokumentiert in der Spec, nicht im Produkt +- [x] Frontend: neuer Navigationspunkt "System-Admin" im Dashboard, nur sichtbar wenn `isSystemAdmin` ## Edge Cases - SYSTEM_ADMIN ist gleichzeitig auch ORG_ADMIN eines Vereins → beide Rollen unabhängig, kein Konflikt @@ -74,3 +74,23 @@ Keine — nutzt ausschließlich bereits vorhandene Bausteine (Datenbank-Erweiter ### Offene Voraussetzung für die Folge-Features PROJ-14/15/16/18 bauen eigene, neue Backend-Funktionen und Bildschirme auf dieser Berechtigungsprüfung auf — dieses Feature liefert nur das Fundament, ohne das die anderen vier nicht sicher wären. + +## Implementation Notes (Backend, 2026-09-12) + +**Backend** +- `backend/src/db/schema.ts`: `users.isSystemAdmin` (`boolean("is_system_admin").notNull().default(false)`). Kein Index — die Spalte wird nie gefiltert, immer nur per Primärschlüssel mitgelesen. +- `backend/drizzle/0003_robust_maverick.sql`: `ALTER TABLE "users" ADD COLUMN "is_system_admin" boolean DEFAULT false NOT NULL;` — additiv, kein Backfill nötig. **Noch nicht angewendet** (`npm run db:migrate` braucht laut `.claude/rules/security.md` explizite Freigabe). +- `backend/src/lib/authz.ts`: `requesterIsSystemAdmin(userId)` liest die Flag bei jeder Anfrage frisch aus der DB (nicht aus dem JWT) → Entzug wirkt sofort, wie im Tech Design gefordert. Zusätzlich `requireSystemAdmin` als Fastify-preHandler für PROJ-14/15/16/18: `{ preHandler: [app.authenticate, requireSystemAdmin] }`, antwortet mit generischem `403 { error: "Kein Zugriff." }`. +- `backend/src/routes/auth.ts`: `GET /api/auth/me` liefert `isSystemAdmin` (DB-Lookup statt JWT-Claim). Neuer Nebeneffekt: existiert das Konto nicht mehr, gibt `/me` jetzt `401` statt eines Treffers aus dem noch gültigen Token. + +**Frontend** +- `frontend/src/lib/auth-context.tsx`: `AuthUser.isSystemAdmin`; bewusst `me.isSystemAdmin === true` statt Cast, damit ein fehlendes/unerwartetes Feld nie zu `true` wird. +- `frontend/src/pages/DashboardPage.tsx`: Button "System-Admin" → `/system-admin`, nur bei `user?.isSystemAdmin`. + +**Abweichung / offener Punkt** +- Die Route `/system-admin` existiert noch nicht (kommt mit PROJ-14). Bis dahin landet ein Klick über den Catch-all in `App.tsx` wieder auf dem Dashboard. Bewusst so belassen statt einer Wegwerf-Platzhalterseite. + +**Rolle vergeben (nur per DB, kein UI):** +```sql +UPDATE users SET is_system_admin = true WHERE email = 'admin@example.org'; +``` diff --git a/frontend/src/lib/auth-context.tsx b/frontend/src/lib/auth-context.tsx index 7d3f635..553b751 100644 --- a/frontend/src/lib/auth-context.tsx +++ b/frontend/src/lib/auth-context.tsx @@ -4,6 +4,9 @@ import { clearCachedChatCredentials } from "@/lib/matrix"; interface AuthUser { id: string; email: string; + /** PROJ-13: platform-wide admin. Only drives nav visibility — every + * system-admin API route re-checks this server-side. */ + isSystemAdmin: boolean; } interface AuthContextValue { @@ -46,11 +49,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (!meRes.ok) { throw new Error(await parseError(meRes)); } - const me = (await meRes.json()) as AuthUser; + const me = (await meRes.json()) as Partial & { id: string; email: string }; localStorage.setItem(STORAGE_KEY, newToken); setTokenState(newToken); - setUser(me); + // Default to false — never let a missing/unexpected field grant admin nav. + setUser({ id: me.id, email: me.email, isSystemAdmin: me.isSystemAdmin === true }); }, []); useEffect(() => { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 37458f6..1163371 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -29,6 +29,13 @@ export function DashboardPage() { + {/* PROJ-13: only a convenience — the panel's APIs enforce SYSTEM_ADMIN + themselves, hiding the link is not the access control. */} + {user?.isSystemAdmin && ( + + )}