This commit is contained in:
parent 3cc47bf56b
commit 221878529f
5 changed files with 353 additions and 4 deletions

View File

@ -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/dist ./dist
COPY --from=build --chown=nestjs:nodejs /app/node_modules ./node_modules 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/package*.json ./
COPY --from=build --chown=nestjs:nodejs /app/scripts ./scripts
# Set environment variables # Set environment variables
ENV NODE_ENV=production ENV NODE_ENV=production
@ -78,5 +79,5 @@ EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ 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))" CMD node -e "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# Start the application # Start the application with migrations
CMD ["node", "dist/main.js"] CMD ["sh", "-c", "node scripts/run-migrations.js && node dist/main.js"]

View File

@ -22,6 +22,7 @@
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts", "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: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: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" "seed": "ts-node -r tsconfig-paths/register src/database/seeds/run-seed.ts"
}, },
"dependencies": { "dependencies": {

41
scripts/run-migrations.js Normal file
View File

@ -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();

View File

@ -1,6 +1,7 @@
import { DataSource, DataSourceOptions } from 'typeorm'; import { DataSource, DataSourceOptions } from 'typeorm';
import * as dotenv from 'dotenv'; import * as dotenv from 'dotenv';
dotenv.config({ path: '.env' });
dotenv.config({ path: '.env.development' }); dotenv.config({ path: '.env.development' });
export const dataSourceOptions: DataSourceOptions = { export const dataSourceOptions: DataSourceOptions = {
@ -10,8 +11,8 @@ export const dataSourceOptions: DataSourceOptions = {
username: process.env.DB_USERNAME || 'finance_user', username: process.env.DB_USERNAME || 'finance_user',
password: process.env.DB_PASSWORD || 'dev_password_123', password: process.env.DB_PASSWORD || 'dev_password_123',
database: process.env.DB_NAME || 'finance_app', database: process.env.DB_NAME || 'finance_app',
entities: ['src/**/*.entity{.ts,.js}'], entities: [__dirname + '/../**/*.entity{.ts,.js}'],
migrations: ['src/database/migrations/*{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'],
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV === 'development', logging: process.env.NODE_ENV === 'development',
}; };

View File

@ -0,0 +1,305 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class InitialSchema1734256800000 implements MigrationInterface {
name = 'InitialSchema1734256800000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`,
);
}
}