From 1e6d96fdb8c81ffdd829261af34f1f94f5a9d1ac Mon Sep 17 00:00:00 2001 From: Jovinull Date: Fri, 28 Aug 2026 19:20:24 -0300 Subject: [PATCH 1/2] feat(privacidade): exportar dados e excluir conta --- README.md | 8 + backend/drizzle/0014_misty_silver_samurai.sql | 2 + backend/drizzle/meta/0014_snapshot.json | 1186 +++++++++++++++++ backend/drizzle/meta/_journal.json | 7 + backend/src/db/schema/auditLogs.ts | 4 +- backend/src/modules/users/privacy.service.ts | 85 ++ .../src/modules/users/schemas/user.schemas.ts | 4 + backend/src/modules/users/users.controller.ts | 21 +- backend/src/routes/users.routes.ts | 11 + .../integration/routes/user.routes.test.ts | 32 + .../modules/users/privacy.service.test.ts | 101 ++ .../components/profile/PrivacyPanel.tsx | 60 + .../components/profile/ProfileTab.tsx | 2 + .../infrastructure/userDashboardApi.ts | 14 + .../tests/unit/new_dashboard/privacy.test.tsx | 33 + 15 files changed, 1568 insertions(+), 2 deletions(-) create mode 100644 backend/drizzle/0014_misty_silver_samurai.sql create mode 100644 backend/drizzle/meta/0014_snapshot.json create mode 100644 backend/src/modules/users/privacy.service.ts create mode 100644 backend/tests/unit/modules/users/privacy.service.test.ts create mode 100644 frontend/src/domains/new_dashboard/components/profile/PrivacyPanel.tsx create mode 100644 frontend/tests/unit/new_dashboard/privacy.test.tsx diff --git a/README.md b/README.md index 1b81131..2f06dab 100644 --- a/README.md +++ b/README.md @@ -407,6 +407,14 @@ Saída esperada do instalador: ## Variáveis de ambiente +## Privacidade e dados pessoais + +Usuários autenticados podem exportar seus dados em `GET /users/export` e solicitar a exclusão definitiva da conta em `DELETE /users/account`, enviando `{ "confirmation": "EXCLUIR" }`. + +A exportação inclui perfil, preferências, vagas salvas, eventos de candidatura, notificações, keywords e provedores conectados. Senhas, hashes, tokens de acesso e valores internos de criptografia não são incluídos. + +A exclusão remove a conta e os registros relacionados por chaves com `ON DELETE CASCADE`; o cookie de sessão também é invalidado. Logs de auditoria são preservados sem vínculo ao usuário excluído. + Arquivos de exemplo: - .env.example diff --git a/backend/drizzle/0014_misty_silver_samurai.sql b/backend/drizzle/0014_misty_silver_samurai.sql new file mode 100644 index 0000000..4fba58d --- /dev/null +++ b/backend/drizzle/0014_misty_silver_samurai.sql @@ -0,0 +1,2 @@ +ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_actor_id_users_id_fk"; +ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; diff --git a/backend/drizzle/meta/0014_snapshot.json b/backend/drizzle/meta/0014_snapshot.json new file mode 100644 index 0000000..d4f2a96 --- /dev/null +++ b/backend/drizzle/meta/0014_snapshot.json @@ -0,0 +1,1186 @@ +{ + "id": "fe2dd5c7-b308-459d-acd6-f27160b2e4ba", + "prevId": "979ff559-fdaa-4fd2-ad0e-00019bbbd23e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "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 + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_provider_unique": { + "name": "accounts_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.application_events": { + "name": "application_events", + "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 + }, + "saved_job_id": { + "name": "saved_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "to_status": { + "name": "to_status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "application_events_saved_job_id_created_at_idx": { + "name": "application_events_saved_job_id_created_at_idx", + "columns": [ + { + "expression": "saved_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "application_events_user_id_users_id_fk": { + "name": "application_events_user_id_users_id_fk", + "tableFrom": "application_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_events_saved_job_id_saved_jobs_id_fk": { + "name": "application_events_saved_job_id_saved_jobs_id_fk", + "tableFrom": "application_events", + "tableTo": "saved_jobs", + "columnsFrom": [ + "saved_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_role": { + "name": "actor_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_logs_actor_id_users_id_fk": { + "name": "audit_logs_actor_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credentials": { + "name": "credentials", + "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 + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "credentials_user_id_users_id_fk": { + "name": "credentials_user_id_users_id_fk", + "tableFrom": "credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "credentials_user_id_unique": { + "name": "credentials_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "credentials_email_unique": { + "name": "credentials_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "credentials_email_hash_unique": { + "name": "credentials_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.keywords": { + "name": "keywords", + "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 + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "keywords_user_keyword_unique": { + "name": "keywords_user_keyword_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "keyword", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "keywords_user_id_users_id_fk": { + "name": "keywords_user_id_users_id_fk", + "tableFrom": "keywords", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_rules": { + "name": "permission_rules", + "schema": "", + "columns": { + "resource": { + "name": "resource", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "min_role": { + "name": "min_role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "permission_rules_resource_action_pk": { + "name": "permission_rules_resource_action_pk", + "columns": [ + "resource", + "action" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_jobs": { + "name": "saved_jobs", + "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 + }, + "job_link": { + "name": "job_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "keyword": { + "name": "keyword", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'saved'" + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "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": { + "saved_jobs_user_id_users_id_fk": { + "name": "saved_jobs_user_id_users_id_fk", + "tableFrom": "saved_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_notifications": { + "name": "user_notifications", + "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 + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'notification'" + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_notifications_user_created_at_idx": { + "name": "user_notifications_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_notifications_user_read_at_idx": { + "name": "user_notifications_user_read_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "read_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_notifications_user_id_users_id_fk": { + "name": "user_notifications_user_id_users_id_fk", + "tableFrom": "user_notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "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 + }, + "keywords": { + "name": "keywords", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "search_location": { + "name": "search_location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "search_language": { + "name": "search_language", + "type": "varchar(10)", + "primaryKey": false, + "notNull": false + }, + "remote_only": { + "name": "remote_only", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "job_types": { + "name": "job_types", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "email_notifications": { + "name": "email_notifications", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "career_checklist": { + "name": "career_checklist", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "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": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_preferences_user_id_unique": { + "name": "user_preferences_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name_encrypted": { + "name": "first_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name_encrypted": { + "name": "last_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name_encrypted": { + "name": "display_name_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_encrypted": { + "name": "email_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url_encrypted": { + "name": "avatar_url_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "phone_encrypted": { + "name": "phone_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpf": { + "name": "cpf", + "type": "varchar(14)", + "primaryKey": false, + "notNull": false + }, + "cpf_encrypted": { + "name": "cpf_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cpf_hash": { + "name": "cpf_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technologies": { + "name": "technologies", + "type": "text[]", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "technologies_encrypted": { + "name": "technologies_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "technology_experiences_encrypted": { + "name": "technology_experiences_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "level_encrypted": { + "name": "level_encrypted", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "is_blocked": { + "name": "is_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": 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()" + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "username", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_email_hash_unique": { + "name": "users_email_hash_unique", + "columns": [ + { + "expression": "email_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "notification", + "message" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "job_saved", + "job_applied", + "job_status_changed", + "high_match", + "mentor", + "system" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "support", + "admin", + "super_admin" + ] + } + }, + "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 9ad8e6c..086a06d 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -99,6 +99,13 @@ "when": 1786569164248, "tag": "0013_chunky_wonder_man", "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1787955499700, + "tag": "0014_misty_silver_samurai", + "breakpoints": true } ] } \ No newline at end of file diff --git a/backend/src/db/schema/auditLogs.ts b/backend/src/db/schema/auditLogs.ts index 75085f5..ff2de82 100644 --- a/backend/src/db/schema/auditLogs.ts +++ b/backend/src/db/schema/auditLogs.ts @@ -12,7 +12,9 @@ import { userRoleEnum, users } from "./users"; export const auditLogs = pgTable("audit_logs", { id: serial("id").primaryKey(), - actorId: uuid("actor_id").references(() => users.id), + actorId: uuid("actor_id").references(() => users.id, { + onDelete: "set null", + }), actorRole: userRoleEnum("actor_role").notNull(), action: varchar("action", { length: 100 }).notNull(), targetType: text("target_type"), diff --git a/backend/src/modules/users/privacy.service.ts b/backend/src/modules/users/privacy.service.ts new file mode 100644 index 0000000..71b1ba5 --- /dev/null +++ b/backend/src/modules/users/privacy.service.ts @@ -0,0 +1,85 @@ +import { eq } from "drizzle-orm"; +import { db } from "../../db/client"; +import { + accounts, + applicationEvents, + keywords, + savedJobs, + userNotifications, + userPreferences, + users, +} from "../../db/schema"; +import type { DB } from "../../db/types/types"; +import { AppError } from "../../lib/errors"; +import { toPublicUser } from "./users.mapper"; + +function exportProfile(user: typeof users.$inferSelect) { + const publicUser = toPublicUser(user); + const { + emailEncrypted: _emailEncrypted, + emailHash: _emailHash, + firstNameEncrypted: _firstNameEncrypted, + lastNameEncrypted: _lastNameEncrypted, + displayNameEncrypted: _displayNameEncrypted, + avatarUrlEncrypted: _avatarUrlEncrypted, + phoneEncrypted: _phoneEncrypted, + cpfEncrypted: _cpfEncrypted, + cpfHash: _cpfHash, + technologiesEncrypted: _technologiesEncrypted, + technologyExperiencesEncrypted: _technologyExperiencesEncrypted, + levelEncrypted: _levelEncrypted, + ...profile + } = publicUser; + return profile; +} + +export class PrivacyService { + constructor(private readonly database: DB = db) {} + + async exportUserData(userId: string) { + const user = await this.database.query.users.findFirst({ + where: eq(users.id, userId), + }); + if (!user) throw AppError.notFound("Usuário não encontrado"); + + const [preferences, jobs, notifications, events, userKeywords, providers] = + await Promise.all([ + this.database.query.userPreferences.findFirst({ + where: eq(userPreferences.userId, userId), + }), + this.database.select().from(savedJobs).where(eq(savedJobs.userId, userId)), + this.database + .select() + .from(userNotifications) + .where(eq(userNotifications.userId, userId)), + this.database + .select() + .from(applicationEvents) + .where(eq(applicationEvents.userId, userId)), + this.database.select().from(keywords).where(eq(keywords.userId, userId)), + this.database + .select({ provider: accounts.provider, createdAt: accounts.createdAt }) + .from(accounts) + .where(eq(accounts.userId, userId)), + ]); + + return { + exportedAt: new Date().toISOString(), + profile: exportProfile(user), + preferences: preferences ?? null, + savedJobs: jobs, + applicationEvents: events, + notifications, + keywords: userKeywords, + connectedAccounts: providers, + }; + } + + async deleteAccount(userId: string): Promise { + const [deleted] = await this.database + .delete(users) + .where(eq(users.id, userId)) + .returning({ id: users.id }); + if (!deleted) throw AppError.notFound("Usuário não encontrado"); + } +} diff --git a/backend/src/modules/users/schemas/user.schemas.ts b/backend/src/modules/users/schemas/user.schemas.ts index 105c714..2b4d40b 100644 --- a/backend/src/modules/users/schemas/user.schemas.ts +++ b/backend/src/modules/users/schemas/user.schemas.ts @@ -71,6 +71,10 @@ export const updatePreferencesSchema = z export const createPreferencesSchema = updatePreferencesSchema; +export const deleteAccountSchema = z.object({ + confirmation: z.literal("EXCLUIR"), +}); + // ── Tipos inferidos ─────────────────────────────────────────────────────────── export type UpdateProfileData = z.infer; diff --git a/backend/src/modules/users/users.controller.ts b/backend/src/modules/users/users.controller.ts index 4d33174..80e02cc 100644 --- a/backend/src/modules/users/users.controller.ts +++ b/backend/src/modules/users/users.controller.ts @@ -4,9 +4,13 @@ import { AppError } from "../../lib/errors"; import { sessionOptions } from "../../lib/session"; import { Session } from "../types/auth.types"; import { UsersService } from "./users.service"; +import { PrivacyService } from "./privacy.service"; export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly privacyService = new PrivacyService(), + ) {} private async getSession(req: Request, res: Response) { return getIronSession(req, res, sessionOptions); @@ -63,4 +67,19 @@ export class UsersController { ); return res.json(updated); } + + async exportData(req: Request, res: Response) { + const userId = await this.requireUserId(req, res); + const data = await this.privacyService.exportUserData(userId); + res.attachment("meus-dados.json"); + return res.json(data); + } + + async deleteAccount(req: Request, res: Response) { + const userId = await this.requireUserId(req, res); + await this.privacyService.deleteAccount(userId); + const session = await this.getSession(req, res); + await session.destroy(); + return res.status(204).send(); + } } diff --git a/backend/src/routes/users.routes.ts b/backend/src/routes/users.routes.ts index f49df08..ab674bb 100644 --- a/backend/src/routes/users.routes.ts +++ b/backend/src/routes/users.routes.ts @@ -4,6 +4,7 @@ import { UsersController } from "../modules/users/users.controller"; import { UsersService } from "../modules/users/users.service"; import { createPreferencesSchema, + deleteAccountSchema, updatePreferencesSchema, updateProfileSchema, } from "../modules/users/schemas/user.schemas"; @@ -22,6 +23,16 @@ router.patch( usersController.updateProfile(req, res).catch(next); }, ); +router.get("/export", (req, res, next) => { + usersController.exportData(req, res).catch(next); +}); +router.delete( + "/account", + validate({ body: deleteAccountSchema }), + (req, res, next) => { + usersController.deleteAccount(req, res).catch(next); + }, +); router.get("/preferences", (req, res, next) => { usersController.getPreferences(req, res).catch(next); }); diff --git a/backend/tests/integration/routes/user.routes.test.ts b/backend/tests/integration/routes/user.routes.test.ts index 33585b4..8fc28cb 100644 --- a/backend/tests/integration/routes/user.routes.test.ts +++ b/backend/tests/integration/routes/user.routes.test.ts @@ -10,6 +10,10 @@ const mockUsersService = vi.hoisted(() => ({ createPreferences: vi.fn(), updatePreferences: vi.fn(), })); +const mockPrivacyService = vi.hoisted(() => ({ + exportUserData: vi.fn(), + deleteAccount: vi.fn(), +})); vi.mock("../../../src/modules/users/users.service", () => ({ UsersService: class { @@ -18,6 +22,13 @@ vi.mock("../../../src/modules/users/users.service", () => ({ } }, })); +vi.mock("../../../src/modules/users/privacy.service", () => ({ + PrivacyService: class { + constructor() { + return mockPrivacyService; + } + }, +})); // ── iron-session ────────────────────────────────────────────────────────────── // O UsersController chama getIronSession diretamente no método getSession(), @@ -84,6 +95,8 @@ describe("Integration - Users Routes", () => { userId: "user_abc", remoteOnly: false, }); + mockPrivacyService.exportUserData.mockResolvedValue({ profile: fixtureUser }); + mockPrivacyService.deleteAccount.mockResolvedValue(undefined); app = createJobsApiApp(); }); @@ -118,6 +131,25 @@ describe("Integration - Users Routes", () => { }); }); + describe("dados e exclusão de conta", () => { + it("exporta somente os dados do usuário autenticado", async () => { + const res = await request(app).get(`${BASE}/export`).expect(200); + expect(res.headers["content-disposition"]).toContain("meus-dados.json"); + expect(res.body).toEqual({ profile: fixtureUser }); + expect(mockPrivacyService.exportUserData).toHaveBeenCalledWith("user_abc"); + }); + + it("exige confirmação explícita e encerra a sessão ao excluir", async () => { + await request(app).delete(`${BASE}/account`).send({}).expect(400); + await request(app) + .delete(`${BASE}/account`) + .send({ confirmation: "EXCLUIR" }) + .expect(204); + expect(mockPrivacyService.deleteAccount).toHaveBeenCalledWith("user_abc"); + expect(fixtureSession.destroy).toHaveBeenCalled(); + }); + }); + // ── PATCH /profile ──────────────────────────────────────────────────────── describe("PATCH /profile", () => { diff --git a/backend/tests/unit/modules/users/privacy.service.test.ts b/backend/tests/unit/modules/users/privacy.service.test.ts new file mode 100644 index 0000000..94f5165 --- /dev/null +++ b/backend/tests/unit/modules/users/privacy.service.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { toPublicUser } = vi.hoisted(() => ({ + toPublicUser: vi.fn(), +})); + +vi.mock("../../../../src/modules/users/users.mapper", () => ({ + toPublicUser, +})); + +import { PrivacyService } from "../../../../src/modules/users/privacy.service"; + +function makeDatabase() { + const select = vi.fn(); + const remove = vi.fn(); + + return { + query: { + users: { findFirst: vi.fn() }, + userPreferences: { findFirst: vi.fn() }, + }, + select, + delete: remove, + }; +} + +function selectResult(result: unknown) { + return { + from: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(result), + }), + }; +} + +describe("PrivacyService", () => { + let database: ReturnType; + let service: PrivacyService; + + beforeEach(() => { + database = makeDatabase(); + service = new PrivacyService(database as any); + toPublicUser.mockReset(); + }); + + it("exporta os dados próprios sem campos criptografados ou hashes", async () => { + database.query.users.findFirst.mockResolvedValue({ id: "user-1" }); + database.query.userPreferences.findFirst.mockResolvedValue({ theme: "dark" }); + database.select + .mockReturnValueOnce(selectResult([{ id: "job-1" }])) + .mockReturnValueOnce(selectResult([{ id: "notification-1" }])) + .mockReturnValueOnce(selectResult([{ id: "event-1" }])) + .mockReturnValueOnce(selectResult([{ id: "keyword-1" }])) + .mockReturnValueOnce(selectResult([{ provider: "google" }])); + toPublicUser.mockReturnValue({ + id: "user-1", + email: "pessoa@example.com", + emailEncrypted: "ciphertext", + emailHash: "hash", + cpfEncrypted: "ciphertext", + cpfHash: "hash", + }); + + const result = await service.exportUserData("user-1"); + + expect(result).toMatchObject({ + profile: { id: "user-1", email: "pessoa@example.com" }, + preferences: { theme: "dark" }, + savedJobs: [{ id: "job-1" }], + notifications: [{ id: "notification-1" }], + applicationEvents: [{ id: "event-1" }], + keywords: [{ id: "keyword-1" }], + connectedAccounts: [{ provider: "google" }], + }); + expect(result.profile).not.toHaveProperty("emailEncrypted"); + expect(result.profile).not.toHaveProperty("emailHash"); + expect(result.profile).not.toHaveProperty("cpfEncrypted"); + expect(result.profile).not.toHaveProperty("cpfHash"); + }); + + it("recusa exportar dados de usuário inexistente", async () => { + database.query.users.findFirst.mockResolvedValue(undefined); + + await expect(service.exportUserData("inexistente")).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); + + it("exclui a conta e retorna not found quando ela não existe", async () => { + const returning = vi.fn().mockResolvedValueOnce([{ id: "user-1" }]).mockResolvedValueOnce([]); + database.delete.mockReturnValue({ + where: vi.fn().mockReturnValue({ returning }), + }); + + await expect(service.deleteAccount("user-1")).resolves.toBeUndefined(); + await expect(service.deleteAccount("inexistente")).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + }); +}); diff --git a/frontend/src/domains/new_dashboard/components/profile/PrivacyPanel.tsx b/frontend/src/domains/new_dashboard/components/profile/PrivacyPanel.tsx new file mode 100644 index 0000000..196153e --- /dev/null +++ b/frontend/src/domains/new_dashboard/components/profile/PrivacyPanel.tsx @@ -0,0 +1,60 @@ +import { useState } from "react"; +import { deleteUserAccount, exportUserData } from "../../infrastructure/userDashboardApi"; + +export function PrivacyPanel({ onDeleted = () => window.location.assign("/") }: { onDeleted?: () => void }) { + const [isExporting, setIsExporting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [confirming, setConfirming] = useState(false); + const [message, setMessage] = useState(""); + + async function handleExport() { + setIsExporting(true); + setMessage(""); + try { + await exportUserData(); + setMessage("Seus dados foram preparados para download."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Não foi possível exportar seus dados."); + } finally { + setIsExporting(false); + } + } + + async function handleDelete() { + setIsDeleting(true); + setMessage(""); + try { + await deleteUserAccount(); + onDeleted(); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Não foi possível excluir sua conta."); + setIsDeleting(false); + } + } + + return ( +
+

