diff --git a/src/migrations/1731513600000-InitialSchema.ts b/src/migrations/1731513600000-InitialSchema.ts index 7e9c840..cfe85ec 100644 --- a/src/migrations/1731513600000-InitialSchema.ts +++ b/src/migrations/1731513600000-InitialSchema.ts @@ -2,11 +2,275 @@ import { MigrationInterface, QueryRunner } from "typeorm"; import { logger } from "../observability/logger"; import { AppError } from "../utils/http-error"; +const MIGRATION_NAME = "InitialSchema1731513600000"; + +/** + * Canonical platform schema. + * + * Each DDL step lives in its own guarded method so failures are logged with + * precise context and re-raised as an {@link AppError} with a 500 status. + * TypeORM runs the migration inside a single transaction, so a failure in any + * step rolls back the whole schema and never leaves a partially applied state. + * + * Table creation is guarded with {@link QueryRunner.hasTable} so a partially + * applied or already-created schema is not replayed destructively, while a + * fresh environment still converges to the exact same target schema. + */ export class InitialSchema1731513600000 implements MigrationInterface { - name = "InitialSchema1731513600000"; + name = MIGRATION_NAME; public async up(queryRunner: QueryRunner): Promise { try { + logger.info("migration.start", { name: MIGRATION_NAME, direction: "up" }); + + await this.createEnumerations(queryRunner); + await this.ensureTable(queryRunner, "users", this.createUsersTable); + await this.ensureTable(queryRunner, "invoices", this.createInvoicesTable); + await this.ensureTable(queryRunner, "investments", this.createInvestmentsTable); + await this.ensureTable(queryRunner, "transactions", this.createTransactionsTable); + await this.ensureTable(queryRunner, "kyc_verifications", this.createKycVerificationsTable); + await this.ensureTable(queryRunner, "notifications", this.createNotificationsTable); + + logger.info("migration.complete", { name: MIGRATION_NAME, direction: "up" }); + } catch (error) { + logger.error("migration.failed", { + name: MIGRATION_NAME, + direction: "up", + error, + }); + const message = + error instanceof Error ? error.message : String(error); + throw new AppError(500, `Migration "${MIGRATION_NAME}" failed: ${message}`, "MIGRATION_FAILED", { name: MIGRATION_NAME }); + } + } + + public async down(queryRunner: QueryRunner): Promise { + try { + logger.info("migration.start", { name: MIGRATION_NAME, direction: "down" }); + + await this.dropTable(queryRunner, "notifications"); + await this.dropTable(queryRunner, "kyc_verifications"); + await this.dropTable(queryRunner, "transactions"); + await this.dropTable(queryRunner, "investments"); + await this.dropTable(queryRunner, "invoices"); + await this.dropTable(queryRunner, "users"); + + await this.dropEnumerations(queryRunner); + + logger.info("migration.complete", { name: MIGRATION_NAME, direction: "down" }); + } catch (error) { + logger.error("migration.failed", { + name: MIGRATION_NAME, + direction: "down", + error, + }); + const message = + error instanceof Error ? error.message : String(error); + throw new AppError(500, `Migration "${MIGRATION_NAME}" rollback failed: ${message}`, "MIGRATION_ROLLBACK_FAILED", { name: MIGRATION_NAME }); + } + } + + /** + * Runs `create` only when the target table does not yet exist, which keeps + * the migration resilient to partial application without changing the + * schema produced on a fresh database. + */ + private async ensureTable( + queryRunner: QueryRunner, + tableName: string, + create: (runner: QueryRunner) => Promise, + ): Promise { + const exists = await this.tableExists(queryRunner, tableName); + if (exists) { + logger.warn("migration.table_exists", { table: tableName, skipped: "create" }); + return; + } + await create(queryRunner); + } + + private async tableExists(queryRunner: QueryRunner, tableName: string): Promise { + const rows: unknown[] = await queryRunner.query( + `SELECT to_regclass('public."${tableName}"') AS "table"`, + ); + const first = rows?.[0] as { table?: string | null } | undefined; + return Boolean(first?.table); + } + + private async dropTable(queryRunner: QueryRunner, tableName: string): Promise { + if (await this.tableExists(queryRunner, tableName)) { + await queryRunner.query(`DROP TABLE "public"."${tableName}"`); + } + } + + private async createEnumerations(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "public"."users_usertype_enum" AS ENUM('seller', 'investor', 'both'); + CREATE TYPE "public"."users_kycstatus_enum" AS ENUM('pending', 'in_review', 'approved', 'rejected'); + CREATE TYPE "public"."invoices_invoicestatus_enum" AS ENUM('draft', 'pending', 'published', 'funded', 'settled', 'cancelled', 'rejected'); + CREATE TYPE "public"."investments_investmentstatus_enum" AS ENUM('pending', 'confirmed', 'settled', 'cancelled'); + CREATE TYPE "public"."transactions_transactiontype_enum" AS ENUM('investment', 'payment', 'withdrawal', 'refund'); + CREATE TYPE "public"."transactions_transactionstatus_enum" AS ENUM('pending', 'completed', 'failed'); + CREATE TYPE "public"."kyc_verifications_verificationtype_enum" AS ENUM('identity', 'address', 'business'); + CREATE TYPE "public"."notifications_notificationtype_enum" AS ENUM('invoice', 'investment', 'payment', 'kyc', 'system'); + `); + } + + private async dropEnumerations(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TYPE "public"."notifications_notificationtype_enum"`); + await queryRunner.query(`DROP TYPE "public"."kyc_verifications_verificationtype_enum"`); + await queryRunner.query(`DROP TYPE "public"."transactions_transactionstatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."transactions_transactiontype_enum"`); + await queryRunner.query(`DROP TYPE "public"."investments_investmentstatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."invoices_invoicestatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."users_kycstatus_enum"`); + await queryRunner.query(`DROP TYPE "public"."users_usertype_enum"`); + } + + private async createUsersTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "users" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "stellarAddress" character varying(56) NOT NULL, + "email" character varying(255), + "userType" "public"."users_usertype_enum" NOT NULL DEFAULT 'investor', + "kycStatus" "public"."users_kycstatus_enum" NOT NULL DEFAULT 'pending', + "is_kyc_verified" boolean NOT NULL DEFAULT false, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "UQ_users_stellarAddress" UNIQUE ("stellarAddress"), + CONSTRAINT "PK_users" PRIMARY KEY ("id") + ); + CREATE UNIQUE INDEX IF NOT EXISTS "idx_users_stellar_address" ON "users" ("stellarAddress"); + CREATE INDEX IF NOT EXISTS "idx_users_user_type" ON "users" ("userType"); + CREATE INDEX IF NOT EXISTS "idx_users_kyc_status" ON "users" ("kycStatus"); + CREATE INDEX IF NOT EXISTS "idx_users_email" ON "users" ("email"); + `); + } + + private async createInvoicesTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "invoices" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "seller_id" uuid NOT NULL, + "invoice_number" character varying(64) NOT NULL, + "customer_name" character varying(255) NOT NULL, + "amount" decimal(18,4) NOT NULL DEFAULT 0, + "discount_rate" decimal(5,2) NOT NULL DEFAULT 0, + "net_amount" decimal(18,4) NOT NULL DEFAULT 0, + "due_date" date NOT NULL, + "ipfs_hash" character varying(128), + "risk_score" decimal(5,2), + "status" "public"."invoices_invoicestatus_enum" NOT NULL DEFAULT 'draft', + "smart_contract_id" character varying(64), + "rejection_reason" text, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "UQ_invoices_invoice_number" UNIQUE ("invoice_number"), + CONSTRAINT "PK_invoices" PRIMARY KEY ("id"), + CONSTRAINT "FK_invoices_seller" FOREIGN KEY ("seller_id") REFERENCES "users"("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_invoices_seller_id" ON "invoices" ("seller_id"); + CREATE UNIQUE INDEX IF NOT EXISTS "idx_invoices_invoice_number" ON "invoices" ("invoice_number"); + CREATE INDEX IF NOT EXISTS "idx_invoices_customer_name" ON "invoices" ("customer_name"); + CREATE INDEX IF NOT EXISTS "idx_invoices_due_date" ON "invoices" ("due_date"); + CREATE INDEX IF NOT EXISTS "idx_invoices_status" ON "invoices" ("status"); + `); + } + + private async createInvestmentsTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "investments" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "invoice_id" uuid NOT NULL, + "investor_id" uuid NOT NULL, + "investment_amount" decimal(18,4) NOT NULL, + "expected_return" decimal(18,4) NOT NULL, + "actual_return" decimal(18,4), + "status" "public"."investments_investmentstatus_enum" NOT NULL DEFAULT 'pending', + "transaction_hash" character varying(64), + "stellar_operation_index" integer, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "PK_investments" PRIMARY KEY ("id"), + CONSTRAINT "FK_investments_invoice" FOREIGN KEY ("invoice_id") REFERENCES "invoices"("id") ON DELETE CASCADE, + CONSTRAINT "FK_investments_investor" FOREIGN KEY ("investor_id") REFERENCES "users"("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_investments_invoice_id" ON "investments" ("invoice_id"); + CREATE INDEX IF NOT EXISTS "idx_investments_investor_id" ON "investments" ("investor_id"); + CREATE INDEX IF NOT EXISTS "idx_investments_status" ON "investments" ("status"); + `); + } + + private async createTransactionsTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "transactions" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "investment_id" uuid, + "invoice_id" uuid, + "type" "public"."transactions_transactiontype_enum" NOT NULL, + "amount" decimal(18,4) NOT NULL, + "stellar_tx_hash" character varying(64), + "stellar_operation_index" integer, + "status" "public"."transactions_transactionstatus_enum" NOT NULL DEFAULT 'pending', + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "PK_transactions" PRIMARY KEY ("id"), + CONSTRAINT "FK_transactions_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_transactions_investment" FOREIGN KEY ("investment_id") REFERENCES "investments"("id") ON DELETE SET NULL, + CONSTRAINT "FK_transactions_invoice" FOREIGN KEY ("invoice_id") REFERENCES "invoices"("id") ON DELETE SET NULL + ); + CREATE INDEX IF NOT EXISTS "idx_transactions_user_id" ON "transactions" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_transactions_investment_id" ON "transactions" ("investment_id"); + CREATE INDEX IF NOT EXISTS "idx_transactions_invoice_id" ON "transactions" ("invoice_id"); + CREATE INDEX IF NOT EXISTS "idx_transactions_type" ON "transactions" ("type"); + CREATE INDEX IF NOT EXISTS "idx_transactions_status" ON "transactions" ("status"); + `); + } + + private async createKycVerificationsTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "kyc_verifications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "verification_type" "public"."kyc_verifications_verificationtype_enum" NOT NULL, + "status" "public"."users_kycstatus_enum" NOT NULL DEFAULT 'pending', + "documents" jsonb, + "verified_at" TIMESTAMP WITH TIME ZONE, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "PK_kyc_verifications" PRIMARY KEY ("id"), + CONSTRAINT "FK_kyc_verifications_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_kyc_verifications_user_id" ON "kyc_verifications" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_kyc_verifications_type" ON "kyc_verifications" ("verification_type"); + CREATE INDEX IF NOT EXISTS "idx_kyc_verifications_status" ON "kyc_verifications" ("status"); + `); + } + + private async createNotificationsTable(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "notifications" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "user_id" uuid NOT NULL, + "type" "public"."notifications_notificationtype_enum" NOT NULL, + "title" character varying(255) NOT NULL, + "message" text NOT NULL, + "read" boolean NOT NULL DEFAULT false, + "timestamp" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "updated_at" TIMESTAMP NOT NULL DEFAULT now(), + "deleted_at" TIMESTAMP, + CONSTRAINT "PK_notifications" PRIMARY KEY ("id"), + CONSTRAINT "FK_notifications_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS "idx_notifications_user_id" ON "notifications" ("user_id"); + CREATE INDEX IF NOT EXISTS "idx_notifications_type" ON "notifications" ("type"); + `); + } logger.info("Executing up migration for InitialSchema1731513600000"); await queryRunner.query(` diff --git a/src/migrations/1732300000000-AddUserAndTransactionPerformanceIndexes.ts b/src/migrations/1732300000000-AddUserAndTransactionPerformanceIndexes.ts index be06687..841b3a4 100644 --- a/src/migrations/1732300000000-AddUserAndTransactionPerformanceIndexes.ts +++ b/src/migrations/1732300000000-AddUserAndTransactionPerformanceIndexes.ts @@ -20,9 +20,18 @@ export class AddUserAndTransactionPerformanceIndexes1732300000000 CREATE INDEX IF NOT EXISTS "idx_transactions_stellar_tx_hash" ON "transactions" ("stellar_tx_hash"); `); + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "idx_transactions_user_id_timestamp" + ON "transactions" ("user_id", "timestamp"); + `); } public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS "public"."idx_transactions_user_id_timestamp"; + `); + await queryRunner.query(` DROP INDEX IF EXISTS "public"."idx_transactions_stellar_tx_hash"; `); diff --git a/src/models/Transaction.model.ts b/src/models/Transaction.model.ts index ec2e6b6..c065502 100644 --- a/src/models/Transaction.model.ts +++ b/src/models/Transaction.model.ts @@ -17,6 +17,7 @@ import { logger } from "../observability/logger"; @Entity("transactions") @Index("idx_transactions_status_type_timestamp", ["status", "type", "timestamp"]) +@Index("idx_transactions_user_id_timestamp", ["userId", "timestamp"]) @Index("idx_transactions_user_status_timestamp", ["userId", "status", "timestamp"]) @Index("idx_transactions_investment_status", ["investmentId", "status"]) @Index("idx_transactions_invoice_status", ["invoiceId", "status"]) diff --git a/tests/initial-schema-migration.test.ts b/tests/initial-schema-migration.test.ts new file mode 100644 index 0000000..ff96462 --- /dev/null +++ b/tests/initial-schema-migration.test.ts @@ -0,0 +1,115 @@ +import { InitialSchema1731513600000 } from "../src/migrations/1731513600000-InitialSchema"; +import { AppError } from "../src/utils/http-error"; + +interface QueryRunnerMock { + query: jest.Mock; +} + +function createQueryRunnerMock(overrides: Partial = {}): QueryRunnerMock { + const query = jest.fn(async (sql: string) => { + if (sql.includes("to_regclass")) { + return []; + } + return []; + }); + return { query, ...overrides }; +} + +describe("InitialSchema1731513600000", () => { + const migration = new InitialSchema1731513600000(); + + it("reports the expected migration name", () => { + expect(migration.name).toBe("InitialSchema1731513600000"); + }); + + it("creates all expected tables on up", async () => { + const runner = createQueryRunnerMock(); + await migration.up(runner as never); + + const ddl = runner.query.mock.calls.map(([sql]) => String(sql)).join("\n"); + + expect(ddl).toContain('CREATE TABLE "users"'); + expect(ddl).toContain('CREATE TABLE "invoices"'); + expect(ddl).toContain('CREATE TABLE "investments"'); + expect(ddl).toContain('CREATE TABLE "transactions"'); + expect(ddl).toContain('CREATE TABLE "kyc_verifications"'); + expect(ddl).toContain('CREATE TABLE "notifications"'); + }); + + it("creates all enum types and expected indexes on up", async () => { + const runner = createQueryRunnerMock(); + await migration.up(runner as never); + + const ddl = runner.query.mock.calls.map(([sql]) => String(sql)).join("\n"); + + expect(ddl).toContain('CREATE TYPE "public"."users_usertype_enum"'); + expect(ddl).toContain('CREATE TYPE "public"."transactions_transactiontype_enum"'); + expect(ddl).toContain('CREATE INDEX IF NOT EXISTS "idx_transactions_user_id"'); + expect(ddl).toContain('CREATE INDEX IF NOT EXISTS "idx_invoices_seller_id"'); + }); + + it("skips table creation when the table already exists", async () => { + const runner = createQueryRunnerMock({ + query: jest.fn(async (sql: string) => { + if (sql.includes("to_regclass")) { + return [{ table: 'public."users"' }]; + } + return []; + }), + }); + + await migration.up(runner as never); + + const ddl = runner.query.mock.calls.map(([sql]) => String(sql)).join("\n"); + expect(ddl).not.toContain('CREATE TABLE "users"'); + }); + + it("drops all tables and enum types on down", async () => { + const runner = createQueryRunnerMock({ + query: jest.fn(async (sql: string) => { + if (sql.includes("to_regclass")) { + return [{ table: 'public."notifications"' }]; + } + return []; + }), + }); + + await migration.down(runner as never); + + const ddl = runner.query.mock.calls.map(([sql]) => String(sql)).join("\n"); + expect(ddl).toContain('DROP TABLE "public"."notifications"'); + expect(ddl).toContain('DROP TABLE "public"."users"'); + expect(ddl).toContain('DROP TYPE "public"."users_usertype_enum"'); + }); + + it("wraps a failed up in an AppError with MIGRATION_FAILED code", async () => { + const runner = createQueryRunnerMock({ + query: jest.fn(async (sql: string) => { + if (sql.includes("to_regclass")) { + return []; + } + throw new Error("connection lost"); + }), + }); + + await expect(migration.up(runner as never)).rejects.toBeInstanceOf(AppError); + await expect(migration.up(runner as never)).rejects.toMatchObject({ + code: "MIGRATION_FAILED", + statusCode: 500, + }); + }); + + it("wraps a failed down in an AppError with MIGRATION_ROLLBACK_FAILED code", async () => { + const runner = createQueryRunnerMock({ + query: jest.fn(async () => { + throw new Error("connection lost"); + }), + }); + + await expect(migration.down(runner as never)).rejects.toBeInstanceOf(AppError); + await expect(migration.down(runner as never)).rejects.toMatchObject({ + code: "MIGRATION_ROLLBACK_FAILED", + statusCode: 500, + }); + }); +}); diff --git a/tests/transaction.model.test.ts b/tests/transaction.model.test.ts new file mode 100644 index 0000000..711b773 --- /dev/null +++ b/tests/transaction.model.test.ts @@ -0,0 +1,100 @@ +import "reflect-metadata"; +import { getMetadataArgsStorage } from "typeorm"; +import { Transaction } from "../src/models/Transaction.model"; +import { TransactionType, TransactionStatus } from "../src/types/enums"; + +function getEntityIndexNames(entity: new (...args: never[]) => unknown): Set { + const storage = getMetadataArgsStorage(); + return new Set( + storage.indices + .filter((index) => index.target === entity) + .map((index) => String(index.name)), + ); +} + +/** Maps column propertyName -> explicit DB column name (when configured). */ +function getEntityColumnNames(entity: new (...args: never[]) => unknown): Map { + const storage = getMetadataArgsStorage(); + const map = new Map(); + for (const column of storage.columns) { + if (column.target !== entity) continue; + if (!column.propertyName) continue; + map.set(column.propertyName, column.options?.name); + } + return map; +} + +function getEntityRelationNames(entity: new (...args: never[]) => unknown): Set { + const storage = getMetadataArgsStorage(); + return new Set( + storage.relations + .filter((relation) => relation.target === entity) + .map((relation) => String(relation.propertyName)), + ); +} + +describe("Transaction entity", () => { + it("maps to the transactions table", () => { + const storage = getMetadataArgsStorage(); + const entity = storage.tables.find((table) => table.target === Transaction); + expect(entity).toBeDefined(); + expect(entity?.name).toBe("transactions"); + }); + + it("declares a composite status/type/timestamp index", () => { + const names = getEntityIndexNames(Transaction); + expect(names.has("idx_transactions_status_type_timestamp")).toBe(true); + }); + + it("declares a composite user_id/timestamp index for history fetching", () => { + const names = getEntityIndexNames(Transaction); + expect(names.has("idx_transactions_user_id_timestamp")).toBe(true); + }); + + it("exposes the expected scalar columns", () => { + const columns = getEntityColumnNames(Transaction); + + for (const property of [ + "id", + "userId", + "investmentId", + "invoiceId", + "amount", + "type", + "stellarTxHash", + "stellarOperationIndex", + "status", + "timestamp", + ]) { + expect(columns.has(property)).toBe(true); + } + + expect(columns.get("userId")).toBe("user_id"); + expect(columns.get("investmentId")).toBe("investment_id"); + expect(columns.get("invoiceId")).toBe("invoice_id"); + expect(columns.get("stellarTxHash")).toBe("stellar_tx_hash"); + expect(columns.get("stellarOperationIndex")).toBe("stellar_operation_index"); + }); + + it("declares user, investment, and invoice relations", () => { + const relations = getEntityRelationNames(Transaction); + expect(relations.has("user")).toBe(true); + expect(relations.has("investment")).toBe(true); + expect(relations.has("invoice")).toBe(true); + }); +}); + +describe("Transaction type/status enums", () => { + it("has all transaction types used by the domain", () => { + expect(TransactionType.INVESTMENT).toBe("investment"); + expect(TransactionType.PAYMENT).toBe("payment"); + expect(TransactionType.WITHDRAWAL).toBe("withdrawal"); + expect(TransactionType.REFUND).toBe("refund"); + }); + + it("has all transaction statuses", () => { + expect(TransactionStatus.PENDING).toBe("pending"); + expect(TransactionStatus.COMPLETED).toBe("completed"); + expect(TransactionStatus.FAILED).toBe("failed"); + }); +});