From 242eaeb3b5a86e3474ca9195142aa75f3a0bb93b Mon Sep 17 00:00:00 2001 From: Ruhan Freitas Date: Mon, 17 Aug 2026 17:58:28 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20adiciona=20hist=C3=B3rico=20de=20ev?= =?UTF-8?q?entos=20de=20candidatura?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/drizzle/0012_woozy_mephistopheles.sql | 2 + backend/drizzle/0013_chunky_wonder_man.sql | 14 + backend/drizzle/meta/0012_snapshot.json | 1074 +++++++++++++++ backend/drizzle/meta/0013_snapshot.json | 1186 +++++++++++++++++ backend/drizzle/meta/_journal.json | 16 +- backend/src/db/schema/applicationEvents.ts | 59 + backend/src/db/schema/index.ts | 1 + backend/src/db/schema/savedJobs.ts | 5 +- .../modules/savedJobs/savedJobs.controller.ts | 12 + .../modules/savedJobs/savedJobs.service.ts | 90 +- backend/src/routes/savedJobs.routes.ts | 3 + .../routes/savedJobs.routes.test.ts | 75 ++ .../savedJobs/savedJobs.controller.test.ts | 53 + .../savedJobs/savedJobs.service.test.ts | 247 +++- 14 files changed, 2781 insertions(+), 56 deletions(-) create mode 100644 backend/drizzle/0012_woozy_mephistopheles.sql create mode 100644 backend/drizzle/0013_chunky_wonder_man.sql create mode 100644 backend/drizzle/meta/0012_snapshot.json create mode 100644 backend/drizzle/meta/0013_snapshot.json create mode 100644 backend/src/db/schema/applicationEvents.ts diff --git a/backend/drizzle/0012_woozy_mephistopheles.sql b/backend/drizzle/0012_woozy_mephistopheles.sql new file mode 100644 index 0000000..31aacbf --- /dev/null +++ b/backend/drizzle/0012_woozy_mephistopheles.sql @@ -0,0 +1,2 @@ +-- Baseline para sincronizar os snapshots do Drizzle com o schema atual. +-- Nenhuma alteração no banco é necessária nesta migration. \ No newline at end of file diff --git a/backend/drizzle/0013_chunky_wonder_man.sql b/backend/drizzle/0013_chunky_wonder_man.sql new file mode 100644 index 0000000..afbc28e --- /dev/null +++ b/backend/drizzle/0013_chunky_wonder_man.sql @@ -0,0 +1,14 @@ +CREATE TABLE "application_events" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "saved_job_id" uuid NOT NULL, + "type" varchar(50) NOT NULL, + "from_status" varchar(50) NOT NULL, + "to_status" varchar(50) NOT NULL, + "metadata" jsonb, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "application_events" ADD CONSTRAINT "application_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "application_events" ADD CONSTRAINT "application_events_saved_job_id_saved_jobs_id_fk" FOREIGN KEY ("saved_job_id") REFERENCES "public"."saved_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "application_events_saved_job_id_created_at_idx" ON "application_events" USING btree ("saved_job_id","created_at"); \ No newline at end of file diff --git a/backend/drizzle/meta/0012_snapshot.json b/backend/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..07d740f --- /dev/null +++ b/backend/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1074 @@ +{ + "id": "81b25c95-62a5-4ec5-a108-d4e2bb04a22c", + "prevId": "e14a6919-eef3-4d88-b1a5-4c11f1c62b6f", + "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.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": "no action", + "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": true + }, + "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/0013_snapshot.json b/backend/drizzle/meta/0013_snapshot.json new file mode 100644 index 0000000..17255aa --- /dev/null +++ b/backend/drizzle/meta/0013_snapshot.json @@ -0,0 +1,1186 @@ +{ + "id": "979ff559-fdaa-4fd2-ad0e-00019bbbd23e", + "prevId": "81b25c95-62a5-4ec5-a108-d4e2bb04a22c", + "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.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": "no action", + "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": true + }, + "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 + }, + "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 + } + }, + "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 ec7a727..9ad8e6c 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -85,6 +85,20 @@ "when": 1784294010863, "tag": "0011_user_notifications", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1786568945436, + "tag": "0012_woozy_mephistopheles", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1786569164248, + "tag": "0013_chunky_wonder_man", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/backend/src/db/schema/applicationEvents.ts b/backend/src/db/schema/applicationEvents.ts new file mode 100644 index 0000000..9afc360 --- /dev/null +++ b/backend/src/db/schema/applicationEvents.ts @@ -0,0 +1,59 @@ +import { InferInsertModel, InferSelectModel } from "drizzle-orm"; +import { + index, + jsonb, + pgTable, + timestamp, + uuid, + varchar, +} from "drizzle-orm/pg-core"; +import { JobStatus, savedJobs } from "./savedJobs"; +import { users } from "./users"; + +export const applicationEventTypeEnum = ["status_changed"] as const; + +export type ApplicationEventType = + (typeof applicationEventTypeEnum)[number]; + +export const applicationEvents = pgTable( + "application_events", + { + id: uuid("id").defaultRandom().primaryKey(), + + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + + savedJobId: uuid("saved_job_id") + .notNull() + .references(() => savedJobs.id, { onDelete: "cascade" }), + + type: varchar("type", { length: 50 }) + .$type() + .notNull(), + + fromStatus: varchar("from_status", { length: 50 }) + .$type() + .notNull(), + + toStatus: varchar("to_status", { length: 50 }) + .$type() + .notNull(), + + metadata: jsonb("metadata").$type>(), + + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => ({ + savedJobCreatedAtIdx: index( + "application_events_saved_job_id_created_at_idx", + ).on(table.savedJobId, table.createdAt), + }), +); + +export type ApplicationEvent = InferSelectModel< + typeof applicationEvents +>; +export type NewApplicationEvent = InferInsertModel< + typeof applicationEvents +>; \ No newline at end of file diff --git a/backend/src/db/schema/index.ts b/backend/src/db/schema/index.ts index 802a982..ca3cfde 100644 --- a/backend/src/db/schema/index.ts +++ b/backend/src/db/schema/index.ts @@ -1,4 +1,5 @@ export * from "./accounts"; +export * from "./applicationEvents"; export * from "./auditLogs"; export * from "./credentials"; export * from "./keywords"; diff --git a/backend/src/db/schema/savedJobs.ts b/backend/src/db/schema/savedJobs.ts index 07f19da..dd1f7a4 100644 --- a/backend/src/db/schema/savedJobs.ts +++ b/backend/src/db/schema/savedJobs.ts @@ -26,7 +26,10 @@ export const savedJobs = pgTable("saved_jobs", { source: text("source"), keyword: text("keyword"), - status: varchar("status", { length: 50 }).default("saved").notNull(), + status: varchar("status", { length: 50 }) + .$type() + .default("saved") + .notNull(), appliedAt: timestamp("applied_at"), notes: text("notes"), diff --git a/backend/src/modules/savedJobs/savedJobs.controller.ts b/backend/src/modules/savedJobs/savedJobs.controller.ts index 3e22e8a..e44821d 100644 --- a/backend/src/modules/savedJobs/savedJobs.controller.ts +++ b/backend/src/modules/savedJobs/savedJobs.controller.ts @@ -55,6 +55,18 @@ export class SavedJobsController { return res.json(job); } + // GET /saved-jobs/:id/events + async getEvents(req: Request, res: Response) { + const userId = await this.requireUserId(req, res); + + const events = await this.service.getEvents( + userId, + req.params.id as string, + ); + + return res.json(events); + } + // DELETE /api/saved-jobs/:id async delete(req: Request, res: Response) { const userId = await this.requireUserId(req, res); diff --git a/backend/src/modules/savedJobs/savedJobs.service.ts b/backend/src/modules/savedJobs/savedJobs.service.ts index eb08a1f..40a6aa6 100644 --- a/backend/src/modules/savedJobs/savedJobs.service.ts +++ b/backend/src/modules/savedJobs/savedJobs.service.ts @@ -1,6 +1,12 @@ import { and, eq } from "drizzle-orm"; import { db } from "../../db/client"; -import { NewSavedJob, SavedJob, savedJobs } from "../../db/schema"; +import { + ApplicationEvent, + applicationEvents, + NewSavedJob, + SavedJob, + savedJobs, +} from "../../db/schema"; import { DB } from "../../db/types/types"; import { ownedBy } from "../../lib/authorization/ownership"; import { AppError } from "../../lib/errors"; @@ -48,26 +54,76 @@ export class SavedJobsService { jobId: string, data: Partial, ): Promise { - const previous = await this.getById(userId, jobId); - if (!previous) { - throw AppError.notFound("Vaga não encontrada"); - } + return this.tx.transaction(async (tx) => { + const [currentJob] = await tx + .select() + .from(savedJobs) + .where( + and(eq(savedJobs.id, jobId), eq(savedJobs.userId, userId)), + ) + .limit(1) + .for("update"); - const result = await this.tx - .update(savedJobs) - .set({ ...data, updatedAt: new Date() }) - .where(and(eq(savedJobs.id, jobId), ownedBy(userId, savedJobs.userId))) - .returning(); + if (!currentJob) { + throw AppError.notFound("Vaga não encontrada"); + } + + const statusChanged = + data.status !== undefined && data.status !== currentJob.status; + + const [updatedJob] = await tx + .update(savedJobs) + .set({ + ...data, + updatedAt: new Date(), + }) + .where( + and(eq(savedJobs.id, jobId), eq(savedJobs.userId, userId)), + ) + .returning(); + + if (!updatedJob) { + throw AppError.notFound("Vaga não encontrada"); + } + + if (statusChanged && data.status) { + await tx.insert(applicationEvents).values({ + userId, + savedJobId: jobId, + type: "status_changed", + fromStatus: currentJob.status, + toStatus: data.status, + }); + + await new NotificationsService(tx).createForJobStatusChange( + userId, + currentJob, + updatedJob, + ); + } - if (!result[0]) { + return updatedJob; + }); + } + + async getEvents( + userId: string, + jobId: string, + ): Promise { + const job = await this.getById(userId, jobId); + + if (!job) { throw AppError.notFound("Vaga não encontrada"); } - await new NotificationsService(this.tx).createForJobStatusChange( - userId, - previous, - result[0], - ); - return result[0]; + + return this.tx.query.applicationEvents.findMany({ + where: (event, { and, eq }) => + and(eq(event.userId, userId), eq(event.savedJobId, jobId)), + orderBy: (event, { asc }) => [ + asc(event.createdAt), + asc(event.id), + ], + }); } async delete(userId: string, jobId: string): Promise { diff --git a/backend/src/routes/savedJobs.routes.ts b/backend/src/routes/savedJobs.routes.ts index e253090..7439a2e 100644 --- a/backend/src/routes/savedJobs.routes.ts +++ b/backend/src/routes/savedJobs.routes.ts @@ -17,6 +17,9 @@ router.get("/", (req, res, next) => { router.get("/:id", (req, res, next) => { controller.getById(req, res).catch(next); }); +router.get("/:id/events", (req, res, next) => { + controller.getEvents(req, res).catch(next); +}); router.post("/", validate({ body: createSavedJobSchema }), (req, res, next) => { controller.create(req, res).catch(next); }); diff --git a/backend/tests/integration/routes/savedJobs.routes.test.ts b/backend/tests/integration/routes/savedJobs.routes.test.ts index 07bbb09..d885491 100644 --- a/backend/tests/integration/routes/savedJobs.routes.test.ts +++ b/backend/tests/integration/routes/savedJobs.routes.test.ts @@ -7,6 +7,7 @@ import { AppError } from "../../../src/lib/errors"; const mockSavedJobsService = vi.hoisted(() => ({ getAll: vi.fn(), getById: vi.fn(), + getEvents: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -66,6 +67,29 @@ const createPayload = { status: "saved", }; +const fixtureEvents = [ + { + id: "event-1", + userId: "user_abc", + savedJobId: "job-1", + type: "status_changed", + fromStatus: "saved", + toStatus: "applied", + metadata: null, + createdAt: new Date("2024-01-02").toISOString(), + }, + { + id: "event-2", + userId: "user_abc", + savedJobId: "job-1", + type: "status_changed", + fromStatus: "applied", + toStatus: "interviewing", + metadata: null, + createdAt: new Date("2024-01-03").toISOString(), + }, +]; + // ───────────────────────────────────────────────────────────────────────────── describe("Integration - SavedJobs Routes", () => { @@ -79,6 +103,7 @@ describe("Integration - SavedJobs Routes", () => { mockSavedJobsService.getAll.mockResolvedValue([fixtureJob]); mockSavedJobsService.getById.mockResolvedValue(fixtureJob); + mockSavedJobsService.getEvents.mockResolvedValue(fixtureEvents); mockSavedJobsService.create.mockResolvedValue(fixtureJob); mockSavedJobsService.update.mockResolvedValue({ ...fixtureJob, @@ -161,6 +186,56 @@ describe("Integration - SavedJobs Routes", () => { }); }); + // ── GET /:id/events ─────────────────────────────────────────────────────── + + describe("GET /:id/events", () => { + it("retorna os eventos em ordem cronológica", async () => { + const res = await request(app) + .get(`${BASE}/job-1/events`) + .expect(200); + + expect(res.body).toEqual(fixtureEvents); + expect(res.body.map((event: { id: string }) => event.id)).toEqual([ + "event-1", + "event-2", + ]); + }); + + it("usa o usuário autenticado para isolar a listagem", async () => { + await request(app).get(`${BASE}/job-1/events`).expect(200); + + expect(mockSavedJobsService.getEvents).toHaveBeenCalledWith( + "user_abc", + "job-1", + ); + }); + + it("retorna 401 quando não há usuário autenticado", async () => { + vi.mocked(getIronSession).mockResolvedValueOnce({ + userId: undefined, + } as any); + + await request(app).get(`${BASE}/job-1/events`).expect(401); + + expect(mockSavedJobsService.getEvents).not.toHaveBeenCalled(); + }); + + it("retorna 404 quando a vaga não pertence ao usuário", async () => { + mockSavedJobsService.getEvents.mockRejectedValueOnce( + AppError.notFound("Vaga não encontrada"), + ); + + const res = await request(app) + .get(`${BASE}/job-2/events`) + .expect(404); + + expect(res.body).toEqual({ + code: "NOT_FOUND", + message: "Vaga não encontrada", + }); + }); + }); + // ── POST / ──────────────────────────────────────────────────────────────── describe("POST /", () => { diff --git a/backend/tests/unit/modules/savedJobs/savedJobs.controller.test.ts b/backend/tests/unit/modules/savedJobs/savedJobs.controller.test.ts index 717ab78..75fa5a0 100644 --- a/backend/tests/unit/modules/savedJobs/savedJobs.controller.test.ts +++ b/backend/tests/unit/modules/savedJobs/savedJobs.controller.test.ts @@ -10,6 +10,7 @@ vi.mock("iron-session", () => ({ const mockService = { getAll: vi.fn(), getById: vi.fn(), + getEvents: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -167,6 +168,58 @@ describe("SavedJobsController", () => { }); }); + describe("getEvents", () => { + it("lança UNAUTHORIZED quando não autenticado", async () => { + (getIronSession as any).mockResolvedValue({}); + + await expect( + controller.getEvents( + { params: { id: "job-1" } } as any, + createMockResponse(), + ), + ).rejects.toMatchObject({ code: "UNAUTHORIZED", statusCode: 401 }); + }); + + it("lista eventos da vaga do usuário autenticado", async () => { + const events = [ + { + id: "event-1", + fromStatus: "saved", + toStatus: "applied", + }, + ]; + + (getIronSession as any).mockResolvedValue({ userId: "user-1" }); + mockService.getEvents.mockResolvedValue(events); + const res = createMockResponse(); + + await controller.getEvents( + { params: { id: "job-1" } } as any, + res, + ); + + expect(mockService.getEvents).toHaveBeenCalledWith( + "user-1", + "job-1", + ); + expect(res.json).toHaveBeenCalledWith(events); + }); + + it("propaga NOT_FOUND quando a vaga não pertence ao usuário", async () => { + (getIronSession as any).mockResolvedValue({ userId: "user-1" }); + mockService.getEvents.mockRejectedValue( + AppError.notFound("Vaga não encontrada"), + ); + + await expect( + controller.getEvents( + { params: { id: "job-2" } } as any, + createMockResponse(), + ), + ).rejects.toMatchObject({ code: "NOT_FOUND", statusCode: 404 }); + }); + }); + describe("delete", () => { it("lança UNAUTHORIZED", async () => { (getIronSession as any).mockResolvedValue({}); diff --git a/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts b/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts index 8247dc4..e0255f0 100644 --- a/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts +++ b/backend/tests/unit/modules/savedJobs/savedJobs.service.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SavedJob } from "../../../../src/db/schema"; const drizzleMocks = vi.hoisted(() => ({ and: vi.fn(), @@ -16,12 +17,18 @@ vi.mock("drizzle-orm", async (importOriginal) => { // ─── Fixtures ───────────────────────────────────────────────────────────────── -const mockJob = { +const mockJob: SavedJob = { id: "job-1", userId: "user-1", jobLink: "https://example.com/job/1", jobTitle: "Engenheiro de Software", company: "Empresa X", + status: "saved", + appliedAt: null, + notes: null, + location: null, + source: null, + keyword: null, createdAt: new Date("2024-01-01"), updatedAt: new Date("2024-01-01"), }; @@ -29,17 +36,50 @@ const mockJob = { // ─── Mock DB factory ────────────────────────────────────────────────────────── function makeMockTx() { - return { + const tx: any = { query: { savedJobs: { findMany: vi.fn(), findFirst: vi.fn(), }, + applicationEvents: { + findMany: vi.fn(), + }, }, + select: vi.fn(), insert: vi.fn(), update: vi.fn(), delete: vi.fn(), + transaction: vi.fn(), }; + + tx.transaction.mockImplementation( + async (callback: (transaction: typeof tx) => unknown) => + callback(tx), + ); + + return tx; +} + +// HELPER + +function mockLockedJob( + tx: ReturnType, + job: SavedJob | undefined, +) { + const forUpdate = vi.fn().mockResolvedValue(job ? [job] : []); + + tx.select.mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + for: forUpdate, + }), + }), + }), + }); + + return forUpdate; } // ─── Import after vitest setup ──────────────────────────────────────────────── @@ -56,6 +96,7 @@ describe("SavedJobsService", () => { beforeEach(() => { tx = makeMockTx(); service = new SavedJobsService(tx as any); + mockLockedJob(tx, mockJob); drizzleMocks.and.mockImplementation((...conditions) => conditions); drizzleMocks.eq.mockImplementation((column, value) => ({ column, @@ -201,7 +242,6 @@ describe("SavedJobsService", () => { describe("update", () => { it("atualiza e retorna a vaga", async () => { - tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); tx.update.mockReturnValue({ set: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ @@ -244,13 +284,93 @@ describe("SavedJobsService", () => { }); }); + it("cria evento e notificação quando o status muda", async () => { + const previousJob = { + ...mockJob, + status: "saved" as const, + }; + + const updatedJob = { + ...mockJob, + status: "applied" as const, + }; + + mockLockedJob(tx, previousJob); + + tx.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([updatedJob]), + }), + }), + }); + + const eventValues = vi.fn().mockResolvedValue(undefined); + + const notificationValues = vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { id: "notification-1" }, + ]), + }); + + tx.insert + .mockReturnValueOnce({ values: eventValues }) + .mockReturnValueOnce({ values: notificationValues }); + + const result = await service.update("user-1", "job-1", { + status: "applied", + }); + + expect(result.status).toBe("applied"); + + expect(eventValues).toHaveBeenCalledWith({ + userId: "user-1", + savedJobId: "job-1", + type: "status_changed", + fromStatus: "saved", + toStatus: "applied", + }); + + expect(notificationValues).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + type: "job_applied", + entityId: "job-1", + }), + ); + + expect(tx.transaction).toHaveBeenCalledOnce(); + }); + + it("não cria evento quando o status não muda", async () => { + const appliedJob = { + ...mockJob, + status: "applied" as const, + }; + + mockLockedJob(tx, appliedJob); + + tx.update.mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([appliedJob]), + }), + }), + }); + + await service.update("user-1", "job-1", { + status: "applied", + }); + + expect(tx.insert).not.toHaveBeenCalled(); + }); + it("inclui updatedAt no set", async () => { const setMock = vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([mockJob]), }), }); - tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); tx.update.mockReturnValue({ set: setMock }); await service.update("user-1", "job-1", { jobTitle: "X" }); @@ -264,7 +384,6 @@ describe("SavedJobsService", () => { const whereMock = vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([mockJob]), }); - tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); tx.update.mockReturnValue({ set: vi.fn().mockReturnValue({ where: whereMock }), }); @@ -276,43 +395,97 @@ describe("SavedJobsService", () => { { column: expect.anything(), value: "user-1" }, ]); }); + }); - it("cria notificação quando o status da vaga muda", async () => { - const previousJob = { ...mockJob, status: "saved" }; - const updatedJob = { ...mockJob, status: "applied" }; - const notificationValues = vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([{ id: "notification-1" }]), - }); + // ── getEvents ────────────────────────────────────────────────────────────── - tx.query.savedJobs.findFirst.mockResolvedValue(previousJob); - tx.update.mockReturnValue({ - set: vi.fn().mockReturnValue({ - where: vi.fn().mockReturnValue({ - returning: vi.fn().mockResolvedValue([updatedJob]), - }), - }), - }); - tx.insert.mockReturnValueOnce({ values: notificationValues }); + describe("getEvents", () => { + const events = [ + { + id: "event-1", + userId: "user-1", + savedJobId: "job-1", + type: "status_changed", + fromStatus: "saved", + toStatus: "applied", + metadata: null, + createdAt: new Date("2024-01-01"), + }, + { + id: "event-2", + userId: "user-1", + savedJobId: "job-1", + type: "status_changed", + fromStatus: "applied", + toStatus: "interviewing", + metadata: null, + createdAt: new Date("2024-01-02"), + }, + ]; - const result = await service.update("user-1", "job-1", { - status: "applied", - }); + it("retorna somente os eventos da vaga do usuário", async () => { + tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); + tx.query.applicationEvents.findMany.mockResolvedValue(events); - expect(result.status).toBe("applied"); - expect(tx.insert).toHaveBeenCalledOnce(); - expect(notificationValues).toHaveBeenCalledWith( - expect.objectContaining({ - userId: "user-1", - channel: "notification", - type: "job_applied", - entityType: "job", - entityId: updatedJob.id, - metadata: expect.objectContaining({ - previousStatus: "saved", - status: "applied", - }), - }), + const result = await service.getEvents("user-1", "job-1"); + + expect(result).toEqual(events); + + const options = tx.query.applicationEvents.findMany.mock.calls[0][0]; + const operators = { + and: (...conditions: unknown[]) => conditions, + eq: (column: unknown, value: unknown) => ({ column, value }), + }; + const table = { + userId: "applicationEvents.userId", + savedJobId: "applicationEvents.savedJobId", + }; + + expect(options.where(table, operators)).toEqual([ + { column: "applicationEvents.userId", value: "user-1" }, + { column: "applicationEvents.savedJobId", value: "job-1" }, + ]); + }); + + it("ordena do evento mais antigo para o mais recente", async () => { + tx.query.savedJobs.findFirst.mockResolvedValue(mockJob); + tx.query.applicationEvents.findMany.mockResolvedValue(events); + + await service.getEvents("user-1", "job-1"); + + const options = tx.query.applicationEvents.findMany.mock.calls[0][0]; + const asc = vi.fn((column) => ({ direction: "asc", column })); + const order = options.orderBy( + { + createdAt: "applicationEvents.createdAt", + id: "applicationEvents.id", + }, + { asc }, ); + + expect(order).toEqual([ + { + direction: "asc", + column: "applicationEvents.createdAt", + }, + { + direction: "asc", + column: "applicationEvents.id", + }, + ]); + }); + + it("não lista eventos de vaga que não pertence ao usuário", async () => { + tx.query.savedJobs.findFirst.mockResolvedValue(undefined); + + await expect( + service.getEvents("user-2", "job-1"), + ).rejects.toMatchObject({ + code: "NOT_FOUND", + statusCode: 404, + }); + + expect(tx.query.applicationEvents.findMany).not.toHaveBeenCalled(); }); }); From 48df9a587d599bd3037b86bdd2d2c8781e389016 Mon Sep 17 00:00:00 2001 From: Ruhan Freitas Date: Mon, 17 Aug 2026 19:52:02 -0300 Subject: [PATCH 2/2] chore: tigerr CI