Privacidade e dados

+

Baixe uma cópia dos seus dados ou exclua sua conta definitivamente.

+ {message ?

{message}

: null} +
+ + {!confirming ? ( + + ) : ( +
+ Esta ação é definitiva. + + +
+ )} +
+
+ ); +} diff --git a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx index 4b9fe5b..691c7e1 100644 --- a/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx +++ b/frontend/src/domains/new_dashboard/components/profile/ProfileTab.tsx @@ -2,6 +2,7 @@ import type { SearchPreferences, UserProfile } from "../../types"; import { ConnectionsForm } from "./ConnectionsForm"; import { PreferencesForm } from "./PreferencesForm"; import { ProfileForm } from "./ProfileForm"; +import { PrivacyPanel } from "./PrivacyPanel"; interface ProfileTabProps { userProfile: UserProfile; @@ -39,6 +40,7 @@ export function ProfileTab({ onSave={onSavePreferences} /> + ); } diff --git a/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts b/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts index 8bef0e4..28033ef 100644 --- a/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts +++ b/frontend/src/domains/new_dashboard/infrastructure/userDashboardApi.ts @@ -213,3 +213,17 @@ export async function updateUserPreferences(preferences: SearchPreferences) { ); return toSearchPreferences(data); } + +export async function exportUserData() { + const { data } = await api.get("/users/export", { responseType: "blob" }); + const url = URL.createObjectURL(data); + const link = document.createElement("a"); + link.href = url; + link.download = "meus-dados.json"; + link.click(); + URL.revokeObjectURL(url); +} + +export async function deleteUserAccount() { + await api.delete("/users/account", { data: { confirmation: "EXCLUIR" } }); +} diff --git a/frontend/tests/unit/new_dashboard/privacy.test.tsx b/frontend/tests/unit/new_dashboard/privacy.test.tsx new file mode 100644 index 0000000..50ff347 --- /dev/null +++ b/frontend/tests/unit/new_dashboard/privacy.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PrivacyPanel } from "@/domains/new_dashboard/components/profile/PrivacyPanel"; + +const mocks = vi.hoisted(() => ({ + exportUserData: vi.fn(), + deleteUserAccount: vi.fn(), +})); + +vi.mock("@/domains/new_dashboard/infrastructure/userDashboardApi", () => mocks); + +describe("PrivacyPanel", () => { + beforeEach(() => vi.clearAllMocks()); + + it("exporta os dados do usuário", async () => { + mocks.exportUserData.mockResolvedValue(undefined); + render(); + fireEvent.click(screen.getByRole("button", { name: "Exportar meus dados" })); + await waitFor(() => expect(mocks.exportUserData).toHaveBeenCalled()); + expect(screen.getByText("Seus dados foram preparados para download.")).toBeInTheDocument(); + }); + + it("exige uma segunda confirmação antes de excluir", async () => { + mocks.deleteUserAccount.mockResolvedValue(undefined); + const onDeleted = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "Excluir minha conta" })); + expect(screen.getByText("Esta ação é definitiva.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Confirmar exclusão" })); + await waitFor(() => expect(mocks.deleteUserAccount).toHaveBeenCalled()); + expect(onDeleted).toHaveBeenCalled(); + }); +}); From 404543c280e80afad1e97fab901232cacec7d865 Mon Sep 17 00:00:00 2001 From: Jovinull Date: Sat, 29 Aug 2026 15:24:00 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(privacidade):=20anonimizar=20logs=20na?= =?UTF-8?q?=20exclus=C3=A3o=20de=20conta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- backend/src/modules/users/privacy.service.ts | 13 ++++++++++++- .../unit/modules/users/privacy.service.test.ts | 13 +++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2f06dab..6bd18f3 100644 --- a/README.md +++ b/README.md @@ -413,7 +413,7 @@ Usuários autenticados podem exportar seus dados em `GET /users/export` e solici A exportação inclui perfil, preferências, vagas salvas, eventos de candidatura, notificações, keywords e provedores conectados. Senhas, hashes, tokens de acesso e valores internos de criptografia não são incluídos. -A exclusão remove a conta e os registros relacionados por chaves com `ON DELETE CASCADE`; o cookie de sessão também é invalidado. Logs de auditoria são preservados sem vínculo ao usuário excluído. +A exclusão remove a conta e os registros relacionados por chaves com `ON DELETE CASCADE`; o cookie de sessão também é invalidado. Logs de auditoria são preservados apenas para fins operacionais, sem `actor_id`, `target_id`, metadados ou IP que possam vincular o registro ao usuário excluído. Arquivos de exemplo: diff --git a/backend/src/modules/users/privacy.service.ts b/backend/src/modules/users/privacy.service.ts index 71b1ba5..e1bad01 100644 --- a/backend/src/modules/users/privacy.service.ts +++ b/backend/src/modules/users/privacy.service.ts @@ -1,8 +1,9 @@ -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import { db } from "../../db/client"; import { accounts, applicationEvents, + auditLogs, keywords, savedJobs, userNotifications, @@ -76,6 +77,16 @@ export class PrivacyService { } async deleteAccount(userId: string): Promise { + await this.database + .update(auditLogs) + .set({ + actorId: null, + targetId: null, + metadata: null, + ip: null, + }) + .where(or(eq(auditLogs.actorId, userId), eq(auditLogs.targetId, userId))); + const [deleted] = await this.database .delete(users) .where(eq(users.id, userId)) diff --git a/backend/tests/unit/modules/users/privacy.service.test.ts b/backend/tests/unit/modules/users/privacy.service.test.ts index 94f5165..6a14739 100644 --- a/backend/tests/unit/modules/users/privacy.service.test.ts +++ b/backend/tests/unit/modules/users/privacy.service.test.ts @@ -12,6 +12,7 @@ import { PrivacyService } from "../../../../src/modules/users/privacy.service"; function makeDatabase() { const select = vi.fn(); + const update = vi.fn(); const remove = vi.fn(); return { @@ -20,6 +21,7 @@ function makeDatabase() { userPreferences: { findFirst: vi.fn() }, }, select, + update, delete: remove, }; } @@ -87,12 +89,23 @@ describe("PrivacyService", () => { }); it("exclui a conta e retorna not found quando ela não existe", async () => { + const updateWhere = vi.fn().mockResolvedValue(undefined); + database.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ where: updateWhere }), + }); const returning = vi.fn().mockResolvedValueOnce([{ id: "user-1" }]).mockResolvedValueOnce([]); database.delete.mockReturnValue({ where: vi.fn().mockReturnValue({ returning }), }); await expect(service.deleteAccount("user-1")).resolves.toBeUndefined(); + expect(updateWhere).toHaveBeenCalledOnce(); + expect(database.update.mock.results[0].value.set).toHaveBeenCalledWith({ + actorId: null, + targetId: null, + metadata: null, + ip: null, + }); await expect(service.deleteAccount("inexistente")).rejects.toMatchObject({ code: "NOT_FOUND", statusCode: 404,