diff --git a/README.md b/README.md index 1b81131..6bd18f3 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 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: - .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..e1bad01 --- /dev/null +++ b/backend/src/modules/users/privacy.service.ts @@ -0,0 +1,96 @@ +import { eq, or } from "drizzle-orm"; +import { db } from "../../db/client"; +import { + accounts, + applicationEvents, + auditLogs, + 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 { + 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)) + .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..6a14739 --- /dev/null +++ b/backend/tests/unit/modules/users/privacy.service.test.ts @@ -0,0 +1,114 @@ +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 update = vi.fn(); + const remove = vi.fn(); + + return { + query: { + users: { findFirst: vi.fn() }, + userPreferences: { findFirst: vi.fn() }, + }, + select, + update, + 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 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, + }); + }); +}); 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(); + }); +});