From 12ddb6f9b9caea5b8cea4ea2a4e6d1ecc2a65962 Mon Sep 17 00:00:00 2001 From: devjaes Date: Tue, 25 Nov 2025 07:50:37 -0500 Subject: [PATCH 1/6] feat: [phase-4] introduce recommendations feature with user-specific suggestions - Added a new `recommendations` table to store personalized suggestions for users, including types, priorities, and actionable items. - Implemented a `recommendations_enabled` column in the `users` table to control feature access. - Developed services for generating recommendations based on spending analysis, goal optimization, debt reminders, and more. - Created DTOs for handling recommendation data and integrated AI-driven insights for actionable suggestions. - Enhanced the database schema and updated snapshots to reflect the new structure and relationships. --- drizzle/0008_brown_stark_industries.sql | 1 + drizzle/0009_overjoyed_dust.sql | 23 + drizzle/meta/0008_snapshot.json | 1798 +++++++++++++++ drizzle/meta/0009_snapshot.json | 1979 +++++++++++++++++ drizzle/meta/_journal.json | 14 + src/core/infrastructure/database/schema.ts | 36 + .../application/dtos/recommendation.dto.ts | 75 + .../services/budget-suggestion.service.ts | 255 +++ .../services/debt-reminder.service.ts | 256 +++ .../services/goal-optimization.service.ts | 284 +++ .../services/spending-analysis.service.ts | 252 +++ .../domain/entities/recommendation.entity.ts | 29 + .../domain/entities/recommendation.types.ts | 19 + .../ports/analysis.service.interface.ts | 15 + .../ports/orchestrator.service.interface.ts | 5 + .../recommendation.repository.interface.ts | 29 + 16 files changed, 5070 insertions(+) create mode 100644 drizzle/0008_brown_stark_industries.sql create mode 100644 drizzle/0009_overjoyed_dust.sql create mode 100644 drizzle/meta/0008_snapshot.json create mode 100644 drizzle/meta/0009_snapshot.json create mode 100644 src/features/recommendations/application/dtos/recommendation.dto.ts create mode 100644 src/features/recommendations/application/services/budget-suggestion.service.ts create mode 100644 src/features/recommendations/application/services/debt-reminder.service.ts create mode 100644 src/features/recommendations/application/services/goal-optimization.service.ts create mode 100644 src/features/recommendations/application/services/spending-analysis.service.ts create mode 100644 src/features/recommendations/domain/entities/recommendation.entity.ts create mode 100644 src/features/recommendations/domain/entities/recommendation.types.ts create mode 100644 src/features/recommendations/domain/ports/analysis.service.interface.ts create mode 100644 src/features/recommendations/domain/ports/orchestrator.service.interface.ts create mode 100644 src/features/recommendations/domain/ports/recommendation.repository.interface.ts diff --git a/drizzle/0008_brown_stark_industries.sql b/drizzle/0008_brown_stark_industries.sql new file mode 100644 index 0000000..d07d0ce --- /dev/null +++ b/drizzle/0008_brown_stark_industries.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "recommendations_enabled" boolean DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/0009_overjoyed_dust.sql b/drizzle/0009_overjoyed_dust.sql new file mode 100644 index 0000000..34255f7 --- /dev/null +++ b/drizzle/0009_overjoyed_dust.sql @@ -0,0 +1,23 @@ +CREATE TABLE "recommendations" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer NOT NULL, + "type" varchar(50) NOT NULL, + "priority" varchar(20) NOT NULL, + "title" varchar(255) NOT NULL, + "description" text NOT NULL, + "data" jsonb, + "actionable" boolean DEFAULT false NOT NULL, + "actions" jsonb, + "status" varchar(20) DEFAULT 'PENDING' NOT NULL, + "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, + "expires_at" timestamp, + "viewed_at" timestamp, + "dismissed_at" timestamp, + "acted_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "recommendations" ADD CONSTRAINT "recommendations_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "rec_user_idx" ON "recommendations" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "rec_status_idx" ON "recommendations" USING btree ("status");--> statement-breakpoint +CREATE INDEX "rec_type_idx" ON "recommendations" USING btree ("type");--> statement-breakpoint +CREATE INDEX "rec_created_idx" ON "recommendations" USING btree ("created_at"); \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..7399774 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1798 @@ +{ + "id": "e4b93977-a996-4efc-8131-3bdc1d6f44e8", + "prevId": "0af304cf-d2cb-4241-afc6-947d48b98414", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.budgets": { + "name": "budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "limit_amount": { + "name": "limit_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "budget_user_idx": { + "name": "budget_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_idx": { + "name": "budget_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_month_idx": { + "name": "budget_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budgets_user_id_users_id_fk": { + "name": "budgets_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_shared_user_id_users_id_fk": { + "name": "budgets_shared_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_category_id_categories_id_fk": { + "name": "budgets_category_id_categories_id_fk", + "tableFrom": "budgets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.debts": { + "name": "debts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_amount": { + "name": "original_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "pending_amount": { + "name": "pending_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creditor_id": { + "name": "creditor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "debts_user_idx": { + "name": "debts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_creditor_idx": { + "name": "debts_creditor_idx", + "columns": [ + { + "expression": "creditor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_due_date_idx": { + "name": "debts_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "debts_user_id_users_id_fk": { + "name": "debts_user_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_creditor_id_users_id_fk": { + "name": "debts_creditor_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "creditor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_category_id_categories_id_fk": { + "name": "debts_category_id_categories_id_fk", + "tableFrom": "debts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friends": { + "name": "friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_date": { + "name": "connection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "friends_user_idx": { + "name": "friends_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friends_friend_idx": { + "name": "friends_friend_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contribution_schedule": { + "name": "goal_contribution_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gcs_goal_idx": { + "name": "gcs_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_user_idx": { + "name": "gcs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_date_idx": { + "name": "gcs_date_idx", + "columns": [ + { + "expression": "scheduled_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_status_idx": { + "name": "gcs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contribution_schedule_goal_id_goals_id_fk": { + "name": "goal_contribution_schedule_goal_id_goals_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_user_id_users_id_fk": { + "name": "goal_contribution_schedule_user_id_users_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_contribution_id_goal_contributions_id_fk": { + "name": "goal_contribution_schedule_contribution_id_goal_contributions_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contributions": { + "name": "goal_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gc_goal_idx": { + "name": "gc_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_user_idx": { + "name": "gc_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_date_idx": { + "name": "gc_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contributions_goal_id_goals_id_fk": { + "name": "goal_contributions_goal_id_goals_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contributions_user_id_users_id_fk": { + "name": "goal_contributions_user_id_users_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "contribution_frequency": { + "name": "contribution_frequency", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_amount": { + "name": "contribution_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "goals_user_idx": { + "name": "goals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "goals_shared_user_idx": { + "name": "goals_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_user_id_users_id_fk": { + "name": "goals_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_shared_user_id_users_id_fk": { + "name": "goals_shared_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_category_id_categories_id_fk": { + "name": "goals_category_id_categories_id_fk", + "tableFrom": "goals", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notif_user_idx": { + "name": "notif_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_read_idx": { + "name": "notif_read_idx", + "columns": [ + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_expires_idx": { + "name": "notif_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_four_digits": { + "name": "last_four_digits", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pm_user_idx": { + "name": "pm_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pm_shared_user_idx": { + "name": "pm_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_users_id_fk": { + "name": "payment_methods_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_methods_shared_user_id_users_id_fk": { + "name": "payment_methods_shared_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reports_user_id_users_id_fk": { + "name": "reports_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_transactions": { + "name": "scheduled_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "next_execution_date": { + "name": "next_execution_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "st_user_idx": { + "name": "st_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "st_next_date_idx": { + "name": "st_next_date_idx", + "columns": [ + { + "expression": "next_execution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_transactions_user_id_users_id_fk": { + "name": "scheduled_transactions_user_id_users_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_category_id_categories_id_fk": { + "name": "scheduled_transactions_category_id_categories_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_payment_method_id_payment_methods_id_fk": { + "name": "scheduled_transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scheduled_transaction_id": { + "name": "scheduled_transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "debt_id": { + "name": "debt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tx_user_idx": { + "name": "tx_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_date_idx": { + "name": "tx_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_category_idx": { + "name": "tx_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_budget_idx": { + "name": "tx_budget_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_category_id_categories_id_fk": { + "name": "transactions_category_id_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_payment_method_id_payment_methods_id_fk": { + "name": "transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_debt_id_debts_id_fk": { + "name": "transactions_debt_id_debts_id_fk", + "tableFrom": "transactions", + "tableTo": "debts", + "columnsFrom": [ + "debt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_contribution_id_goal_contributions_id_fk": { + "name": "transactions_contribution_id_goal_contributions_id_fk", + "tableFrom": "transactions", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_budget_id_budgets_id_fk": { + "name": "transactions_budget_id_budgets_id_fk", + "tableFrom": "transactions", + "tableTo": "budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "recovery_token": { + "name": "recovery_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "recovery_token_expires": { + "name": "recovery_token_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "recommendations_enabled": { + "name": "recommendations_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..13caf79 --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1979 @@ +{ + "id": "8a929132-e101-46b3-afd5-d42f74355682", + "prevId": "e4b93977-a996-4efc-8131-3bdc1d6f44e8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.budgets": { + "name": "budgets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "limit_amount": { + "name": "limit_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "month": { + "name": "month", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "budget_user_idx": { + "name": "budget_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_category_idx": { + "name": "budget_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "budget_month_idx": { + "name": "budget_month_idx", + "columns": [ + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "budgets_user_id_users_id_fk": { + "name": "budgets_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_shared_user_id_users_id_fk": { + "name": "budgets_shared_user_id_users_id_fk", + "tableFrom": "budgets", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "budgets_category_id_categories_id_fk": { + "name": "budgets_category_id_categories_id_fk", + "tableFrom": "budgets", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_name_unique": { + "name": "categories_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.debts": { + "name": "debts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_amount": { + "name": "original_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "pending_amount": { + "name": "pending_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid": { + "name": "paid", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "creditor_id": { + "name": "creditor_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "debts_user_idx": { + "name": "debts_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_creditor_idx": { + "name": "debts_creditor_idx", + "columns": [ + { + "expression": "creditor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "debts_due_date_idx": { + "name": "debts_due_date_idx", + "columns": [ + { + "expression": "due_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "debts_user_id_users_id_fk": { + "name": "debts_user_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_creditor_id_users_id_fk": { + "name": "debts_creditor_id_users_id_fk", + "tableFrom": "debts", + "tableTo": "users", + "columnsFrom": [ + "creditor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "debts_category_id_categories_id_fk": { + "name": "debts_category_id_categories_id_fk", + "tableFrom": "debts", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.friends": { + "name": "friends", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "friend_id": { + "name": "friend_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "connection_date": { + "name": "connection_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "friends_user_idx": { + "name": "friends_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "friends_friend_idx": { + "name": "friends_friend_idx", + "columns": [ + { + "expression": "friend_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "friends_user_id_users_id_fk": { + "name": "friends_user_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "friends_friend_id_users_id_fk": { + "name": "friends_friend_id_users_id_fk", + "tableFrom": "friends", + "tableTo": "users", + "columnsFrom": [ + "friend_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contribution_schedule": { + "name": "goal_contribution_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "scheduled_date": { + "name": "scheduled_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gcs_goal_idx": { + "name": "gcs_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_user_idx": { + "name": "gcs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_date_idx": { + "name": "gcs_date_idx", + "columns": [ + { + "expression": "scheduled_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gcs_status_idx": { + "name": "gcs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contribution_schedule_goal_id_goals_id_fk": { + "name": "goal_contribution_schedule_goal_id_goals_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_user_id_users_id_fk": { + "name": "goal_contribution_schedule_user_id_users_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contribution_schedule_contribution_id_goal_contributions_id_fk": { + "name": "goal_contribution_schedule_contribution_id_goal_contributions_id_fk", + "tableFrom": "goal_contribution_schedule", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goal_contributions": { + "name": "goal_contributions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "goal_id": { + "name": "goal_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "gc_goal_idx": { + "name": "gc_goal_idx", + "columns": [ + { + "expression": "goal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_user_idx": { + "name": "gc_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "gc_date_idx": { + "name": "gc_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goal_contributions_goal_id_goals_id_fk": { + "name": "goal_contributions_goal_id_goals_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "goals", + "columnsFrom": [ + "goal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goal_contributions_user_id_users_id_fk": { + "name": "goal_contributions_user_id_users_id_fk", + "tableFrom": "goal_contributions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.goals": { + "name": "goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "target_amount": { + "name": "target_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "current_amount": { + "name": "current_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "contribution_frequency": { + "name": "contribution_frequency", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "contribution_amount": { + "name": "contribution_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "goals_user_idx": { + "name": "goals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "goals_shared_user_idx": { + "name": "goals_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "goals_user_id_users_id_fk": { + "name": "goals_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_shared_user_id_users_id_fk": { + "name": "goals_shared_user_id_users_id_fk", + "tableFrom": "goals", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "goals_category_id_categories_id_fk": { + "name": "goals_category_id_categories_id_fk", + "tableFrom": "goals", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read": { + "name": "read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "notif_user_idx": { + "name": "notif_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_read_idx": { + "name": "notif_read_idx", + "columns": [ + { + "expression": "read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notif_expires_idx": { + "name": "notif_expires_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payment_methods": { + "name": "payment_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shared_user_id": { + "name": "shared_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "last_four_digits": { + "name": "last_four_digits", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "pm_user_idx": { + "name": "pm_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pm_shared_user_idx": { + "name": "pm_shared_user_idx", + "columns": [ + { + "expression": "shared_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_methods_user_id_users_id_fk": { + "name": "payment_methods_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "payment_methods_shared_user_id_users_id_fk": { + "name": "payment_methods_shared_user_id_users_id_fk", + "tableFrom": "payment_methods", + "tableTo": "users", + "columnsFrom": [ + "shared_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recommendations": { + "name": "recommendations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actionable": { + "name": "actionable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'PENDING'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dismissed_at": { + "name": "dismissed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "acted_at": { + "name": "acted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "rec_user_idx": { + "name": "rec_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_status_idx": { + "name": "rec_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_type_idx": { + "name": "rec_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "rec_created_idx": { + "name": "rec_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recommendations_user_id_users_id_fk": { + "name": "recommendations_user_id_users_id_fk", + "tableFrom": "recommendations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "reports_user_id_users_id_fk": { + "name": "reports_user_id_users_id_fk", + "tableFrom": "reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scheduled_transactions": { + "name": "scheduled_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "frequency": { + "name": "frequency", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "next_execution_date": { + "name": "next_execution_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "st_user_idx": { + "name": "st_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "st_next_date_idx": { + "name": "st_next_date_idx", + "columns": [ + { + "expression": "next_execution_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scheduled_transactions_user_id_users_id_fk": { + "name": "scheduled_transactions_user_id_users_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_category_id_categories_id_fk": { + "name": "scheduled_transactions_category_id_categories_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "scheduled_transactions_payment_method_id_payment_methods_id_fk": { + "name": "scheduled_transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "scheduled_transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.transactions": { + "name": "transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_method_id": { + "name": "payment_method_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "scheduled_transaction_id": { + "name": "scheduled_transaction_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "debt_id": { + "name": "debt_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "contribution_id": { + "name": "contribution_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "budget_id": { + "name": "budget_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": { + "tx_user_idx": { + "name": "tx_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_date_idx": { + "name": "tx_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_category_idx": { + "name": "tx_category_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tx_budget_idx": { + "name": "tx_budget_idx", + "columns": [ + { + "expression": "budget_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "transactions_user_id_users_id_fk": { + "name": "transactions_user_id_users_id_fk", + "tableFrom": "transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_category_id_categories_id_fk": { + "name": "transactions_category_id_categories_id_fk", + "tableFrom": "transactions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_payment_method_id_payment_methods_id_fk": { + "name": "transactions_payment_method_id_payment_methods_id_fk", + "tableFrom": "transactions", + "tableTo": "payment_methods", + "columnsFrom": [ + "payment_method_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_debt_id_debts_id_fk": { + "name": "transactions_debt_id_debts_id_fk", + "tableFrom": "transactions", + "tableTo": "debts", + "columnsFrom": [ + "debt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_contribution_id_goal_contributions_id_fk": { + "name": "transactions_contribution_id_goal_contributions_id_fk", + "tableFrom": "transactions", + "tableTo": "goal_contributions", + "columnsFrom": [ + "contribution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "transactions_budget_id_budgets_id_fk": { + "name": "transactions_budget_id_budgets_id_fk", + "tableFrom": "transactions", + "tableTo": "budgets", + "columnsFrom": [ + "budget_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "registration_date": { + "name": "registration_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "recovery_token": { + "name": "recovery_token", + "type": "varchar", + "primaryKey": false, + "notNull": false + }, + "recovery_token_expires": { + "name": "recovery_token_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "recommendations_enabled": { + "name": "recommendations_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "CURRENT_TIMESTAMP" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index bf31a60..c6c2225 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -57,6 +57,20 @@ "when": 1745570066019, "tag": "0007_acoustic_doctor_faustus", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1764072661093, + "tag": "0008_brown_stark_industries", + "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1764072927377, + "tag": "0009_overjoyed_dust", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/core/infrastructure/database/schema.ts b/src/core/infrastructure/database/schema.ts index 99d7fbd..a943135 100644 --- a/src/core/infrastructure/database/schema.ts +++ b/src/core/infrastructure/database/schema.ts @@ -26,6 +26,9 @@ export const users = pgTable("users", { active: boolean("active").default(true).notNull(), recovery_token: varchar("recovery_token"), recovery_token_expires: timestamp("recovery_token_expires"), + recommendations_enabled: boolean("recommendations_enabled") + .default(false) + .notNull(), created_at: timestamp("created_at") .default(sql`CURRENT_TIMESTAMP`) .notNull(), @@ -384,3 +387,36 @@ export const reports = pgTable("reports", { .notNull(), expires_at: timestamp("expires_at").notNull(), }); + +export const recommendations = pgTable( + "recommendations", + { + id: serial("id").primaryKey(), + user_id: integer("user_id") + .references(() => users.id) + .notNull(), + type: varchar("type", { length: 50 }).notNull(), + priority: varchar("priority", { length: 20 }).notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description").notNull(), + data: jsonb("data"), + actionable: boolean("actionable").default(false).notNull(), + actions: jsonb("actions"), + status: varchar("status", { length: 20 }).default("PENDING").notNull(), + created_at: timestamp("created_at") + .default(sql`CURRENT_TIMESTAMP`) + .notNull(), + expires_at: timestamp("expires_at"), + viewed_at: timestamp("viewed_at"), + dismissed_at: timestamp("dismissed_at"), + acted_at: timestamp("acted_at"), + }, + (table) => { + return { + user_idx: index("rec_user_idx").on(table.user_id), + status_idx: index("rec_status_idx").on(table.status), + type_idx: index("rec_type_idx").on(table.type), + created_idx: index("rec_created_idx").on(table.created_at), + }; + } +); diff --git a/src/features/recommendations/application/dtos/recommendation.dto.ts b/src/features/recommendations/application/dtos/recommendation.dto.ts new file mode 100644 index 0000000..81fe36c --- /dev/null +++ b/src/features/recommendations/application/dtos/recommendation.dto.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../../domain/entities/recommendation.types"; +import { + Recommendation, + QuickAction, +} from "../../domain/entities/recommendation.entity"; + +export const createRecommendationSchema = z.object({ + userId: z.number(), + type: z.enum([ + "SPENDING_ANALYSIS", + "GOAL_OPTIMIZATION", + "BUDGET_SUGGESTION", + "DEBT_REMINDER", + ]), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]), + title: z.string().min(1).max(255), + description: z.string().min(1), + data: z.any().optional(), + actionable: z.boolean().default(false), + actions: z + .array( + z.object({ + label: z.string(), + path: z.string(), + prefilledData: z.record(z.any()).optional(), + }) + ) + .optional(), + expiresAt: z.date().optional(), +}); + +export type CreateRecommendationDTO = z.infer< + typeof createRecommendationSchema +>; + +export const updateStatusSchema = z.object({ + status: z.enum(["VIEWED", "DISMISSED", "ACTED"]), +}); + +export type UpdateStatusDTO = z.infer; + +export interface RecommendationResponseDTO { + id: number; + type: RecommendationType; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actionable: boolean; + actions?: QuickAction[]; + status: RecommendationStatus; + createdAt: string; + expiresAt?: string; +} + +export function toResponseDTO(rec: Recommendation): RecommendationResponseDTO { + return { + id: rec.id, + type: rec.type, + priority: rec.priority, + title: rec.title, + description: rec.description, + data: rec.data, + actionable: rec.actionable, + actions: rec.actions, + status: rec.status, + createdAt: rec.createdAt.toISOString(), + expiresAt: rec.expiresAt?.toISOString(), + }; +} diff --git a/src/features/recommendations/application/services/budget-suggestion.service.ts b/src/features/recommendations/application/services/budget-suggestion.service.ts new file mode 100644 index 0000000..edb0384 --- /dev/null +++ b/src/features/recommendations/application/services/budget-suggestion.service.ts @@ -0,0 +1,255 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgTransactionRepository } from "@/transactions/infrastructure/adapters/transaction.repository"; +import { PgBudgetRepository } from "@/budgets/infrastructure/adapters/budget.repository"; +import { PgCategoryRepository } from "@/categories/infrastructure/adapters/category.repository"; + +interface CategoryWithoutBudget { + categoryId: number; + categoryName: string; + averageMonthlySpending: number; +} + +export class BudgetSuggestionService implements IAnalysisService { + private static instance: BudgetSuggestionService; + private transactionRepository: PgTransactionRepository; + private budgetRepository: PgBudgetRepository; + private categoryRepository: PgCategoryRepository; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + private readonly MIN_AVERAGE_SPENDING = 50; + + private constructor() { + this.transactionRepository = PgTransactionRepository.getInstance(); + this.budgetRepository = PgBudgetRepository.getInstance(); + this.categoryRepository = PgCategoryRepository.getInstance(); + } + + public static getInstance(): BudgetSuggestionService { + if (!BudgetSuggestionService.instance) { + BudgetSuggestionService.instance = new BudgetSuggestionService(); + } + return BudgetSuggestionService.instance; + } + + async analyze(userId: number): Promise { + try { + const categoryNeedingBudget = await this.findCategoryNeedingBudget( + userId + ); + + if (!categoryNeedingBudget) { + return null; + } + + const aiSuggestion = await this.getAISuggestion(categoryNeedingBudget); + + const priority = this.calculatePriority( + categoryNeedingBudget.averageMonthlySpending + ); + + return { + shouldGenerate: true, + priority, + title: `Crear presupuesto para ${categoryNeedingBudget.categoryName}`, + description: aiSuggestion.reasoning, + data: { + categoryId: categoryNeedingBudget.categoryId, + categoryName: categoryNeedingBudget.categoryName, + averageSpending: categoryNeedingBudget.averageMonthlySpending, + suggestedBudget: aiSuggestion.suggestedBudget, + }, + actions: [ + { + label: "Crear presupuesto", + path: "/management/budgets/create", + prefilledData: { + categoryId: categoryNeedingBudget.categoryId, + limitAmount: aiSuggestion.suggestedBudget, + month: this.getCurrentMonthString(), + }, + }, + { + label: "Ver gastos", + path: "/management/transactions", + prefilledData: { + categoryId: categoryNeedingBudget.categoryId, + type: "EXPENSE", + }, + }, + ], + }; + } catch (error) { + console.error("Error in BudgetSuggestionService:", error); + return null; + } + } + + private async findCategoryNeedingBudget( + userId: number + ): Promise { + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); + + const recentExpenses = await this.transactionRepository.findByFilters( + userId, + { + type: "EXPENSE", + startDate: threeMonthsAgo.toISOString(), + endDate: new Date().toISOString(), + } + ); + + const currentMonthBudgets = + await this.budgetRepository.findByUserIdAndMonth( + userId, + new Date(this.getCurrentMonthString()) + ); + + const budgetedCategoryIds = new Set( + currentMonthBudgets.map((b) => b.categoryId).filter((id) => id !== null) + ); + + const spendingByCategory: Record = + {}; + + for (const expense of recentExpenses) { + if (!expense.categoryId) continue; + + if (budgetedCategoryIds.has(expense.categoryId)) continue; + + if (!spendingByCategory[expense.categoryId]) { + spendingByCategory[expense.categoryId] = { + name: expense.category?.name || "Sin nombre", + total: 0, + }; + } + + spendingByCategory[expense.categoryId].total += expense.amount; + } + + const categoriesNeedingBudget: CategoryWithoutBudget[] = []; + + for (const [categoryIdStr, data] of Object.entries(spendingByCategory)) { + const categoryId = Number(categoryIdStr); + const averageMonthlySpending = data.total / 3; + + if (averageMonthlySpending >= this.MIN_AVERAGE_SPENDING) { + categoriesNeedingBudget.push({ + categoryId, + categoryName: data.name, + averageMonthlySpending: Math.round(averageMonthlySpending), + }); + } + } + + if (categoriesNeedingBudget.length === 0) { + return null; + } + + categoriesNeedingBudget.sort( + (a, b) => b.averageMonthlySpending - a.averageMonthlySpending + ); + + return categoriesNeedingBudget[0]; + } + + private async getAISuggestion( + category: CategoryWithoutBudget + ): Promise<{ suggestedBudget: number; reasoning: string }> { + const prompt = `El usuario gasta en promedio $${category.averageMonthlySpending.toFixed( + 2 + )}/mes en "${ + category.categoryName + }" pero no tiene un presupuesto establecido para esta categoría. + +Sugiere un presupuesto mensual razonable que: +1. Incluya un buffer del 10-20% para flexibilidad +2. Sea realista basado en su patrón de gasto actual +3. Le ayude a controlar sus gastos sin ser demasiado restrictivo + +Proporciona un razonamiento breve (2-3 oraciones) sobre por qué este presupuesto es apropiado. + +Responde SOLO con un JSON válido en este formato: +{ + "suggestedBudget": número (monto sugerido para el presupuesto mensual), + "reasoning": "explicación breve sobre el presupuesto sugerido" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto en presupuestos personales. Proporciona recomendaciones prácticas y motivadoras en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + const suggestedBudget = Math.ceil(category.averageMonthlySpending * 1.15); + + return { + suggestedBudget, + reasoning: `Basado en tu gasto promedio de $${category.averageMonthlySpending.toFixed( + 2 + )}, te sugerimos un presupuesto de $${suggestedBudget.toFixed( + 2 + )} mensual que incluye un 15% de flexibilidad para imprevistos en ${ + category.categoryName + }.`, + }; + } + } + + private calculatePriority(averageSpending: number): RecommendationPriority { + if (averageSpending >= 500) { + return RecommendationPriority.HIGH; + } else if (averageSpending >= 200) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } + + private getCurrentMonthString(): string { + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + return `${year}-${month}`; + } +} diff --git a/src/features/recommendations/application/services/debt-reminder.service.ts b/src/features/recommendations/application/services/debt-reminder.service.ts new file mode 100644 index 0000000..c8e099d --- /dev/null +++ b/src/features/recommendations/application/services/debt-reminder.service.ts @@ -0,0 +1,256 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgDebtRepository } from "@/debts/infrastructure/adapters/debt.repository"; +import { PgTransactionRepository } from "@/transactions/infrastructure/adapters/transaction.repository"; +import { IDebt } from "@/debts/domain/entities/IDebt"; + +interface DebtOpportunity { + debt: IDebt; + daysUntilDue: number; + availableBalance: number; + suggestedPayment: number; + canPayFull: boolean; +} + +export class DebtReminderService implements IAnalysisService { + private static instance: DebtReminderService; + private debtRepository: PgDebtRepository; + private transactionRepository: PgTransactionRepository; + private readonly DAYS_AHEAD_THRESHOLD = 15; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + + private constructor() { + this.debtRepository = PgDebtRepository.getInstance(); + this.transactionRepository = PgTransactionRepository.getInstance(); + } + + public static getInstance(): DebtReminderService { + if (!DebtReminderService.instance) { + DebtReminderService.instance = new DebtReminderService(); + } + return DebtReminderService.instance; + } + + async analyze(userId: number): Promise { + try { + const debtOpportunity = await this.findPaymentOpportunity(userId); + + if (!debtOpportunity) { + return null; + } + + const aiSuggestion = await this.getAISuggestion(debtOpportunity); + + const priority = this.calculatePriority(debtOpportunity.daysUntilDue); + + return { + shouldGenerate: true, + priority, + title: `Oportunidad de pago: ${debtOpportunity.debt.description}`, + description: aiSuggestion.reasoning, + data: { + debtId: debtOpportunity.debt.id, + debtName: debtOpportunity.debt.description, + pendingAmount: debtOpportunity.debt.pendingAmount, + daysUntilDue: debtOpportunity.daysUntilDue, + dueDate: debtOpportunity.debt.dueDate.toISOString(), + availableBalance: debtOpportunity.availableBalance, + suggestedPayment: aiSuggestion.suggestedPayment, + canPayFull: debtOpportunity.canPayFull, + }, + actions: [ + { + label: debtOpportunity.canPayFull + ? "Pagar deuda completa" + : "Hacer pago parcial", + path: `/management/debts/${debtOpportunity.debt.id}/pay`, + prefilledData: { + amount: aiSuggestion.suggestedPayment, + }, + }, + { + label: "Ver detalles de deuda", + path: `/management/debts/${debtOpportunity.debt.id}`, + prefilledData: {}, + }, + ], + }; + } catch (error) { + console.error("Error in DebtReminderService:", error); + return null; + } + } + + private async findPaymentOpportunity( + userId: number + ): Promise { + const userDebts = await this.debtRepository.findByUserId(userId); + + const activeDebts = userDebts.filter( + (debt) => !debt.paid && debt.pendingAmount > 0 + ); + + if (activeDebts.length === 0) { + return null; + } + + const now = new Date(); + const upcomingDebts = activeDebts.filter((debt) => { + const daysUntilDue = Math.ceil( + (debt.dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + return daysUntilDue > 0 && daysUntilDue <= this.DAYS_AHEAD_THRESHOLD; + }); + + if (upcomingDebts.length === 0) { + return null; + } + + const availableBalance = await this.calculateAvailableBalance(userId); + + if (availableBalance <= 0) { + return null; + } + + const opportunities: DebtOpportunity[] = []; + + for (const debt of upcomingDebts) { + const daysUntilDue = Math.ceil( + (debt.dueDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + + const canPayFull = availableBalance >= debt.pendingAmount; + + const suggestedPayment = canPayFull + ? debt.pendingAmount + : Math.min( + Math.floor(availableBalance * 0.5), + debt.pendingAmount + ); + + if (suggestedPayment > 0) { + opportunities.push({ + debt, + daysUntilDue, + availableBalance, + suggestedPayment, + canPayFull, + }); + } + } + + if (opportunities.length === 0) { + return null; + } + + opportunities.sort((a, b) => a.daysUntilDue - b.daysUntilDue); + + return opportunities[0]; + } + + private async calculateAvailableBalance(userId: number): Promise { + const now = new Date(); + const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const monthlyBalance = await this.transactionRepository.getMonthlyBalance( + userId, + firstDayOfMonth + ); + + return monthlyBalance.balance; + } + + private async getAISuggestion( + opportunity: DebtOpportunity + ): Promise<{ suggestedPayment: number; reasoning: string }> { + const prompt = `El usuario tiene disponible $${opportunity.availableBalance.toFixed(2)} este mes. La deuda "${opportunity.debt.description}" de $${opportunity.debt.pendingAmount.toFixed(2)} vence en ${opportunity.daysUntilDue} días. + +${ + opportunity.canPayFull + ? "Tiene suficiente para pagar la deuda completa." + : `No puede pagar la deuda completa, pero puede hacer un pago parcial significativo.` +} + +Sugiere cuánto debería pagar ahora considerando: +1. El monto disponible +2. Los días restantes hasta el vencimiento +3. Mantener un colchón de emergencia (no usar todo el balance disponible) +4. ${opportunity.canPayFull ? "Beneficios de liquidar la deuda completa" : "Reducir el saldo pendiente para evitar intereses"} + +Proporciona un razonamiento motivador (2-3 oraciones) sobre por qué es importante hacer este pago ahora. + +Responde SOLO con un JSON válido en este formato: +{ + "suggestedPayment": número (monto sugerido para pagar), + "reasoning": "explicación breve y motivadora" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto en gestión de deudas. Proporciona consejos prácticos y motivadores en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + const suggestedPayment = opportunity.canPayFull + ? opportunity.debt.pendingAmount + : Math.floor(opportunity.availableBalance * 0.4); + + return { + suggestedPayment, + reasoning: opportunity.canPayFull + ? `Tienes suficiente saldo para liquidar esta deuda de $${opportunity.debt.pendingAmount.toFixed(2)}. Pagarla ahora te liberará de esta obligación y evitará posibles cargos por intereses.` + : `Con tu saldo actual, puedes hacer un pago de $${suggestedPayment.toFixed(2)} que reducirá significativamente tu deuda. Esto te acercará a liquidarla y minimizará los intereses acumulados.`, + }; + } + } + + private calculatePriority(daysUntilDue: number): RecommendationPriority { + if (daysUntilDue <= 5) { + return RecommendationPriority.HIGH; + } else if (daysUntilDue <= 10) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } +} diff --git a/src/features/recommendations/application/services/goal-optimization.service.ts b/src/features/recommendations/application/services/goal-optimization.service.ts new file mode 100644 index 0000000..9ed0f4c --- /dev/null +++ b/src/features/recommendations/application/services/goal-optimization.service.ts @@ -0,0 +1,284 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgGoalRepository } from "@/goals/infrastucture/adapters/goal.repository"; +import { PgGoalContributionRepository } from "@/goals/infrastucture/adapters/goal-contribution.repository"; +import { IGoal } from "@/goals/domain/entities/IGoal"; + +interface GoalAnalysis { + goal: IGoal; + daysRemaining: number; + amountRemaining: number; + dailyRequiredContribution: number; + averageContribution: number; + isUnrealistic: boolean; + realismRatio: number; +} + +export class GoalOptimizationService implements IAnalysisService { + private static instance: GoalOptimizationService; + private goalRepository: PgGoalRepository; + private contributionRepository: PgGoalContributionRepository; + private readonly UNREALISTIC_THRESHOLD = 2; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + + private constructor() { + this.goalRepository = PgGoalRepository.getInstance(); + this.contributionRepository = PgGoalContributionRepository.getInstance(); + } + + public static getInstance(): GoalOptimizationService { + if (!GoalOptimizationService.instance) { + GoalOptimizationService.instance = new GoalOptimizationService(); + } + return GoalOptimizationService.instance; + } + + async analyze(userId: number): Promise { + try { + const unrealisticGoal = await this.findUnrealisticGoal(userId); + + if (!unrealisticGoal) { + return null; + } + + const aiSuggestion = await this.getAISuggestion(unrealisticGoal); + + const priority = this.calculatePriority(unrealisticGoal.realismRatio); + + const suggestedValues = this.calculateSuggestedValues(unrealisticGoal); + + return { + shouldGenerate: true, + priority, + title: `Meta "${unrealisticGoal.goal.name}" necesita ajuste`, + description: aiSuggestion.reasoning, + data: { + goalId: unrealisticGoal.goal.id, + goalName: unrealisticGoal.goal.name, + daysRemaining: unrealisticGoal.daysRemaining, + amountRemaining: unrealisticGoal.amountRemaining, + dailyRequired: unrealisticGoal.dailyRequiredContribution, + averageContribution: unrealisticGoal.averageContribution, + realismRatio: unrealisticGoal.realismRatio, + suggestionType: aiSuggestion.suggestion, + suggestedNewValue: aiSuggestion.newValue, + suggestedTargetAmount: suggestedValues.targetAmount, + suggestedEndDate: suggestedValues.endDate, + }, + actions: [ + { + label: "Ajustar meta", + path: `/management/goals/${unrealisticGoal.goal.id}/edit`, + prefilledData: + aiSuggestion.suggestion === "extend" + ? { + endDate: suggestedValues.endDate, + } + : { + targetAmount: suggestedValues.targetAmount, + }, + }, + { + label: "Ver contribuciones", + path: `/management/goals/${unrealisticGoal.goal.id}`, + prefilledData: {}, + }, + ], + }; + } catch (error) { + console.error("Error in GoalOptimizationService:", error); + return null; + } + } + + private async findUnrealisticGoal( + userId: number + ): Promise { + const activeGoals = await this.goalRepository.findAllActive(); + + const userGoals = activeGoals.filter( + (goal) => goal.userId === userId || goal.sharedUserId === userId + ); + + if (userGoals.length === 0) { + return null; + } + + const goalAnalyses: GoalAnalysis[] = []; + + for (const goal of userGoals) { + const contributions = await this.contributionRepository.findByGoalId( + goal.id + ); + + if (contributions.length === 0) continue; + + const daysRemaining = Math.ceil( + (goal.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24) + ); + + if (daysRemaining <= 0) continue; + + const amountRemaining = goal.targetAmount - goal.currentAmount; + + if (amountRemaining <= 0) continue; + + const dailyRequiredContribution = amountRemaining / daysRemaining; + + const totalContributions = contributions.reduce( + (sum, c) => sum + c.amount, + 0 + ); + const firstContribution = contributions[contributions.length - 1]; + const daysSinceStart = Math.ceil( + (Date.now() - firstContribution.date.getTime()) / (1000 * 60 * 60 * 24) + ); + + const averageContribution = + daysSinceStart > 0 ? totalContributions / daysSinceStart : 0; + + if (averageContribution === 0) continue; + + const realismRatio = dailyRequiredContribution / averageContribution; + + const isUnrealistic = realismRatio > this.UNREALISTIC_THRESHOLD; + + if (isUnrealistic) { + goalAnalyses.push({ + goal, + daysRemaining, + amountRemaining, + dailyRequiredContribution, + averageContribution, + isUnrealistic, + realismRatio, + }); + } + } + + if (goalAnalyses.length === 0) { + return null; + } + + goalAnalyses.sort((a, b) => b.realismRatio - a.realismRatio); + + return goalAnalyses[0]; + } + + private async getAISuggestion( + analysis: GoalAnalysis + ): Promise<{ suggestion: "extend" | "reduce"; newValue: number; reasoning: string }> { + const prompt = `La meta "${analysis.goal.name}" del usuario requiere ahorrar $${analysis.dailyRequiredContribution.toFixed(2)}/día durante los próximos ${analysis.daysRemaining} días para alcanzar $${analysis.amountRemaining.toFixed(2)} faltantes. + +Su contribución promedio histórica es de $${analysis.averageContribution.toFixed(2)}/día, lo cual hace esta meta ${Math.round(analysis.realismRatio)}x más exigente de lo que ha logrado mantener. + +Sugiere: +1. Extender la fecha límite (¿cuántos días más necesitaría manteniendo su ritmo actual?) +2. Reducir el monto objetivo (¿a qué monto realista podría llegar con su ritmo actual en el tiempo restante?) + +Proporciona un razonamiento breve (2-3 oraciones) y la recomendación específica. + +Responde SOLO con un JSON válido en este formato: +{ + "suggestion": "extend" o "reduce", + "newValue": número (días adicionales si extend, o nuevo monto objetivo si reduce), + "reasoning": "explicación breve y motivadora" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto en establecimiento de metas realistas. Proporciona consejos motivadores y prácticos en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + const daysNeeded = Math.ceil( + analysis.amountRemaining / analysis.averageContribution + ); + const additionalDays = daysNeeded - analysis.daysRemaining; + + return { + suggestion: additionalDays > 0 ? "extend" : "reduce", + newValue: + additionalDays > 0 + ? additionalDays + : Math.floor( + analysis.averageContribution * analysis.daysRemaining + ), + reasoning: + "Tu ritmo de ahorro actual sugiere que necesitas ajustar tu meta para hacerla más alcanzable. Esto te ayudará a mantener la motivación y el progreso constante.", + }; + } + } + + private calculateSuggestedValues(analysis: GoalAnalysis): { + targetAmount: number; + endDate: string; + } { + const daysNeeded = Math.ceil( + analysis.amountRemaining / analysis.averageContribution + ); + + const realisticAmount = Math.floor( + analysis.goal.currentAmount + + analysis.averageContribution * analysis.daysRemaining + ); + + const extendedDate = new Date(analysis.goal.endDate); + extendedDate.setDate(extendedDate.getDate() + (daysNeeded - analysis.daysRemaining)); + + return { + targetAmount: realisticAmount, + endDate: extendedDate.toISOString(), + }; + } + + private calculatePriority(realismRatio: number): RecommendationPriority { + if (realismRatio >= 3) { + return RecommendationPriority.HIGH; + } else if (realismRatio >= 2) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } +} diff --git a/src/features/recommendations/application/services/spending-analysis.service.ts b/src/features/recommendations/application/services/spending-analysis.service.ts new file mode 100644 index 0000000..d031f50 --- /dev/null +++ b/src/features/recommendations/application/services/spending-analysis.service.ts @@ -0,0 +1,252 @@ +import { + IAnalysisService, + AnalysisResult, +} from "../../domain/ports/analysis.service.interface"; +import { RecommendationPriority } from "../../domain/entities/recommendation.types"; +import { PgTransactionRepository } from "@/transactions/infrastructure/adapters/transaction.repository"; +import { sql } from "drizzle-orm"; + +interface CategorySpending { + categoryId: number | null; + categoryName: string; + currentMonthTotal: number; + threeMonthAverage: number; + percentageIncrease: number; +} + +export class SpendingAnalysisService implements IAnalysisService { + private static instance: SpendingAnalysisService; + private transactionRepository: PgTransactionRepository; + private readonly THRESHOLD_PERCENTAGE = 20; + private readonly OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; + + private constructor() { + this.transactionRepository = PgTransactionRepository.getInstance(); + } + + public static getInstance(): SpendingAnalysisService { + if (!SpendingAnalysisService.instance) { + SpendingAnalysisService.instance = new SpendingAnalysisService(); + } + return SpendingAnalysisService.instance; + } + + async analyze(userId: number): Promise { + try { + const unusualSpending = await this.findUnusualSpending(userId); + + if (!unusualSpending) { + return null; + } + + const aiInsight = await this.getAIInsight(unusualSpending); + + const priority = this.calculatePriority( + unusualSpending.percentageIncrease + ); + + return { + shouldGenerate: true, + priority, + title: `Gasto inusual en ${unusualSpending.categoryName}`, + description: aiInsight.insight, + data: { + categoryId: unusualSpending.categoryId, + categoryName: unusualSpending.categoryName, + currentAmount: unusualSpending.currentMonthTotal, + averageAmount: unusualSpending.threeMonthAverage, + percentageIncrease: unusualSpending.percentageIncrease, + suggestion: aiInsight.suggestion, + }, + actions: [ + { + label: "Ver transacciones", + path: "/management/transactions", + prefilledData: { + categoryId: unusualSpending.categoryId, + startDate: this.getFirstDayOfCurrentMonth(), + endDate: new Date().toISOString(), + }, + }, + { + label: "Crear presupuesto", + path: "/management/budgets/create", + prefilledData: { + categoryId: unusualSpending.categoryId, + limitAmount: Math.ceil(unusualSpending.threeMonthAverage * 1.1), + }, + }, + ], + }; + } catch (error) { + console.error("Error in SpendingAnalysisService:", error); + return null; + } + } + + private async findUnusualSpending( + userId: number + ): Promise { + const now = new Date(); + const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1); + const threeMonthsAgo = new Date( + now.getFullYear(), + now.getMonth() - 3, + 1 + ); + const previousMonthStart = new Date( + now.getFullYear(), + now.getMonth() - 1, + 1 + ); + + const currentMonthExpenses = await this.transactionRepository.findByFilters( + userId, + { + type: "EXPENSE", + startDate: currentMonthStart.toISOString(), + endDate: now.toISOString(), + } + ); + + const previousThreeMonthsExpenses = + await this.transactionRepository.findByFilters(userId, { + type: "EXPENSE", + startDate: threeMonthsAgo.toISOString(), + endDate: previousMonthStart.toISOString(), + }); + + const currentMonthByCategory = this.groupByCategory(currentMonthExpenses); + const previousMonthsByCategory = this.groupByCategory( + previousThreeMonthsExpenses + ); + + const categoryAnalysis: CategorySpending[] = []; + + for (const [categoryKey, currentTotal] of Object.entries( + currentMonthByCategory + )) { + const [categoryId, categoryName] = categoryKey.split("|"); + const previousTotal = previousMonthsByCategory[categoryKey] || 0; + const threeMonthAverage = previousTotal / 3; + + if (threeMonthAverage === 0) continue; + + const percentageIncrease = + ((currentTotal - threeMonthAverage) / threeMonthAverage) * 100; + + if (percentageIncrease > this.THRESHOLD_PERCENTAGE) { + categoryAnalysis.push({ + categoryId: categoryId ? Number(categoryId) : null, + categoryName: categoryName || "Sin categoría", + currentMonthTotal: currentTotal, + threeMonthAverage: threeMonthAverage, + percentageIncrease: Math.round(percentageIncrease), + }); + } + } + + if (categoryAnalysis.length === 0) { + return null; + } + + categoryAnalysis.sort( + (a, b) => b.percentageIncrease - a.percentageIncrease + ); + + return categoryAnalysis[0]; + } + + private groupByCategory( + transactions: any[] + ): Record { + return transactions.reduce((acc, tx) => { + const key = `${tx.categoryId || "null"}|${tx.category?.name || "Sin categoría"}`; + acc[key] = (acc[key] || 0) + tx.amount; + return acc; + }, {} as Record); + } + + private async getAIInsight( + spending: CategorySpending + ): Promise<{ insight: string; suggestion: string }> { + const prompt = `El usuario ha gastado $${spending.currentMonthTotal.toFixed(2)} en "${spending.categoryName}" este mes, lo cual es ${spending.percentageIncrease}% más que su promedio de 3 meses de $${spending.threeMonthAverage.toFixed(2)}. + +Proporciona: +1. Un análisis breve (2-3 oraciones) sobre este patrón de gasto +2. Una sugerencia accionable para el usuario + +Responde SOLO con un JSON válido en este formato: +{ + "insight": "análisis del patrón", + "suggestion": "sugerencia accionable" +}`; + + try { + const response = await fetch( + "https://api.openai.com/v1/chat/completions", + { + method: "POST", + headers: { + Authorization: `Bearer ${this.OPENAI_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "gpt-4", + messages: [ + { + role: "system", + content: + "Eres un asesor financiero experto. Proporciona análisis claros y sugerencias prácticas en español.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + max_tokens: 300, + response_format: { type: "json_object" }, + }), + } + ); + + if (!response.ok) { + throw new Error(`OpenAI API error: ${response.status}`); + } + + const result = await response.json(); + const content = result.choices[0]?.message?.content; + + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + + return { + insight: `Has incrementado tu gasto en ${spending.categoryName} en un ${spending.percentageIncrease}% comparado con tu promedio mensual. Este cambio significativo merece atención.`, + suggestion: + "Revisa tus transacciones recientes en esta categoría para identificar gastos innecesarios y considera establecer un presupuesto mensual.", + }; + } + } + + private calculatePriority( + percentageIncrease: number + ): RecommendationPriority { + if (percentageIncrease >= 50) { + return RecommendationPriority.HIGH; + } else if (percentageIncrease >= 30) { + return RecommendationPriority.MEDIUM; + } + return RecommendationPriority.LOW; + } + + private getFirstDayOfCurrentMonth(): string { + const now = new Date(); + return new Date(now.getFullYear(), now.getMonth(), 1).toISOString(); + } +} diff --git a/src/features/recommendations/domain/entities/recommendation.entity.ts b/src/features/recommendations/domain/entities/recommendation.entity.ts new file mode 100644 index 0000000..5de0787 --- /dev/null +++ b/src/features/recommendations/domain/entities/recommendation.entity.ts @@ -0,0 +1,29 @@ +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "./recommendation.types"; + +export interface QuickAction { + label: string; + path: string; + prefilledData?: Record; +} + +export interface Recommendation { + id: number; + userId: number; + type: RecommendationType; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actionable: boolean; + actions?: QuickAction[]; + status: RecommendationStatus; + createdAt: Date; + expiresAt?: Date; + viewedAt?: Date; + dismissedAt?: Date; + actedAt?: Date; +} diff --git a/src/features/recommendations/domain/entities/recommendation.types.ts b/src/features/recommendations/domain/entities/recommendation.types.ts new file mode 100644 index 0000000..867faa7 --- /dev/null +++ b/src/features/recommendations/domain/entities/recommendation.types.ts @@ -0,0 +1,19 @@ +export enum RecommendationType { + SPENDING_ANALYSIS = "SPENDING_ANALYSIS", + GOAL_OPTIMIZATION = "GOAL_OPTIMIZATION", + BUDGET_SUGGESTION = "BUDGET_SUGGESTION", + DEBT_REMINDER = "DEBT_REMINDER", +} + +export enum RecommendationPriority { + HIGH = "HIGH", + MEDIUM = "MEDIUM", + LOW = "LOW", +} + +export enum RecommendationStatus { + PENDING = "PENDING", + VIEWED = "VIEWED", + DISMISSED = "DISMISSED", + ACTED = "ACTED", +} diff --git a/src/features/recommendations/domain/ports/analysis.service.interface.ts b/src/features/recommendations/domain/ports/analysis.service.interface.ts new file mode 100644 index 0000000..d59fc03 --- /dev/null +++ b/src/features/recommendations/domain/ports/analysis.service.interface.ts @@ -0,0 +1,15 @@ +import { RecommendationPriority } from "../entities/recommendation.types"; +import { QuickAction } from "../entities/recommendation.entity"; + +export interface AnalysisResult { + shouldGenerate: boolean; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actions?: QuickAction[]; +} + +export interface IAnalysisService { + analyze(userId: number): Promise; +} diff --git a/src/features/recommendations/domain/ports/orchestrator.service.interface.ts b/src/features/recommendations/domain/ports/orchestrator.service.interface.ts new file mode 100644 index 0000000..b583a61 --- /dev/null +++ b/src/features/recommendations/domain/ports/orchestrator.service.interface.ts @@ -0,0 +1,5 @@ +import { Recommendation } from "../entities/recommendation.entity"; + +export interface IRecommendationOrchestrator { + generateDailyRecommendation(userId: number): Promise; +} diff --git a/src/features/recommendations/domain/ports/recommendation.repository.interface.ts b/src/features/recommendations/domain/ports/recommendation.repository.interface.ts new file mode 100644 index 0000000..82b8ece --- /dev/null +++ b/src/features/recommendations/domain/ports/recommendation.repository.interface.ts @@ -0,0 +1,29 @@ +import { Recommendation } from "../entities/recommendation.entity"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../entities/recommendation.types"; +import { QuickAction } from "../entities/recommendation.entity"; + +export interface CreateRecommendationData { + userId: number; + type: RecommendationType; + priority: RecommendationPriority; + title: string; + description: string; + data?: any; + actionable: boolean; + actions?: QuickAction[]; + expiresAt?: Date; +} + +export interface IRecommendationRepository { + create(data: CreateRecommendationData): Promise; + findById(id: number): Promise; + findPendingByUserId(userId: number): Promise; + markAsViewed(id: number): Promise; + markAsDismissed(id: number): Promise; + markAsActed(id: number): Promise; + deleteExpired(): Promise; +} From c18fced7bee2f19f68fcf70d6171accd9ad63722 Mon Sep 17 00:00:00 2001 From: devjaes Date: Thu, 27 Nov 2025 01:36:34 -0500 Subject: [PATCH 2/6] feat: recomendations fase 7 - Added the `openai` package to `package.json` for improved AI capabilities. - Updated TypeScript configuration to include path mapping for easier imports. - Integrated a new daily recommendations job in the application to provide users with personalized suggestions. - Enhanced the notification repository to ensure notifications are created with a default unread status and improved query readability. --- bun.lockb | Bin 226677 -> 214116 bytes package.json | 1 + src/app.ts | 6 +- .../cron/daily-recommendations.cron.ts | 76 +++++++ .../adapters/notification.repository.ts | 69 +++--- .../recommendation-orchestrator.service.ts | 203 ++++++++++++++++++ .../adapters/pg-recommendation.repository.ts | 142 ++++++++++++ .../infrastructure/clients/openai.client.ts | 57 +++++ .../controllers/recommendation.controller.ts | 172 +++++++++++++++ .../controllers/recommendation.routes.ts | 191 ++++++++++++++++ tsconfig.json | 3 + 11 files changed, 885 insertions(+), 35 deletions(-) create mode 100644 src/core/infrastructure/cron/daily-recommendations.cron.ts create mode 100644 src/features/recommendations/application/services/recommendation-orchestrator.service.ts create mode 100644 src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts create mode 100644 src/features/recommendations/infrastructure/clients/openai.client.ts create mode 100644 src/features/recommendations/infrastructure/controllers/recommendation.controller.ts create mode 100644 src/features/recommendations/infrastructure/controllers/recommendation.routes.ts diff --git a/bun.lockb b/bun.lockb index bb2bca560c07a293d2ec97d8ff8777601932ceba..1d6f26ff0d50a64af7734bb05d7260e24ccf8223 100755 GIT binary patch delta 64610 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

mTdd$;yo%7OI|$OYrxV8 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 delta 70250 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 orCo($oI~F3P@HX;Tdm { export type AppType = (typeof routes)[number]; -export default app; \ No newline at end of file +export default app; diff --git a/src/core/infrastructure/cron/daily-recommendations.cron.ts b/src/core/infrastructure/cron/daily-recommendations.cron.ts new file mode 100644 index 0000000..4011ae9 --- /dev/null +++ b/src/core/infrastructure/cron/daily-recommendations.cron.ts @@ -0,0 +1,76 @@ +import cron from "cron"; +import { db } from "@/db"; +import { users } from "@/schema"; +import { eq } from "drizzle-orm"; +import { RecommendationOrchestratorService } from "@/features/recommendations/application/services/recommendation-orchestrator.service"; +import { PgRecommendationRepository } from "@/features/recommendations/infrastructure/adapters/pg-recommendation.repository"; + +const recommendationRepository = PgRecommendationRepository.getInstance(); +const orchestrator = RecommendationOrchestratorService.getInstance( + recommendationRepository +); + +const dailyRecommendationsJob = new cron.CronJob( + "0 6 * * *", + async () => { + console.log("Running daily recommendations job..."); + + try { + const enabledUsers = await db + .select({ id: users.id }) + .from(users) + .where(eq(users.recommendations_enabled, true)); + + console.log( + `Found ${enabledUsers.length} users with recommendations enabled` + ); + + let successCount = 0; + let errorCount = 0; + + for (const user of enabledUsers) { + try { + const recommendation = await orchestrator.generateDailyRecommendation( + user.id + ); + + if (recommendation) { + console.log( + `Generated recommendation for user ${user.id}: ${recommendation.type}` + ); + successCount++; + } else { + console.log( + `No recommendation generated for user ${user.id} (already has one or no insights found)` + ); + } + } catch (error) { + console.error( + `Failed to generate recommendation for user ${user.id}:`, + error + ); + errorCount++; + } + } + + console.log( + `Daily recommendations job completed: ${successCount} successful, ${errorCount} errors` + ); + } catch (error) { + console.error("Error in daily recommendations job:", error); + } + }, + null, + false, + "America/New_York" +); + +export function startDailyRecommendationsJob() { + dailyRecommendationsJob.start(); + console.log("Daily recommendations job started (runs at 6 AM daily)"); +} + +export function stopDailyRecommendationsJob() { + dailyRecommendationsJob.stop(); + console.log("Daily recommendations job stopped"); +} diff --git a/src/features/notifications/infrastructure/adapters/notification.repository.ts b/src/features/notifications/infrastructure/adapters/notification.repository.ts index e949c6f..ce2c912 100644 --- a/src/features/notifications/infrastructure/adapters/notification.repository.ts +++ b/src/features/notifications/infrastructure/adapters/notification.repository.ts @@ -1,7 +1,10 @@ import { eq, and, lt } from "drizzle-orm"; import DatabaseConnection from "@/core/infrastructure/database"; import { notifications } from "@/schema"; -import { INotification, NotificationType } from "../../domain/entities/INotification"; +import { + INotification, + NotificationType, +} from "../../domain/entities/INotification"; import { INotificationRepository } from "../../domain/ports/notification-repository.port"; export class PgNotificationRepository implements INotificationRepository { @@ -45,16 +48,18 @@ export class PgNotificationRepository implements INotificationRepository { .select() .from(notifications) .where( - and( - eq(notifications.user_id, userId), - eq(notifications.read, false) - ) + and(eq(notifications.user_id, userId), eq(notifications.read, false)) ); return result.map(this.mapToEntity); } - async create(notificationData: Omit): Promise { + async create( + notificationData: Omit< + INotification, + "id" | "createdAt" | "updatedAt" | "read" + > + ): Promise { const result = await this.db .insert(notifications) .values({ @@ -62,7 +67,7 @@ export class PgNotificationRepository implements INotificationRepository { title: notificationData.title, subtitle: notificationData.subtitle || null, message: notificationData.message, - read: notificationData.read, + read: false, type: notificationData.type, expires_at: notificationData.expiresAt || null, }) @@ -71,15 +76,24 @@ export class PgNotificationRepository implements INotificationRepository { return this.mapToEntity(result[0]); } - async update(id: number, notificationData: Partial): Promise { + async update( + id: number, + notificationData: Partial + ): Promise { const updateData: Record = {}; - if (notificationData.title !== undefined) updateData.title = notificationData.title; - if (notificationData.subtitle !== undefined) updateData.subtitle = notificationData.subtitle; - if (notificationData.message !== undefined) updateData.message = notificationData.message; - if (notificationData.read !== undefined) updateData.read = notificationData.read; - if (notificationData.type !== undefined) updateData.type = notificationData.type; - if (notificationData.expiresAt !== undefined) updateData.expires_at = notificationData.expiresAt; + if (notificationData.title !== undefined) + updateData.title = notificationData.title; + if (notificationData.subtitle !== undefined) + updateData.subtitle = notificationData.subtitle; + if (notificationData.message !== undefined) + updateData.message = notificationData.message; + if (notificationData.read !== undefined) + updateData.read = notificationData.read; + if (notificationData.type !== undefined) + updateData.type = notificationData.type; + if (notificationData.expiresAt !== undefined) + updateData.expires_at = notificationData.expiresAt; const result = await this.db .update(notifications) @@ -114,10 +128,7 @@ export class PgNotificationRepository implements INotificationRepository { .update(notifications) .set({ read: true }) .where( - and( - eq(notifications.user_id, userId), - eq(notifications.read, false) - ) + and(eq(notifications.user_id, userId), eq(notifications.read, false)) ) .returning(); @@ -128,16 +139,12 @@ export class PgNotificationRepository implements INotificationRepository { const now = new Date(); const result = await this.db .delete(notifications) - .where( - and( - lt(notifications.expires_at, now) - ) - ) + .where(and(lt(notifications.expires_at, now))) .returning(); return result.length; } - + async findByUserIdAndType( userId: number, type: NotificationType, @@ -147,18 +154,12 @@ export class PgNotificationRepository implements INotificationRepository { eq(notifications.user_id, userId), eq(notifications.type, type) ); - + if (afterDate) { - conditions = and( - conditions, - lt(notifications.created_at, afterDate) - ); + conditions = and(conditions, lt(notifications.created_at, afterDate)); } - - const result = await this.db - .select() - .from(notifications) - .where(conditions); + + const result = await this.db.select().from(notifications).where(conditions); return result.map(this.mapToEntity); } diff --git a/src/features/recommendations/application/services/recommendation-orchestrator.service.ts b/src/features/recommendations/application/services/recommendation-orchestrator.service.ts new file mode 100644 index 0000000..c971192 --- /dev/null +++ b/src/features/recommendations/application/services/recommendation-orchestrator.service.ts @@ -0,0 +1,203 @@ +import { IRecommendationOrchestrator } from "../../domain/ports/orchestrator.service.interface"; +import { IRecommendationRepository } from "../../domain/ports/recommendation.repository.interface"; +import { IAnalysisService } from "../../domain/ports/analysis.service.interface"; +import { Recommendation } from "../../domain/entities/recommendation.entity"; +import { RecommendationType } from "../../domain/entities/recommendation.types"; +import { SpendingAnalysisService } from "./spending-analysis.service"; +import { GoalOptimizationService } from "./goal-optimization.service"; +import { BudgetSuggestionService } from "./budget-suggestion.service"; +import { DebtReminderService } from "./debt-reminder.service"; +import { PgNotificationRepository } from "@/notifications/infrastructure/adapters/notification.repository"; +import { NotificationType } from "@/notifications/domain/entities/INotification"; +import { db } from "@/db"; +import { users } from "@/schema"; +import { eq, and, gte } from "drizzle-orm"; + +export class RecommendationOrchestratorService + implements IRecommendationOrchestrator +{ + private static instance: RecommendationOrchestratorService; + private services: Map; + private notificationRepository: PgNotificationRepository; + + private constructor( + private readonly recommendationRepository: IRecommendationRepository + ) { + this.services = new Map([ + [ + RecommendationType.SPENDING_ANALYSIS, + SpendingAnalysisService.getInstance(), + ], + [ + RecommendationType.GOAL_OPTIMIZATION, + GoalOptimizationService.getInstance(), + ], + [ + RecommendationType.BUDGET_SUGGESTION, + BudgetSuggestionService.getInstance(), + ], + [RecommendationType.DEBT_REMINDER, DebtReminderService.getInstance()], + ]); + + this.notificationRepository = PgNotificationRepository.getInstance(); + } + + public static getInstance( + recommendationRepository: IRecommendationRepository + ): RecommendationOrchestratorService { + if (!RecommendationOrchestratorService.instance) { + RecommendationOrchestratorService.instance = + new RecommendationOrchestratorService(recommendationRepository); + } + return RecommendationOrchestratorService.instance; + } + + async generateDailyRecommendation( + userId: number + ): Promise { + try { + const isEnabled = await this.checkUserRecommendationsEnabled(userId); + if (!isEnabled) { + console.log( + `User ${userId} does not have recommendations enabled. Skipping.` + ); + return null; + } + + const hasRecentRecommendation = await this.checkRecentRecommendation( + userId + ); + if (hasRecentRecommendation) { + console.log( + `User ${userId} already has a pending recommendation from today. Skipping.` + ); + return null; + } + + const randomType = this.selectRandomType(); + console.log( + `Selected recommendation type for user ${userId}: ${randomType}` + ); + + const service = this.services.get(randomType); + if (!service) { + console.error(`No service found for type: ${randomType}`); + return null; + } + + const analysisResult = await service.analyze(userId); + if (!analysisResult || !analysisResult.shouldGenerate) { + console.log( + `No recommendation generated for user ${userId} with type ${randomType}` + ); + return null; + } + + const recommendation = await this.recommendationRepository.create({ + userId, + type: randomType, + priority: analysisResult.priority, + title: analysisResult.title, + description: analysisResult.description, + data: analysisResult.data, + actionable: + !!analysisResult.actions && analysisResult.actions.length > 0, + actions: analysisResult.actions, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days + }); + + await this.createNotification(recommendation); + + console.log( + `Successfully created recommendation ${recommendation.id} for user ${userId}` + ); + + return recommendation; + } catch (error) { + console.error( + `Error generating daily recommendation for user ${userId}:`, + error + ); + return null; + } + } + + private async checkUserRecommendationsEnabled( + userId: number + ): Promise { + try { + const [user] = await db + .select({ recommendationsEnabled: users.recommendations_enabled }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user?.recommendationsEnabled || false; + } catch (error) { + console.error( + `Error checking recommendations_enabled for user ${userId}:`, + error + ); + return false; + } + } + + private async checkRecentRecommendation(userId: number): Promise { + try { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + + const pendingRecommendations = + await this.recommendationRepository.findPendingByUserId(userId); + + const hasRecentRecommendation = pendingRecommendations.some( + (rec) => rec.createdAt >= todayStart + ); + + return hasRecentRecommendation; + } catch (error) { + console.error( + `Error checking recent recommendations for user ${userId}:`, + error + ); + return false; + } + } + + private selectRandomType(): RecommendationType { + const types = Object.values(RecommendationType); + const randomIndex = Math.floor(Math.random() * types.length); + return types[randomIndex]; + } + + private async createNotification( + recommendation: Recommendation + ): Promise { + try { + await this.notificationRepository.create({ + userId: recommendation.userId, + title: recommendation.title, + subtitle: this.getTypeLabel(recommendation.type), + message: recommendation.description, + type: NotificationType.SUGGESTION, + expiresAt: recommendation.expiresAt || undefined, + }); + } catch (error) { + console.error( + `Error creating notification for recommendation ${recommendation.id}:`, + error + ); + } + } + + private getTypeLabel(type: RecommendationType): string { + const labels: Record = { + [RecommendationType.SPENDING_ANALYSIS]: "Análisis de Gastos", + [RecommendationType.GOAL_OPTIMIZATION]: "Optimización de Metas", + [RecommendationType.BUDGET_SUGGESTION]: "Sugerencia de Presupuesto", + [RecommendationType.DEBT_REMINDER]: "Recordatorio de Deuda", + }; + + return labels[type]; + } +} diff --git a/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts b/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts new file mode 100644 index 0000000..58c9e26 --- /dev/null +++ b/src/features/recommendations/infrastructure/adapters/pg-recommendation.repository.ts @@ -0,0 +1,142 @@ +import { db } from "@/db"; +import { recommendations } from "@/schema"; +import { eq, and, gt, lt } from "drizzle-orm"; +import { IRecommendationRepository } from "../../domain/ports/recommendation.repository.interface"; +import { + Recommendation, + QuickAction, +} from "../../domain/entities/recommendation.entity"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../../domain/entities/recommendation.types"; +import { CreateRecommendationData } from "../../domain/ports/recommendation.repository.interface"; + +export class PgRecommendationRepository implements IRecommendationRepository { + private static instance: PgRecommendationRepository; + + private constructor() {} + + public static getInstance(): PgRecommendationRepository { + if (!PgRecommendationRepository.instance) { + PgRecommendationRepository.instance = new PgRecommendationRepository(); + } + return PgRecommendationRepository.instance; + } + + async create(data: CreateRecommendationData): Promise { + const expiresAt = + data.expiresAt || new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + const [recommendation] = await db + .insert(recommendations) + .values({ + user_id: data.userId, + type: data.type, + priority: data.priority, + title: data.title, + description: data.description, + data: data.data, + actionable: data.actionable, + actions: data.actions as any, + status: RecommendationStatus.PENDING, + expires_at: expiresAt, + }) + .returning(); + + return this.mapToEntity(recommendation); + } + + async findById(id: number): Promise { + const [recommendation] = await db + .select() + .from(recommendations) + .where(eq(recommendations.id, id)) + .limit(1); + + if (!recommendation) { + return null; + } + + return this.mapToEntity(recommendation); + } + + async findPendingByUserId(userId: number): Promise { + const now = new Date(); + + const results = await db + .select() + .from(recommendations) + .where( + and( + eq(recommendations.user_id, userId), + eq(recommendations.status, RecommendationStatus.PENDING), + gt(recommendations.expires_at, now) + ) + ) + .orderBy(recommendations.created_at); + + return results.map((rec) => this.mapToEntity(rec)); + } + + async markAsViewed(id: number): Promise { + await db + .update(recommendations) + .set({ + status: RecommendationStatus.VIEWED, + viewed_at: new Date(), + }) + .where(eq(recommendations.id, id)); + } + + async markAsDismissed(id: number): Promise { + await db + .update(recommendations) + .set({ + status: RecommendationStatus.DISMISSED, + dismissed_at: new Date(), + }) + .where(eq(recommendations.id, id)); + } + + async markAsActed(id: number): Promise { + await db + .update(recommendations) + .set({ + status: RecommendationStatus.ACTED, + acted_at: new Date(), + }) + .where(eq(recommendations.id, id)); + } + + async deleteExpired(): Promise { + const now = new Date(); + + const result = await db + .delete(recommendations) + .where(lt(recommendations.expires_at, now)); + + return result.rowCount || 0; + } + + private mapToEntity(dbRec: any): Recommendation { + return { + id: dbRec.id, + userId: dbRec.user_id, + type: dbRec.type as RecommendationType, + priority: dbRec.priority as RecommendationPriority, + title: dbRec.title, + description: dbRec.description, + data: dbRec.data, + actionable: dbRec.actionable, + actions: dbRec.actions as QuickAction[] | undefined, + status: dbRec.status as RecommendationStatus, + createdAt: dbRec.created_at, + expiresAt: dbRec.expires_at || undefined, + viewedAt: dbRec.viewed_at || undefined, + dismissedAt: dbRec.dismissed_at || undefined, + actedAt: dbRec.acted_at || undefined, + }; + } +} diff --git a/src/features/recommendations/infrastructure/clients/openai.client.ts b/src/features/recommendations/infrastructure/clients/openai.client.ts new file mode 100644 index 0000000..ce2e2d9 --- /dev/null +++ b/src/features/recommendations/infrastructure/clients/openai.client.ts @@ -0,0 +1,57 @@ +import OpenAI from "openai"; + +export class OpenAIClient { + private static instance: OpenAIClient; + private client: OpenAI; + + private constructor() { + this.client = new OpenAI({ + apiKey: process.env.OPENAI_API_KEY, + }); + } + + public static getInstance(): OpenAIClient { + if (!OpenAIClient.instance) { + OpenAIClient.instance = new OpenAIClient(); + } + return OpenAIClient.instance; + } + + async generateRecommendation(prompt: string): Promise { + try { + const response = await this.client.chat.completions.create( + { + model: "gpt-4", + messages: [ + { + role: "system", + content: + "You are a financial advisor assistant. Provide concise, actionable advice in Spanish. Always respond with valid JSON.", + }, + { + role: "user", + content: prompt, + }, + ], + temperature: 0.7, + response_format: { type: "json_object" }, + }, + { + timeout: 30000, + } + ); + + const content = response.choices[0].message.content; + if (!content) { + throw new Error("Empty response from OpenAI"); + } + + return JSON.parse(content); + } catch (error) { + console.error("Error calling OpenAI API:", error); + throw error; + } + } +} + +export const openAIClient = OpenAIClient.getInstance(); diff --git a/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts b/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts new file mode 100644 index 0000000..c07936c --- /dev/null +++ b/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts @@ -0,0 +1,172 @@ +import { createRouter } from "@/core/infrastructure/lib/create-app"; +import * as routes from "./recommendation.routes"; +import { PgRecommendationRepository } from "../adapters/pg-recommendation.repository"; +import { toResponseDTO } from "../../application/dtos/recommendation.dto"; +import { Context } from "hono"; +import * as HttpStatusCodes from "stoker/http-status-codes"; + +const recommendationRepository = PgRecommendationRepository.getInstance(); + +const getPendingHandler = async (c: Context) => { + try { + const userId = c.get("userId"); + + if (!userId) { + return c.json( + { + success: false, + data: null, + message: "User ID not found in context", + }, + HttpStatusCodes.UNAUTHORIZED + ); + } + + const recommendations = + await recommendationRepository.findPendingByUserId(userId); + + return c.json( + { + success: true, + data: recommendations.map(toResponseDTO), + message: "Recommendations retrieved successfully", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error getting pending recommendations:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to retrieve recommendations", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const markAsViewedHandler = async (c: Context) => { + try { + const { id } = c.req.param(); + + const recommendation = await recommendationRepository.findById(Number(id)); + if (!recommendation) { + return c.json( + { + success: false, + data: null, + message: "Recommendation not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + await recommendationRepository.markAsViewed(Number(id)); + + return c.json( + { + success: true, + data: { success: true }, + message: "Recommendation marked as viewed", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error marking recommendation as viewed:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to mark recommendation as viewed", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const markAsDismissedHandler = async (c: Context) => { + try { + const { id } = c.req.param(); + + const recommendation = await recommendationRepository.findById(Number(id)); + if (!recommendation) { + return c.json( + { + success: false, + data: null, + message: "Recommendation not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + await recommendationRepository.markAsDismissed(Number(id)); + + return c.json( + { + success: true, + data: { success: true }, + message: "Recommendation dismissed successfully", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error dismissing recommendation:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to dismiss recommendation", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const markAsActedHandler = async (c: Context) => { + try { + const { id } = c.req.param(); + + const recommendation = await recommendationRepository.findById(Number(id)); + if (!recommendation) { + return c.json( + { + success: false, + data: null, + message: "Recommendation not found", + }, + HttpStatusCodes.NOT_FOUND + ); + } + + await recommendationRepository.markAsActed(Number(id)); + + return c.json( + { + success: true, + data: { success: true }, + message: "Recommendation marked as acted", + }, + HttpStatusCodes.OK + ); + } catch (error) { + console.error("Error marking recommendation as acted:", error); + return c.json( + { + success: false, + data: null, + message: "Failed to mark recommendation as acted", + }, + HttpStatusCodes.INTERNAL_SERVER_ERROR + ); + } +}; + +const router = createRouter() + .openapi(routes.getPending, getPendingHandler) + .openapi(routes.markAsViewed, markAsViewedHandler) + .openapi(routes.markAsDismissed, markAsDismissedHandler) + .openapi(routes.markAsActed, markAsActedHandler); + +export default router; diff --git a/src/features/recommendations/infrastructure/controllers/recommendation.routes.ts b/src/features/recommendations/infrastructure/controllers/recommendation.routes.ts new file mode 100644 index 0000000..adef321 --- /dev/null +++ b/src/features/recommendations/infrastructure/controllers/recommendation.routes.ts @@ -0,0 +1,191 @@ +import { createRoute } from "@hono/zod-openapi"; +import { z } from "zod"; +import * as HttpStatusCodes from "stoker/http-status-codes"; +import { + RecommendationType, + RecommendationPriority, + RecommendationStatus, +} from "../../domain/entities/recommendation.types"; + +const tags = ["Recommendations"]; + +const quickActionSchema = z.object({ + label: z.string(), + path: z.string(), + prefilledData: z.record(z.any()).optional(), +}); + +const recommendationResponseSchema = z.object({ + id: z.number(), + type: z.nativeEnum(RecommendationType), + priority: z.nativeEnum(RecommendationPriority), + title: z.string(), + description: z.string(), + data: z.any().optional(), + actionable: z.boolean(), + actions: z.array(quickActionSchema).optional(), + status: z.nativeEnum(RecommendationStatus), + createdAt: z.string(), + expiresAt: z.string().optional(), +}); + +const baseResponseSchema = (schema: T) => + z.object({ + success: z.boolean(), + data: schema, + message: z.string().optional(), + }); + +const errorResponseSchema = z.object({ + success: z.boolean(), + data: z.null(), + message: z.string(), +}); + +export const getPending = createRoute({ + path: "/recommendations", + method: "get", + tags, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.array(recommendationResponseSchema)), + }, + }, + description: "Pending recommendations retrieved successfully", + }, + [HttpStatusCodes.UNAUTHORIZED]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "User not authorized to access recommendations", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error retrieving recommendations", + }, + }, +}); + +export const markAsViewed = createRoute({ + path: "/recommendations/:id/view", + method: "patch", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.object({ success: z.boolean() })), + }, + }, + description: "Recommendation marked as viewed", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Recommendation not found", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error marking recommendation as viewed", + }, + }, +}); + +export const markAsDismissed = createRoute({ + path: "/recommendations/:id/dismiss", + method: "patch", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.object({ success: z.boolean() })), + }, + }, + description: "Recommendation dismissed successfully", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Recommendation not found", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error dismissing recommendation", + }, + }, +}); + +export const markAsActed = createRoute({ + path: "/recommendations/:id/act", + method: "patch", + tags, + request: { + params: z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }), + }, + responses: { + [HttpStatusCodes.OK]: { + content: { + "application/json": { + schema: baseResponseSchema(z.object({ success: z.boolean() })), + }, + }, + description: "Recommendation marked as acted", + }, + [HttpStatusCodes.NOT_FOUND]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Recommendation not found", + }, + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: { + content: { + "application/json": { + schema: errorResponseSchema, + }, + }, + description: "Unexpected error marking recommendation as acted", + }, + }, +}); + +export type GetPendingRoute = typeof getPending; +export type MarkAsViewedRoute = typeof markAsViewed; +export type MarkAsDismissedRoute = typeof markAsDismissed; +export type MarkAsActedRoute = typeof markAsActed; diff --git a/tsconfig.json b/tsconfig.json index 70f3468..7272e43 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ "target": "esnext", "module": "esnext", "paths": { + "@/*": [ + "./src/*" + ], "@/env": [ "./src/core/infrastructure/env/env.ts" ], From e7224a5ee6160379dc9688923d0135c23abc0c8e Mon Sep 17 00:00:00 2001 From: devjaes Date: Fri, 28 Nov 2025 01:44:29 -0500 Subject: [PATCH 3/6] refactor: enhance contribution recalculation cron job - Updated the cron job to run daily at 8 AM (America/Bogota) instead of every 2 minutes. - Added a mechanism to prevent concurrent executions of the job. - Improved logic for recalculating contribution amounts and sending notifications for inactive goals. - Refactored notification handling to avoid duplicate suggestions within a 24-hour period. - Cleaned up code for better readability and maintainability. --- .../recalculate-contribution-amount.cron.ts | 145 ++++++++++-------- .../controllers/recommendation.controller.ts | 7 +- 2 files changed, 87 insertions(+), 65 deletions(-) diff --git a/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts b/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts index edac5ef..e57e9e5 100644 --- a/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts +++ b/src/core/infrastructure/cron/recalculate-contribution-amount.cron.ts @@ -1,25 +1,32 @@ import { CronJob } from "cron"; import { PgGoalContributionRepository } from "../../../features/goals/infrastucture/adapters/goal-contribution.repository"; -import { EmailService } from "../../../features/email/application/services/email.service"; -import { PgUserRepository } from "../../../features/users/infrastructure/adapters/user.repository"; import { PgGoalRepository } from "../../../features/goals/infrastucture/adapters/goal.repository"; import { GoalSuggestionService } from "../../../features/goals/application/services/goal-suggestion.service"; import { NotificationUtilsService } from "../../../features/notifications/application/services/notification-utils.service"; import { PgNotificationRepository } from "../../../features/notifications/infrastructure/adapters/notification.repository"; import { NotificationType } from "../../../features/notifications/domain/entities/INotification"; -// Recalculate contribution amount cron job that runs every day at 8am UTC-5 +let isRunning = false; + +/** + * Recalculate contribution amount for inactive goals and generate suggestions. + * Runs once per day at 8 AM (America/Bogota). + */ export const recalculateContributionAmountCron = new CronJob( - // runs every 2 minutes - "*/1 * * * *", + "0 8 * * *", async () => { + if (isRunning) { + return; + } + try { + isRunning = true; console.log("Starting contribution amount recalculation job"); - // Inicializar servicios const goalSuggestionService = GoalSuggestionService.getInstance(); + const notificationRepository = PgNotificationRepository.getInstance(); const notificationUtils = NotificationUtilsService.getInstance( - PgNotificationRepository.getInstance() + notificationRepository ); // Find goals where last contribution was more than 1 week ago @@ -37,10 +44,7 @@ export const recalculateContributionAmountCron = new CronJob( // Process all active goals for suggestions for (const goal of allActiveGoals) { - // Check for goal at risk await goalSuggestionService.checkGoalAtRisk(goal); - - // Check for inactivity await goalSuggestionService.checkInactivity(goal); // Weekly saving suggestion (only for goals that haven't been updated in contribution amount) @@ -56,7 +60,7 @@ export const recalculateContributionAmountCron = new CronJob( if (contributions.length >= 5) { // Check if we've sent this suggestion recently (in the last 14 days) const recentSuggestions = - await PgNotificationRepository.getInstance().findByUserIdAndType( + await notificationRepository.findByUserIdAndType( goal.userId, NotificationType.SUGGESTION, new Date(Date.now() - 14 * 24 * 60 * 60 * 1000) @@ -74,9 +78,8 @@ export const recalculateContributionAmountCron = new CronJob( } } - // Now process the original recalculation logic + // Process recalculation logic and notifications for inactive goals for (const goal of goalsToUpdate) { - // Get the latest contribution for this goal const latestContribution = await PgGoalContributionRepository.getInstance().findLatestContribution( goal.id @@ -84,71 +87,89 @@ export const recalculateContributionAmountCron = new CronJob( const lastContributionDate = latestContribution?.date; - // Check if the last contribution was more than a week ago or if there's no contribution yet - if (lastContributionDate) { - // Recalculate the contribution amount based on remaining amount, time, and frequency - const today = new Date(); - const daysRemaining = Math.ceil( - (goal.endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) - ); + if (!lastContributionDate) { + continue; + } - // Adjust for contribution frequency (e.g., 7 for weekly, 30 for monthly) - const contributionFrequency = goal.contributionFrequency || 7; // Default to weekly if not set - const contributionsRemaining = Math.ceil( - daysRemaining / contributionFrequency - ); + // Recalculate the contribution amount based on remaining amount, time, and frequency + const today = new Date(); + const daysRemaining = Math.ceil( + (goal.endDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) + ); - if (contributionsRemaining <= 0) { - console.log( - `Goal ${goal.id} has no contributions remaining, skipping recalculation` - ); - continue; - } + const contributionFrequency = goal.contributionFrequency || 7; // Default to weekly if not set + const contributionsRemaining = Math.ceil( + daysRemaining / contributionFrequency + ); - const amountRemaining = - Number(goal.targetAmount) - Number(goal.currentAmount); - const newContributionAmount = - amountRemaining / contributionsRemaining; + if (contributionsRemaining <= 0) { + console.log( + `Goal ${goal.id} has no contributions remaining, skipping recalculation` + ); + continue; + } + + const amountRemaining = + Number(goal.targetAmount) - Number(goal.currentAmount); + const newContributionAmount = amountRemaining / contributionsRemaining; - // Update the goal with the new contribution amount - await PgGoalRepository.getInstance().update(goal.id, { - contributionAmount: Number(newContributionAmount.toFixed(2)), - }); + // Update the goal with the new contribution amount + await PgGoalRepository.getInstance().update(goal.id, { + contributionAmount: Number(newContributionAmount.toFixed(2)), + }); - // Create notification using the notification utils service - await notificationUtils.createSuggestionNotification( + // Avoid sending duplicate recalculation suggestions too frequently + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + const recentRecalculationSuggestions = + await notificationRepository.findByUserIdAndType( goal.userId, - `Meta: ${goal.name} - Aporte recalculado`, - `No has realizado aportes a tu meta "${ - goal.name - }" en más de una semana. Tu monto de aporte sugerido ha sido recalculado a $${newContributionAmount.toFixed( - 2 - )} para que puedas alcanzar tu objetivo a tiempo. Monto restante: $${amountRemaining.toFixed( - 2 - )}.`, - true // Send email + NotificationType.SUGGESTION, + oneDayAgo ); + const hasRecentRecalculationSuggestion = + recentRecalculationSuggestions.some( + (notification) => + notification.title.includes("Meta:") && + notification.title.includes(goal.name) && + notification.title.includes("Aporte recalculado") + ); + + if (hasRecentRecalculationSuggestion) { console.log( - `Recalculated contribution for goal ${ - goal.id - } to $${newContributionAmount.toFixed(2)}` + `Skipping recalculation notification for goal ${goal.id} (already sent recently)` ); + continue; } + + await notificationUtils.createSuggestionNotification( + goal.userId, + `Meta: ${goal.name} - Aporte recalculado`, + `No has realizado aportes a tu meta "${ + goal.name + }" en más de una semana. Tu monto de aporte sugerido ha sido recalculado a $${newContributionAmount.toFixed( + 2 + )} para que puedas alcanzar tu objetivo a tiempo. Monto restante: $${amountRemaining.toFixed( + 2 + )}.`, + true + ); + + console.log( + `Recalculated contribution for goal ${ + goal.id + } to $${newContributionAmount.toFixed(2)}` + ); } console.log("Contribution amount recalculation job completed"); } catch (error) { console.error("Error in contribution amount recalculation job:", error); + } finally { + isRunning = false; } }, - null, // onComplete - false, // start - "America/Bogota" // Timezone UTC-5 + null, + false, + "America/Bogota" ); - -// Helper function to get user email from user ID -async function getUserEmail(userId: number): Promise { - const result = await PgUserRepository.getInstance().findById(userId); - return result?.email || ""; -} diff --git a/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts b/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts index c07936c..b69bb29 100644 --- a/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts +++ b/src/features/recommendations/infrastructure/controllers/recommendation.controller.ts @@ -9,7 +9,7 @@ const recommendationRepository = PgRecommendationRepository.getInstance(); const getPendingHandler = async (c: Context) => { try { - const userId = c.get("userId"); + const userId = c.req.query("userId")?.toString(); if (!userId) { return c.json( @@ -22,8 +22,9 @@ const getPendingHandler = async (c: Context) => { ); } - const recommendations = - await recommendationRepository.findPendingByUserId(userId); + const recommendations = await recommendationRepository.findPendingByUserId( + Number(userId) + ); return c.json( { From d8409a50309c63783507064dae6c4ca315e69139 Mon Sep 17 00:00:00 2001 From: devjaes Date: Fri, 28 Nov 2025 02:45:19 -0500 Subject: [PATCH 4/6] feat: Implement comprehensive unit and integration testing, add a new Drizzle database migration, and update bun dependencies. --- .cursorignore | 4 + .env.test.example | 39 + TESTING_PLAN.md | 167 ++ bun.lock | 885 +++++++ bun.lockb | Bin 214116 -> 0 bytes drizzle.config.ts | 44 +- drizzle/0011_true_leper_queen.sql | 12 + drizzle/meta/0011_snapshot.json | 2198 +++++++++++++++++ drizzle/meta/_journal.json | 7 + restore-backup.sh | 44 + .../websocket/notification-socket.router.ts | 3 +- .../websocket/notification-socket.service.ts | 33 +- src/shared/utils/crypto.util.ts | 51 +- tests/helpers/mocks.ts | 34 + tests/helpers/test-db.ts | 149 ++ tests/helpers/test-helpers.ts | 105 + tests/integration/auth.test.ts | 184 ++ tests/integration/recommendations.test.ts | 172 ++ tests/setup.ts | 113 + tests/unit/services/auth.service.test.ts | 259 ++ tests/unit/utils/crypto.util.test.ts | 57 + tests/unit/utils/jwt.util.test.ts | 76 + vitest.config.ts | 15 +- 23 files changed, 4633 insertions(+), 18 deletions(-) create mode 100644 .cursorignore create mode 100644 .env.test.example create mode 100644 TESTING_PLAN.md create mode 100644 bun.lock delete mode 100755 bun.lockb create mode 100644 drizzle/0011_true_leper_queen.sql create mode 100644 drizzle/meta/0011_snapshot.json create mode 100755 restore-backup.sh create mode 100644 tests/helpers/mocks.ts create mode 100644 tests/helpers/test-db.ts create mode 100644 tests/helpers/test-helpers.ts create mode 100644 tests/integration/auth.test.ts create mode 100644 tests/integration/recommendations.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/unit/services/auth.service.test.ts create mode 100644 tests/unit/utils/crypto.util.test.ts create mode 100644 tests/unit/utils/jwt.util.test.ts diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..6017cfe --- /dev/null +++ b/.cursorignore @@ -0,0 +1,4 @@ +!.env +!.env.test +!.env.docker +!.env.test \ No newline at end of file diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..84b964b --- /dev/null +++ b/.env.test.example @@ -0,0 +1,39 @@ +# Environment Configuration for Tests +# Copy this file to .env.test and update with your test database values + +# IMPORTANT: Use a DIFFERENT database than your main database +# The database name MUST contain "test" to prevent accidental data loss + +NODE_ENV=test + +# Server Configuration (usually not needed for tests) +PORT=3005 + +# Logging +LOG_LEVEL=error + +# Test Database Configuration +# Option 1: Use individual variables +DATABASE_CONTAINER_NAME=container +DATABASE_PORT=5438 +DATABASE_NAME=database_test +DATABASE_USERNAME=username +DATABASE_PASSWORD=passwd + +# Option 2: Use TEST_DATABASE_URL (recommended) +# This should point to a DIFFERENT database than your main one +TEST_DATABASE_URL=postgresql://username:passwd@localhost:5438/database_test + +# JWT Secret for tests (must be at least 32 characters long) +JWT_SECRET=test-secret-key-for-jwt-testing-minimum-32-characters-long + +# Email Configuration (optional for tests, can use dummy values) +BREVO_API_KEY= +EMAIL_FROM_ADDRESS=test@example.com +EMAIL_FROM_NAME=Foppy AI Test + +# Frontend URL for email links (optional for tests) +FRONTEND_URL=http://localhost:3001 + +# Database Auth Token (not needed for tests) +# DATABASE_AUTH_TOKEN= diff --git a/TESTING_PLAN.md b/TESTING_PLAN.md new file mode 100644 index 0000000..aecd20e --- /dev/null +++ b/TESTING_PLAN.md @@ -0,0 +1,167 @@ +# Plan de Testing - Fopymes Backend + +## Objetivo +Implementar tests unitarios y de integración para las funcionalidades más importantes del sistema de finanzas personales. + +## Stack de Testing +- **Framework**: Bun Test (nativo de Bun) +- **Base de datos de pruebas**: PostgreSQL (contenedor Docker separado o base de datos de test) +- **Mocks**: Bun test mocking capabilities + +## Estructura de Tests + +``` +tests/ +├── unit/ +│ ├── utils/ +│ │ ├── crypto.util.test.ts +│ │ ├── jwt.util.test.ts +│ │ └── date.utils.test.ts +│ ├── services/ +│ │ ├── auth.service.test.ts +│ │ ├── transaction.service.test.ts +│ │ ├── goal.service.test.ts +│ │ └── goal-contribution.service.test.ts +│ └── cron/ +│ └── recalculate-contribution-amount.test.ts +├── integration/ +│ ├── auth.test.ts +│ ├── transactions.test.ts +│ ├── goals.test.ts +│ └── recommendations.test.ts +└── helpers/ + ├── test-db.ts + ├── test-helpers.ts + └── mocks.ts +``` + +## Features a Probar (Prioridad) + +### Alta Prioridad +1. **Autenticación (Auth)** + - Login con credenciales válidas/inválidas + - Registro de usuarios + - Recuperación de contraseña + - Validación de tokens JWT + +2. **Transacciones** + - Crear transacción (ingreso/gasto) + - Actualizar transacción + - Filtrar transacciones por fecha, tipo, categoría + - Validación de métodos de pago + +3. **Metas de Ahorro (Goals)** + - Crear meta + - Crear contribución a meta + - Actualizar progreso de meta + - Generar calendario de contribuciones + - Recalcular monto de contribución (cron job) + +4. **Recomendaciones** + - Obtener recomendaciones pendientes + - Marcar como vista/descartada/actuada + +### Media Prioridad +5. **Presupuestos (Budgets)** + - Crear presupuesto + - Validar límites de presupuesto + +6. **Deudas (Debts)** + - Crear deuda + - Registrar pago de deuda + +## Tests Unitarios + +### 1. Utilidades (Utils) +- **crypto.util.test.ts** + - `hash()`: Debe generar hash válido + - `verify()`: Debe verificar password correcto/incorrecto + +- **jwt.util.test.ts** + - `generateToken()`: Debe generar token JWT válido + - `verifyToken()`: Debe verificar token válido/inválido/expirado + +### 2. Servicios (Services) +- **auth.service.test.ts** + - Login exitoso + - Login con credenciales inválidas + - Registro exitoso + - Registro con email duplicado + - Recuperación de contraseña + +- **transaction.service.test.ts** + - Crear transacción válida + - Validar método de pago + - Filtrar transacciones + - Actualizar transacción + +- **goal.service.test.ts** + - Crear meta válida + - Validar usuario compartido + - Calcular progreso + +- **goal-contribution.service.test.ts** + - Crear contribución + - Actualizar progreso de meta + - Notificaciones de hitos + +### 3. Cron Jobs +- **recalculate-contribution-amount.test.ts** + - Recalcular monto para metas inactivas + - Generar notificaciones de recálculo + - Evitar duplicados + +## Tests de Integración + +### 1. Auth Endpoints +- `POST /auth/login` - 200, 400 +- `POST /auth/register` - 201, 400 +- `POST /auth/forgot-password` - 200, 404 + +### 2. Transaction Endpoints +- `POST /transactions` - 201, 400, 404 +- `GET /transactions?userId=X` - 200 +- `PUT /transactions/:id` - 200, 404 + +### 3. Goal Endpoints +- `POST /goals` - 201, 400, 404 +- `GET /goals?userId=X` - 200 +- `POST /goal-contributions` - 201, 400, 404 + +### 4. Recommendation Endpoints +- `GET /recommendations/pending?userId=X` - 200, 401 +- `PUT /recommendations/:id/viewed` - 200, 404 +- `PUT /recommendations/:id/dismissed` - 200, 404 + +## Configuración + +### Variables de Entorno de Test +```env +NODE_ENV=test +DATABASE_URL=postgresql://user:password@localhost:5432/fopymes_test +JWT_SECRET=test-secret-key +``` + +### Scripts en package.json +```json +{ + "test": "bun test", + "test:unit": "bun test tests/unit", + "test:integration": "bun test tests/integration", + "test:watch": "bun test --watch" +} +``` + +## Cobertura Objetivo +- **Cobertura mínima**: 60% de código crítico +- **Cobertura ideal**: 80% de código crítico +- **Enfoque**: Servicios de negocio, utilidades críticas, endpoints principales + +## Notas de Implementación +- Usar base de datos de test separada +- Limpiar datos entre tests +- Usar factories para crear datos de prueba +- Mockear servicios externos (email, etc.) +- Verificar cada suite de tests antes de continuar + + diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..f67c12e --- /dev/null +++ b/bun.lock @@ -0,0 +1,885 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "finance-app", + "dependencies": { + "@getbrevo/brevo": "^2.2.0", + "@hono/zod-openapi": "^0.18.3", + "@langchain/core": "^0.3.77", + "@scalar/hono-api-reference": "^0.5.162", + "@types/json2csv": "^5.0.7", + "@types/uuid": "^10.0.0", + "cron": "^4.3.0", + "date-fns": "^4.1.0", + "dotenv": "^16.4.5", + "dotenv-expand": "^12.0.1", + "drizzle-orm": "^0.38.2", + "drizzle-zod": "^0.6.0", + "exceljs": "^4.4.0", + "hono": "^4.6.12", + "hono-pino": "^0.7.0", + "i": "^0.3.7", + "jose": "^5.9.6", + "json2csv": "^6.0.0-alpha.2", + "langchain": "^0.3.34", + "npm": "^11.3.0", + "openai": "^6.9.1", + "pdf-lib": "^1.17.1", + "pdfkit": "^0.17.0", + "pg": "^8.13.1", + "pino": "^9.5.0", + "pino-pretty": "^13.0.0", + "stoker": "^1.4.2", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pdfkit": "^0.13.9", + "@types/pg": "^8.11.10", + "drizzle-kit": "^0.30.1", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^4.0.14", + }, + }, + }, + "packages": { + "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.2.0", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, ""], + + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, ""], + + "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, ""], + + "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, ""], + + "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, ""], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.19.12", "", { "os": "darwin", "cpu": "arm64" }, ""], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.6.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, ""], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, ""], + + "@eslint/eslintrc": ["@eslint/eslintrc@2.1.4", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, ""], + + "@eslint/js": ["@eslint/js@8.57.1", "", {}, ""], + + "@fast-csv/format": ["@fast-csv/format@4.3.5", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.isboolean": "^3.0.3", "lodash.isequal": "^4.5.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0" } }, ""], + + "@fast-csv/parse": ["@fast-csv/parse@4.3.6", "", { "dependencies": { "@types/node": "^14.0.1", "lodash.escaperegexp": "^4.1.2", "lodash.groupby": "^4.6.0", "lodash.isfunction": "^3.0.9", "lodash.isnil": "^4.0.0", "lodash.isundefined": "^3.0.1", "lodash.uniq": "^4.5.0" } }, ""], + + "@getbrevo/brevo": ["@getbrevo/brevo@2.2.0", "", { "dependencies": { "bluebird": "^3.5.0", "request": "^2.81.0", "rewire": "^7.0.0" } }, ""], + + "@hono/zod-openapi": ["@hono/zod-openapi@0.18.3", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^7.1.0", "@hono/zod-validator": "^0.4.1" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "3.*" } }, ""], + + "@hono/zod-validator": ["@hono/zod-validator@0.4.1", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, ""], + + "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, ""], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, ""], + + "@humanwhocodes/object-schema": ["@humanwhocodes/object-schema@2.0.3", "", {}, ""], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, ""], + + "@langchain/core": ["@langchain/core@0.3.77", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": "^0.3.67", "mustache": "^4.2.0", "p-queue": "^6.6.2", "p-retry": "4", "uuid": "^10.0.0", "zod": "^3.25.32", "zod-to-json-schema": "^3.22.3" } }, ""], + + "@langchain/openai": ["@langchain/openai@0.6.13", "", { "dependencies": { "js-tiktoken": "^1.0.12", "openai": "5.12.2", "zod": "^3.25.32" }, "peerDependencies": { "@langchain/core": ">=0.3.68 <0.4.0" } }, ""], + + "@langchain/textsplitters": ["@langchain/textsplitters@0.1.0", "", { "dependencies": { "js-tiktoken": "^1.0.12" }, "peerDependencies": { "@langchain/core": ">=0.2.21 <0.4.0" } }, ""], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, ""], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, ""], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, ""], + + "@pdf-lib/standard-fonts": ["@pdf-lib/standard-fonts@1.0.0", "", { "dependencies": { "pako": "^1.0.6" } }, ""], + + "@pdf-lib/upng": ["@pdf-lib/upng@1.0.1", "", { "dependencies": { "pako": "^1.0.10" } }, ""], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.53.3", "", { "os": "darwin", "cpu": "arm64" }, ""], + + "@scalar/hono-api-reference": ["@scalar/hono-api-reference@0.5.162", "", { "dependencies": { "@scalar/types": "0.0.22" }, "peerDependencies": { "hono": "^4.0.0" } }, ""], + + "@scalar/openapi-types": ["@scalar/openapi-types@0.1.5", "", {}, ""], + + "@scalar/types": ["@scalar/types@0.0.22", "", { "dependencies": { "@scalar/openapi-types": "0.1.5", "@unhead/schema": "^1.11.11" } }, ""], + + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, ""], + + "@streamparser/json": ["@streamparser/json@0.0.6", "", {}, ""], + + "@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, ""], + + "@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, ""], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, ""], + + "@types/estree": ["@types/estree@1.0.8", "", {}, ""], + + "@types/json2csv": ["@types/json2csv@5.0.7", "", { "dependencies": { "@types/node": "*" } }, ""], + + "@types/luxon": ["@types/luxon@3.6.2", "", {}, ""], + + "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + + "@types/pdfkit": ["@types/pdfkit@0.13.9", "", { "dependencies": { "@types/node": "*" } }, ""], + + "@types/pg": ["@types/pg@8.11.10", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^4.0.1" } }, ""], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, ""], + + "@types/retry": ["@types/retry@0.12.0", "", {}, ""], + + "@types/uuid": ["@types/uuid@10.0.0", "", {}, ""], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, ""], + + "@unhead/schema": ["@unhead/schema@1.11.13", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, ""], + + "@vitest/expect": ["@vitest/expect@4.0.14", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, ""], + + "@vitest/mocker": ["@vitest/mocker@4.0.14", "", { "dependencies": { "@vitest/spy": "4.0.14", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw"] }, ""], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.14", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, ""], + + "@vitest/runner": ["@vitest/runner@4.0.14", "", { "dependencies": { "@vitest/utils": "4.0.14", "pathe": "^2.0.3" } }, ""], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.14", "", { "dependencies": { "@vitest/pretty-format": "4.0.14", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, ""], + + "@vitest/spy": ["@vitest/spy@4.0.14", "", {}, ""], + + "@vitest/utils": ["@vitest/utils@4.0.14", "", { "dependencies": { "@vitest/pretty-format": "4.0.14", "tinyrainbow": "^3.0.3" } }, ""], + + "acorn": ["acorn@8.14.1", "", { "bin": "bin/acorn" }, ""], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, ""], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, ""], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, ""], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, ""], + + "archiver": ["archiver@5.3.2", "", { "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", "buffer-crc32": "^0.2.1", "readable-stream": "^3.6.0", "readdir-glob": "^1.1.2", "tar-stream": "^2.2.0", "zip-stream": "^4.1.0" } }, ""], + + "archiver-utils": ["archiver-utils@2.1.0", "", { "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^2.0.0" } }, ""], + + "argparse": ["argparse@2.0.1", "", {}, ""], + + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, ""], + + "assert-plus": ["assert-plus@1.0.0", "", {}, ""], + + "assertion-error": ["assertion-error@2.0.1", "", {}, ""], + + "async": ["async@3.2.6", "", {}, ""], + + "asynckit": ["asynckit@0.4.0", "", {}, ""], + + "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, ""], + + "aws-sign2": ["aws-sign2@0.7.0", "", {}, ""], + + "aws4": ["aws4@1.13.2", "", {}, ""], + + "balanced-match": ["balanced-match@1.0.2", "", {}, ""], + + "base64-js": ["base64-js@1.5.1", "", {}, ""], + + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, ""], + + "big-integer": ["big-integer@1.6.52", "", {}, ""], + + "binary": ["binary@0.3.0", "", { "dependencies": { "buffers": "~0.1.1", "chainsaw": "~0.1.0" } }, ""], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, ""], + + "bluebird": ["bluebird@3.7.2", "", {}, ""], + + "brace-expansion": ["brace-expansion@1.1.11", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, ""], + + "brotli": ["brotli@1.3.3", "", { "dependencies": { "base64-js": "^1.1.2" } }, ""], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, ""], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, ""], + + "buffer-from": ["buffer-from@1.1.2", "", {}, ""], + + "buffer-indexof-polyfill": ["buffer-indexof-polyfill@1.0.2", "", {}, ""], + + "buffers": ["buffers@0.1.1", "", {}, ""], + + "bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="], + + "callsites": ["callsites@3.1.0", "", {}, ""], + + "camelcase": ["camelcase@6.3.0", "", {}, ""], + + "caseless": ["caseless@0.12.0", "", {}, ""], + + "chai": ["chai@6.2.1", "", {}, ""], + + "chainsaw": ["chainsaw@0.1.0", "", { "dependencies": { "traverse": ">=0.3.0 <0.4" } }, ""], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, ""], + + "clone": ["clone@2.1.2", "", {}, ""], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, ""], + + "color-name": ["color-name@1.1.4", "", {}, ""], + + "colorette": ["colorette@2.0.20", "", {}, ""], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, ""], + + "commander": ["commander@6.2.1", "", {}, ""], + + "compress-commons": ["compress-commons@4.1.2", "", { "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, ""], + + "concat-map": ["concat-map@0.0.1", "", {}, ""], + + "console-table-printer": ["console-table-printer@2.14.6", "", { "dependencies": { "simple-wcswidth": "^1.0.1" } }, ""], + + "core-util-is": ["core-util-is@1.0.2", "", {}, ""], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, ""], + + "crc32-stream": ["crc32-stream@4.0.3", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" } }, ""], + + "cron": ["cron@4.3.0", "", { "dependencies": { "@types/luxon": "~3.6.0", "luxon": "~3.6.0" } }, ""], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, ""], + + "crypto-js": ["crypto-js@4.2.0", "", {}, ""], + + "csstype": ["csstype@3.1.3", "", {}, ""], + + "dashdash": ["dashdash@1.14.1", "", { "dependencies": { "assert-plus": "^1.0.0" } }, ""], + + "date-fns": ["date-fns@4.1.0", "", {}, ""], + + "dateformat": ["dateformat@4.6.3", "", {}, ""], + + "dayjs": ["dayjs@1.11.13", "", {}, ""], + + "debug": ["debug@4.4.0", "", { "dependencies": { "ms": "^2.1.3" } }, ""], + + "decamelize": ["decamelize@1.2.0", "", {}, ""], + + "deep-is": ["deep-is@0.1.4", "", {}, ""], + + "defu": ["defu@6.1.4", "", {}, ""], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, ""], + + "dfa": ["dfa@1.2.0", "", {}, ""], + + "doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, ""], + + "dotenv": ["dotenv@16.4.5", "", {}, ""], + + "dotenv-expand": ["dotenv-expand@12.0.1", "", { "dependencies": { "dotenv": "^16.4.5" } }, ""], + + "drizzle-kit": ["drizzle-kit@0.30.1", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": "bin.cjs" }, ""], + + "drizzle-orm": ["drizzle-orm@0.38.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "expo-sqlite", "knex", "kysely", "mysql2", "postgres", "react", "sql.js", "sqlite3"] }, ""], + + "drizzle-zod": ["drizzle-zod@0.6.0", "", { "peerDependencies": { "drizzle-orm": ">=0.36.0", "zod": ">=3.0.0" } }, ""], + + "duplexer2": ["duplexer2@0.1.4", "", { "dependencies": { "readable-stream": "^2.0.2" } }, ""], + + "ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, ""], + + "end-of-stream": ["end-of-stream@1.4.4", "", { "dependencies": { "once": "^1.4.0" } }, ""], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, ""], + + "esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/darwin-arm64": "0.19.12" }, "bin": "bin/esbuild" }, ""], + + "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, ""], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, ""], + + "eslint": ["eslint@8.57.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", "@eslint/eslintrc": "^2.1.4", "@eslint/js": "8.57.1", "@humanwhocodes/config-array": "^0.13.0", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", "@ungap/structured-clone": "^1.2.0", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", "eslint-scope": "^7.2.2", "eslint-visitor-keys": "^3.4.3", "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", "graphemer": "^1.4.0", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3", "strip-ansi": "^6.0.1", "text-table": "^0.2.0" }, "bin": "bin/eslint.js" }, ""], + + "eslint-scope": ["eslint-scope@7.2.2", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, ""], + + "eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, ""], + + "espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, ""], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, ""], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, ""], + + "estraverse": ["estraverse@5.3.0", "", {}, ""], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, ""], + + "esutils": ["esutils@2.0.3", "", {}, ""], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, ""], + + "exceljs": ["exceljs@4.4.0", "", { "dependencies": { "archiver": "^5.0.0", "dayjs": "^1.8.34", "fast-csv": "^4.3.1", "jszip": "^3.10.1", "readable-stream": "^3.6.0", "saxes": "^5.0.1", "tmp": "^0.2.0", "unzipper": "^0.10.11", "uuid": "^8.3.0" } }, ""], + + "expect-type": ["expect-type@1.2.2", "", {}, ""], + + "extend": ["extend@3.0.2", "", {}, ""], + + "extsprintf": ["extsprintf@1.3.0", "", {}, ""], + + "fast-copy": ["fast-copy@3.0.2", "", {}, ""], + + "fast-csv": ["fast-csv@4.3.6", "", { "dependencies": { "@fast-csv/format": "4.3.5", "@fast-csv/parse": "4.3.6" } }, ""], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, ""], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, ""], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, ""], + + "fast-redact": ["fast-redact@3.5.0", "", {}, ""], + + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, ""], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, ""], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, ""], + + "file-entry-cache": ["file-entry-cache@6.0.1", "", { "dependencies": { "flat-cache": "^3.0.4" } }, ""], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, ""], + + "flat-cache": ["flat-cache@3.2.0", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, ""], + + "flatted": ["flatted@3.3.3", "", {}, ""], + + "fontkit": ["fontkit@2.0.4", "", { "dependencies": { "@swc/helpers": "^0.5.12", "brotli": "^1.3.2", "clone": "^2.1.2", "dfa": "^1.2.0", "fast-deep-equal": "^3.1.3", "restructure": "^3.0.0", "tiny-inflate": "^1.0.3", "unicode-properties": "^1.4.0", "unicode-trie": "^2.0.0" } }, ""], + + "forever-agent": ["forever-agent@0.6.1", "", {}, ""], + + "form-data": ["form-data@2.3.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.6", "mime-types": "^2.1.12" } }, ""], + + "fs-constants": ["fs-constants@1.0.0", "", {}, ""], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, ""], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, ""], + + "fstream": ["fstream@1.0.12", "", { "dependencies": { "graceful-fs": "^4.1.2", "inherits": "~2.0.0", "mkdirp": ">=0.5 0", "rimraf": "2" } }, ""], + + "get-tsconfig": ["get-tsconfig@4.8.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, ""], + + "getpass": ["getpass@0.1.7", "", { "dependencies": { "assert-plus": "^1.0.0" } }, ""], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, ""], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, ""], + + "globals": ["globals@13.24.0", "", { "dependencies": { "type-fest": "^0.20.2" } }, ""], + + "globrex": ["globrex@0.1.2", "", {}, "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, ""], + + "graphemer": ["graphemer@1.4.0", "", {}, ""], + + "har-schema": ["har-schema@2.0.0", "", {}, ""], + + "har-validator": ["har-validator@5.1.5", "", { "dependencies": { "ajv": "^6.12.3", "har-schema": "^2.0.0" } }, ""], + + "has-flag": ["has-flag@4.0.0", "", {}, ""], + + "help-me": ["help-me@5.0.0", "", {}, ""], + + "hono": ["hono@4.6.12", "", {}, ""], + + "hono-pino": ["hono-pino@0.7.0", "", { "dependencies": { "defu": "^6.1.4" }, "peerDependencies": { "hono": ">=4.0.0", "pino": ">=7.1.0" } }, ""], + + "hookable": ["hookable@5.5.3", "", {}, ""], + + "http-signature": ["http-signature@1.2.0", "", { "dependencies": { "assert-plus": "^1.0.0", "jsprim": "^1.2.2", "sshpk": "^1.7.0" } }, ""], + + "i": ["i@0.3.7", "", {}, ""], + + "ieee754": ["ieee754@1.2.1", "", {}, ""], + + "ignore": ["ignore@5.3.2", "", {}, ""], + + "immediate": ["immediate@3.0.6", "", {}, ""], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, ""], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, ""], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, ""], + + "inherits": ["inherits@2.0.4", "", {}, ""], + + "is-extglob": ["is-extglob@2.1.1", "", {}, ""], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, ""], + + "is-path-inside": ["is-path-inside@3.0.3", "", {}, ""], + + "is-typedarray": ["is-typedarray@1.0.0", "", {}, ""], + + "isarray": ["isarray@1.0.0", "", {}, ""], + + "isexe": ["isexe@2.0.0", "", {}, ""], + + "isstream": ["isstream@0.1.2", "", {}, ""], + + "jose": ["jose@5.9.6", "", {}, ""], + + "joycon": ["joycon@3.1.1", "", {}, ""], + + "jpeg-exif": ["jpeg-exif@1.1.4", "", {}, ""], + + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, ""], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, ""], + + "jsbn": ["jsbn@0.1.1", "", {}, ""], + + "json-buffer": ["json-buffer@3.0.1", "", {}, ""], + + "json-schema": ["json-schema@0.4.0", "", {}, ""], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, ""], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, ""], + + "json-stringify-safe": ["json-stringify-safe@5.0.1", "", {}, ""], + + "json2csv": ["json2csv@6.0.0-alpha.2", "", { "dependencies": { "@streamparser/json": "^0.0.6", "commander": "^6.2.0", "lodash.get": "^4.4.2" }, "bin": "bin/json2csv.js" }, ""], + + "jsonpointer": ["jsonpointer@5.0.1", "", {}, ""], + + "jsprim": ["jsprim@1.4.2", "", { "dependencies": { "assert-plus": "1.0.0", "extsprintf": "1.3.0", "json-schema": "0.4.0", "verror": "1.10.0" } }, ""], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, ""], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, ""], + + "langchain": ["langchain@0.3.34", "", { "dependencies": { "@langchain/openai": ">=0.1.0 <0.7.0", "@langchain/textsplitters": ">=0.0.0 <0.2.0", "js-tiktoken": "^1.0.12", "js-yaml": "^4.1.0", "jsonpointer": "^5.0.1", "langsmith": "^0.3.67", "openapi-types": "^12.1.3", "p-retry": "4", "uuid": "^10.0.0", "yaml": "^2.2.1", "zod": "^3.25.32" }, "peerDependencies": { "@langchain/anthropic": "*", "@langchain/aws": "*", "@langchain/cerebras": "*", "@langchain/cohere": "*", "@langchain/core": ">=0.3.58 <0.4.0", "@langchain/deepseek": "*", "@langchain/google-genai": "*", "@langchain/google-vertexai": "*", "@langchain/google-vertexai-web": "*", "@langchain/groq": "*", "@langchain/mistralai": "*", "@langchain/ollama": "*", "@langchain/xai": "*", "axios": "*", "cheerio": "*", "handlebars": "^4.7.8", "peggy": "^3.0.2", "typeorm": "*" }, "optionalPeers": ["@langchain/anthropic", "@langchain/aws", "@langchain/cerebras", "@langchain/cohere", "@langchain/deepseek", "@langchain/google-genai", "@langchain/google-vertexai", "@langchain/google-vertexai-web", "@langchain/groq", "@langchain/mistralai", "@langchain/ollama", "@langchain/xai", "axios", "cheerio", "handlebars", "peggy", "typeorm"] }, ""], + + "langsmith": ["langsmith@0.3.71", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^4.1.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "p-retry": "4", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base"] }, ""], + + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, ""], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, ""], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, ""], + + "linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, ""], + + "listenercount": ["listenercount@1.0.1", "", {}, ""], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, ""], + + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, ""], + + "lodash.difference": ["lodash.difference@4.5.0", "", {}, ""], + + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, ""], + + "lodash.flatten": ["lodash.flatten@4.4.0", "", {}, ""], + + "lodash.get": ["lodash.get@4.4.2", "", {}, ""], + + "lodash.groupby": ["lodash.groupby@4.6.0", "", {}, ""], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, ""], + + "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, ""], + + "lodash.isfunction": ["lodash.isfunction@3.0.9", "", {}, ""], + + "lodash.isnil": ["lodash.isnil@4.0.0", "", {}, ""], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, ""], + + "lodash.isundefined": ["lodash.isundefined@3.0.1", "", {}, ""], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, ""], + + "lodash.union": ["lodash.union@4.6.0", "", {}, ""], + + "lodash.uniq": ["lodash.uniq@4.5.0", "", {}, ""], + + "luxon": ["luxon@3.6.1", "", {}, ""], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, ""], + + "mime-db": ["mime-db@1.52.0", "", {}, ""], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, ""], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, ""], + + "minimist": ["minimist@1.2.8", "", {}, ""], + + "mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": "bin/cmd.js" }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], + + "ms": ["ms@2.1.3", "", {}, ""], + + "mustache": ["mustache@4.2.0", "", { "bin": "bin/mustache" }, ""], + + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, ""], + + "natural-compare": ["natural-compare@1.4.0", "", {}, ""], + + "normalize-path": ["normalize-path@3.0.0", "", {}, ""], + + "npm": ["npm@11.3.0", "", { "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, ""], + + "oauth-sign": ["oauth-sign@0.9.0", "", {}, ""], + + "obuf": ["obuf@1.1.2", "", {}, ""], + + "obug": ["obug@2.1.1", "", {}, ""], + + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, ""], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, ""], + + "openai": ["openai@6.9.1", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-vQ5Rlt0ZgB3/BNmTa7bIijYFhz3YBceAA3Z4JuoMSBftBF9YqFHIEhZakSs+O/Ad7EaoEimZvHxD5ylRjN11Lg=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, ""], + + "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, ""], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, ""], + + "p-finally": ["p-finally@1.0.0", "", {}, ""], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, ""], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, ""], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, ""], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, ""], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, ""], + + "pako": ["pako@1.0.11", "", {}, ""], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, ""], + + "path-exists": ["path-exists@4.0.0", "", {}, ""], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, ""], + + "path-key": ["path-key@3.1.1", "", {}, ""], + + "pathe": ["pathe@2.0.3", "", {}, ""], + + "pdf-lib": ["pdf-lib@1.17.1", "", { "dependencies": { "@pdf-lib/standard-fonts": "^1.0.0", "@pdf-lib/upng": "^1.0.1", "pako": "^1.0.11", "tslib": "^1.11.1" } }, ""], + + "pdfkit": ["pdfkit@0.17.0", "", { "dependencies": { "crypto-js": "^4.2.0", "fontkit": "^2.0.4", "jpeg-exif": "^1.1.4", "linebreak": "^1.1.0", "png-js": "^1.0.0" } }, ""], + + "performance-now": ["performance-now@2.1.0", "", {}, ""], + + "pg": ["pg@8.13.1", "", { "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.7.0", "pg-protocol": "^1.7.0", "pg-types": "^2.1.0", "pgpass": "1.x" }, "optionalDependencies": { "pg-cloudflare": "^1.1.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, ""], + + "pg-cloudflare": ["pg-cloudflare@1.1.1", "", {}, ""], + + "pg-connection-string": ["pg-connection-string@2.7.0", "", {}, ""], + + "pg-int8": ["pg-int8@1.0.1", "", {}, ""], + + "pg-numeric": ["pg-numeric@1.0.2", "", {}, ""], + + "pg-pool": ["pg-pool@3.7.0", "", { "peerDependencies": { "pg": ">=8.0" } }, ""], + + "pg-protocol": ["pg-protocol@1.7.0", "", {}, ""], + + "pg-types": ["pg-types@4.0.2", "", { "dependencies": { "pg-int8": "1.0.1", "pg-numeric": "1.0.2", "postgres-array": "~3.0.1", "postgres-bytea": "~3.0.0", "postgres-date": "~2.1.0", "postgres-interval": "^3.0.0", "postgres-range": "^1.1.1" } }, ""], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, ""], + + "picocolors": ["picocolors@1.1.1", "", {}, ""], + + "picomatch": ["picomatch@4.0.3", "", {}, ""], + + "pino": ["pino@9.5.0", "", { "dependencies": { "atomic-sleep": "^1.0.0", "fast-redact": "^3.1.1", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^4.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": "bin.js" }, ""], + + "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, ""], + + "pino-pretty": ["pino-pretty@13.0.0", "", { "dependencies": { "colorette": "^2.0.7", "dateformat": "^4.6.3", "fast-copy": "^3.0.2", "fast-safe-stringify": "^2.1.1", "help-me": "^5.0.0", "joycon": "^3.1.1", "minimist": "^1.2.6", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pump": "^3.0.0", "secure-json-parse": "^2.4.0", "sonic-boom": "^4.0.1", "strip-json-comments": "^3.1.1" }, "bin": "bin.js" }, ""], + + "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, ""], + + "png-js": ["png-js@1.0.0", "", {}, ""], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, ""], + + "postgres-array": ["postgres-array@3.0.2", "", {}, ""], + + "postgres-bytea": ["postgres-bytea@3.0.0", "", { "dependencies": { "obuf": "~1.1.2" } }, ""], + + "postgres-date": ["postgres-date@2.1.0", "", {}, ""], + + "postgres-interval": ["postgres-interval@3.0.0", "", {}, ""], + + "postgres-range": ["postgres-range@1.1.4", "", {}, ""], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, ""], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, ""], + + "process-warning": ["process-warning@4.0.0", "", {}, ""], + + "psl": ["psl@1.15.0", "", { "dependencies": { "punycode": "^2.3.1" } }, ""], + + "pump": ["pump@3.0.2", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, ""], + + "punycode": ["punycode@2.3.1", "", {}, ""], + + "qs": ["qs@6.5.3", "", {}, ""], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, ""], + + "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, ""], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, ""], + + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, ""], + + "real-require": ["real-require@0.2.0", "", {}, ""], + + "request": ["request@2.88.2", "", { "dependencies": { "aws-sign2": "~0.7.0", "aws4": "^1.8.0", "caseless": "~0.12.0", "combined-stream": "~1.0.6", "extend": "~3.0.2", "forever-agent": "~0.6.1", "form-data": "~2.3.2", "har-validator": "~5.1.3", "http-signature": "~1.2.0", "is-typedarray": "~1.0.0", "isstream": "~0.1.2", "json-stringify-safe": "~5.0.1", "mime-types": "~2.1.19", "oauth-sign": "~0.9.0", "performance-now": "^2.1.0", "qs": "~6.5.2", "safe-buffer": "^5.1.2", "tough-cookie": "~2.5.0", "tunnel-agent": "^0.6.0", "uuid": "^3.3.2" } }, ""], + + "resolve-from": ["resolve-from@4.0.0", "", {}, ""], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, ""], + + "restructure": ["restructure@3.0.2", "", {}, ""], + + "retry": ["retry@0.13.1", "", {}, ""], + + "reusify": ["reusify@1.1.0", "", {}, ""], + + "rewire": ["rewire@7.0.0", "", { "dependencies": { "eslint": "^8.47.0" } }, ""], + + "rimraf": ["rimraf@2.7.1", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, ""], + + "rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "4.53.3", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, ""], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, ""], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, ""], + + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, ""], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, ""], + + "saxes": ["saxes@5.0.1", "", { "dependencies": { "xmlchars": "^2.2.0" } }, ""], + + "secure-json-parse": ["secure-json-parse@2.7.0", "", {}, ""], + + "semver": ["semver@7.7.1", "", { "bin": "bin/semver.js" }, ""], + + "setimmediate": ["setimmediate@1.0.5", "", {}, ""], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, ""], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, ""], + + "siginfo": ["siginfo@2.0.0", "", {}, ""], + + "simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, ""], + + "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, ""], + + "source-map": ["source-map@0.6.1", "", {}, ""], + + "source-map-js": ["source-map-js@1.2.1", "", {}, ""], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, ""], + + "split2": ["split2@4.2.0", "", {}, ""], + + "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, ""], + + "stackback": ["stackback@0.0.2", "", {}, ""], + + "std-env": ["std-env@3.10.0", "", {}, ""], + + "stoker": ["stoker@1.4.2", "", { "peerDependencies": { "@asteasolutions/zod-to-openapi": "^7.0.0", "@hono/zod-openapi": ">=0.16.0", "hono": "^4.0.0", "openapi3-ts": "^4.4.0" } }, ""], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, ""], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, ""], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, ""], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, ""], + + "text-table": ["text-table@0.2.0", "", {}, ""], + + "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, ""], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, ""], + + "tinybench": ["tinybench@2.9.0", "", {}, ""], + + "tinyexec": ["tinyexec@0.3.2", "", {}, ""], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, ""], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, ""], + + "tmp": ["tmp@0.2.3", "", {}, ""], + + "tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, ""], + + "traverse": ["traverse@0.3.9", "", {}, ""], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@1.14.1", "", {}, ""], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, ""], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, ""], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, ""], + + "type-fest": ["type-fest@0.20.2", "", {}, ""], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unicode-properties": ["unicode-properties@1.4.1", "", { "dependencies": { "base64-js": "^1.3.0", "unicode-trie": "^2.0.0" } }, ""], + + "unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, ""], + + "unzipper": ["unzipper@0.10.14", "", { "dependencies": { "big-integer": "^1.6.17", "binary": "~0.3.0", "bluebird": "~3.4.1", "buffer-indexof-polyfill": "~1.0.0", "duplexer2": "~0.1.4", "fstream": "^1.0.12", "graceful-fs": "^4.2.2", "listenercount": "~1.0.1", "readable-stream": "~2.3.6", "setimmediate": "~1.0.4" } }, ""], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, ""], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, ""], + + "uuid": ["uuid@10.0.0", "", { "bin": "dist/bin/uuid" }, ""], + + "verror": ["verror@1.10.0", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, ""], + + "vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, ""], + + "vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="], + + "vitest": ["vitest@4.0.14", "", { "dependencies": { "@vitest/expect": "4.0.14", "@vitest/mocker": "4.0.14", "@vitest/pretty-format": "4.0.14", "@vitest/runner": "4.0.14", "@vitest/snapshot": "4.0.14", "@vitest/spy": "4.0.14", "@vitest/utils": "4.0.14", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.14", "@vitest/browser-preview": "4.0.14", "@vitest/browser-webdriverio": "4.0.14", "@vitest/ui": "4.0.14", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, ""], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, ""], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, ""], + + "word-wrap": ["word-wrap@1.2.5", "", {}, ""], + + "wrappy": ["wrappy@1.0.2", "", {}, ""], + + "xmlchars": ["xmlchars@2.2.0", "", {}, ""], + + "xtend": ["xtend@4.0.2", "", {}, ""], + + "yaml": ["yaml@2.6.1", "", { "bin": "bin.mjs" }, ""], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, ""], + + "zhead": ["zhead@2.2.4", "", {}, ""], + + "zip-stream": ["zip-stream@4.1.1", "", { "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", "readable-stream": "^3.6.0" } }, ""], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, ""], + + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/darwin-arm64": "0.18.20" }, "bin": "bin/esbuild" }, ""], + + "@eslint/eslintrc/strip-json-comments": ["strip-json-comments@3.1.1", "", {}, ""], + + "@fast-csv/format/@types/node": ["@types/node@14.18.63", "", {}, ""], + + "@fast-csv/parse/@types/node": ["@types/node@14.18.63", "", {}, ""], + + "@langchain/openai/openai": ["openai@5.12.2", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws"], "bin": "bin/cli" }, ""], + + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, ""], + + "archiver-utils/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, ""], + + "duplexer2/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "exceljs/uuid": ["uuid@8.3.2", "", { "bin": "dist/bin/uuid" }, ""], + + "flat-cache/rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": "bin.js" }, ""], + + "jszip/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "linebreak/base64-js": ["base64-js@0.0.8", "", {}, ""], + + "pg/pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, ""], + + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, ""], + + "request/uuid": ["uuid@3.4.0", "", { "bin": "bin/uuid" }, ""], + + "unicode-trie/pako": ["pako@0.2.9", "", {}, ""], + + "unzipper/bluebird": ["bluebird@3.4.7", "", {}, ""], + + "unzipper/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, ""], + + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/darwin-arm64": "0.25.12" }, "bin": "bin/esbuild" }, ""], + + "zip-stream/archiver-utils": ["archiver-utils@3.0.4", "", { "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", "lazystream": "^1.0.0", "lodash.defaults": "^4.2.0", "lodash.difference": "^4.5.0", "lodash.flatten": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.union": "^4.6.0", "normalize-path": "^3.0.0", "readable-stream": "^3.6.0" } }, ""], + + "@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, ""], + + "archiver-utils/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "archiver-utils/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "duplexer2/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "duplexer2/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "jszip/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "jszip/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "pg/pg-types/postgres-array": ["postgres-array@2.0.0", "", {}, ""], + + "pg/pg-types/postgres-bytea": ["postgres-bytea@1.0.0", "", {}, ""], + + "pg/pg-types/postgres-date": ["postgres-date@1.0.7", "", {}, ""], + + "pg/pg-types/postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, ""], + + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, ""], + + "unzipper/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, ""], + + "unzipper/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, ""], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, ""], + } +} diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 1d6f26ff0d50a64af7734bb05d7260e24ccf8223..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 214116 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 z%0D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/dev/null || echo "No hay archivos de backup" + exit 1 +fi + +echo "📦 Restaurando backup: $BACKUP_FILE" +echo "⚠️ Esto borrará todos los datos actuales en la base de datos 'database'" +read -p "¿Continuar? (s/N): " -n 1 -r +echo +if [[ ! $REPLY =~ ^[Ss]$ ]]; then + echo "Operación cancelada" + exit 1 +fi + +# Asegurar que el contenedor esté corriendo +echo "🔧 Verificando contenedor de base de datos..." +docker compose up -d db + +# Esperar a que PostgreSQL esté listo +echo "⏳ Esperando a que PostgreSQL esté listo..." +sleep 5 + +# Restaurar el backup +echo "📥 Restaurando datos..." +docker exec -i container psql -U username -d database < "$BACKUP_FILE" + +if [ $? -eq 0 ]; then + echo "✅ Backup restaurado exitosamente!" + echo "📊 Verificando datos..." + docker exec container psql -U username -d database -c "SELECT COUNT(*) as total_tablas FROM information_schema.tables WHERE table_schema = 'public';" +else + echo "❌ Error al restaurar el backup" + exit 1 +fi + diff --git a/src/features/notifications/infrastructure/websocket/notification-socket.router.ts b/src/features/notifications/infrastructure/websocket/notification-socket.router.ts index 4a8940f..8350718 100644 --- a/src/features/notifications/infrastructure/websocket/notification-socket.router.ts +++ b/src/features/notifications/infrastructure/websocket/notification-socket.router.ts @@ -8,7 +8,8 @@ const router = createRouter(); const notificationSocketService = NotificationSocketService.getInstance(); // WebSocket endpoint for notifications -router.get('/notifications/ws', notificationSocketService.createWebSocketMiddleware()); +const { upgrade } = notificationSocketService.createWebSocketMiddleware(); +router.get('/notifications/ws', upgrade); // Endpoint to get WebSocket connection statistics (for monitoring/debugging) router.openapi(openapi.wsStats, async (c) => { diff --git a/src/features/notifications/infrastructure/websocket/notification-socket.service.ts b/src/features/notifications/infrastructure/websocket/notification-socket.service.ts index 7b6a28c..714d663 100644 --- a/src/features/notifications/infrastructure/websocket/notification-socket.service.ts +++ b/src/features/notifications/infrastructure/websocket/notification-socket.service.ts @@ -1,8 +1,19 @@ import { Hono } from "hono"; -import { createBunWebSocket } from "hono/bun"; import { verifyToken } from "@/shared/utils/jwt.util"; import { INotification } from "../../domain/entities/INotification"; import { NotificationApiAdapter } from "../adapters/notification-api.adapter"; +type CreateBunWebSocket = typeof import("hono/bun").createBunWebSocket; + +const isBunRuntime = + typeof (globalThis as any).Bun !== "undefined" && + typeof (globalThis as any).Bun?.serve === "function"; + +let createBunWebSocketFn: CreateBunWebSocket | null = null; + +if (isBunRuntime) { + const bunModule = await import("hono/bun"); + createBunWebSocketFn = bunModule.createBunWebSocket; +} /** * Service for managing WebSocket connections for real-time notifications @@ -24,8 +35,24 @@ export class NotificationSocketService { /** * Create WebSocket middleware for Hono with Bun */ - public createWebSocketMiddleware(): any { - const { upgradeWebSocket, websocket } = createBunWebSocket(); + public createWebSocketMiddleware(): { + upgrade: any; + websocket?: any; + } { + if (!createBunWebSocketFn) { + const fallback = (c: any) => + c.json( + { + success: false, + message: + "WebSocket no disponible en este entorno (requiere runtime Bun)", + }, + 501 + ); + return { upgrade: fallback }; + } + + const { upgradeWebSocket, websocket } = createBunWebSocketFn(); // Using bind to preserve 'this' context in the callback const upgrade = upgradeWebSocket((c) => { diff --git a/src/shared/utils/crypto.util.ts b/src/shared/utils/crypto.util.ts index 3628cbe..e4a9cde 100644 --- a/src/shared/utils/crypto.util.ts +++ b/src/shared/utils/crypto.util.ts @@ -1,10 +1,53 @@ +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; + +const bunPasswordApi = + typeof globalThis !== "undefined" && + typeof (globalThis as any).Bun?.password?.hash === "function" && + typeof (globalThis as any).Bun?.password?.verify === "function" + ? (globalThis as any).Bun.password + : null; + +const hashWithNodeCrypto = (password: string): string => { + const salt = randomBytes(16).toString("hex"); + const derivedKey = scryptSync(password, salt, 64).toString("hex"); + return `${salt}:${derivedKey}`; +}; + +const verifyWithNodeCrypto = (password: string, hashedValue: string): boolean => { + const [salt, storedKey] = hashedValue.split(":"); + if (!salt || !storedKey) { + return false; + } + + const derivedKey = scryptSync(password, salt, 64); + const storedKeyBuffer = Buffer.from(storedKey, "hex"); + + if (derivedKey.length !== storedKeyBuffer.length) { + return false; + } + + return timingSafeEqual(derivedKey, storedKeyBuffer); +}; + export const hash = async (password: string): Promise => { - return Bun.password.hash(password); + if (bunPasswordApi) { + return bunPasswordApi.hash(password); + } + + return hashWithNodeCrypto(password); }; export const verify = async ( - password: string, - hash: string + password: string, + hashedValue: string ): Promise => { - return Bun.password.verify(password, hash); + if (bunPasswordApi) { + return bunPasswordApi.verify(password, hashedValue); + } + + try { + return verifyWithNodeCrypto(password, hashedValue); + } catch { + return false; + } }; diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts new file mode 100644 index 0000000..dab9df1 --- /dev/null +++ b/tests/helpers/mocks.ts @@ -0,0 +1,34 @@ +/** + * Mocks para servicios externos + */ + +export const mockEmailService = { + sendEmail: async (to: string, subject: string, body: string) => { + // Mock implementation - no envía emails reales + return { success: true }; + }, + sendPasswordResetEmail: async (email: string, token: string) => { + return { success: true }; + }, +}; + +export const mockNotificationService = { + create: async (notification: any) => { + return { id: 1, ...notification }; + }, + findByUserId: async (userId: number) => { + return []; + }, +}; + +/** + * Mock para servicios de IA/LLM + */ +export const mockLLMService = { + generateRecommendation: async (prompt: string) => { + return "Mock recommendation"; + }, + transcribeAudio: async (audio: Buffer) => { + return "Mock transcription"; + }, +}; diff --git a/tests/helpers/test-db.ts b/tests/helpers/test-db.ts new file mode 100644 index 0000000..f890313 --- /dev/null +++ b/tests/helpers/test-db.ts @@ -0,0 +1,149 @@ +import { drizzle } from "drizzle-orm/node-postgres"; +import { getTableName } from "drizzle-orm"; +import { Pool } from "pg"; +import * as schema from "@/core/infrastructure/database/schema"; +import { NodePgDatabase } from "drizzle-orm/node-postgres"; + +/** + * Configuración de base de datos para tests + * Usa DATABASE_URL de test o crea una nueva conexión + */ +export class TestDatabase { + private static instance: TestDatabase; + private pool: Pool; + private db: NodePgDatabase; + + private constructor() { + // FORZAR uso de base de datos de test - NUNCA usar la base de datos principal + // Si no hay TEST_DATABASE_URL configurado, usar un nombre de BD diferente + let databaseUrl = process.env.TEST_DATABASE_URL; + + if (!databaseUrl) { + // Si hay DATABASE_URL, cambiar el nombre de la base de datos a uno de test + if (process.env.DATABASE_URL) { + const mainUrl = new URL(process.env.DATABASE_URL); + const mainDbName = mainUrl.pathname.replace("/", ""); + + // ADVERTENCIA: Verificar que no estamos usando la base de datos principal + if (!mainDbName.endsWith("_test") && !mainDbName.includes("test")) { + console.warn( + "⚠️ ADVERTENCIA: Se está usando la base de datos principal como test!" + ); + console.warn( + "⚠️ Configura TEST_DATABASE_URL en .env.test para usar una BD separada" + ); + console.warn("⚠️ Base de datos principal:", mainDbName); + + // Forzar cambio a nombre de test + mainUrl.pathname = `/${mainDbName}_test`; + databaseUrl = mainUrl.toString(); + console.warn("⚠️ Usando base de datos de test:", mainUrl.pathname); + } else { + databaseUrl = process.env.DATABASE_URL; + } + } else { + // Fallback a configuración por defecto de test + databaseUrl = + "postgresql://postgres:postgres@localhost:5432/fopymes_test"; + } + } + + // Verificación final de seguridad + const testDbName = new URL(databaseUrl).pathname + .replace("/", "") + .toLowerCase(); + if (!testDbName.includes("test") && process.env.NODE_ENV === "test") { + console.error( + "❌ ERROR CRÍTICO: La base de datos de test no contiene 'test' en el nombre!" + ); + console.error("❌ Base de datos:", testDbName); + console.error("❌ Esto podría borrar datos de producción!"); + throw new Error( + "Base de datos de test no configurada correctamente. " + + "Configura TEST_DATABASE_URL con una base de datos que contenga 'test' en el nombre." + ); + } + + console.log( + "🔧 TestDatabase usando:", + databaseUrl.replace(/:[^:@]+@/, ":****@") + ); // Ocultar password en log + + this.pool = new Pool({ + connectionString: databaseUrl, + ssl: false, + max: 10, + }); + + this.db = drizzle(this.pool, { schema }); + } + + public static getInstance(): TestDatabase { + if (!TestDatabase.instance) { + TestDatabase.instance = new TestDatabase(); + } + return TestDatabase.instance; + } + + public getDb(): NodePgDatabase { + return this.db; + } + + public async close(): Promise { + await this.pool.end(); + } + + private async safeDelete(table: any): Promise { + const tableName = getTableName(table); + try { + await this.db.delete(table); + } catch (error: any) { + const message = error?.message || ""; + if (typeof message === "string" && message.includes("does not exist")) { + console.warn( + `[TestDatabase] Tabla ${tableName} no existe en la BD de test, se omite.` + ); + return; + } + throw error; + } + } + + /** + * Limpia todas las tablas en orden correcto (respetando foreign keys) + */ + public async cleanDatabase(): Promise { + // Orden de eliminación respetando foreign keys + // Primero eliminar tablas que referencian a otras + await this.safeDelete(schema.goal_contribution_schedule); + await this.safeDelete(schema.transactions); // Referencia a goal_contributions, debts, budgets, etc. + await this.safeDelete(schema.goal_contributions); // Después de transactions + await this.safeDelete(schema.scheduled_transactions); + await this.safeDelete(schema.debts); + await this.safeDelete(schema.budgets); + await this.safeDelete(schema.notifications); + await this.safeDelete(schema.recommendations); + await this.safeDelete(schema.goals); + await this.safeDelete(schema.payment_methods); + await this.safeDelete(schema.friends); + await this.safeDelete(schema.users); + // categories no se eliminan porque son datos de referencia + } + + /** + * Verifica conexión a la base de datos + */ + public async checkConnection(): Promise { + try { + const client = await this.pool.connect(); + await client.query("SELECT NOW()"); + client.release(); + return true; + } catch (error) { + console.error("Error connecting to test database:", error); + return false; + } + } +} + +export const testDb = TestDatabase.getInstance(); diff --git a/tests/helpers/test-helpers.ts b/tests/helpers/test-helpers.ts new file mode 100644 index 0000000..2820656 --- /dev/null +++ b/tests/helpers/test-helpers.ts @@ -0,0 +1,105 @@ +import { hash } from "@/shared/utils/crypto.util"; +import { generateToken } from "@/shared/utils/jwt.util"; +import { TestDatabase } from "./test-db"; +import { users } from "@/core/infrastructure/database/schema"; +import { IUser } from "@/features/users/domain/entities/IUser"; + +const testDb = TestDatabase.getInstance(); + +/** + * Factory para crear usuarios de prueba + */ +export async function createTestUser( + overrides?: Partial<{ + name: string; + username: string; + email: string; + password: string; + active: boolean; + }> +): Promise { + const db = testDb.getDb(); + const password = overrides?.password || "TestPassword123!"; + const passwordHash = await hash(password); + + const [user] = await db + .insert(users) + .values({ + name: overrides?.name || "Test User", + username: overrides?.username || `testuser_${Date.now()}`, + email: overrides?.email || `test_${Date.now()}@example.com`, + password_hash: passwordHash, + active: overrides?.active !== undefined ? overrides.active : true, + }) + .returning(); + + return { + id: user.id, + name: user.name, + username: user.username, + email: user.email, + passwordHash: user.password_hash, + active: user.active, + registration_date: user.registration_date, + recoveryToken: user.recovery_token || null, + recoveryTokenExpires: user.recovery_token_expires || null, + }; +} + +/** + * Crea un token JWT válido para un usuario + */ +export async function createAuthToken( + userId: number, + email: string +): Promise { + return await generateToken({ id: userId, email }); +} + +/** + * Helper para hacer requests HTTP en tests de integración + */ +export async function makeRequest( + app: any, + method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH", + path: string, + options?: { + body?: any; + headers?: Record; + query?: Record; + } +): Promise { + const url = new URL(path, "http://localhost"); + if (options?.query) { + Object.entries(options.query).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + } + + const request = new Request(url.toString(), { + method, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + body: options?.body ? JSON.stringify(options.body) : undefined, + }); + + return await app.fetch(request); +} + +/** + * Helper para parsear respuesta JSON + */ +export async function parseJsonResponse(response: Response): Promise { + return await response.json(); +} + +/** + * Helper para esperar un tiempo (útil para tests de timing) + */ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export { testDb }; diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 0000000..75c7a1f --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "vitest"; +import app from "@/app"; +import { + testDb, + createTestUser, + createAuthToken, +} from "../helpers/test-helpers"; +import { makeRequest, parseJsonResponse } from "../helpers/test-helpers"; + +describe("Auth Integration Tests", () => { + let testUser: any; + let authToken: string; + + beforeAll(async () => { + // Verificar conexión a la base de datos + const isConnected = await testDb.checkConnection(); + if (!isConnected) { + throw new Error("No se pudo conectar a la base de datos de pruebas"); + } + }); + + beforeEach(async () => { + // Limpiar base de datos antes de cada test + await testDb.cleanDatabase(); + + // Crear usuario de prueba + testUser = await createTestUser({ + email: "test@example.com", + password: "TestPassword123!", + }); + + authToken = await createAuthToken(testUser.id, testUser.email); + }); + + describe("POST /auth/login", () => { + it("debe hacer login exitoso con credenciales válidas", async () => { + const response = await makeRequest(app, "POST", "/auth/login", { + body: { + email: "test@example.com", + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe("test@example.com"); + expect(json.data.id).toBe(testUser.id); + expect(json.message).toBe("Login successful"); + }); + + it("debe rechazar login con email incorrecto", async () => { + const response = await makeRequest(app, "POST", "/auth/login", { + body: { + email: "nonexistent@example.com", + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + + it("debe rechazar login con contraseña incorrecta", async () => { + const response = await makeRequest(app, "POST", "/auth/login", { + body: { + email: "test@example.com", + password: "WrongPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + }); + + describe("POST /auth/register", () => { + it("debe registrar un nuevo usuario exitosamente", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "New User", + username: "newuser", + email: "newuser@example.com", + password: "NewPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe("newuser@example.com"); + expect(json.data.username).toBe("newuser"); + expect(json.message).toBe("Registration successful"); + }); + + it("debe rechazar registro con email duplicado", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "Duplicate User", + username: "duplicateuser", + email: "test@example.com", // Email ya existe + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Email already exists"); + }); + + it("debe rechazar registro con username duplicado", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "Duplicate User", + username: testUser.username, // Username ya existe + email: "different@example.com", + password: "TestPassword123!", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Username already exists"); + }); + + it("debe validar formato de contraseña", async () => { + const response = await makeRequest(app, "POST", "/auth/register", { + body: { + name: "Test User", + username: "testuser2", + email: "test2@example.com", + password: "weak", // Contraseña débil + }, + }); + + // Debe fallar la validación de Zod + expect(response.status).toBeGreaterThanOrEqual(400); + }); + }); + + describe("POST /auth/forgot-password", () => { + it("debe procesar solicitud de recuperación de contraseña", async () => { + const response = await makeRequest(app, "POST", "/auth/forgot-password", { + body: { + email: "test@example.com", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.message).toContain("recovery link will be sent"); + }); + + it("debe retornar éxito incluso si el email no existe (seguridad)", async () => { + const response = await makeRequest(app, "POST", "/auth/forgot-password", { + body: { + email: "nonexistent@example.com", + }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.message).toContain("recovery link will be sent"); + }); + }); +}); diff --git a/tests/integration/recommendations.test.ts b/tests/integration/recommendations.test.ts new file mode 100644 index 0000000..f3b2a41 --- /dev/null +++ b/tests/integration/recommendations.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, beforeAll } from "vitest"; +import app from "@/app"; +import { + testDb, + createTestUser, + createAuthToken, +} from "../helpers/test-helpers"; +import { makeRequest, parseJsonResponse } from "../helpers/test-helpers"; +import { recommendations } from "@/core/infrastructure/database/schema"; +import { testDb as dbHelper } from "../helpers/test-db"; +import { eq } from "drizzle-orm"; + +describe("Recommendations Integration Tests", () => { + let testUser: any; + let authToken: string; + + beforeAll(async () => { + const isConnected = await testDb.checkConnection(); + if (!isConnected) { + throw new Error("No se pudo conectar a la base de datos de pruebas"); + } + }); + + beforeEach(async () => { + await testDb.cleanDatabase(); + + testUser = await createTestUser({ + email: "test@example.com", + password: "TestPassword123!", + }); + + authToken = await createAuthToken(testUser.id, testUser.email); + }); + + describe("GET /recommendations", () => { + it("debe obtener recomendaciones pendientes del usuario", async () => { + // Crear una recomendación de prueba + const db = dbHelper.getDb(); + await db.insert(recommendations).values({ + user_id: testUser.id, + title: "Test Recommendation", + description: "This is a test recommendation", + type: "FINANCIAL_TIP", + priority: "MEDIUM", + status: "PENDING", + }); + + const response = await makeRequest(app, "GET", "/recommendations", { + query: { userId: testUser.id.toString() }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(Array.isArray(json.data)).toBe(true); + // Puede que el repositorio filtre por status "PENDING" (mayúsculas) + if (json.data.length > 0) { + expect(json.data[0].title).toBe("Test Recommendation"); + } + }); + + it("debe retornar error si no se proporciona userId", async () => { + const response = await makeRequest(app, "GET", "/recommendations", {}); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(401); + expect(json.success).toBe(false); + expect(json.message).toContain("User ID"); + }); + + it("debe retornar array vacío si no hay recomendaciones pendientes", async () => { + const response = await makeRequest(app, "GET", "/recommendations", { + query: { userId: testUser.id.toString() }, + }); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(Array.isArray(json.data)).toBe(true); + expect(json.data.length).toBe(0); + }); + }); + + describe("PATCH /recommendations/:id/view", () => { + it("debe marcar una recomendación como vista", async () => { + // Crear una recomendación de prueba + const db = dbHelper.getDb(); + const [rec] = await db + .insert(recommendations) + .values({ + user_id: testUser.id, + title: "Test Recommendation", + description: "This is a test recommendation", + type: "FINANCIAL_TIP", + priority: "MEDIUM", + status: "PENDING", + }) + .returning(); + + const response = await makeRequest( + app, + "PATCH", + `/recommendations/${rec.id}/view`, + {} + ); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.message).toBe("Recommendation marked as viewed"); + + // Verificar que se actualizó en la base de datos + const updatedRec = await db + .select() + .from(recommendations) + .where(eq(recommendations.id, rec.id)); + // El status puede estar en mayúsculas + expect(updatedRec[0].status?.toUpperCase()).toBe("VIEWED"); + }); + + it("debe retornar error si la recomendación no existe", async () => { + const response = await makeRequest( + app, + "PATCH", + "/recommendations/99999/view", + {} + ); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(404); + expect(json.success).toBe(false); + expect(json.message).toBe("Recommendation not found"); + }); + }); + + describe("PATCH /recommendations/:id/dismiss", () => { + it("debe descartar una recomendación", async () => { + const db = dbHelper.getDb(); + const [rec] = await db + .insert(recommendations) + .values({ + user_id: testUser.id, + title: "Test Recommendation", + description: "This is a test recommendation", + type: "FINANCIAL_TIP", + priority: "MEDIUM", + status: "PENDING", + }) + .returning(); + + const response = await makeRequest( + app, + "PATCH", + `/recommendations/${rec.id}/dismiss`, + {} + ); + + const json = await parseJsonResponse(response); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.message).toBe("Recommendation dismissed successfully"); + }); + }); +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..cd44ac6 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,113 @@ +import path from "node:path"; +import { existsSync } from "node:fs"; +import { config } from "dotenv"; +import { expand } from "dotenv-expand"; +import { execSync } from "node:child_process"; + +/** + * Setup global para tests + * Asegura que NODE_ENV=test esté configurado antes de cualquier import + * + * ⚠️ IMPORTANTE: Este archivo se ejecuta ANTES de cualquier import + * para asegurar que se use la configuración de test correcta + */ + +const envTestPath = path.resolve(process.cwd(), ".env.test"); + +if (existsSync(envTestPath)) { + expand( + config({ + path: envTestPath, + }) + ); +} else { + expand(config()); +} + +// Configurar NODE_ENV=test ANTES de cualquier otra cosa +if (process.env.NODE_ENV !== "test") { + process.env.NODE_ENV = "test"; + console.log("🔧 NODE_ENV configurado a 'test'"); +} + +// Asegurar que se use una base de datos de test diferente +if (!process.env.TEST_DATABASE_URL) { + if (process.env.DATABASE_URL) { + const mainUrl = new URL(process.env.DATABASE_URL); + const mainDbName = mainUrl.pathname.replace("/", ""); + + // Si la BD principal no termina en _test, crear una nueva URL con _test + if (!mainDbName.endsWith("_test") && !mainDbName.includes("test")) { + mainUrl.pathname = `/${mainDbName}_test`; + process.env.TEST_DATABASE_URL = mainUrl.toString(); + console.log( + "⚠️ TEST_DATABASE_URL no configurado, usando:", + mainUrl.pathname + ); + console.log( + "⚠️ Configura TEST_DATABASE_URL en .env.test para mayor seguridad" + ); + } else { + // Si ya tiene test en el nombre, usarla directamente + process.env.TEST_DATABASE_URL = process.env.DATABASE_URL; + } + } else { + console.warn( + "⚠️ TEST_DATABASE_URL no configurado y DATABASE_URL no encontrado." + ); + } +} + +// Forzar a que la app use la base de datos de test +if (process.env.TEST_DATABASE_URL) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; +} + +console.log("🧪 Entorno de test configurado"); +console.log("📦 NODE_ENV:", process.env.NODE_ENV); +if (process.env.TEST_DATABASE_URL) { + const testUrl = new URL(process.env.TEST_DATABASE_URL); + console.log("🗄️ Base de datos de test:", testUrl.pathname); + + // Advertencia si el nombre no contiene "test" + if (!testUrl.pathname.toLowerCase().includes("test")) { + console.error( + "❌ ADVERTENCIA: El nombre de la BD de test no contiene 'test'!" + ); + console.error("❌ Esto podría ser peligroso. Verifica tu configuración."); + } +} else { + console.warn("⚠️ TEST_DATABASE_URL no está configurado"); + console.warn("⚠️ Crea un archivo .env.test con TEST_DATABASE_URL"); +} + +const ensureMigrations = () => { + const flag = "__TEST_MIGRATIONS_APPLIED__"; + if ((globalThis as Record)[flag]) { + return; + } + + if (!process.env.DATABASE_URL) { + console.warn( + "⚠️ No se ejecutarán migraciones porque DATABASE_URL no está definido." + ); + return; + } + + const drizzleConfigPath = path.resolve(process.cwd(), "drizzle.config.ts"); + const command = `bunx drizzle-kit migrate --config ${drizzleConfigPath}`; + + try { + execSync(command, { + stdio: "inherit", + env: process.env, + }); + console.log("✅ Migraciones de prueba aplicadas correctamente"); + (globalThis as Record)[flag] = true; + } catch (error) { + console.error("❌ Error aplicando migraciones de prueba:", error); + throw error; + } +}; + +ensureMigrations(); diff --git a/tests/unit/services/auth.service.test.ts b/tests/unit/services/auth.service.test.ts new file mode 100644 index 0000000..a9d8fe8 --- /dev/null +++ b/tests/unit/services/auth.service.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { AuthService } from "@/features/auth/application/services/auth.service"; +import { IUserRepository } from "@/features/users/domain/ports/user-repository.port"; +import { IUser } from "@/features/users/domain/entities/IUser"; +import { hash } from "@/shared/utils/crypto.util"; +import { createTestUser, testDb } from "../../helpers/test-helpers"; +import { PgUserRepository } from "@/features/users/infrastructure/adapters/user.repository"; + +describe("AuthService", () => { + let authService: AuthService; + let userRepository: IUserRepository; + let testUser: IUser; + + beforeEach(async () => { + // Limpiar base de datos antes de cada test + await testDb.cleanDatabase(); + + // Usar repositorio real para tests más realistas + userRepository = PgUserRepository.getInstance(); + authService = AuthService.getInstance(userRepository); + + // Crear usuario de prueba + testUser = await createTestUser({ + email: "test@example.com", + password: "TestPassword123!", + }); + }); + + describe("login", () => { + it("debe hacer login exitoso con credenciales válidas", async () => { + const mockContext = createMockContext({ + email: "test@example.com", + password: "TestPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe(testUser.email); + expect(json.message).toBe("Login successful"); + }); + + it("debe rechazar login con email incorrecto", async () => { + const mockContext = createMockContext({ + email: "nonexistent@example.com", + password: "TestPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + + it("debe rechazar login con contraseña incorrecta", async () => { + const mockContext = createMockContext({ + email: "test@example.com", + password: "WrongPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + + it("debe rechazar login con usuario inactivo", async () => { + // Crear usuario inactivo + const inactiveUser = await createTestUser({ + email: "inactive@example.com", + password: "TestPassword123!", + active: false, + }); + + const mockContext = createMockContext({ + email: "inactive@example.com", + password: "TestPassword123!", + }); + + const response = await authService.login(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid credentials"); + }); + }); + + describe("register", () => { + it("debe registrar un nuevo usuario exitosamente", async () => { + const mockContext = createMockContext({ + name: "New User", + username: "newuser", + email: "newuser@example.com", + password: "NewPassword123!", + }); + + const response = await authService.register(mockContext); + const json = await response.json(); + + expect(response.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data).toBeDefined(); + expect(json.data.token).toBeDefined(); + expect(json.data.email).toBe("newuser@example.com"); + expect(json.data.username).toBe("newuser"); + expect(json.message).toBe("Registration successful"); + }); + + it("debe rechazar registro con email duplicado", async () => { + const mockContext = createMockContext({ + name: "Duplicate User", + username: "duplicateuser", + email: "test@example.com", // Email ya existe + password: "TestPassword123!", + }); + + const response = await authService.register(mockContext); + const json = await response.json(); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Email already exists"); + }); + + it("debe rechazar registro con username duplicado", async () => { + const mockContext = createMockContext({ + name: "Duplicate User", + username: testUser.username, // Username ya existe + email: "different@example.com", + password: "TestPassword123!", + }); + + const response = await authService.register(mockContext); + const json = await response.json(); + + expect(response.status).toBe(409); + expect(json.success).toBe(false); + expect(json.message).toBe("Username already exists"); + }); + }); + + describe("forgotPassword", () => { + it("debe generar token de recuperación para email válido", async () => { + const mockContext = createMockContext({ + email: "test@example.com", + }); + + const response = await authService.forgotPassword(mockContext); + const json = await response.json(); + + expect(response.status).toBe(200); + // El servicio puede retornar success: false si el email falla, pero el token se genera + // Verificamos que el token se guardó correctamente + const updatedUser = await userRepository.findByEmail("test@example.com"); + expect(updatedUser?.recoveryToken).toBeDefined(); + expect(updatedUser?.recoveryTokenExpires).toBeDefined(); + expect(json.message).toContain("recovery link will be sent"); + }); + + it("debe retornar éxito incluso si el email no existe (seguridad)", async () => { + const mockContext = createMockContext({ + email: "nonexistent@example.com", + }); + + const response = await authService.forgotPassword(mockContext); + const json = await response.json(); + + expect(response.status).toBe(200); + // El servicio retorna success: false cuando el usuario no existe, pero status 200 por seguridad + expect(json.message).toContain("recovery link will be sent"); + }); + }); + + describe("resetPassword", () => { + it("debe resetear contraseña con token válido", async () => { + // Primero generar token de recuperación + const forgotContext = createMockContext({ + email: "test@example.com", + }); + await authService.forgotPassword(forgotContext); + + // Obtener el token generado + const user = await userRepository.findByEmail("test@example.com"); + const token = user?.recoveryToken; + expect(token).toBeDefined(); + + // Resetear contraseña + const resetContext = createMockContext({ + token: token!, + password: "NewPassword123!", + }); + + const response = await authService.resetPassword(resetContext); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.message).toBe("Password has been reset successfully"); + + // Verificar que el token fue limpiado + const updatedUser = await userRepository.findByEmail("test@example.com"); + expect(updatedUser?.recoveryToken).toBeNull(); + + // Verificar que la contraseña fue actualizada (intentando login) + const loginContext = createMockContext({ + email: "test@example.com", + password: "NewPassword123!", + }); + const loginResponse = await authService.login(loginContext); + const loginJson = await loginResponse.json(); + expect(loginJson.success).toBe(true); + }); + + it("debe rechazar reset con token inválido", async () => { + const mockContext = createMockContext({ + token: "invalid_token_12345", + password: "NewPassword123!", + }); + + const response = await authService.resetPassword(mockContext); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe("Invalid or expired token"); + }); + }); +}); + +/** + * Helper para crear un mock context de Hono + */ +function createMockContext(body: any): any { + return { + req: { + valid: (type: string) => { + if (type === "json") { + return body; + } + return body; + }, + }, + json: (data: any, status: number = 200) => { + return new Response(JSON.stringify(data), { + status, + headers: { "Content-Type": "application/json" }, + }); + }, + }; +} diff --git a/tests/unit/utils/crypto.util.test.ts b/tests/unit/utils/crypto.util.test.ts new file mode 100644 index 0000000..f2560e6 --- /dev/null +++ b/tests/unit/utils/crypto.util.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { hash, verify } from "@/shared/utils/crypto.util"; + +describe("crypto.util", () => { + describe("hash", () => { + it("debe generar un hash válido para una contraseña", async () => { + const password = "TestPassword123!"; + const hashed = await hash(password); + + expect(hashed).toBeDefined(); + expect(typeof hashed).toBe("string"); + expect(hashed.length).toBeGreaterThan(0); + expect(hashed).not.toBe(password); + }); + + it("debe generar hashes diferentes para la misma contraseña (sal aleatoria)", async () => { + const password = "TestPassword123!"; + const hash1 = await hash(password); + const hash2 = await hash(password); + + // Los hashes deben ser diferentes debido al salt + expect(hash1).not.toBe(hash2); + }); + }); + + describe("verify", () => { + it("debe verificar correctamente una contraseña válida", async () => { + const password = "TestPassword123!"; + const hashed = await hash(password); + + const isValid = await verify(password, hashed); + expect(isValid).toBe(true); + }); + + it("debe rechazar una contraseña incorrecta", async () => { + const password = "TestPassword123!"; + const wrongPassword = "WrongPassword123!"; + const hashed = await hash(password); + + const isValid = await verify(wrongPassword, hashed); + expect(isValid).toBe(false); + }); + + it("debe rechazar un hash inválido", async () => { + const password = "TestPassword123!"; + const invalidHash = "invalid_hash_string"; + + try { + const isValid = await verify(password, invalidHash); + expect(isValid).toBe(false); + } catch (error) { + // Bun.password.verify lanza error con hash inválido, lo cual es el comportamiento esperado + expect(error).toBeDefined(); + } + }); + }); +}); diff --git a/tests/unit/utils/jwt.util.test.ts b/tests/unit/utils/jwt.util.test.ts new file mode 100644 index 0000000..b442bef --- /dev/null +++ b/tests/unit/utils/jwt.util.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { generateToken, verifyToken } from "@/shared/utils/jwt.util"; + +describe("jwt.util", () => { + // Asegurar que JWT_SECRET esté configurado + beforeAll(() => { + if (!process.env.JWT_SECRET) { + process.env.JWT_SECRET = + "test-secret-key-for-jwt-testing-minimum-32-chars"; + } + }); + + describe("generateToken", () => { + it("debe generar un token JWT válido", async () => { + const payload = { id: 1, email: "test@example.com" }; + const token = await generateToken(payload); + + expect(token).toBeDefined(); + expect(typeof token).toBe("string"); + expect(token.split(".").length).toBe(3); // JWT tiene 3 partes separadas por puntos + }); + + it("debe generar tokens diferentes para diferentes payloads", async () => { + const payload1 = { id: 1, email: "test1@example.com" }; + const payload2 = { id: 2, email: "test2@example.com" }; + + const token1 = await generateToken(payload1); + const token2 = await generateToken(payload2); + + expect(token1).not.toBe(token2); + }); + + it("debe incluir el payload en el token", async () => { + const payload = { id: 123, email: "user@example.com" }; + const token = await generateToken(payload); + const verified = await verifyToken(token); + + expect(verified).toBeDefined(); + expect(verified?.id).toBe(payload.id); + expect(verified?.email).toBe(payload.email); + }); + }); + + describe("verifyToken", () => { + it("debe verificar correctamente un token válido", async () => { + const payload = { id: 1, email: "test@example.com" }; + const token = await generateToken(payload); + const verified = await verifyToken(token); + + expect(verified).toBeDefined(); + expect(verified?.id).toBe(payload.id); + expect(verified?.email).toBe(payload.email); + }); + + it("debe rechazar un token inválido", async () => { + const invalidToken = "invalid.token.string"; + const verified = await verifyToken(invalidToken); + + expect(verified).toBeNull(); + }); + + it("debe rechazar un token malformado", async () => { + const malformedToken = "not.a.valid.jwt.token"; + const verified = await verifyToken(malformedToken); + + expect(verified).toBeNull(); + }); + + it("debe rechazar un token vacío", async () => { + const emptyToken = ""; + const verified = await verifyToken(emptyToken); + + expect(verified).toBeNull(); + }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 6823cdc..a58da7a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,11 +1,18 @@ -import { defineConfig } from 'vitest/config'; -import tsconfigPaths from 'vite-tsconfig-paths'; +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ plugins: [tsconfigPaths()], test: { globals: true, - environment: 'node', - include: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + environment: "node", + setupFiles: ["tests/setup.ts"], + fileParallelism: false, // Run test files sequentially to avoid database conflicts + include: [ + "src/**/*.test.ts", + "src/**/*.spec.ts", + "tests/**/*.test.ts", + "tests/**/*.spec.ts", + ], }, }); From f9c0cde6cb7a01f9c34d54e29d5fc3fca6c35e93 Mon Sep 17 00:00:00 2001 From: devjaes Date: Fri, 28 Nov 2025 02:51:03 -0500 Subject: [PATCH 5/6] feat: Enhance environment configuration with JWT and frontend URL, and add Drizzle support for test database URLs. --- .env.example | 33 +++++++++++++++++++++++++++------ drizzle.config.ts | 10 ++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 207a39a..bf1f99e 100644 --- a/.env.example +++ b/.env.example @@ -1,17 +1,38 @@ +# Environment Configuration Example +# Copy this file to .env and update with your actual values + +# Application Environment NODE_ENV=development + +# Server Configuration PORT=3005 + +# Logging LOG_LEVEL=debug +# Database Configuration DATABASE_CONTAINER_NAME=container -DATABASE_PORT=5432 +DATABASE_PORT=5438 DATABASE_NAME=database DATABASE_USERNAME=username DATABASE_PASSWORD=passwd + +# Database URL (can use environment variables) DATABASE_URL=postgresql://${DATABASE_USERNAME}:${DATABASE_PASSWORD}@localhost:${DATABASE_PORT}/${DATABASE_NAME} -DATABASE_AUTH_TOKEN=when-on-production +# Alternative: Direct database URL (if not using individual variables above) +# DATABASE_URL=postgresql://username:passwd@localhost:5438/database -# Email configuration -BREVO_API_KEY=your-brevo-api-key -EMAIL_FROM_ADDRESS=no-reply@example.com -EMAIL_FROM_NAME=App Name \ No newline at end of file +# JWT Secret (must be at least 32 characters long) +JWT_SECRET=your-secret-key-here-must-be-at-least-32-characters-long + +# Email Configuration (Brevo/Sendinblue) +BREVO_API_KEY=your-brevo-api-key-here +EMAIL_FROM_ADDRESS=your-email@example.com +EMAIL_FROM_NAME=Foppy AI + +# Frontend URL for email links +FRONTEND_URL=http://localhost:3001 + +# Database Auth Token (required in production) +DATABASE_AUTH_TOKEN=when-on-production diff --git a/drizzle.config.ts b/drizzle.config.ts index b81683b..b62f4f7 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -33,6 +33,16 @@ if (process.env.DATABASE_URL) { } } +const isTestEnv = process.env.NODE_ENV === "test"; +const connectionString = + (isTestEnv && process.env.TEST_DATABASE_URL) || env.DATABASE_URL; + +if (isTestEnv && !process.env.TEST_DATABASE_URL) { + console.warn( + "⚠️ TEST_DATABASE_URL no está configurado. Usando DATABASE_URL por defecto." + ); +} + export default { schema: "./src/core/infrastructure/database/schema.ts", out: "./drizzle", From d9a38b62cc840bd32e722f405895a11962ccde8b Mon Sep 17 00:00:00 2001 From: devjaes Date: Fri, 28 Nov 2025 13:29:11 -0500 Subject: [PATCH 6/6] chore: add new line to test files for consistency --- tests/helpers/mocks.ts | 1 + tests/integration/auth.test.ts | 1 + tests/unit/utils/jwt.util.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index dab9df1..ba3b1ec 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -32,3 +32,4 @@ export const mockLLMService = { return "Mock transcription"; }, }; + diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts index 75c7a1f..2c38424 100644 --- a/tests/integration/auth.test.ts +++ b/tests/integration/auth.test.ts @@ -182,3 +182,4 @@ describe("Auth Integration Tests", () => { }); }); }); + diff --git a/tests/unit/utils/jwt.util.test.ts b/tests/unit/utils/jwt.util.test.ts index b442bef..244a4ef 100644 --- a/tests/unit/utils/jwt.util.test.ts +++ b/tests/unit/utils/jwt.util.test.ts @@ -74,3 +74,4 @@ describe("jwt.util", () => { }); }); }); +