From f7653ac38e13b4a34ee861412052130a8ecad238 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sun, 29 Mar 2026 13:10:16 +0100 Subject: [PATCH 1/2] Implement Transaction Tagging and Categorization --- backend/package.json | 2 + backend/pnpm-lock.yaml | 6 + ...000000000-AddTransactionTagsAndCategory.ts | 27 ++++ .../auto-categorization.service.ts | 51 ++++++++ .../modules/transactions/dto/bulk-tag.dto.ts | 32 +++++ .../transactions/dto/tag-transaction.dto.ts | 31 +++++ .../transactions/dto/transaction-query.dto.ts | 36 +++++- .../dto/transaction-response.dto.ts | 10 ++ .../entities/transaction.entity.ts | 7 ++ .../transactions.controller.spec.ts | 47 +++++++ .../transactions/transactions.controller.ts | 32 ++++- .../transactions/transactions.module.ts | 2 + .../transactions.service.tagging.spec.ts | 116 ++++++++++++++++++ .../transactions/transactions.service.ts | 101 +++++++++++++++ 14 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts create mode 100644 backend/src/modules/transactions/auto-categorization.service.ts create mode 100644 backend/src/modules/transactions/dto/bulk-tag.dto.ts create mode 100644 backend/src/modules/transactions/dto/tag-transaction.dto.ts create mode 100644 backend/src/modules/transactions/transactions.service.tagging.spec.ts diff --git a/backend/package.json b/backend/package.json index b744bb725..8fbb3d654 100644 --- a/backend/package.json +++ b/backend/package.json @@ -74,8 +74,10 @@ "@types/node": "^22.10.7", "@types/nodemailer": "^7.0.11", "@types/passport-jwt": "^4.0.1", + "@types/superagent": "^8.1.9", "@types/supertest": "^6.0.2", "@types/uuid": "^11.0.0", + "@types/validator": "^13.15.10", "eslint": "^9.39.3", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.2.2", diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 2eb5519a5..51a4e4fb3 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -162,12 +162,18 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 + '@types/superagent': + specifier: ^8.1.9 + version: 8.1.9 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 '@types/uuid': specifier: ^11.0.0 version: 11.0.0 + '@types/validator': + specifier: ^13.15.10 + version: 13.15.10 eslint: specifier: ^9.39.3 version: 9.39.3(jiti@2.6.1) diff --git a/backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts b/backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts new file mode 100644 index 000000000..e28e7fb07 --- /dev/null +++ b/backend/src/migrations/1780000000000-AddTransactionTagsAndCategory.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddTransactionTagsAndCategory1780000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "transactions" + ADD COLUMN IF NOT EXISTS "category" varchar; + `); + + await queryRunner.query(` + ALTER TABLE "transactions" + ADD COLUMN IF NOT EXISTS "tags" text[] DEFAULT ARRAY[]::text[]; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "transactions" + DROP COLUMN IF EXISTS "tags"; + `); + + await queryRunner.query(` + ALTER TABLE "transactions" + DROP COLUMN IF EXISTS "category"; + `); + } +} diff --git a/backend/src/modules/transactions/auto-categorization.service.ts b/backend/src/modules/transactions/auto-categorization.service.ts new file mode 100644 index 000000000..7bc2c5f8c --- /dev/null +++ b/backend/src/modules/transactions/auto-categorization.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Simple rule-based auto-categorization service. + * This is a lightweight starting point that can be replaced by an ML model later. + */ +@Injectable() +export class AutoCategorizationService { + private keywordMap: Record = { + grocery: 'Groceries', + supermarket: 'Groceries', + starbucks: 'Dining', + restaurant: 'Dining', + uber: 'Transport', + lyft: 'Transport', + rent: 'Rent', + salary: 'Income', + paycheck: 'Income', + amazon: 'Shopping', + }; + + predictCategory(metadata: Record | undefined): string | null { + if (!metadata) return null; + + // Look into common fields + const searchable: string[] = []; + + if (typeof metadata.description === 'string') + searchable.push(metadata.description); + + if (typeof metadata.memo === 'string') searchable.push(metadata.memo); + + if (typeof metadata.counterparty === 'string') + searchable.push(metadata.counterparty); + + // Include merchant/name fields in metadata + if (metadata?.merchant && typeof metadata.merchant === 'string') { + searchable.push(metadata.merchant); + } + + const haystack = searchable.join(' ').toLowerCase(); + + for (const key of Object.keys(this.keywordMap)) { + if (haystack.includes(key)) { + return this.keywordMap[key]; + } + } + + return null; + } +} diff --git a/backend/src/modules/transactions/dto/bulk-tag.dto.ts b/backend/src/modules/transactions/dto/bulk-tag.dto.ts new file mode 100644 index 000000000..c703e1d28 --- /dev/null +++ b/backend/src/modules/transactions/dto/bulk-tag.dto.ts @@ -0,0 +1,32 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsArray, IsString } from 'class-validator'; + +export class BulkTagDto { + @ApiPropertyOptional({ + description: 'List of transaction IDs to operate on', + isArray: true, + example: ['uuid-1', 'uuid-2'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + ids?: string[]; + + @ApiPropertyOptional({ + description: 'Tags to apply', + isArray: true, + example: ['groceries'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: 'Category to set', + example: 'Groceries', + }) + @IsOptional() + @IsString() + category?: string; +} diff --git a/backend/src/modules/transactions/dto/tag-transaction.dto.ts b/backend/src/modules/transactions/dto/tag-transaction.dto.ts new file mode 100644 index 000000000..a38328830 --- /dev/null +++ b/backend/src/modules/transactions/dto/tag-transaction.dto.ts @@ -0,0 +1,31 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsArray, IsString, IsIn } from 'class-validator'; + +export class TagTransactionDto { + @ApiPropertyOptional({ + description: 'Tags to apply to the transaction', + isArray: true, + example: ['groceries', 'food'], + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + tags?: string[]; + + @ApiPropertyOptional({ + description: 'Category to assign', + example: 'Groceries', + }) + @IsOptional() + @IsString() + category?: string; + + @ApiPropertyOptional({ + description: 'Action to perform on tags', + example: 'add', + }) + @IsOptional() + @IsString() + @IsIn(['add', 'remove', 'set']) + action?: 'add' | 'remove' | 'set'; +} diff --git a/backend/src/modules/transactions/dto/transaction-query.dto.ts b/backend/src/modules/transactions/dto/transaction-query.dto.ts index d5367a623..467a44982 100644 --- a/backend/src/modules/transactions/dto/transaction-query.dto.ts +++ b/backend/src/modules/transactions/dto/transaction-query.dto.ts @@ -1,6 +1,12 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type, Transform } from 'class-transformer'; -import { IsOptional, IsEnum, IsDateString, IsArray } from 'class-validator'; +import { + IsOptional, + IsEnum, + IsDateString, + IsArray, + IsString, +} from 'class-validator'; import { PageOptionsDto } from '../../../common/dto/page-options.dto'; import { LedgerTransactionType } from '../../blockchain/entities/transaction.entity'; @@ -44,4 +50,32 @@ export class TransactionQueryDto extends PageOptionsDto { }) @IsOptional() readonly poolId?: string; + + @ApiPropertyOptional({ + description: 'Filter by category', + example: 'Groceries', + }) + @IsOptional() + @IsString() + readonly category?: string; + + @ApiPropertyOptional({ + description: 'Filter by tags (comma-separated or array)', + example: 'food,groceries', + isArray: true, + }) + @IsOptional() + @Transform(({ value }) => { + if (!value) return undefined; + if (typeof value === 'string') { + return value + .split(',') + .map((v) => v.trim()) + .filter(Boolean); + } + return Array.isArray(value) ? value : undefined; + }) + @IsArray() + @IsString({ each: true }) + readonly tags?: string[]; } diff --git a/backend/src/modules/transactions/dto/transaction-response.dto.ts b/backend/src/modules/transactions/dto/transaction-response.dto.ts index 5c746e2a1..deff220bc 100644 --- a/backend/src/modules/transactions/dto/transaction-response.dto.ts +++ b/backend/src/modules/transactions/dto/transaction-response.dto.ts @@ -76,6 +76,16 @@ export class TransactionResponseDto { @ApiProperty({ description: 'Additional metadata', nullable: true }) metadata: Record | null; + @ApiProperty({ description: 'Transaction category', nullable: true }) + category?: string | null; + + @ApiProperty({ + description: 'Tags attached to the transaction', + nullable: true, + isArray: true, + }) + tags?: string[]; + @ApiProperty({ description: 'Transaction creation date (ISO 8601)' }) createdAt: string; diff --git a/backend/src/modules/transactions/entities/transaction.entity.ts b/backend/src/modules/transactions/entities/transaction.entity.ts index 901def7e4..757849f38 100644 --- a/backend/src/modules/transactions/entities/transaction.entity.ts +++ b/backend/src/modules/transactions/entities/transaction.entity.ts @@ -69,6 +69,13 @@ export class Transaction extends BaseEntity { @CreateDateColumn() createdAt: Date; + @Column({ type: 'varchar', nullable: true }) + category: string | null; + + // Tags stored as postgres text[] for efficient filtering/overlap checks + @Column('text', { array: true, default: () => 'ARRAY[]::text[]' }) + tags: string[]; + get transactionHash(): string | null | undefined { return this.txHash; } diff --git a/backend/src/modules/transactions/transactions.controller.spec.ts b/backend/src/modules/transactions/transactions.controller.spec.ts index 84dce5362..9a464c955 100644 --- a/backend/src/modules/transactions/transactions.controller.spec.ts +++ b/backend/src/modules/transactions/transactions.controller.spec.ts @@ -14,6 +14,9 @@ describe('TransactionsController', () => { const mockTransactionsService = { findAllForUser: jest.fn(), + tagTransaction: jest.fn(), + listCategories: jest.fn(), + bulkTag: jest.fn(), }; beforeEach(async () => { @@ -119,5 +122,49 @@ describe('TransactionsController', () => { queryDto, ); }); + + it('should call tagTransaction on POST /:id/tag', async () => { + const payload = { tags: ['food'], category: 'Groceries', action: 'add' }; + mockTransactionsService.tagTransaction.mockResolvedValue({ ok: true }); + + const res = await controller.tagTransaction( + mockUser, + 'tx-1', + payload as any, + ); + + expect(service.tagTransaction).toHaveBeenCalledWith( + mockUser.id, + 'tx-1', + payload, + ); + expect(res).toEqual({ ok: true }); + }); + + it('should return categories from GET /categories', async () => { + mockTransactionsService.listCategories.mockResolvedValue([ + 'Groceries', + 'Transport', + ]); + + const res = await controller.getCategories(mockUser); + + expect(service.listCategories).toHaveBeenCalledWith(mockUser.id); + expect(res).toEqual(['Groceries', 'Transport']); + }); + + it('should call bulkTag on POST /tags/bulk', async () => { + const body = { + ids: ['tx-1', 'tx-2'], + tags: ['food'], + category: 'Groceries', + }; + mockTransactionsService.bulkTag.mockResolvedValue({ ok: true, count: 2 }); + + const res = await controller.bulkTag(mockUser, body as any); + + expect(service.bulkTag).toHaveBeenCalledWith(mockUser.id, body); + expect(res).toEqual({ ok: true, count: 2 }); + }); }); }); diff --git a/backend/src/modules/transactions/transactions.controller.ts b/backend/src/modules/transactions/transactions.controller.ts index 8fc71cb2b..7e58e3ce2 100644 --- a/backend/src/modules/transactions/transactions.controller.ts +++ b/backend/src/modules/transactions/transactions.controller.ts @@ -1,4 +1,13 @@ -import { Controller, Get, Query, UseGuards, Res } from '@nestjs/common'; +import { + Controller, + Get, + Query, + UseGuards, + Res, + Param, + Post, + Body, +} from '@nestjs/common'; import { Response } from 'express'; import { ApiBearerAuth, @@ -9,6 +18,8 @@ import { import { TransactionsService } from './transactions.service'; import { TransactionQueryDto } from './dto/transaction-query.dto'; import { TransactionResponseDto } from './dto/transaction-response.dto'; +import { TagTransactionDto } from './dto/tag-transaction.dto'; +import { BulkTagDto } from './dto/bulk-tag.dto'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { PageDto } from '../../common/dto/page.dto'; @@ -72,4 +83,23 @@ export class TransactionsController { csvStream.pipe(res); } + + @Post(':id/tag') + async tagTransaction( + @CurrentUser() user: { id: string }, + @Param('id') id: string, + @Body() payload: TagTransactionDto, + ) { + return this.transactionsService.tagTransaction(user.id, id, payload); + } + + @Get('categories') + async getCategories(@CurrentUser() user: { id: string }) { + return this.transactionsService.listCategories(user.id); + } + + @Post('tags/bulk') + async bulkTag(@CurrentUser() user: { id: string }, @Body() body: BulkTagDto) { + return this.transactionsService.bulkTag(user.id, body); + } } diff --git a/backend/src/modules/transactions/transactions.module.ts b/backend/src/modules/transactions/transactions.module.ts index d94195849..5e0ccceb1 100644 --- a/backend/src/modules/transactions/transactions.module.ts +++ b/backend/src/modules/transactions/transactions.module.ts @@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { APP_INTERCEPTOR } from '@nestjs/core'; import { TransactionsController } from './transactions.controller'; import { TransactionsService } from './transactions.service'; +import { AutoCategorizationService } from './auto-categorization.service'; import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; import { TransactionFormattingInterceptor } from '../../common/interceptors/transaction-formatting.interceptor'; @@ -11,6 +12,7 @@ import { TransactionFormattingInterceptor } from '../../common/interceptors/tran controllers: [TransactionsController], providers: [ TransactionsService, + AutoCategorizationService, { provide: APP_INTERCEPTOR, useClass: TransactionFormattingInterceptor, diff --git a/backend/src/modules/transactions/transactions.service.tagging.spec.ts b/backend/src/modules/transactions/transactions.service.tagging.spec.ts new file mode 100644 index 000000000..e470ee379 --- /dev/null +++ b/backend/src/modules/transactions/transactions.service.tagging.spec.ts @@ -0,0 +1,116 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { TransactionsService } from './transactions.service'; +import { LedgerTransaction } from '../blockchain/entities/transaction.entity'; + +describe('TransactionsService tagging', () => { + let service: TransactionsService; + + const mockRepository: any = { + findOne: jest.fn(), + save: jest.fn(), + createQueryBuilder: jest.fn(), + findBy: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TransactionsService, + { + provide: getRepositoryToken(LedgerTransaction), + useValue: mockRepository, + }, + ], + }).compile(); + + service = module.get(TransactionsService); + + jest.clearAllMocks(); + }); + + it('returns not found when tagging a missing transaction', async () => { + mockRepository.findOne.mockResolvedValue(undefined); + + const res = await service.tagTransaction('user-1', 'tx-1', { tags: ['a'] }); + + expect(res).toEqual({ ok: false, message: 'Transaction not found' }); + expect(mockRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'tx-1', userId: 'user-1' }, + }); + }); + + it('adds tags and sets category for a transaction', async () => { + const tx = { + id: 'tx-2', + userId: 'user-1', + tags: ['food'], + category: null, + createdAt: new Date(), + } as any; + + mockRepository.findOne.mockResolvedValue(tx); + mockRepository.save.mockImplementation(async (t) => t); + + const payload = { tags: ['groceries'], category: 'Groceries' }; + + const res = await service.tagTransaction('user-1', 'tx-2', payload); + + expect(res.ok).toBe(true); + expect(res.transaction).toBeDefined(); + // narrow the type for TS strict checks + const t = res.transaction!; + expect(t.tags).toEqual(expect.arrayContaining(['food', 'groceries'])); + expect(t.category).toBe('Groceries'); + expect(mockRepository.save).toHaveBeenCalled(); + }); + + it('bulkTag returns error when no ids provided', async () => { + const res = await service.bulkTag('user-1', { ids: [] }); + expect(res).toEqual({ ok: false, message: 'No ids provided' }); + }); + + it('bulkTag updates multiple transactions and returns count', async () => { + const txs = [ + { id: 'a', userId: 'user-1', tags: ['x'], category: null }, + { id: 'b', userId: 'user-1', tags: [], category: null }, + ]; + + mockRepository.findBy.mockResolvedValue(txs); + mockRepository.save.mockImplementation(async (items) => items); + + const res = await service.bulkTag('user-1', { + ids: ['a', 'b'], + tags: ['new'], + action: 'add', + }); + + expect(res.ok).toBe(true); + expect(res.count).toBe(2); + expect(mockRepository.save).toHaveBeenCalledWith(txs); + // ensure tags updated + expect(txs[0].tags).toEqual(expect.arrayContaining(['x', 'new'])); + expect(txs[1].tags).toEqual(expect.arrayContaining(['new'])); + }); + + it('listCategories returns distinct categories', async () => { + const qb: any = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + getRawMany: jest + .fn() + .mockResolvedValue([{ category: 'A' }, { category: 'B' }]), + }; + + mockRepository.createQueryBuilder.mockReturnValue(qb); + + const res = await service.listCategories('user-1'); + + expect(res).toEqual(['A', 'B']); + expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith( + 'transaction', + ); + }); +}); diff --git a/backend/src/modules/transactions/transactions.service.ts b/backend/src/modules/transactions/transactions.service.ts index aac33a007..e9232df0b 100644 --- a/backend/src/modules/transactions/transactions.service.ts +++ b/backend/src/modules/transactions/transactions.service.ts @@ -72,6 +72,8 @@ export class TransactionsService { publicKey: dto.publicKey ?? '', eventId: dto.eventId, transactionHash: dto.transactionHash ?? '', + category: dto.category ?? '', + tags: dto.tags ? dto.tags.join(';') : '', ledgerSequence: dto.ledgerSequence ?? '', poolId: dto.poolId ?? '', assetId: dto.assetId ?? '', @@ -127,6 +129,21 @@ export class TransactionsService { }); } + // Filter by category + if (queryDto.category) { + queryBuilder.andWhere('transaction.category = :category', { + category: queryDto.category, + }); + } + + // Filter by tags (any overlap) + if (queryDto.tags && queryDto.tags.length > 0) { + // Use Postgres array overlap operator (&&) + queryBuilder.andWhere('transaction.tags && :tags', { + tags: queryDto.tags, + }); + } + // Apply ordering queryBuilder.orderBy('transaction.createdAt', queryDto.order ?? 'DESC'); @@ -149,6 +166,8 @@ export class TransactionsService { publicKey: transaction.publicKey, eventId: transaction.eventId, transactionHash: transaction.transactionHash, + category: transaction.category ?? null, + tags: transaction.tags ?? [], ledgerSequence: transaction.ledgerSequence, poolId: transaction.poolId, metadata: transaction.metadata, @@ -168,6 +187,88 @@ export class TransactionsService { } as TransactionResponseDto; } + async tagTransaction(userId: string, transactionId: string, payload: any) { + const tx = await this.transactionRepository.findOne({ + where: { id: transactionId, userId }, + }); + + if (!tx) { + return { ok: false, message: 'Transaction not found' }; + } + + // Handle tags + if (payload?.tags) { + const current = tx.tags ?? []; + const incoming = Array.isArray(payload.tags) ? payload.tags : []; + + if (payload.action === 'remove') { + tx.tags = current.filter((t) => !incoming.includes(t)); + } else if (payload.action === 'set') { + tx.tags = incoming; + } else { + // add + const set = new Set(current.concat(incoming)); + tx.tags = Array.from(set); + } + } + + if (typeof payload?.category === 'string') { + tx.category = payload.category; + } + + await this.transactionRepository.save(tx); + + return { ok: true, transaction: this.transformToResponseDto(tx) }; + } + + async listCategories(userId: string) { + const rows = await this.transactionRepository + .createQueryBuilder('transaction') + .select('DISTINCT transaction.category', 'category') + .where('transaction.userId = :userId', { userId }) + .andWhere('transaction.category IS NOT NULL') + .orderBy('transaction.category', 'ASC') + .getRawMany(); + + return rows.map((r) => r.category); + } + + async bulkTag(userId: string, body: any) { + // Support ids-based operations for now + if (!body?.ids || !Array.isArray(body.ids) || !body.ids.length) { + return { ok: false, message: 'No ids provided' }; + } + + const txs = await this.transactionRepository.findBy({ + id: body.ids, + userId, + } as any); + + for (const tx of txs) { + if (body.tags) { + const current = tx.tags ?? []; + const incoming = Array.isArray(body.tags) ? body.tags : []; + + if (body.action === 'remove') { + tx.tags = current.filter((t) => !incoming.includes(t)); + } else if (body.action === 'set') { + tx.tags = incoming; + } else { + const set = new Set(current.concat(incoming)); + tx.tags = Array.from(set); + } + } + + if (typeof body.category === 'string') { + tx.category = body.category; + } + } + + await this.transactionRepository.save(txs); + + return { ok: true, count: txs.length }; + } + /** * Extract asset ID from transaction metadata or return default */ From 926f7300c8681ebd5c34721a594e12e6f45348c1 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sun, 29 Mar 2026 15:46:21 +0100 Subject: [PATCH 2/2] feat: Implement Savings Product Waitlist --- backend/src/app.module.ts | 5 +- .../src/common/entities/audit-log.entity.ts | 66 +- .../interceptors/audit-log.interceptor.ts | 223 +++--- .../correlation-id.interceptor.ts | 50 +- ...nhanceSavingsProductWithTvlAndRiskLevel.ts | 8 +- .../1775300000000-CreateAuditLogsTable.ts | 226 +++---- ...000000-CreateWaitlistAndProductCapacity.ts | 63 ++ .../admin/admin-waitlist.controller.ts | 138 ++++ backend/src/modules/admin/admin.module.ts | 7 +- .../event-handlers/deposit.handler.spec.ts | 58 +- .../event-handlers/deposit.handler.ts | 3 +- .../governance/dto/proposal-list-item.dto.ts | 25 +- backend/src/modules/mail/mail.service.ts | 24 + .../entities/notification.entity.ts | 1 + .../milestone-scheduler.service.ts | 7 +- .../notifications/notifications.module.ts | 8 +- .../notifications/notifications.service.ts | 94 +++ .../modules/savings/dto/create-product.dto.ts | 5 +- .../savings/dto/product-details.dto.ts | 5 +- .../savings/dto/savings-product.dto.ts | 5 +- .../entities/savings-product.entity.ts | 3 + .../savings/entities/waitlist-entry.entity.ts | 36 + .../savings/entities/waitlist-event.entity.ts | 36 + backend/src/modules/savings/savings.module.ts | 12 +- .../src/modules/savings/savings.service.ts | 47 +- .../modules/savings/waitlist.controller.ts | 41 ++ .../src/modules/savings/waitlist.service.ts | 123 ++++ backend/test/critical-path.e2e-spec.ts | 638 +++++++++--------- 28 files changed, 1314 insertions(+), 643 deletions(-) create mode 100644 backend/src/migrations/1790000000000-CreateWaitlistAndProductCapacity.ts create mode 100644 backend/src/modules/admin/admin-waitlist.controller.ts create mode 100644 backend/src/modules/savings/entities/waitlist-entry.entity.ts create mode 100644 backend/src/modules/savings/entities/waitlist-event.entity.ts create mode 100644 backend/src/modules/savings/waitlist.controller.ts create mode 100644 backend/src/modules/savings/waitlist.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2ed7fd65b..f57f90593 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -73,7 +73,8 @@ const envValidationSchema = Joi.object({ imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => { - const isProduction = configService.get('NODE_ENV') === 'production'; + const isProduction = + configService.get('NODE_ENV') === 'production'; return { pinoHttp: { transport: isProduction @@ -200,4 +201,4 @@ const envValidationSchema = Joi.object({ }, ], }) -export class AppModule { } +export class AppModule {} diff --git a/backend/src/common/entities/audit-log.entity.ts b/backend/src/common/entities/audit-log.entity.ts index 00f4cf66f..699493bff 100644 --- a/backend/src/common/entities/audit-log.entity.ts +++ b/backend/src/common/entities/audit-log.entity.ts @@ -1,17 +1,17 @@ import { - Entity, - Column, - PrimaryGeneratedColumn, - CreateDateColumn, - Index, + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, } from 'typeorm'; /** * AuditLog Entity - * + * * Stores structured audit entries for all trade and dispute mutations. * Enables forensic traceability and incident debugging. - * + * * Indexed by: * - correlation_id: Trace full request lifecycle * - resource_id: Find all mutations for a specific trade/dispute @@ -26,42 +26,42 @@ import { @Index('idx_audit_logs_timestamp', ['timestamp']) @Index('idx_audit_logs_action', ['action']) export class AuditLog { - @PrimaryGeneratedColumn('uuid') - id: string; + @PrimaryGeneratedColumn('uuid') + id: string; - @Column() - correlationId: string; + @Column() + correlationId: string; - @CreateDateColumn() - timestamp: Date; + @CreateDateColumn() + timestamp: Date; - @Column() - endpoint: string; + @Column() + endpoint: string; - @Column() - method: string; + @Column() + method: string; - @Column() - action: string; // CREATE, UPDATE, DELETE + @Column() + action: string; // CREATE, UPDATE, DELETE - @Column() - actor: string; // wallet or email + @Column() + actor: string; // wallet or email - @Column({ nullable: true, type: 'uuid' }) - resourceId: string | null; + @Column({ nullable: true, type: 'uuid' }) + resourceId: string | null; - @Column() - resourceType: string; // TRADE, DISPUTE, CLAIM + @Column() + resourceType: string; // TRADE, DISPUTE, CLAIM - @Column() - statusCode: number; + @Column() + statusCode: number; - @Column() - durationMs: number; + @Column() + durationMs: number; - @Column({ default: true }) - success: boolean; + @Column({ default: true }) + success: boolean; - @Column({ nullable: true, type: 'text' }) - errorMessage: string | null; + @Column({ nullable: true, type: 'text' }) + errorMessage: string | null; } diff --git a/backend/src/common/interceptors/audit-log.interceptor.ts b/backend/src/common/interceptors/audit-log.interceptor.ts index 89e0970e9..082d53eee 100644 --- a/backend/src/common/interceptors/audit-log.interceptor.ts +++ b/backend/src/common/interceptors/audit-log.interceptor.ts @@ -1,9 +1,9 @@ import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { tap, catchError } from 'rxjs/operators'; @@ -12,7 +12,7 @@ import { throwError } from 'rxjs'; /** * Audit Log Interceptor - * + * * Logs structured audit entries for trade and dispute mutations. * Captures: * - Request ID (correlation ID) @@ -21,122 +21,119 @@ import { throwError } from 'rxjs'; * - Trade/Dispute ID from params or body * - Request/response status * - Timestamp - * + * * Enables forensic traceability for incident debugging. */ @Injectable() export class AuditLogInterceptor implements NestInterceptor { - private readonly logger = new Logger(AuditLogInterceptor.name); - - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); - const response = context.switchToHttp().getResponse(); - - // Extract correlation ID from request - const correlationId = (request as any).correlationId || 'unknown'; - - // Determine if this is a mutation endpoint (POST, PATCH, PUT, DELETE) - const isMutation = ['POST', 'PATCH', 'PUT', 'DELETE'].includes( - request.method, - ); - - // Extract audit-relevant paths - const isMutationEndpoint = - isMutation && - (request.url.includes('/claims') || - request.url.includes('/disputes') || - request.url.includes('/trades')); - - if (!isMutationEndpoint) { - return next.handle(); - } - - const startTime = Date.now(); - const auditEntry = this.buildAuditEntry(request, correlationId); - - return next.handle().pipe( - tap((data) => { - const duration = Date.now() - startTime; - this.logAuditEntry({ - ...auditEntry, - status: response.statusCode, - duration, - success: true, - }); - }), - catchError((error) => { - const duration = Date.now() - startTime; - this.logAuditEntry({ - ...auditEntry, - status: error.status || 500, - duration, - success: false, - error: error.message, - }); - return throwError(() => error); - }), - ); - } + private readonly logger = new Logger(AuditLogInterceptor.name); - private buildAuditEntry(request: Request, correlationId: string) { - const body = request.body || {}; - const params = request.params || {}; - - // Extract resource IDs - const tradeId = params.id || body.tradeId || body.claimId || null; - const disputeId = params.id || body.disputeId || null; - const resourceId = tradeId || disputeId; - - // Extract actor (wallet or user email) - const actor = - body.actor || - body.wallet || - body.email || - (request.user as any)?.email || - 'anonymous'; - - // Determine action type - const action = this.getActionType(request.method, request.url); - - return { - correlationId, - timestamp: new Date().toISOString(), - endpoint: request.url, - method: request.method, - action, - actor, - resourceId, - resourceType: this.getResourceType(request.url), - }; - } + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); - private getActionType(method: string, url: string): string { - if (method === 'POST') return 'CREATE'; - if (method === 'PATCH' || method === 'PUT') return 'UPDATE'; - if (method === 'DELETE') return 'DELETE'; - return 'UNKNOWN'; - } + // Extract correlation ID from request + const correlationId = (request as any).correlationId || 'unknown'; - private getResourceType(url: string): string { - if (url.includes('/claims')) return 'CLAIM'; - if (url.includes('/disputes')) return 'DISPUTE'; - if (url.includes('/trades')) return 'TRADE'; - return 'UNKNOWN'; - } + // Determine if this is a mutation endpoint (POST, PATCH, PUT, DELETE) + const isMutation = ['POST', 'PATCH', 'PUT', 'DELETE'].includes( + request.method, + ); - private logAuditEntry(entry: any) { - const logMessage = `[AUDIT] ${entry.correlationId} | ${entry.action} ${entry.resourceType} | Actor: ${entry.actor} | Resource: ${entry.resourceId} | Status: ${entry.status} | Duration: ${entry.duration}ms`; + // Extract audit-relevant paths + const isMutationEndpoint = + isMutation && + (request.url.includes('/claims') || + request.url.includes('/disputes') || + request.url.includes('/trades')); - if (entry.success) { - this.logger.log(logMessage); - } else { - this.logger.error( - `${logMessage} | Error: ${entry.error}`, - 'AuditLog', - ); - } + if (!isMutationEndpoint) { + return next.handle(); + } - // Structured logging for log aggregation systems - this.logger.debug(JSON.stringify(entry), 'AuditLogStructured'); + const startTime = Date.now(); + const auditEntry = this.buildAuditEntry(request, correlationId); + + return next.handle().pipe( + tap((data) => { + const duration = Date.now() - startTime; + this.logAuditEntry({ + ...auditEntry, + status: response.statusCode, + duration, + success: true, + }); + }), + catchError((error) => { + const duration = Date.now() - startTime; + this.logAuditEntry({ + ...auditEntry, + status: error.status || 500, + duration, + success: false, + error: error.message, + }); + return throwError(() => error); + }), + ); + } + + private buildAuditEntry(request: Request, correlationId: string) { + const body = request.body || {}; + const params = request.params || {}; + + // Extract resource IDs + const tradeId = params.id || body.tradeId || body.claimId || null; + const disputeId = params.id || body.disputeId || null; + const resourceId = tradeId || disputeId; + + // Extract actor (wallet or user email) + const actor = + body.actor || + body.wallet || + body.email || + (request.user as any)?.email || + 'anonymous'; + + // Determine action type + const action = this.getActionType(request.method, request.url); + + return { + correlationId, + timestamp: new Date().toISOString(), + endpoint: request.url, + method: request.method, + action, + actor, + resourceId, + resourceType: this.getResourceType(request.url), + }; + } + + private getActionType(method: string, url: string): string { + if (method === 'POST') return 'CREATE'; + if (method === 'PATCH' || method === 'PUT') return 'UPDATE'; + if (method === 'DELETE') return 'DELETE'; + return 'UNKNOWN'; + } + + private getResourceType(url: string): string { + if (url.includes('/claims')) return 'CLAIM'; + if (url.includes('/disputes')) return 'DISPUTE'; + if (url.includes('/trades')) return 'TRADE'; + return 'UNKNOWN'; + } + + private logAuditEntry(entry: any) { + const logMessage = `[AUDIT] ${entry.correlationId} | ${entry.action} ${entry.resourceType} | Actor: ${entry.actor} | Resource: ${entry.resourceId} | Status: ${entry.status} | Duration: ${entry.duration}ms`; + + if (entry.success) { + this.logger.log(logMessage); + } else { + this.logger.error(`${logMessage} | Error: ${entry.error}`, 'AuditLog'); } + + // Structured logging for log aggregation systems + this.logger.debug(JSON.stringify(entry), 'AuditLogStructured'); + } } diff --git a/backend/src/common/interceptors/correlation-id.interceptor.ts b/backend/src/common/interceptors/correlation-id.interceptor.ts index c9d233196..f16666e66 100644 --- a/backend/src/common/interceptors/correlation-id.interceptor.ts +++ b/backend/src/common/interceptors/correlation-id.interceptor.ts @@ -1,9 +1,9 @@ import { - Injectable, - NestInterceptor, - ExecutionContext, - CallHandler, - Logger, + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, + Logger, } from '@nestjs/common'; import { Observable } from 'rxjs'; import { v4 as uuidv4 } from 'uuid'; @@ -11,10 +11,10 @@ import { Request, Response } from 'express'; /** * Correlation ID Interceptor - * + * * Generates or forwards request correlation IDs for tracing requests * through the entire system (API → DB → listeners → contracts). - * + * * - Checks for X-Correlation-ID header * - Generates UUID if not present * - Attaches to request object for downstream use @@ -23,28 +23,28 @@ import { Request, Response } from 'express'; */ @Injectable() export class CorrelationIdInterceptor implements NestInterceptor { - private readonly logger = new Logger(CorrelationIdInterceptor.name); + private readonly logger = new Logger(CorrelationIdInterceptor.name); - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); - const response = context.switchToHttp().getResponse(); + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); - // Check for existing correlation ID or generate new one - const correlationId = - (request.headers['x-correlation-id'] as string) || uuidv4(); + // Check for existing correlation ID or generate new one + const correlationId = + (request.headers['x-correlation-id'] as string) || uuidv4(); - // Attach to request for downstream use - (request as any).correlationId = correlationId; + // Attach to request for downstream use + (request as any).correlationId = correlationId; - // Add to response headers - response.setHeader('X-Correlation-ID', correlationId); + // Add to response headers + response.setHeader('X-Correlation-ID', correlationId); - // Log request with correlation ID - this.logger.debug( - `[${correlationId}] ${request.method} ${request.url}`, - 'CorrelationId', - ); + // Log request with correlation ID + this.logger.debug( + `[${correlationId}] ${request.method} ${request.url}`, + 'CorrelationId', + ); - return next.handle(); - } + return next.handle(); + } } diff --git a/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts b/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts index 721e7658c..b5b0bb410 100644 --- a/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts +++ b/backend/src/migrations/1775200000000-EnhanceSavingsProductWithTvlAndRiskLevel.ts @@ -5,13 +5,11 @@ import { TableColumnOptions, } from 'typeorm'; -export class EnhanceSavingsProductWithTvlAndRiskLevel1775200000000 - implements MigrationInterface -{ +export class EnhanceSavingsProductWithTvlAndRiskLevel1775200000000 implements MigrationInterface { public async up(queryRunner: QueryRunner): Promise { // Add tvlAmount column if it doesn't exist const table = await queryRunner.getTable('savings_products'); - + if (table && !table.findColumnByName('tvlAmount')) { await queryRunner.addColumn( 'savings_products', @@ -29,7 +27,7 @@ export class EnhanceSavingsProductWithTvlAndRiskLevel1775200000000 // Update riskLevel column from varchar to enum if (table && table.findColumnByName('riskLevel')) { const riskLevelColumn = table.findColumnByName('riskLevel'); - + if (riskLevelColumn && riskLevelColumn.type !== 'enum') { // Drop the old constraint and column with old data await queryRunner.changeColumn( diff --git a/backend/src/migrations/1775300000000-CreateAuditLogsTable.ts b/backend/src/migrations/1775300000000-CreateAuditLogsTable.ts index bd1728583..3ef9b94f0 100644 --- a/backend/src/migrations/1775300000000-CreateAuditLogsTable.ts +++ b/backend/src/migrations/1775300000000-CreateAuditLogsTable.ts @@ -1,118 +1,118 @@ import { MigrationInterface, QueryRunner, Table } from 'typeorm'; export class CreateAuditLogsTable1775300000000 implements MigrationInterface { - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.createTable( - new Table({ - name: 'audit_logs', - columns: [ - { - name: 'id', - type: 'uuid', - isPrimary: true, - generationStrategy: 'uuid', - default: 'gen_random_uuid()', - }, - { - name: 'correlation_id', - type: 'varchar', - isNullable: false, - comment: 'Request correlation ID for tracing', - }, - { - name: 'timestamp', - type: 'timestamp', - isNullable: false, - default: 'CURRENT_TIMESTAMP', - }, - { - name: 'endpoint', - type: 'varchar', - isNullable: false, - comment: 'API endpoint path', - }, - { - name: 'method', - type: 'varchar', - isNullable: false, - comment: 'HTTP method (POST, PATCH, PUT, DELETE)', - }, - { - name: 'action', - type: 'varchar', - isNullable: false, - comment: 'Action type (CREATE, UPDATE, DELETE)', - }, - { - name: 'actor', - type: 'varchar', - isNullable: false, - comment: 'User wallet or email performing action', - }, - { - name: 'resource_id', - type: 'uuid', - isNullable: true, - comment: 'ID of affected resource (trade, dispute, claim)', - }, - { - name: 'resource_type', - type: 'varchar', - isNullable: false, - comment: 'Type of resource (TRADE, DISPUTE, CLAIM)', - }, - { - name: 'status_code', - type: 'int', - isNullable: false, - comment: 'HTTP response status code', - }, - { - name: 'duration_ms', - type: 'int', - isNullable: false, - comment: 'Request duration in milliseconds', - }, - { - name: 'success', - type: 'boolean', - isNullable: false, - default: true, - }, - { - name: 'error_message', - type: 'text', - isNullable: true, - comment: 'Error message if request failed', - }, - ], - indices: [ - { - name: 'idx_audit_logs_correlation_id', - columnNames: ['correlation_id'], - }, - { - name: 'idx_audit_logs_resource_id', - columnNames: ['resource_id'], - }, - { - name: 'idx_audit_logs_actor', - columnNames: ['actor'], - }, - { - name: 'idx_audit_logs_timestamp', - columnNames: ['timestamp'], - }, - { - name: 'idx_audit_logs_action', - columnNames: ['action'], - }, - ], - }), - ); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'audit_logs', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { + name: 'correlation_id', + type: 'varchar', + isNullable: false, + comment: 'Request correlation ID for tracing', + }, + { + name: 'timestamp', + type: 'timestamp', + isNullable: false, + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'endpoint', + type: 'varchar', + isNullable: false, + comment: 'API endpoint path', + }, + { + name: 'method', + type: 'varchar', + isNullable: false, + comment: 'HTTP method (POST, PATCH, PUT, DELETE)', + }, + { + name: 'action', + type: 'varchar', + isNullable: false, + comment: 'Action type (CREATE, UPDATE, DELETE)', + }, + { + name: 'actor', + type: 'varchar', + isNullable: false, + comment: 'User wallet or email performing action', + }, + { + name: 'resource_id', + type: 'uuid', + isNullable: true, + comment: 'ID of affected resource (trade, dispute, claim)', + }, + { + name: 'resource_type', + type: 'varchar', + isNullable: false, + comment: 'Type of resource (TRADE, DISPUTE, CLAIM)', + }, + { + name: 'status_code', + type: 'int', + isNullable: false, + comment: 'HTTP response status code', + }, + { + name: 'duration_ms', + type: 'int', + isNullable: false, + comment: 'Request duration in milliseconds', + }, + { + name: 'success', + type: 'boolean', + isNullable: false, + default: true, + }, + { + name: 'error_message', + type: 'text', + isNullable: true, + comment: 'Error message if request failed', + }, + ], + indices: [ + { + name: 'idx_audit_logs_correlation_id', + columnNames: ['correlation_id'], + }, + { + name: 'idx_audit_logs_resource_id', + columnNames: ['resource_id'], + }, + { + name: 'idx_audit_logs_actor', + columnNames: ['actor'], + }, + { + name: 'idx_audit_logs_timestamp', + columnNames: ['timestamp'], + }, + { + name: 'idx_audit_logs_action', + columnNames: ['action'], + }, + ], + }), + ); + } - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.dropTable('audit_logs'); - } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('audit_logs'); + } } diff --git a/backend/src/migrations/1790000000000-CreateWaitlistAndProductCapacity.ts b/backend/src/migrations/1790000000000-CreateWaitlistAndProductCapacity.ts new file mode 100644 index 000000000..61d8ed094 --- /dev/null +++ b/backend/src/migrations/1790000000000-CreateWaitlistAndProductCapacity.ts @@ -0,0 +1,63 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableColumn, + TableIndex, +} from 'typeorm'; + +export class CreateWaitlistAndProductCapacity1790000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Add capacity column to savings_products + await queryRunner.addColumn( + 'savings_products', + new TableColumn({ + name: 'capacity', + type: 'int', + isNullable: true, + }), + ); + + // Create waitlist_entries table + await queryRunner.createTable( + new Table({ + name: 'waitlist_entries', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + isGenerated: true, + generationStrategy: 'uuid', + }, + { name: 'userId', type: 'uuid' }, + { name: 'productId', type: 'uuid' }, + { name: 'priority', type: 'int', default: 0 }, + { name: 'notifiedAt', type: 'timestamp', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + ); + + await queryRunner.createIndex( + 'waitlist_entries', + new TableIndex({ + name: 'IDX_waitlist_product', + columnNames: ['productId'], + }), + ); + + await queryRunner.createIndex( + 'waitlist_entries', + new TableIndex({ name: 'IDX_waitlist_user', columnNames: ['userId'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('waitlist_entries', 'IDX_waitlist_user'); + await queryRunner.dropIndex('waitlist_entries', 'IDX_waitlist_product'); + await queryRunner.dropTable('waitlist_entries'); + await queryRunner.dropColumn('savings_products', 'capacity'); + } +} diff --git a/backend/src/modules/admin/admin-waitlist.controller.ts b/backend/src/modules/admin/admin-waitlist.controller.ts new file mode 100644 index 000000000..90208d77d --- /dev/null +++ b/backend/src/modules/admin/admin-waitlist.controller.ts @@ -0,0 +1,138 @@ +import { + Controller, + Get, + Param, + Query, + UseGuards, + ParseIntPipe, + Post, + Body, + Delete, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { InjectRepository } from '@nestjs/typeorm'; +import { WaitlistEntry } from '../savings/entities/waitlist-entry.entity'; +import { WaitlistEvent } from '../savings/entities/waitlist-event.entity'; +import { Repository, IsNull } from 'typeorm'; +import { EventEmitter2 } from '@nestjs/event-emitter'; + +@ApiTags('admin/waitlist') +@Controller('admin/waitlists') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +@ApiBearerAuth() +export class AdminWaitlistController { + constructor( + @InjectRepository(WaitlistEntry) + private readonly waitlistRepo: Repository, + @InjectRepository(WaitlistEvent) + private readonly waitlistEventRepo: Repository, + private readonly eventEmitter: EventEmitter2, + ) {} + + @Get(':productId') + async listForProduct( + @Param('productId') productId: string, + @Query('page', ParseIntPipe) page = 1, + @Query('limit', ParseIntPipe) limit = 50, + ) { + const [entries, total] = await this.waitlistRepo.findAndCount({ + where: { productId }, + order: { priority: 'DESC', createdAt: 'ASC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { entries, total }; + } + + @Post(':productId/entries/:entryId/promote') + async promoteEntry( + @Param('productId') productId: string, + @Param('entryId') entryId: string, + @Body() body: { priority?: number; bump?: number }, + ) { + const entry = await this.waitlistRepo.findOneBy({ id: entryId }); + if (!entry || entry.productId !== productId) return { ok: false }; + + if (typeof body.priority === 'number') entry.priority = body.priority; + else if (typeof body.bump === 'number') entry.priority += body.bump; + else entry.priority += 1; + + await this.waitlistRepo.save(entry); + return { ok: true, entry }; + } + + @Delete(':productId/entries/:entryId') + @HttpCode(HttpStatus.NO_CONTENT) + async removeEntry( + @Param('productId') productId: string, + @Param('entryId') entryId: string, + ) { + const entry = await this.waitlistRepo.findOneBy({ id: entryId }); + if (!entry || entry.productId !== productId) return; + await this.waitlistRepo.delete({ id: entryId }); + } + + @Post(':productId/release') + async releaseSpots( + @Param('productId') productId: string, + @Body() body: { spots?: number }, + ) { + const spots = body.spots ?? 1; + this.eventEmitter.emit('waitlist.product.available', { productId, spots }); + return { ok: true }; + } + + @Get(':productId/analytics') + async analyticsForProduct(@Param('productId') productId: string) { + const waitlistSize = await this.waitlistRepo.count({ + where: { productId, notifiedAt: IsNull() } as any, + }); + + const notifiedCount = await this.waitlistEventRepo.count({ + where: { productId, type: 'NOTIFY' as any }, + }); + + const acceptedCount = await this.waitlistEventRepo.count({ + where: { productId, type: 'ACCEPT' as any }, + }); + + // average time from NOTIFY -> ACCEPT for matching entryId + const rows = await this.waitlistEventRepo + .createQueryBuilder('e_notify') + .select( + 'AVG(EXTRACT(EPOCH FROM (e_accept.createdAt - e_notify.createdAt)))', + 'avgSeconds', + ) + .innerJoin( + 'waitlist_events', + 'e_accept', + "e_accept.entryId = e_notify.entryId AND e_accept.type = 'ACCEPT'", + ) + .where("e_notify.productId = :productId AND e_notify.type = 'NOTIFY'", { + productId, + }) + .getRawOne(); + + const avgSeconds = rows?.avgseconds ?? rows?.avgSeconds ?? null; + + const conversionRate = + notifiedCount > 0 ? acceptedCount / notifiedCount : 0; + + return { + productId, + waitlistSize, + notifiedCount, + acceptedCount, + conversionRate, + avgTimeToConvertSeconds: avgSeconds ? Number(avgSeconds) : null, + }; + } +} diff --git a/backend/src/modules/admin/admin.module.ts b/backend/src/modules/admin/admin.module.ts index c369f2a40..551bbcfbb 100644 --- a/backend/src/modules/admin/admin.module.ts +++ b/backend/src/modules/admin/admin.module.ts @@ -3,9 +3,14 @@ import { UserModule } from '../user/user.module'; import { SavingsModule } from '../savings/savings.module'; import { AdminController } from './admin.controller'; import { AdminSavingsController } from './admin-savings.controller'; +import { AdminWaitlistController } from './admin-waitlist.controller'; @Module({ imports: [UserModule, SavingsModule], - controllers: [AdminController, AdminSavingsController], + controllers: [ + AdminController, + AdminSavingsController, + AdminWaitlistController, + ], }) export class AdminModule {} diff --git a/backend/src/modules/blockchain/event-handlers/deposit.handler.spec.ts b/backend/src/modules/blockchain/event-handlers/deposit.handler.spec.ts index 51e53b849..8e6e653de 100644 --- a/backend/src/modules/blockchain/event-handlers/deposit.handler.spec.ts +++ b/backend/src/modules/blockchain/event-handlers/deposit.handler.spec.ts @@ -3,9 +3,15 @@ import { DataSource } from 'typeorm'; import { xdr, nativeToScVal } from '@stellar/stellar-sdk'; import { createHash } from 'crypto'; import { DepositHandler } from './deposit.handler'; -import { UserSubscription, SubscriptionStatus } from '../../savings/entities/user-subscription.entity'; +import { + UserSubscription, + SubscriptionStatus, +} from '../../savings/entities/user-subscription.entity'; import { User } from '../../user/entities/user.entity'; -import { LedgerTransaction, LedgerTransactionType } from '../entities/transaction.entity'; +import { + LedgerTransaction, + LedgerTransactionType, +} from '../entities/transaction.entity'; import { SavingsProduct } from '../../savings/entities/savings-product.entity'; describe('DepositHandler', () => { @@ -62,7 +68,11 @@ describe('DepositHandler', () => { }); describe('handle', () => { - const mockUser = { id: 'user-id', publicKey: 'G...', defaultSavingsProductId: 'prod-id' }; + const mockUser = { + id: 'user-id', + publicKey: 'G...', + defaultSavingsProductId: 'prod-id', + }; const mockProduct = { id: 'prod-id', isActive: true }; const mockEvent = { id: 'event-1', @@ -86,18 +96,26 @@ describe('DepositHandler', () => { it('should process deposit successfully and update subscription', async () => { userRepo.findOne.mockResolvedValue(mockUser); txRepo.findOne.mockResolvedValue(null); - subRepo.findOne.mockResolvedValue({ userId: 'user-id', amount: 1000, status: SubscriptionStatus.ACTIVE }); + subRepo.findOne.mockResolvedValue({ + userId: 'user-id', + amount: 1000, + status: SubscriptionStatus.ACTIVE, + }); const result = await handler.handle(mockEvent); expect(result).toBe(true); - expect(txRepo.save).toHaveBeenCalledWith(expect.objectContaining({ - type: LedgerTransactionType.DEPOSIT, - amount: '500', - })); - expect(subRepo.save).toHaveBeenCalledWith(expect.objectContaining({ - amount: 1500, - })); + expect(txRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + type: LedgerTransactionType.DEPOSIT, + amount: '500', + }), + ); + expect(subRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 1500, + }), + ); }); it('should create new subscription if one does not exist', async () => { @@ -110,9 +128,11 @@ describe('DepositHandler', () => { expect(result).toBe(true); expect(subRepo.create).toHaveBeenCalled(); - expect(subRepo.save).toHaveBeenCalledWith(expect.objectContaining({ - amount: 500, - })); + expect(subRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + amount: 500, + }), + ); }); it('should match topic by symbol', async () => { @@ -121,7 +141,11 @@ describe('DepositHandler', () => { topic: [nativeToScVal('Deposit', { type: 'symbol' }).toXDR('base64')], }; userRepo.findOne.mockResolvedValue(mockUser); - subRepo.findOne.mockResolvedValue({ userId: 'user-id', amount: 100, status: SubscriptionStatus.ACTIVE }); + subRepo.findOne.mockResolvedValue({ + userId: 'user-id', + amount: 100, + status: SubscriptionStatus.ACTIVE, + }); const result = await handler.handle(symbolEvent); expect(result).toBe(true); @@ -130,7 +154,9 @@ describe('DepositHandler', () => { it('should throw error if user not found', async () => { userRepo.findOne.mockResolvedValue(null); - await expect(handler.handle(mockEvent)).rejects.toThrow('Cannot map deposit payload publicKey to user'); + await expect(handler.handle(mockEvent)).rejects.toThrow( + 'Cannot map deposit payload publicKey to user', + ); }); }); }); diff --git a/backend/src/modules/blockchain/event-handlers/deposit.handler.ts b/backend/src/modules/blockchain/event-handlers/deposit.handler.ts index 367c1b5db..bc2d4f647 100644 --- a/backend/src/modules/blockchain/event-handlers/deposit.handler.ts +++ b/backend/src/modules/blockchain/event-handlers/deposit.handler.ts @@ -176,7 +176,8 @@ export class DepositHandler { 'to', ]) ?? ''; - const amountRaw = asRecord['amount'] ?? asRecord['value'] ?? asRecord['amt']; + const amountRaw = + asRecord['amount'] ?? asRecord['value'] ?? asRecord['amt']; const amount = typeof amountRaw === 'bigint' diff --git a/backend/src/modules/governance/dto/proposal-list-item.dto.ts b/backend/src/modules/governance/dto/proposal-list-item.dto.ts index 87bde52eb..7cac04e5a 100644 --- a/backend/src/modules/governance/dto/proposal-list-item.dto.ts +++ b/backend/src/modules/governance/dto/proposal-list-item.dto.ts @@ -1,11 +1,20 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { ProposalCategory, ProposalStatus } from '../entities/governance-proposal.entity'; +import { + ProposalCategory, + ProposalStatus, +} from '../entities/governance-proposal.entity'; export class ProposalTimelineDto { - @ApiProperty({ description: 'Proposal start boundary as UNIX block number', nullable: true }) + @ApiProperty({ + description: 'Proposal start boundary as UNIX block number', + nullable: true, + }) startTime: number | null; - @ApiProperty({ description: 'Proposal end boundary as UNIX block number', nullable: true }) + @ApiProperty({ + description: 'Proposal end boundary as UNIX block number', + nullable: true, + }) endTime: number | null; } @@ -31,10 +40,16 @@ export class ProposalListItemDto { @ApiPropertyOptional() proposer: string | null; - @ApiProperty({ description: 'Percentage of votes cast FOR (0–100)', example: 62.5 }) + @ApiProperty({ + description: 'Percentage of votes cast FOR (0–100)', + example: 62.5, + }) forPercent: number; - @ApiProperty({ description: 'Percentage of votes cast AGAINST (0–100)', example: 37.5 }) + @ApiProperty({ + description: 'Percentage of votes cast AGAINST (0–100)', + example: 37.5, + }) againstPercent: number; @ApiProperty({ type: () => ProposalTimelineDto }) diff --git a/backend/src/modules/mail/mail.service.ts b/backend/src/modules/mail/mail.service.ts index bf991b89f..d791032fd 100644 --- a/backend/src/modules/mail/mail.service.ts +++ b/backend/src/modules/mail/mail.service.ts @@ -102,4 +102,28 @@ export class MailService { ); } } + + async sendWaitlistAvailabilityEmail( + userEmail: string, + name: string, + productId: string, + ): Promise { + try { + await this.mailerService.sendMail({ + to: userEmail, + subject: 'A savings product you waited for is available', + template: './waitlist-available', + context: { + name: name || 'User', + productId, + }, + }); + this.logger.log(`Waitlist availability email sent to ${userEmail}`); + } catch (error) { + this.logger.error( + `Failed to send waitlist availability email to ${userEmail}`, + error, + ); + } + } } diff --git a/backend/src/modules/notifications/entities/notification.entity.ts b/backend/src/modules/notifications/entities/notification.entity.ts index b43dbedd0..e03e3e108 100644 --- a/backend/src/modules/notifications/entities/notification.entity.ts +++ b/backend/src/modules/notifications/entities/notification.entity.ts @@ -14,6 +14,7 @@ export enum NotificationType { CLAIM_REJECTED = 'CLAIM_REJECTED', YIELD_EARNED = 'YIELD_EARNED', DEPOSIT_RECEIVED = 'DEPOSIT_RECEIVED', + WAITLIST_AVAILABLE = 'WAITLIST_AVAILABLE', GOAL_MILESTONE = 'GOAL_MILESTONE', GOAL_COMPLETED = 'GOAL_COMPLETED', } diff --git a/backend/src/modules/notifications/milestone-scheduler.service.ts b/backend/src/modules/notifications/milestone-scheduler.service.ts index cc54cbe22..4d5bc43f7 100644 --- a/backend/src/modules/notifications/milestone-scheduler.service.ts +++ b/backend/src/modules/notifications/milestone-scheduler.service.ts @@ -97,10 +97,7 @@ export class MilestoneSchedulerService { }, ); } catch (e) { - this.logger.warn( - 'Failed to insert milestone analytics row', - e as any, - ); + this.logger.warn('Failed to insert milestone analytics row', e); } } } @@ -109,7 +106,7 @@ export class MilestoneSchedulerService { this.logger.log('Daily milestone scheduler completed'); } catch (error) { - this.logger.error('Error running milestone scheduler', error as any); + this.logger.error('Error running milestone scheduler', error); } } } diff --git a/backend/src/modules/notifications/notifications.module.ts b/backend/src/modules/notifications/notifications.module.ts index 0cabc1929..921faf904 100644 --- a/backend/src/modules/notifications/notifications.module.ts +++ b/backend/src/modules/notifications/notifications.module.ts @@ -4,6 +4,7 @@ import { NotificationsService } from './notifications.service'; import { NotificationsController } from './notifications.controller'; import { Notification } from './entities/notification.entity'; import { NotificationPreference } from './entities/notification-preference.entity'; +import { WaitlistEntry } from '../savings/entities/waitlist-entry.entity'; import { MailModule } from '../mail/mail.module'; import { User } from '../user/entities/user.entity'; import { MilestoneSchedulerService } from './milestone-scheduler.service'; @@ -11,7 +12,12 @@ import { SavingsModule } from '../savings/savings.module'; @Module({ imports: [ - TypeOrmModule.forFeature([Notification, NotificationPreference, User]), + TypeOrmModule.forFeature([ + Notification, + NotificationPreference, + User, + WaitlistEntry, + ]), MailModule, SavingsModule, ], diff --git a/backend/src/modules/notifications/notifications.service.ts b/backend/src/modules/notifications/notifications.service.ts index ae8db4fe6..5fe42798c 100644 --- a/backend/src/modules/notifications/notifications.service.ts +++ b/backend/src/modules/notifications/notifications.service.ts @@ -6,6 +6,8 @@ import { Notification, NotificationType } from './entities/notification.entity'; import { NotificationPreference } from './entities/notification-preference.entity'; import { MailService } from '../mail/mail.service'; import { User } from '../user/entities/user.entity'; +import { WaitlistEntry } from '../savings/entities/waitlist-entry.entity'; +import { WaitlistEvent } from '../savings/entities/waitlist-event.entity'; export interface SweepCompletedEvent { userId: string; @@ -34,6 +36,10 @@ export class NotificationsService { private readonly preferenceRepository: Repository, @InjectRepository(User) private readonly userRepository: Repository, + @InjectRepository(WaitlistEntry) + private readonly waitlistRepository: Repository, + @InjectRepository(WaitlistEvent) + private readonly waitlistEventRepository: Repository, private readonly mailService: MailService, ) {} @@ -258,6 +264,94 @@ export class NotificationsService { } } + /** + * Handle product availability events and notify top waitlist entries. + * Payload: { productId, spots } + */ + @OnEvent('waitlist.product.available') + async handleWaitlistAvailability(event: { + productId: string; + spots?: number; + }) { + const spots = event.spots ?? 1; + + try { + // Get top entries by priority then createdAt + const entries = await this.waitlistRepository + .createQueryBuilder('w') + .where('w.productId = :productId', { productId: event.productId }) + .andWhere('w.notifiedAt IS NULL') + .orderBy('w.priority', 'DESC') + .addOrderBy('w.createdAt', 'ASC') + .limit(spots) + .getMany(); + + if (!entries.length) return; + + for (const entry of entries) { + const user = await this.userRepository.findOne({ + where: { id: entry.userId }, + }); + + if (!user) continue; + + const title = 'Savings product available'; + const message = `A savings product you're waiting for is now available. Visit the app to claim your spot.`; + + // In-app notification + const preferences = await this.getOrCreatePreferences(entry.userId); + + if (preferences.inAppNotifications) { + await this.createNotification({ + userId: entry.userId, + type: NotificationType.WAITLIST_AVAILABLE, + title, + message, + metadata: { productId: event.productId }, + }); + } + + // Email notification + if (preferences.emailNotifications) { + await this.mailService.sendWaitlistAvailabilityEmail( + user.email, + user.name || 'User', + event.productId, + ); + } + + // record NOTIFY event for analytics + try { + await this.waitlistEventRepository.save( + this.waitlistEventRepository.create({ + entryId: entry.id, + userId: entry.userId, + productId: event.productId, + type: 'NOTIFY', + metadata: null, + }), + ); + } catch (e) { + // ignore analytics failures + } + } + + // Mark entries notified + const ids = entries.map((e) => e.id); + await this.waitlistRepository + .createQueryBuilder() + .update(WaitlistEntry) + .set({ notifiedAt: new Date() }) + .where('id IN (:...ids)', { ids }) + .execute(); + } catch (error) { + this.logger.error( + `Error handling waitlist availability for product ${event.productId}`, + error, + ); + } + } + /** * Create a notification in the database */ diff --git a/backend/src/modules/savings/dto/create-product.dto.ts b/backend/src/modules/savings/dto/create-product.dto.ts index 1c084030d..52a8c6486 100644 --- a/backend/src/modules/savings/dto/create-product.dto.ts +++ b/backend/src/modules/savings/dto/create-product.dto.ts @@ -10,7 +10,10 @@ import { MaxLength, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SavingsProductType, RiskLevel } from '../entities/savings-product.entity'; +import { + SavingsProductType, + RiskLevel, +} from '../entities/savings-product.entity'; export class CreateProductDto { @ApiProperty({ example: 'Fixed 12-Month Plan', description: 'Product name' }) diff --git a/backend/src/modules/savings/dto/product-details.dto.ts b/backend/src/modules/savings/dto/product-details.dto.ts index dd798a4eb..56f0ec66d 100644 --- a/backend/src/modules/savings/dto/product-details.dto.ts +++ b/backend/src/modules/savings/dto/product-details.dto.ts @@ -1,5 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SavingsProductType, RiskLevel } from '../entities/savings-product.entity'; +import { + SavingsProductType, + RiskLevel, +} from '../entities/savings-product.entity'; /** * Detailed product response combining static DB attributes with live Soroban contract data diff --git a/backend/src/modules/savings/dto/savings-product.dto.ts b/backend/src/modules/savings/dto/savings-product.dto.ts index 791969f18..88aa58087 100644 --- a/backend/src/modules/savings/dto/savings-product.dto.ts +++ b/backend/src/modules/savings/dto/savings-product.dto.ts @@ -1,5 +1,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { SavingsProductType, RiskLevel } from '../entities/savings-product.entity'; +import { + SavingsProductType, + RiskLevel, +} from '../entities/savings-product.entity'; export class SavingsProductDto { @ApiProperty({ description: 'Product UUID' }) diff --git a/backend/src/modules/savings/entities/savings-product.entity.ts b/backend/src/modules/savings/entities/savings-product.entity.ts index 0aaef71b9..69dd664d2 100644 --- a/backend/src/modules/savings/entities/savings-product.entity.ts +++ b/backend/src/modules/savings/entities/savings-product.entity.ts @@ -51,6 +51,9 @@ export class SavingsProduct { @Column('decimal', { precision: 14, scale: 2, default: 0 }) tvlAmount: number; + @Column('int', { nullable: true }) + capacity: number | null; + @Column({ default: true }) isActive: boolean; diff --git a/backend/src/modules/savings/entities/waitlist-entry.entity.ts b/backend/src/modules/savings/entities/waitlist-entry.entity.ts new file mode 100644 index 000000000..3763cf7e3 --- /dev/null +++ b/backend/src/modules/savings/entities/waitlist-entry.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('waitlist_entries') +export class WaitlistEntry { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column('uuid') + userId: string; + + @Index() + @Column('uuid') + productId: string; + + // higher number = higher priority + @Column('int', { default: 0 }) + priority: number; + + // set when user has been notified/served + @Column({ type: 'timestamp', nullable: true }) + notifiedAt: Date | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/backend/src/modules/savings/entities/waitlist-event.entity.ts b/backend/src/modules/savings/entities/waitlist-event.entity.ts new file mode 100644 index 000000000..0ba311651 --- /dev/null +++ b/backend/src/modules/savings/entities/waitlist-event.entity.ts @@ -0,0 +1,36 @@ +import { + Entity, + Column, + PrimaryGeneratedColumn, + CreateDateColumn, + Index, +} from 'typeorm'; + +export type WaitlistEventType = 'JOIN' | 'NOTIFY' | 'ACCEPT'; + +@Entity('waitlist_events') +export class WaitlistEvent { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column('uuid', { nullable: true }) + entryId: string | null; + + @Index() + @Column('uuid') + userId: string; + + @Index() + @Column('uuid') + productId: string; + + @Column('varchar') + type: WaitlistEventType; + + @Column({ type: 'json', nullable: true }) + metadata: Record | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/modules/savings/savings.module.ts b/backend/src/modules/savings/savings.module.ts index c74c3b7f1..8dc46d45c 100644 --- a/backend/src/modules/savings/savings.module.ts +++ b/backend/src/modules/savings/savings.module.ts @@ -7,6 +7,10 @@ import { SavingsProduct } from './entities/savings-product.entity'; import { UserSubscription } from './entities/user-subscription.entity'; import { SavingsGoal } from './entities/savings-goal.entity'; import { User } from '../user/entities/user.entity'; +import { WaitlistEntry } from './entities/waitlist-entry.entity'; +import { WaitlistEvent } from './entities/waitlist-event.entity'; +import { WaitlistService } from './waitlist.service'; +import { WaitlistController } from './waitlist.controller'; @Module({ imports: [ @@ -15,10 +19,12 @@ import { User } from '../user/entities/user.entity'; UserSubscription, SavingsGoal, User, + WaitlistEntry, + WaitlistEvent, ]), ], - controllers: [SavingsController], - providers: [SavingsService, PredictiveEvaluatorService], - exports: [SavingsService], + controllers: [SavingsController, WaitlistController], + providers: [SavingsService, PredictiveEvaluatorService, WaitlistService], + exports: [SavingsService, WaitlistService], }) export class SavingsModule {} diff --git a/backend/src/modules/savings/savings.service.ts b/backend/src/modules/savings/savings.service.ts index cc4f479e8..df9803fea 100644 --- a/backend/src/modules/savings/savings.service.ts +++ b/backend/src/modules/savings/savings.service.ts @@ -7,9 +7,7 @@ import { } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ConfigService } from '@nestjs/config'; -import { InjectRepository } from '@nestjs/typeorm'; import { Cache } from 'cache-manager'; -import { Repository } from 'typeorm'; import { SavingsProduct, RiskLevel } from './entities/savings-product.entity'; import { UserSubscription, @@ -23,6 +21,10 @@ import { GoalProgressDto } from './dto/goal-progress.dto'; import { User } from '../user/entities/user.entity'; import { SavingsService as BlockchainSavingsService } from '../blockchain/savings.service'; import { PredictiveEvaluatorService } from './services/predictive-evaluator.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Optional } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; export type SavingsGoalProgress = GoalProgressDto; @@ -54,6 +56,7 @@ export class SavingsService { private readonly predictiveEvaluatorService: PredictiveEvaluatorService, private readonly configService: ConfigService, @Inject(CACHE_MANAGER) private readonly cacheManager: Cache, + @Optional() private readonly eventEmitter?: EventEmitter2, ) {} async createProduct(dto: CreateProductDto): Promise { @@ -91,6 +94,41 @@ export class SavingsService { Object.assign(product, dto); const updatedProduct = await this.productRepository.save(product); await this.invalidatePoolsCache(); + + // Emit waitlist availability event when product becomes available or capacity opens + try { + const activeCount = await this.subscriptionRepository.count({ + where: { + productId: updatedProduct.id, + status: SubscriptionStatus.ACTIVE, + }, + }); + + const oldCapacity = (product as any).__oldCapacity ?? null; + const oldIsActive = (product as any).__oldIsActive ?? null; + + // If capacity is set and there's room, notify waitlist + if ( + typeof updatedProduct.capacity === 'number' && + updatedProduct.capacity > activeCount + ) { + const spots = Math.max(1, updatedProduct.capacity - activeCount); + this.eventEmitter?.emit('waitlist.product.available', { + productId: updatedProduct.id, + spots, + }); + } + + // If product was previously inactive and now active, notify waitlist (launch) + if (updatedProduct.isActive && !product.isActive) { + this.eventEmitter?.emit('waitlist.product.available', { + productId: updatedProduct.id, + spots: Math.max(1, (updatedProduct.capacity ?? 1) - activeCount), + }); + } + } catch (e) { + this.logger.warn(`Failed to emit waitlist event for product ${id}: ${e}`); + } return updatedProduct; } @@ -209,7 +247,10 @@ export class SavingsService { })() : null, }); - return await this.subscriptionRepository.save(subscription); + const savedSubscription = + await this.subscriptionRepository.save(subscription); + + return savedSubscription; } async findMySubscriptions( diff --git a/backend/src/modules/savings/waitlist.controller.ts b/backend/src/modules/savings/waitlist.controller.ts new file mode 100644 index 000000000..55b60f7e7 --- /dev/null +++ b/backend/src/modules/savings/waitlist.controller.ts @@ -0,0 +1,41 @@ +import { + Controller, + Post, + Param, + UseGuards, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiTags, + ApiBearerAuth, + ApiParam, + ApiOperation, +} from '@nestjs/swagger'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { WaitlistService } from './waitlist.service'; + +@ApiTags('savings') +@Controller('savings/products') +export class WaitlistController { + constructor(private readonly waitlistService: WaitlistService) {} + + @Post(':id/waitlist') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + @ApiBearerAuth() + @ApiParam({ name: 'id', description: 'Product UUID' }) + @ApiOperation({ summary: 'Join waitlist for a savings product' }) + async join( + @Param('id') id: string, + @CurrentUser() user: { id: string; email: string }, + ) { + const { entry, position } = await this.waitlistService.joinWaitlist( + user.id, + id, + ); + + return { id: entry.id, position }; + } +} diff --git a/backend/src/modules/savings/waitlist.service.ts b/backend/src/modules/savings/waitlist.service.ts new file mode 100644 index 000000000..e1f5aa3fe --- /dev/null +++ b/backend/src/modules/savings/waitlist.service.ts @@ -0,0 +1,123 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Optional, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { WaitlistEntry } from './entities/waitlist-entry.entity'; +import { WaitlistEvent } from './entities/waitlist-event.entity'; +import { SavingsProduct } from './entities/savings-product.entity'; +import { User } from '../user/entities/user.entity'; + +@Injectable() +export class WaitlistService { + constructor( + @InjectRepository(WaitlistEntry) + private readonly waitlistRepo: Repository, + @InjectRepository(SavingsProduct) + private readonly productRepo: Repository, + @InjectRepository(User) + private readonly userRepo: Repository, + @Optional() + @InjectRepository(WaitlistEvent) + private readonly eventRepo?: Repository, + ) {} + + /** + * Add a user to a product waitlist. Returns the user's 1-based position. + * This method is idempotent (if already waiting, returns existing position). + */ + async joinWaitlist(userId: string, productId: string, priority = 0) { + const [product, user] = await Promise.all([ + this.productRepo.findOneBy({ id: productId }), + this.userRepo.findOne({ where: { id: userId }, select: ['id', 'email'] }), + ]); + + if (!product) throw new NotFoundException('Product not found'); + + // If product is available for subscription, reject — no waitlist needed + if (product.isActive && product.capacity == null) { + throw new BadRequestException('Product is currently available'); + } + + // Idempotent: return existing pending entry if present + const existing = await this.waitlistRepo + .createQueryBuilder('w') + .where('w.productId = :productId', { productId }) + .andWhere('w.userId = :userId', { userId }) + .andWhere('w.notifiedAt IS NULL') + .getOne(); + + if (existing) { + const position = await this.getPosition(existing.id); + return { entry: existing, position }; + } + + const entry = this.waitlistRepo.create({ userId, productId, priority }); + const saved = await this.waitlistRepo.save(entry); + // record JOIN event for analytics + try { + if (this.eventRepo) { + await this.eventRepo.save( + this.eventRepo.create({ + entryId: saved.id, + userId, + productId, + type: 'JOIN', + metadata: null, + }), + ); + } + } catch (e) { + // non-fatal + } + const position = await this.getPosition(saved.id); + return { entry: saved, position }; + } + + /** Compute 1-based position for an entry id */ + async getPosition(entryId: string): Promise { + const target = await this.waitlistRepo.findOneBy({ id: entryId }); + if (!target) throw new NotFoundException('Waitlist entry not found'); + + const count = await this.waitlistRepo + .createQueryBuilder('w') + .where('w.productId = :productId', { productId: target.productId }) + .andWhere('w.notifiedAt IS NULL') + .andWhere( + '(w.priority > :priority OR (w.priority = :priority AND w.createdAt <= :createdAt))', + { priority: target.priority, createdAt: target.createdAt }, + ) + .getCount(); + + return count; // count is 1-based position (includes target) + } + + /** + * Fetch next N pending waitlist entries for a product in priority/FCFS order + */ + async popNext(productId: string, limit = 1): Promise { + const entries = await this.waitlistRepo + .createQueryBuilder('w') + .where('w.productId = :productId', { productId }) + .andWhere('w.notifiedAt IS NULL') + .orderBy('w.priority', 'DESC') + .addOrderBy('w.createdAt', 'ASC') + .limit(limit) + .getMany(); + + return entries; + } + + async markNotified(entryIds: string[]) { + if (!entryIds.length) return; + await this.waitlistRepo + .createQueryBuilder() + .update(WaitlistEntry) + .set({ notifiedAt: new Date() }) + .where('id IN (:...ids)', { ids: entryIds }) + .execute(); + } +} diff --git a/backend/test/critical-path.e2e-spec.ts b/backend/test/critical-path.e2e-spec.ts index ce277f54c..28d75e26b 100644 --- a/backend/test/critical-path.e2e-spec.ts +++ b/backend/test/critical-path.e2e-spec.ts @@ -3,345 +3,355 @@ import { INestApplication, ValidationPipe } from '@nestjs/common'; import * as request from 'supertest'; import { AppModule } from '../src/app.module'; import { DataSource } from 'typeorm'; -import { MedicalClaim, ClaimStatus } from '../src/modules/claims/entities/medical-claim.entity'; -import { Dispute, DisputeStatus } from '../src/modules/disputes/entities/dispute.entity'; +import { + MedicalClaim, + ClaimStatus, +} from '../src/modules/claims/entities/medical-claim.entity'; +import { + Dispute, + DisputeStatus, +} from '../src/modules/disputes/entities/dispute.entity'; /** * E2E Critical Path Tests - * + * * Tests the full escrow lifecycle: * - Create trade (claim submission) * - Deposit (claim approval) * - Confirm delivery (claim processing) * - Release funds (claim resolution) * - Initiate dispute and resolve - * + * * Validates API, DB, and business logic integration. */ describe('Critical Path E2E Tests', () => { - let app: INestApplication; - let dataSource: DataSource; - let claimRepository; - let disputeRepository; + let app: INestApplication; + let dataSource: DataSource; + let claimRepository; + let disputeRepository; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + + dataSource = moduleFixture.get(DataSource); + claimRepository = dataSource.getRepository(MedicalClaim); + disputeRepository = dataSource.getRepository(Dispute); + + // Clean up before tests + await cleanupDatabase(); + }); + + afterAll(async () => { + await cleanupDatabase(); + await app.close(); + }); + + async function cleanupDatabase() { + if (!dataSource.isInitialized) return; + + const entities = dataSource.entityMetadatas; + for (const entity of entities) { + const repository = dataSource.getRepository(entity.name); + await repository.query(`TRUNCATE TABLE "${entity.tableName}" CASCADE`); + } + } + + describe('Happy Path: Create → Deposit → Confirm → Release', () => { + let claimId: string; + + it('should create a trade (submit medical claim)', async () => { + const createClaimDto = { + patientName: 'John Doe', + patientId: 'P123456', + patientDateOfBirth: '1990-01-15', + hospitalName: 'City Hospital', + hospitalId: 'H001', + diagnosisCodes: ['J45.9', 'E11.9'], + claimAmount: 5000.0, + notes: 'Asthma and diabetes treatment', + }; + + const response = await request(app.getHttpServer()) + .post('/claims') + .send(createClaimDto) + .expect(201); + + expect(response.body).toHaveProperty('id'); + expect(response.body.status).toBe(ClaimStatus.PENDING); + expect(response.body.claimAmount).toBe('5000.00'); + + claimId = response.body.id; + + // Verify in database + const claim = await claimRepository.findOne({ where: { id: claimId } }); + expect(claim).toBeDefined(); + expect(claim.status).toBe(ClaimStatus.PENDING); + }); + + it('should deposit (approve claim)', async () => { + const response = await request(app.getHttpServer()) + .patch(`/claims/${claimId}`) + .send({ status: ClaimStatus.APPROVED }) + .expect(200); + + expect(response.body.status).toBe(ClaimStatus.APPROVED); + + // Verify in database + const claim = await claimRepository.findOne({ where: { id: claimId } }); + expect(claim.status).toBe(ClaimStatus.APPROVED); + }); + + it('should confirm delivery (process claim)', async () => { + const response = await request(app.getHttpServer()) + .patch(`/claims/${claimId}`) + .send({ status: ClaimStatus.PROCESSING }) + .expect(200); + + expect(response.body.status).toBe(ClaimStatus.PROCESSING); + + const claim = await claimRepository.findOne({ where: { id: claimId } }); + expect(claim.status).toBe(ClaimStatus.PROCESSING); + }); + + it('should release funds (resolve claim)', async () => { + const response = await request(app.getHttpServer()) + .patch(`/claims/${claimId}`) + .send({ status: ClaimStatus.APPROVED }) + .expect(200); + + expect(response.body.status).toBe(ClaimStatus.APPROVED); + + const claim = await claimRepository.findOne({ where: { id: claimId } }); + expect(claim.status).toBe(ClaimStatus.APPROVED); + }); + + it('should retrieve claim with full history', async () => { + const response = await request(app.getHttpServer()) + .get(`/claims/${claimId}`) + .expect(200); + + expect(response.body.id).toBe(claimId); + expect(response.body.status).toBe(ClaimStatus.APPROVED); + expect(response.body.patientName).toBe('John Doe'); + expect(response.body.claimAmount).toBe('5000.00'); + }); + }); + + describe('Dispute Path: Initiate → Review → Resolve', () => { + let claimId: string; + let disputeId: string; beforeAll(async () => { - const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], - }).compile(); - - app = moduleFixture.createNestApplication(); - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - }), - ); - await app.init(); - - dataSource = moduleFixture.get(DataSource); - claimRepository = dataSource.getRepository(MedicalClaim); - disputeRepository = dataSource.getRepository(Dispute); - - // Clean up before tests - await cleanupDatabase(); + // Create a claim to dispute + const claim = claimRepository.create({ + patientName: 'Jane Smith', + patientId: 'P789012', + patientDateOfBirth: new Date('1985-06-20'), + hospitalName: 'General Hospital', + hospitalId: 'H002', + diagnosisCodes: ['M79.3'], + claimAmount: 3500.0, + status: ClaimStatus.APPROVED, + notes: 'Muscle strain treatment', + }); + const savedClaim = await claimRepository.save(claim); + claimId = savedClaim.id; }); - afterAll(async () => { - await cleanupDatabase(); - await app.close(); + it('should initiate dispute on approved claim', async () => { + const createDisputeDto = { + claimId, + disputedBy: 'hospital-admin@example.com', + reason: 'Claim amount exceeds approved treatment cost', + }; + + const response = await request(app.getHttpServer()) + .post('/disputes') + .send(createDisputeDto) + .expect(201); + + expect(response.body).toHaveProperty('id'); + expect(response.body.status).toBe(DisputeStatus.OPEN); + expect(response.body.claimId).toBe(claimId); + expect(response.body.reason).toBe(createDisputeDto.reason); + + disputeId = response.body.id; + + // Verify in database + const dispute = await disputeRepository.findOne({ + where: { id: disputeId }, + }); + expect(dispute).toBeDefined(); + expect(dispute.status).toBe(DisputeStatus.OPEN); }); - async function cleanupDatabase() { - if (!dataSource.isInitialized) return; + it('should add message to dispute (evidence)', async () => { + const addMessageDto = { + author: 'hospital-admin@example.com', + message: 'Attached hospital invoice showing actual cost', + evidenceUrl: 'https://example.com/invoice-123.pdf', + }; + + const response = await request(app.getHttpServer()) + .post(`/disputes/${disputeId}/messages`) + .send(addMessageDto) + .expect(201); + + expect(response.body).toHaveProperty('id'); + expect(response.body.author).toBe(addMessageDto.author); + expect(response.body.evidenceUrl).toBe(addMessageDto.evidenceUrl); + }); - const entities = dataSource.entityMetadatas; - for (const entity of entities) { - const repository = dataSource.getRepository(entity.name); - await repository.query(`TRUNCATE TABLE "${entity.tableName}" CASCADE`); - } - } + it('should transition dispute to under review', async () => { + const response = await request(app.getHttpServer()) + .patch(`/disputes/${disputeId}`) + .send({ status: DisputeStatus.UNDER_REVIEW }) + .expect(200); + + expect(response.body.status).toBe(DisputeStatus.UNDER_REVIEW); + + const dispute = await disputeRepository.findOne({ + where: { id: disputeId }, + }); + expect(dispute.status).toBe(DisputeStatus.UNDER_REVIEW); + }); + + it('should resolve dispute', async () => { + const response = await request(app.getHttpServer()) + .patch(`/disputes/${disputeId}`) + .send({ status: DisputeStatus.RESOLVED }) + .expect(200); + + expect(response.body.status).toBe(DisputeStatus.RESOLVED); + + const dispute = await disputeRepository.findOne({ + where: { id: disputeId }, + relations: ['messages'], + }); + expect(dispute.status).toBe(DisputeStatus.RESOLVED); + expect(dispute.messages.length).toBeGreaterThan(0); + }); - describe('Happy Path: Create → Deposit → Confirm → Release', () => { - let claimId: string; - - it('should create a trade (submit medical claim)', async () => { - const createClaimDto = { - patientName: 'John Doe', - patientId: 'P123456', - patientDateOfBirth: '1990-01-15', - hospitalName: 'City Hospital', - hospitalId: 'H001', - diagnosisCodes: ['J45.9', 'E11.9'], - claimAmount: 5000.00, - notes: 'Asthma and diabetes treatment', - }; - - const response = await request(app.getHttpServer()) - .post('/claims') - .send(createClaimDto) - .expect(201); - - expect(response.body).toHaveProperty('id'); - expect(response.body.status).toBe(ClaimStatus.PENDING); - expect(response.body.claimAmount).toBe('5000.00'); - - claimId = response.body.id; - - // Verify in database - const claim = await claimRepository.findOne({ where: { id: claimId } }); - expect(claim).toBeDefined(); - expect(claim.status).toBe(ClaimStatus.PENDING); - }); - - it('should deposit (approve claim)', async () => { - const response = await request(app.getHttpServer()) - .patch(`/claims/${claimId}`) - .send({ status: ClaimStatus.APPROVED }) - .expect(200); - - expect(response.body.status).toBe(ClaimStatus.APPROVED); - - // Verify in database - const claim = await claimRepository.findOne({ where: { id: claimId } }); - expect(claim.status).toBe(ClaimStatus.APPROVED); - }); - - it('should confirm delivery (process claim)', async () => { - const response = await request(app.getHttpServer()) - .patch(`/claims/${claimId}`) - .send({ status: ClaimStatus.PROCESSING }) - .expect(200); - - expect(response.body.status).toBe(ClaimStatus.PROCESSING); - - const claim = await claimRepository.findOne({ where: { id: claimId } }); - expect(claim.status).toBe(ClaimStatus.PROCESSING); - }); - - it('should release funds (resolve claim)', async () => { - const response = await request(app.getHttpServer()) - .patch(`/claims/${claimId}`) - .send({ status: ClaimStatus.APPROVED }) - .expect(200); - - expect(response.body.status).toBe(ClaimStatus.APPROVED); - - const claim = await claimRepository.findOne({ where: { id: claimId } }); - expect(claim.status).toBe(ClaimStatus.APPROVED); - }); - - it('should retrieve claim with full history', async () => { - const response = await request(app.getHttpServer()) - .get(`/claims/${claimId}`) - .expect(200); - - expect(response.body.id).toBe(claimId); - expect(response.body.status).toBe(ClaimStatus.APPROVED); - expect(response.body.patientName).toBe('John Doe'); - expect(response.body.claimAmount).toBe('5000.00'); - }); + it('should retrieve dispute with full message history', async () => { + const response = await request(app.getHttpServer()) + .get(`/disputes/${disputeId}`) + .expect(200); + + expect(response.body.id).toBe(disputeId); + expect(response.body.status).toBe(DisputeStatus.RESOLVED); + expect(Array.isArray(response.body.messages)).toBe(true); + expect(response.body.messages.length).toBeGreaterThan(0); + }); + }); + + describe('Negative Flows', () => { + it('should reject invalid status transition', async () => { + const claim = claimRepository.create({ + patientName: 'Test User', + patientId: 'P999999', + patientDateOfBirth: new Date('1995-03-10'), + hospitalName: 'Test Hospital', + hospitalId: 'H999', + diagnosisCodes: ['Z00.00'], + claimAmount: 1000.0, + status: ClaimStatus.PENDING, + }); + const savedClaim = await claimRepository.save(claim); + + // Try invalid status + const response = await request(app.getHttpServer()) + .patch(`/claims/${savedClaim.id}`) + .send({ status: 'INVALID_STATUS' }) + .expect(400); + + expect(response.body.message).toBeDefined(); }); - describe('Dispute Path: Initiate → Review → Resolve', () => { - let claimId: string; - let disputeId: string; - - beforeAll(async () => { - // Create a claim to dispute - const claim = claimRepository.create({ - patientName: 'Jane Smith', - patientId: 'P789012', - patientDateOfBirth: new Date('1985-06-20'), - hospitalName: 'General Hospital', - hospitalId: 'H002', - diagnosisCodes: ['M79.3'], - claimAmount: 3500.00, - status: ClaimStatus.APPROVED, - notes: 'Muscle strain treatment', - }); - const savedClaim = await claimRepository.save(claim); - claimId = savedClaim.id; - }); - - it('should initiate dispute on approved claim', async () => { - const createDisputeDto = { - claimId, - disputedBy: 'hospital-admin@example.com', - reason: 'Claim amount exceeds approved treatment cost', - }; - - const response = await request(app.getHttpServer()) - .post('/disputes') - .send(createDisputeDto) - .expect(201); - - expect(response.body).toHaveProperty('id'); - expect(response.body.status).toBe(DisputeStatus.OPEN); - expect(response.body.claimId).toBe(claimId); - expect(response.body.reason).toBe(createDisputeDto.reason); - - disputeId = response.body.id; - - // Verify in database - const dispute = await disputeRepository.findOne({ where: { id: disputeId } }); - expect(dispute).toBeDefined(); - expect(dispute.status).toBe(DisputeStatus.OPEN); - }); - - it('should add message to dispute (evidence)', async () => { - const addMessageDto = { - author: 'hospital-admin@example.com', - message: 'Attached hospital invoice showing actual cost', - evidenceUrl: 'https://example.com/invoice-123.pdf', - }; - - const response = await request(app.getHttpServer()) - .post(`/disputes/${disputeId}/messages`) - .send(addMessageDto) - .expect(201); - - expect(response.body).toHaveProperty('id'); - expect(response.body.author).toBe(addMessageDto.author); - expect(response.body.evidenceUrl).toBe(addMessageDto.evidenceUrl); - }); - - it('should transition dispute to under review', async () => { - const response = await request(app.getHttpServer()) - .patch(`/disputes/${disputeId}`) - .send({ status: DisputeStatus.UNDER_REVIEW }) - .expect(200); - - expect(response.body.status).toBe(DisputeStatus.UNDER_REVIEW); - - const dispute = await disputeRepository.findOne({ where: { id: disputeId } }); - expect(dispute.status).toBe(DisputeStatus.UNDER_REVIEW); - }); - - it('should resolve dispute', async () => { - const response = await request(app.getHttpServer()) - .patch(`/disputes/${disputeId}`) - .send({ status: DisputeStatus.RESOLVED }) - .expect(200); - - expect(response.body.status).toBe(DisputeStatus.RESOLVED); - - const dispute = await disputeRepository.findOne({ - where: { id: disputeId }, - relations: ['messages'], - }); - expect(dispute.status).toBe(DisputeStatus.RESOLVED); - expect(dispute.messages.length).toBeGreaterThan(0); - }); - - it('should retrieve dispute with full message history', async () => { - const response = await request(app.getHttpServer()) - .get(`/disputes/${disputeId}`) - .expect(200); - - expect(response.body.id).toBe(disputeId); - expect(response.body.status).toBe(DisputeStatus.RESOLVED); - expect(Array.isArray(response.body.messages)).toBe(true); - expect(response.body.messages.length).toBeGreaterThan(0); - }); + it('should prevent unauthorized dispute creation', async () => { + const response = await request(app.getHttpServer()) + .post('/disputes') + .send({ + claimId: 'non-existent-id', + disputedBy: 'unauthorized@example.com', + reason: 'Invalid claim', + }) + .expect(400); + + expect(response.body.message).toBeDefined(); }); - describe('Negative Flows', () => { - it('should reject invalid status transition', async () => { - const claim = claimRepository.create({ - patientName: 'Test User', - patientId: 'P999999', - patientDateOfBirth: new Date('1995-03-10'), - hospitalName: 'Test Hospital', - hospitalId: 'H999', - diagnosisCodes: ['Z00.00'], - claimAmount: 1000.00, - status: ClaimStatus.PENDING, - }); - const savedClaim = await claimRepository.save(claim); - - // Try invalid status - const response = await request(app.getHttpServer()) - .patch(`/claims/${savedClaim.id}`) - .send({ status: 'INVALID_STATUS' }) - .expect(400); - - expect(response.body.message).toBeDefined(); - }); - - it('should prevent unauthorized dispute creation', async () => { - const response = await request(app.getHttpServer()) - .post('/disputes') - .send({ - claimId: 'non-existent-id', - disputedBy: 'unauthorized@example.com', - reason: 'Invalid claim', - }) - .expect(400); - - expect(response.body.message).toBeDefined(); - }); - - it('should return 404 for non-existent claim', async () => { - await request(app.getHttpServer()) - .get('/claims/00000000-0000-0000-0000-000000000000') - .expect(404); - }); - - it('should return 404 for non-existent dispute', async () => { - await request(app.getHttpServer()) - .get('/disputes/00000000-0000-0000-0000-000000000000') - .expect(404); - }); + it('should return 404 for non-existent claim', async () => { + await request(app.getHttpServer()) + .get('/claims/00000000-0000-0000-0000-000000000000') + .expect(404); + }); + + it('should return 404 for non-existent dispute', async () => { + await request(app.getHttpServer()) + .get('/disputes/00000000-0000-0000-0000-000000000000') + .expect(404); + }); + }); + + describe('Data Integrity & Cross-Layer Validation', () => { + it('should maintain referential integrity between claims and disputes', async () => { + const claim = claimRepository.create({ + patientName: 'Integrity Test', + patientId: 'P111111', + patientDateOfBirth: new Date('1992-07-25'), + hospitalName: 'Integrity Hospital', + hospitalId: 'H111', + diagnosisCodes: ['A00.0'], + claimAmount: 2000.0, + status: ClaimStatus.APPROVED, + }); + const savedClaim = await claimRepository.save(claim); + + const dispute = disputeRepository.create({ + claimId: savedClaim.id, + disputedBy: 'test@example.com', + reason: 'Test dispute', + status: DisputeStatus.OPEN, + }); + const savedDispute = await disputeRepository.save(dispute); + + // Verify relationship + const retrievedDispute = await disputeRepository.findOne({ + where: { id: savedDispute.id }, + relations: ['claim'], + }); + + expect(retrievedDispute.claim.id).toBe(savedClaim.id); + expect(retrievedDispute.claim.patientName).toBe('Integrity Test'); }); - describe('Data Integrity & Cross-Layer Validation', () => { - it('should maintain referential integrity between claims and disputes', async () => { - const claim = claimRepository.create({ - patientName: 'Integrity Test', - patientId: 'P111111', - patientDateOfBirth: new Date('1992-07-25'), - hospitalName: 'Integrity Hospital', - hospitalId: 'H111', - diagnosisCodes: ['A00.0'], - claimAmount: 2000.00, - status: ClaimStatus.APPROVED, - }); - const savedClaim = await claimRepository.save(claim); - - const dispute = disputeRepository.create({ - claimId: savedClaim.id, - disputedBy: 'test@example.com', - reason: 'Test dispute', - status: DisputeStatus.OPEN, - }); - const savedDispute = await disputeRepository.save(dispute); - - // Verify relationship - const retrievedDispute = await disputeRepository.findOne({ - where: { id: savedDispute.id }, - relations: ['claim'], - }); - - expect(retrievedDispute.claim.id).toBe(savedClaim.id); - expect(retrievedDispute.claim.patientName).toBe('Integrity Test'); - }); - - it('should list all claims with pagination', async () => { - const response = await request(app.getHttpServer()) - .get('/claims') - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - expect(response.body.length).toBeGreaterThan(0); - }); - - it('should list all disputes with pagination', async () => { - const response = await request(app.getHttpServer()) - .get('/disputes') - .expect(200); - - expect(Array.isArray(response.body)).toBe(true); - }); + it('should list all claims with pagination', async () => { + const response = await request(app.getHttpServer()) + .get('/claims') + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + }); + + it('should list all disputes with pagination', async () => { + const response = await request(app.getHttpServer()) + .get('/disputes') + .expect(200); + + expect(Array.isArray(response.body)).toBe(true); }); + }); });