diff --git a/src/database/migrations.integration.spec.ts b/src/database/migrations.integration.spec.ts index bc9fc32..ce3acd8 100644 --- a/src/database/migrations.integration.spec.ts +++ b/src/database/migrations.integration.spec.ts @@ -36,7 +36,7 @@ describe('Database migrations integration', () => { }); it('applies every migration, matches entity metadata, and enforces foreign keys', () => { - expect(result.executedMigrationNames).toHaveLength(8); + expect(result.executedMigrationNames).toHaveLength(9); expect(result.schemaInSync).toBe(true); expect(result.enumValues).toEqual(Object.values(AccountStatus)); expect(result.foreignKeyColumns).toContainEqual(['accountId']); diff --git a/src/database/migrations/1718100008000-AddDeletedAtToAccountsTable.ts b/src/database/migrations/1718100008000-AddDeletedAtToAccountsTable.ts new file mode 100644 index 0000000..85409e4 --- /dev/null +++ b/src/database/migrations/1718100008000-AddDeletedAtToAccountsTable.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddDeletedAtToAccountsTable1718100008000 + implements MigrationInterface +{ + name = 'AddDeletedAtToAccountsTable1718100008000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "accounts" + ADD COLUMN "deletedAt" timestamp NULL + `); + + // Index to keep soft-delete filtering fast on high-traffic queries, + // mirroring the style of 1718100006000-AddHighTrafficIndexes. + await queryRunner.query(` + CREATE INDEX "IDX_accounts_deletedAt" ON "accounts" ("deletedAt") + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX "IDX_accounts_deletedAt" + `); + + await queryRunner.query(` + ALTER TABLE "accounts" + DROP COLUMN "deletedAt" + `); + } +} \ No newline at end of file diff --git a/src/modules/accounts/accounts.service.spec.ts b/src/modules/accounts/accounts.service.spec.ts index a91257f..9735999 100644 --- a/src/modules/accounts/accounts.service.spec.ts +++ b/src/modules/accounts/accounts.service.spec.ts @@ -235,6 +235,7 @@ describe('AccountsService', () => { function makeQueryBuilder(accounts: Account[], total: number) { const qb = { where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), skip: jest.fn().mockReturnThis(), take: jest.fn().mockReturnThis(), getManyAndCount: jest.fn().mockResolvedValue([accounts, total]), @@ -247,13 +248,16 @@ describe('AccountsService', () => { mockRepo.createQueryBuilder.mockReturnValue( makeQueryBuilder(accounts, 1), ); - const result = await service.findAll({ limit: 50, offset: 0 }); - expect(result.total).toBe(1); expect(result.accounts).toHaveLength(1); }); - + it('excludes soft-deleted accounts by default', async () => { + const qb = makeQueryBuilder([], 0); + mockRepo.createQueryBuilder.mockReturnValue(qb); + await service.findAll({ limit: 50, offset: 0 }); + expect(qb.where).toHaveBeenCalledWith('account.deletedAt IS NULL'); + }); it('applies status filter when provided', async () => { const qb = makeQueryBuilder([], 0); mockRepo.createQueryBuilder.mockReturnValue(qb); @@ -264,7 +268,7 @@ describe('AccountsService', () => { offset: 0, }); - expect(qb.where).toHaveBeenCalledWith( + expect(qb.andWhere).toHaveBeenCalledWith( 'account.status = :status', expect.objectContaining({ status: AccountStatus.PENDING_PAYMENT }), ); diff --git a/src/modules/accounts/accounts.service.ts b/src/modules/accounts/accounts.service.ts index 917665a..1f89a6f 100644 --- a/src/modules/accounts/accounts.service.ts +++ b/src/modules/accounts/accounts.service.ts @@ -234,10 +234,12 @@ export class AccountsService { limit: number; offset: number; }): Promise<{ accounts: AccountResponseDto[]; total: number }> { - const query = this.accountsRepository.createQueryBuilder('account'); + const query = this.accountsRepository + .createQueryBuilder('account') + .where('account.deletedAt IS NULL'); if (status) { - query.where('account.status = :status', { status }); + query.andWhere('account.status = :status', { status }); } query.skip(offset).take(Math.min(limit, 100)); diff --git a/src/modules/accounts/entities/account.entity.ts b/src/modules/accounts/entities/account.entity.ts index 99f9ddd..d208535 100644 --- a/src/modules/accounts/entities/account.entity.ts +++ b/src/modules/accounts/entities/account.entity.ts @@ -4,6 +4,7 @@ import { PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, + DeleteDateColumn, Index, } from 'typeorm'; import { AccountStatus } from '../enums/account-status.enum.js'; @@ -22,6 +23,7 @@ import { AccountStatus } from '../enums/account-status.enum.js'; @Index('IDX_accounts_status_expiresAt', ['status', 'expiresAt']) @Index('IDX_accounts_status_createdAt', ['status', 'createdAt']) @Index('IDX_accounts_createdAt', ['createdAt']) +@Index('IDX_accounts_deletedAt', ['deletedAt']) @Entity('accounts') export class Account { @PrimaryGeneratedColumn('uuid') @@ -79,6 +81,9 @@ export class Account { @Column({ type: 'timestamp', nullable: true }) expiredAt: Date | null; // Actual time expiry was processed - set by the expiry handler, null until then - @Column({ type: 'jsonb', nullable: true }) +@Column({ type: 'jsonb', nullable: true }) metadata: Record; + + @DeleteDateColumn({ type: 'timestamp', nullable: true }) + deletedAt: Date | null; } diff --git a/src/modules/claims/providers/claim-redemption.provider.spec.ts b/src/modules/claims/providers/claim-redemption.provider.spec.ts index e892d46..9f52d13 100644 --- a/src/modules/claims/providers/claim-redemption.provider.spec.ts +++ b/src/modules/claims/providers/claim-redemption.provider.spec.ts @@ -77,6 +77,7 @@ describe('ClaimRedemptionProvider', () => { const qb = { setLock: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), getOne: jest.fn().mockResolvedValue(lockedAccount), }; return { diff --git a/src/modules/claims/providers/claim-redemption.provider.ts b/src/modules/claims/providers/claim-redemption.provider.ts index 5d44071..3de5b69 100644 --- a/src/modules/claims/providers/claim-redemption.provider.ts +++ b/src/modules/claims/providers/claim-redemption.provider.ts @@ -84,6 +84,7 @@ export class ClaimRedemptionProvider { .createQueryBuilder(Account, 'account') .setLock('pessimistic_write') .where('account.claimTokenHash = :tokenHash', { tokenHash }) + .andWhere('account.deletedAt IS NULL') .getOne(); if (!locked) { diff --git a/test/migrations.integration.runner.ts b/test/migrations.integration.runner.ts index ecdd9bc..293c870 100644 --- a/test/migrations.integration.runner.ts +++ b/test/migrations.integration.runner.ts @@ -116,6 +116,7 @@ async function main(): Promise { log: () => Promise; } ).log(); + console.error('DEBUG upQueries:', JSON.stringify(schemaLog.upQueries.map((q) => q.query), null, 2)); const enumRows: Array<{ enumlabel: string }> = await dataSource.query(` SELECT e.enumlabel