diff --git a/Dockerfile b/Dockerfile index 54d1ae4..ae87ebb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,6 +63,7 @@ RUN addgroup -g 1001 -S nodejs && \ COPY --from=build --chown=nestjs:nodejs /app/dist ./dist COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules COPY --from=build --chown=nestjs:nodejs /app/package*.json ./ +COPY --from=build --chown=nestjs:nodejs /app/scripts ./scripts # Set environment variables ENV NODE_ENV=production @@ -78,5 +79,5 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))" -# Start the application -CMD ["node", "dist/main.js"] +# Start the application with migrations +CMD ["sh", "-c", "node scripts/run-migrations.js && node dist/main.js"] diff --git a/package.json b/package.json index 5d46f3c..87e6a7b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts", "migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts", "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts", + "migration:run:prod": "node scripts/run-migrations.js", "seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts" }, "dependencies": { diff --git a/scripts/run-migrations.js b/scripts/run-migrations.js new file mode 100644 index 0000000..42abf20 --- /dev/null +++ b/scripts/run-migrations.js @@ -0,0 +1,41 @@ +const { DataSource } = require('typeorm'); +const path = require('path'); + +const dataSourceOptions = { + type: 'postgres', + host: process.env.DB_HOST || 'localhost', + port: parseInt(process.env.DB_PORT || '5432', 10), + username: process.env.DB_USERNAME || 'finance_user', + password: process.env.DB_PASSWORD || 'dev_password_123', + database: process.env.DB_NAME || 'finance_app', + migrations: [path.join(__dirname, '../dist/database/migrations/*.js')], + logging: true, +}; + +async function runMigrations() { + console.log('🔄 Running database migrations...'); + + const dataSource = new DataSource(dataSourceOptions); + + try { + await dataSource.initialize(); + console.log('📦 Database connection established'); + + const migrations = await dataSource.runMigrations({ transaction: 'all' }); + + if (migrations.length === 0) { + console.log('✅ No pending migrations'); + } else { + console.log(`✅ Successfully ran ${migrations.length} migration(s):`); + migrations.forEach((m) => console.log(` - ${m.name}`)); + } + + await dataSource.destroy(); + console.log('🔌 Database connection closed'); + } catch (error) { + console.error('❌ Migration failed:', error.message); + process.exit(1); + } +} + +runMigrations(); diff --git a/src/database/data-source.ts b/src/database/data-source.ts index 5ba7b3d..4d34608 100644 --- a/src/database/data-source.ts +++ b/src/database/data-source.ts @@ -1,6 +1,7 @@ import { DataSource, DataSourceOptions } from 'typeorm'; import * as dotenv from 'dotenv'; +dotenv.config({ path: '.env' }); dotenv.config({ path: '.env.development' }); export const dataSourceOptions: DataSourceOptions = { @@ -10,8 +11,8 @@ export const dataSourceOptions: DataSourceOptions = { username: process.env.DB_USERNAME || 'finance_user', password: process.env.DB_PASSWORD || 'dev_password_123', database: process.env.DB_NAME || 'finance_app', - entities: ['src/**/*.entity{.ts,.js}'], - migrations: ['src/database/migrations/*{.ts,.js}'], + entities: [__dirname + '/../**/*.entity{.ts,.js}'], + migrations: [__dirname + '/migrations/*{.ts,.js}'], synchronize: false, logging: process.env.NODE_ENV === 'development', }; diff --git a/src/database/migrations/1734256800000-InitialSchema.ts b/src/database/migrations/1734256800000-InitialSchema.ts new file mode 100644 index 0000000..34793c7 --- /dev/null +++ b/src/database/migrations/1734256800000-InitialSchema.ts @@ -0,0 +1,305 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class InitialSchema1734256800000 implements MigrationInterface { + name = 'InitialSchema1734256800000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`); + + await queryRunner.query(` + CREATE TYPE "public"."transaction_type_enum" AS ENUM('INCOME', 'EXPENSE') + `); + + await queryRunner.query(` + CREATE TYPE "public"."budget_group_type_enum" AS ENUM('ESSENTIAL', 'PERSONAL', 'SAVINGS') + `); + + await queryRunner.query(` + CREATE TYPE "public"."payment_method_enum" AS ENUM('CASH', 'CARD', 'BANK_TRANSFER') + `); + + await queryRunner.query(` + CREATE TYPE "public"."goal_status_enum" AS ENUM('ACTIVE', 'COMPLETED', 'PAUSED', 'CANCELLED') + `); + + await queryRunner.query(` + CREATE TYPE "public"."goal_priority_enum" AS ENUM('LOW', 'MEDIUM', 'HIGH') + `); + + await queryRunner.query(` + CREATE TYPE "public"."goal_auto_save_frequency_enum" AS ENUM('DAILY', 'WEEKLY', 'MONTHLY') + `); + + await queryRunner.query(` + CREATE TYPE "public"."recommendation_type_enum" AS ENUM('SAVING', 'SPENDING', 'INVESTMENT', 'TAX', 'DEBT', 'BUDGET', 'GOAL') + `); + + await queryRunner.query(` + CREATE TYPE "public"."recommendation_status_enum" AS ENUM('NEW', 'VIEWED', 'APPLIED', 'DISMISSED') + `); + + await queryRunner.query(` + CREATE TABLE "users" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "email" character varying(255) NOT NULL, + "phone" character varying(20), + "password_hash" character varying(255) NOT NULL, + "first_name" character varying(100), + "last_name" character varying(100), + "currency" character varying(3) NOT NULL DEFAULT 'RUB', + "language" character varying(10) NOT NULL DEFAULT 'ru', + "timezone" character varying(50) NOT NULL DEFAULT 'Europe/Moscow', + "is_email_verified" boolean NOT NULL DEFAULT false, + "is_phone_verified" boolean NOT NULL DEFAULT false, + "is_active" boolean NOT NULL DEFAULT true, + "failed_login_attempts" integer NOT NULL DEFAULT 0, + "locked_until" TIMESTAMP, + "last_login_at" TIMESTAMP, + "monthly_income" numeric(15,2) NOT NULL DEFAULT 0, + "financial_goals" jsonb, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "UQ_users_email" UNIQUE ("email"), + CONSTRAINT "UQ_users_phone" UNIQUE ("phone"), + CONSTRAINT "PK_users" PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_users_email" ON "users" ("email")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_users_phone" ON "users" ("phone")`, + ); + + await queryRunner.query(` + CREATE TABLE "refresh_tokens" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "token_hash" character varying(255) NOT NULL, + "user_agent" text, + "ip_address" inet, + "expires_at" TIMESTAMP NOT NULL, + "is_revoked" boolean NOT NULL DEFAULT false, + "replaced_by_token_hash" character varying(255), + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_refresh_tokens" PRIMARY KEY ("id"), + CONSTRAINT "FK_refresh_tokens_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_refresh_tokens_user_id" ON "refresh_tokens" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_refresh_tokens_expires_at" ON "refresh_tokens" ("expires_at")`, + ); + + await queryRunner.query(` + CREATE TABLE "audit_logs" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid, + "action" character varying(50) NOT NULL, + "entity_type" character varying(50) NOT NULL, + "entity_id" uuid, + "old_values" jsonb, + "new_values" jsonb, + "ip_address" inet, + "user_agent" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs" PRIMARY KEY ("id"), + CONSTRAINT "FK_audit_logs_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_audit_logs_user_id" ON "audit_logs" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_audit_logs_created_at" ON "audit_logs" ("created_at")`, + ); + + await queryRunner.query(` + CREATE TABLE "categories" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "name_ru" character varying(100) NOT NULL, + "name_en" character varying(100), + "type" "public"."transaction_type_enum" NOT NULL, + "group_type" "public"."budget_group_type_enum", + "icon" character varying(50), + "color" character varying(7), + "is_default" boolean NOT NULL DEFAULT true, + "user_id" uuid, + "parent_id" uuid, + CONSTRAINT "PK_categories" PRIMARY KEY ("id"), + CONSTRAINT "FK_categories_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_categories_parent" FOREIGN KEY ("parent_id") REFERENCES "categories"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_categories_type" ON "categories" ("type")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_categories_user_id" ON "categories" ("user_id")`, + ); + + await queryRunner.query(` + CREATE TABLE "transactions" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "amount" numeric(15,2) NOT NULL, + "currency" character varying(3) NOT NULL DEFAULT 'RUB', + "type" "public"."transaction_type_enum" NOT NULL, + "category_id" uuid, + "description" text, + "transaction_date" date NOT NULL, + "payment_method" "public"."payment_method_enum", + "receipt_url" character varying(500), + "receipt_processed" boolean NOT NULL DEFAULT false, + "created_by" uuid, + "updated_by" uuid, + "is_recurring" boolean NOT NULL DEFAULT false, + "recurring_pattern" jsonb, + "is_planned" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "CHK_transactions_amount" CHECK ("amount" > 0), + CONSTRAINT "PK_transactions" PRIMARY KEY ("id"), + CONSTRAINT "FK_transactions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_transactions_category" FOREIGN KEY ("category_id") REFERENCES "categories"("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_transactions_user_id" ON "transactions" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_transactions_type" ON "transactions" ("type")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_transactions_category_id" ON "transactions" ("category_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_transactions_transaction_date" ON "transactions" ("transaction_date")`, + ); + + await queryRunner.query(` + CREATE TABLE "budgets" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "month" date NOT NULL, + "total_income" numeric(15,2) NOT NULL DEFAULT 0, + "essentials_limit" numeric(15,2) NOT NULL DEFAULT 0, + "essentials_spent" numeric(15,2) NOT NULL DEFAULT 0, + "personal_limit" numeric(15,2) NOT NULL DEFAULT 0, + "personal_spent" numeric(15,2) NOT NULL DEFAULT 0, + "savings_limit" numeric(15,2) NOT NULL DEFAULT 0, + "savings_spent" numeric(15,2) NOT NULL DEFAULT 0, + "custom_allocations" jsonb, + "is_active" boolean NOT NULL DEFAULT true, + CONSTRAINT "UQ_budgets_user_month" UNIQUE ("user_id", "month"), + CONSTRAINT "PK_budgets" PRIMARY KEY ("id"), + CONSTRAINT "FK_budgets_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_budgets_user_id" ON "budgets" ("user_id")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_budgets_month" ON "budgets" ("month")`, + ); + + await queryRunner.query(` + CREATE TABLE "goals" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "title_ru" character varying(200) NOT NULL, + "description_ru" text, + "target_amount" numeric(15,2) NOT NULL, + "current_amount" numeric(15,2) NOT NULL DEFAULT 0, + "currency" character varying(3) NOT NULL DEFAULT 'RUB', + "target_date" date, + "status" "public"."goal_status_enum" NOT NULL DEFAULT 'ACTIVE', + "priority" "public"."goal_priority_enum" NOT NULL DEFAULT 'MEDIUM', + "icon" character varying(50), + "color" character varying(7), + "auto_save_enabled" boolean NOT NULL DEFAULT false, + "auto_save_amount" numeric(15,2), + "auto_save_frequency" "public"."goal_auto_save_frequency_enum", + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "completed_at" TIMESTAMP, + CONSTRAINT "PK_goals" PRIMARY KEY ("id"), + CONSTRAINT "FK_goals_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_goals_user_id" ON "goals" ("user_id")`, + ); + + await queryRunner.query(` + CREATE TABLE "recommendations" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "user_id" uuid NOT NULL, + "type" "public"."recommendation_type_enum" NOT NULL, + "title_ru" character varying(200) NOT NULL, + "description_ru" text NOT NULL, + "action_text_ru" character varying(200), + "priority_score" numeric(3,2) NOT NULL DEFAULT 0.5, + "confidence_score" numeric(3,2) NOT NULL DEFAULT 0.5, + "potential_savings" numeric(15,2), + "status" "public"."recommendation_status_enum" NOT NULL DEFAULT 'NEW', + "action_data" jsonb, + "expires_at" TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "viewed_at" TIMESTAMP, + "applied_at" TIMESTAMP, + CONSTRAINT "PK_recommendations" PRIMARY KEY ("id"), + CONSTRAINT "FK_recommendations_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ) + `); + + await queryRunner.query( + `CREATE INDEX "IDX_recommendations_user_id" ON "recommendations" ("user_id")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "recommendations"`); + await queryRunner.query(`DROP TABLE IF EXISTS "goals"`); + await queryRunner.query(`DROP TABLE IF EXISTS "budgets"`); + await queryRunner.query(`DROP TABLE IF EXISTS "transactions"`); + await queryRunner.query(`DROP TABLE IF EXISTS "categories"`); + await queryRunner.query(`DROP TABLE IF EXISTS "audit_logs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "refresh_tokens"`); + await queryRunner.query(`DROP TABLE IF EXISTS "users"`); + + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."recommendation_status_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."recommendation_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."goal_auto_save_frequency_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."goal_priority_enum"`, + ); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."goal_status_enum"`); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."payment_method_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."budget_group_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE IF EXISTS "public"."transaction_type_enum"`, + ); + } +}