From 83d52f39691e6a36000ee5f648ee78cfd9ca1b50 Mon Sep 17 00:00:00 2001 From: Iyanu Majekodunmi Date: Sat, 20 Jun 2026 18:06:45 +0000 Subject: [PATCH 1/2] fix: recalculateCampaignStats now decrements raisedAmount after refund Issue #8 - When donations are refunded, the campaign raisedAmount was never decremented, permanently inflating the campaign total. Changes: - recalculateCampaignStats wrapped in Prisma $transaction for atomicity - Added POST /admin/donations/:id/refund endpoint (admin-only) - AdminService.refundDonation uses atomic $transaction to update status and recalculate raisedAmount - verifyDonationOnChain now recalculates stats when confirming donations and protects CONFIRMED donations from accidental FAILED transitions - Regression tests covering all refund scenarios --- src/admin/admin.controller.ts | 24 +++ src/admin/admin.service.spec.ts | 233 +++++++++++++++++++++++++++++ src/admin/admin.service.ts | 64 ++++++++ src/campaigns/campaigns.service.ts | 30 ++-- src/donations/donations.service.ts | 12 ++ 5 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 src/admin/admin.service.spec.ts diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index 2e33047..5d81983 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -6,6 +6,8 @@ import { UseGuards, Request, ParseUUIDPipe, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { AdminService } from './admin.service'; import { SuspendCampaignDto } from './dtos/suspend-campaign.dto'; @@ -28,4 +30,26 @@ export class AdminController { ): Promise<{ message: string }> { return this.adminService.suspendCampaign(id, dto, req.user.sub, req.user.email); } + + /** + * POST /admin/donations/:id/refund + * Refund a confirmed donation, atomically updating the campaign's raisedAmount. + * Only available to admin users. + */ + @Post('donations/:id/refund') + @HttpCode(HttpStatus.OK) + async refundDonation( + @Param('id', ParseUUIDPipe) id: string, + ): Promise<{ + id: string; + amount: string; + assetCode: string; + status: string; + campaignId: string; + donorId: string; + txHash: string | null; + refundedAt: Date; + }> { + return this.adminService.refundDonation(id); + } } diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts new file mode 100644 index 0000000..cc87a04 --- /dev/null +++ b/src/admin/admin.service.spec.ts @@ -0,0 +1,233 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { AdminService } from './admin.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { NotificationsService } from '../notifications/notifications.service'; + +describe('AdminService', () => { + let service: AdminService; + let prisma: PrismaService; + + const mockCampaign = { + id: 'campaign-1', + title: 'Test Campaign', + raisedAmount: { toString: () => '150' }, + status: 'ACTIVE' as const, + creatorId: 'creator-1', + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockDonationConfirmed = { + id: 'donation-1', + amount: { toString: () => '50' }, + assetCode: 'XLM', + status: 'CONFIRMED' as const, + campaignId: 'campaign-1', + donorId: 'donor-1', + txHash: 'tx-hash-1', + confirmedAt: new Date(), + donatedAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockDonationPending = { + ...mockDonationConfirmed, + id: 'donation-2', + status: 'PENDING' as const, + amount: { toString: () => '30' }, + txHash: 'tx-hash-2', + }; + + const mockDonationRefunded = { + ...mockDonationConfirmed, + id: 'donation-3', + status: 'REFUNDED' as const, + amount: { toString: () => '20' }, + txHash: 'tx-hash-3', + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { + provide: PrismaService, + useFactory: () => ({ + $transaction: jest.fn(), + donation: { + findUnique: jest.fn(), + update: jest.fn(), + aggregate: jest.fn(), + }, + campaign: { + findUnique: jest.fn(), + update: jest.fn(), + }, + }), + }, + { + provide: NotificationsService, + useValue: { + sendCampaignSuspensionEmail: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AdminService); + prisma = module.get(PrismaService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('refundDonation', () => { + it('should refund a confirmed donation and recalculate campaign raisedAmount', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationConfirmed), + update: jest.fn().mockResolvedValue({ + ...mockDonationConfirmed, + status: 'REFUNDED', + updatedAt: new Date(), + }), + aggregate: jest.fn().mockResolvedValue({ + _sum: { amount: { toString: () => '100' } }, + }), + }, + campaign: { + update: jest.fn().mockResolvedValue({ + ...mockCampaign, + raisedAmount: { toString: () => '100' }, + }), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + const result = await service.refundDonation('donation-1'); + + expect(result.status).toBe('REFUNDED'); + expect(result.amount).toBe('50'); + expect(result.campaignId).toBe('campaign-1'); + expect(txMock.donation.findUnique).toHaveBeenCalledWith({ + where: { id: 'donation-1' }, + }); + expect(txMock.donation.update).toHaveBeenCalledWith({ + where: { id: 'donation-1' }, + data: { status: 'REFUNDED' }, + }); + expect(txMock.donation.aggregate).toHaveBeenCalledWith({ + where: { + campaignId: 'campaign-1', + status: 'CONFIRMED', + }, + _sum: { amount: true }, + }); + expect(txMock.campaign.update).toHaveBeenCalledWith({ + where: { id: 'campaign-1' }, + data: { raisedAmount: expect.objectContaining({ toString: expect.any(Function) }) }, + }); + }); + + it('should throw NotFoundException for non-existent donation', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(null), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + await expect(service.refundDonation('nonexistent')).rejects.toThrow( + NotFoundException, + ); + }); + + it('should throw BadRequestException when donation is not CONFIRMED', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationPending), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + await expect(service.refundDonation('donation-2')).rejects.toThrow( + BadRequestException, + ); + await expect(service.refundDonation('donation-2')).rejects.toThrow( + 'Only confirmed donations can be refunded', + ); + }); + + it('should throw BadRequestException when donation is already REFUNDED', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationRefunded), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + await expect(service.refundDonation('donation-3')).rejects.toThrow( + BadRequestException, + ); + }); + + it('should correctly decrease raisedAmount by the refunded amount', async () => { + const txMock = { + donation: { + findUnique: jest.fn().mockResolvedValue(mockDonationConfirmed), + update: jest.fn().mockResolvedValue({ + ...mockDonationConfirmed, + status: 'REFUNDED', + updatedAt: new Date(), + }), + aggregate: jest.fn().mockResolvedValue({ + _sum: { amount: { toString: () => '100' } }, + }), + }, + campaign: { + update: jest.fn().mockResolvedValue({ + ...mockCampaign, + raisedAmount: { toString: () => '100' }, + }), + }, + }; + + (prisma.$transaction as jest.Mock).mockImplementation( + async (cb: (tx: typeof txMock) => Promise) => cb(txMock), + ); + + const result = await service.refundDonation('donation-1'); + + expect(result.status).toBe('REFUNDED'); + expect(txMock.donation.aggregate).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + campaignId: 'campaign-1', + status: 'CONFIRMED', + }, + }), + ); + expect(txMock.campaign.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'campaign-1' }, + data: { raisedAmount: expect.anything() }, + }), + ); + }); + }); +}); diff --git a/src/admin/admin.service.ts b/src/admin/admin.service.ts index 05e8284..f6cbc3d 100644 --- a/src/admin/admin.service.ts +++ b/src/admin/admin.service.ts @@ -3,6 +3,7 @@ import { NotFoundException, BadRequestException, } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { NotificationsService } from '../notifications/notifications.service'; import { SuspendCampaignDto } from './dtos/suspend-campaign.dto'; @@ -65,4 +66,67 @@ export class AdminService { return { message: `Campaign ${campaignId} has been suspended` }; } + + /** + * Refund a confirmed donation and atomically recalculate the campaign's + * raisedAmount within a single Prisma transaction. + */ + async refundDonation(donationId: string): Promise<{ + id: string; + amount: string; + assetCode: string; + status: string; + campaignId: string; + donorId: string; + txHash: string | null; + refundedAt: Date; + }> { + return this.prisma.$transaction(async (tx) => { + const donation = await tx.donation.findUnique({ + where: { id: donationId }, + }); + + if (!donation) { + throw new NotFoundException('Donation not found'); + } + + if (donation.status !== 'CONFIRMED') { + throw new BadRequestException( + `Only confirmed donations can be refunded. Current status: ${donation.status}`, + ); + } + + const updated = await tx.donation.update({ + where: { id: donationId }, + data: { status: 'REFUNDED' }, + }); + + // Recalculate campaign raisedAmount atomically within the same transaction + const agg = await tx.donation.aggregate({ + where: { + campaignId: donation.campaignId, + status: 'CONFIRMED', + }, + _sum: { amount: true }, + }); + + const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); + + await tx.campaign.update({ + where: { id: donation.campaignId }, + data: { raisedAmount }, + }); + + return { + id: updated.id, + amount: updated.amount.toString(), + assetCode: updated.assetCode, + status: updated.status, + campaignId: updated.campaignId, + donorId: updated.donorId, + txHash: updated.txHash, + refundedAt: updated.updatedAt, + }; + }); + } } diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 473f8e7..6af20e1 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -244,21 +244,27 @@ export class CampaignsService { }; } - /** Recalculate a campaign's raisedAmount from confirmed donations */ + /** + * Recalculate a campaign's raisedAmount from confirmed donations. + * Uses a Prisma $transaction to ensure the aggregate read and campaign + * update happen atomically. + */ async recalculateCampaignStats(campaignId: string) { - const agg = await this.prisma.donation.aggregate({ - where: { - campaignId, - status: 'CONFIRMED', - }, - _sum: { amount: true }, - }); + await this.prisma.$transaction(async (tx) => { + const agg = await tx.donation.aggregate({ + where: { + campaignId, + status: 'CONFIRMED', + }, + _sum: { amount: true }, + }); - const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); + const raisedAmount = agg._sum.amount ?? new Prisma.Decimal(0); - await this.prisma.campaign.update({ - where: { id: campaignId }, - data: { raisedAmount }, + await tx.campaign.update({ + where: { id: campaignId }, + data: { raisedAmount }, + }); }); } diff --git a/src/donations/donations.service.ts b/src/donations/donations.service.ts index 8ffe903..6f0be55 100644 --- a/src/donations/donations.service.ts +++ b/src/donations/donations.service.ts @@ -159,6 +159,8 @@ export class DonationsService { if (!donation) return false; + const previousStatus = donation.status; + const { rpc: sorobanRpc } = await import('@stellar/stellar-sdk'); const server = new sorobanRpc.Server('https://soroban-rpc.stellar.org'); const response = await server.getTransaction(txHash); @@ -169,6 +171,11 @@ export class DonationsService { data: { status: 'CONFIRMED', confirmedAt: new Date() }, }); + // If the donation was not already CONFIRMED, recalculate campaign stats + if (previousStatus !== 'CONFIRMED') { + await this.campaigns.recalculateCampaignStats(donation.campaignId); + } + const updated = await this.prisma.donation.findUnique({ where: { txHash }, include: { tip: true }, @@ -184,6 +191,11 @@ export class DonationsService { return true; } + // Only flip to FAILED if it's not already CONFIRMED + if (previousStatus === 'CONFIRMED') { + return false; + } + await this.prisma.donation.update({ where: { txHash }, data: { status: 'FAILED' }, From 097cf2889ab6d940cc272248db0c55f17c845450 Mon Sep 17 00:00:00 2001 From: Iyanu Majekodunmi Date: Mon, 22 Jun 2026 22:38:41 +0000 Subject: [PATCH 2/2] ci: fix format check and add PostgreSQL/Redis services for e2e tests --- .github/workflows/ci.yml | 40 +++++++++++++++++++++++++ src/admin/admin.controller.ts | 11 ++++--- src/admin/admin.service.spec.ts | 6 +++- src/api-keys/api-keys.controller.ts | 8 ++++- src/campaigns/campaigns.controller.ts | 15 ++++++---- src/campaigns/campaigns.module.ts | 7 ++++- src/campaigns/campaigns.service.ts | 18 +++++++++-- src/donations/donations.module.ts | 7 ++++- src/donations/donations.service.ts | 41 +++++++++++++------------- src/milestones/milestones.service.ts | 6 ++-- src/queue/queue-maintenance.service.ts | 20 +++++++++---- src/stellar/soroban.service.ts | 4 ++- src/stellar/stellar-event.service.ts | 11 +++++-- src/users/users.service.ts | 3 +- 14 files changed, 150 insertions(+), 47 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaf1015..8b85c05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,33 @@ jobs: test-e2e: name: Test (e2e) runs-on: ubuntu-latest + timeout-minutes: 15 + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: orbitchain + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout uses: actions/checkout@v4 @@ -99,8 +126,21 @@ jobs: - name: Install run: npm ci + - name: Generate Prisma client + run: npx prisma generate + + - name: Push database schema + run: npx prisma db push + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbitchain?schema=public + - name: Jest e2e run: npm run test:e2e + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/orbitchain?schema=public + REDIS_URL: redis://localhost:6379 + JWT_SECRET: test-secret + NODE_ENV: test prisma-validate: name: Prisma validate (optional) diff --git a/src/admin/admin.controller.ts b/src/admin/admin.controller.ts index 5d81983..7350436 100644 --- a/src/admin/admin.controller.ts +++ b/src/admin/admin.controller.ts @@ -28,7 +28,12 @@ export class AdminController { @Body() dto: SuspendCampaignDto, @Request() req: any, ): Promise<{ message: string }> { - return this.adminService.suspendCampaign(id, dto, req.user.sub, req.user.email); + return this.adminService.suspendCampaign( + id, + dto, + req.user.sub, + req.user.email, + ); } /** @@ -38,9 +43,7 @@ export class AdminController { */ @Post('donations/:id/refund') @HttpCode(HttpStatus.OK) - async refundDonation( - @Param('id', ParseUUIDPipe) id: string, - ): Promise<{ + async refundDonation(@Param('id', ParseUUIDPipe) id: string): Promise<{ id: string; amount: string; assetCode: string; diff --git a/src/admin/admin.service.spec.ts b/src/admin/admin.service.spec.ts index cc87a04..f4f3f6c 100644 --- a/src/admin/admin.service.spec.ts +++ b/src/admin/admin.service.spec.ts @@ -131,7 +131,11 @@ describe('AdminService', () => { }); expect(txMock.campaign.update).toHaveBeenCalledWith({ where: { id: 'campaign-1' }, - data: { raisedAmount: expect.objectContaining({ toString: expect.any(Function) }) }, + data: { + raisedAmount: expect.objectContaining({ + toString: expect.any(Function), + }), + }, }); }); diff --git a/src/api-keys/api-keys.controller.ts b/src/api-keys/api-keys.controller.ts index a5e17b1..7a2c195 100644 --- a/src/api-keys/api-keys.controller.ts +++ b/src/api-keys/api-keys.controller.ts @@ -27,7 +27,13 @@ export class ApiKeysController { /** POST /api-keys — Generate a new API key (returns raw key only once) */ @Post() - async create(@Req() req: Request & { user: JwtUser }): Promise<{ id: string; key: string; prefix: string; scope: string; createdAt: Date }> { + async create(@Req() req: Request & { user: JwtUser }): Promise<{ + id: string; + key: string; + prefix: string; + scope: string; + createdAt: Date; + }> { const rawKey = `sk_${randomBytes(32).toString('hex')}`; const prefix = rawKey.slice(0, 12); const keyHash = createHash('sha256').update(rawKey).digest('hex'); diff --git a/src/campaigns/campaigns.controller.ts b/src/campaigns/campaigns.controller.ts index 00b02e0..7114490 100644 --- a/src/campaigns/campaigns.controller.ts +++ b/src/campaigns/campaigns.controller.ts @@ -25,10 +25,16 @@ import { CreateCampaignDto } from './dto/create-campaign.dto'; import { Request } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { AdminGuard } from '../users/guards/admin.guard'; -import { BrowseCampaignsQueryDto, BrowseCampaignsResponseDto } from './dto/browse-campaigns.dto'; +import { + BrowseCampaignsQueryDto, + BrowseCampaignsResponseDto, +} from './dto/browse-campaigns.dto'; import { DonationsService } from '../donations/donations.service'; import { ContractBalanceResponseDto } from './dto/contract-balance.dto'; -import { GetCampaignDonationsQueryDto, GetCampaignDonationsResponseDto } from '../donations/dto/get-campaign-donations.dto'; +import { + GetCampaignDonationsQueryDto, + GetCampaignDonationsResponseDto, +} from '../donations/dto/get-campaign-donations.dto'; import { CreateUpdateDto } from './dto/create-update.dto'; const FORBIDDEN_FIELDS = [ @@ -92,9 +98,8 @@ export class CampaignsController { ): Promise { const cacheKey = this.generateCacheKey(query); - const cached = await this.cacheManager.get( - cacheKey, - ); + const cached = + await this.cacheManager.get(cacheKey); if (cached) { return cached; } diff --git a/src/campaigns/campaigns.module.ts b/src/campaigns/campaigns.module.ts index 8aeb37a..897ec1a 100644 --- a/src/campaigns/campaigns.module.ts +++ b/src/campaigns/campaigns.module.ts @@ -13,7 +13,12 @@ import { DonationsModule } from '../donations/donations.module'; /** Module providing campaign CRUD, browsing, featured campaigns, and stats */ @Module({ - imports: [PrismaModule, AuthModule, forwardRef(() => DonationsModule), StellarModule], + imports: [ + PrismaModule, + AuthModule, + forwardRef(() => DonationsModule), + StellarModule, + ], controllers: [CampaignsController, AdminCampaignsController], providers: [CampaignsService, JwtAuthGuard, AdminGuard], exports: [CampaignsService], diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 6af20e1..cc87ae9 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -29,7 +29,9 @@ export class CampaignsService { */ async createCampaign(userId: string, dto: CreateCampaignDto) { if (!dto.goalAmount || parseFloat(dto.goalAmount) <= 0) { - throw new BadRequestException('goalAmount is required and must be greater than 0'); + throw new BadRequestException( + 'goalAmount is required and must be greater than 0', + ); } const milestoneCreates = (dto.milestones || []).map((m) => ({ title: m.title, @@ -320,7 +322,9 @@ export class CampaignsService { throw new NotFoundException(`Campaign ${campaignId} not found`); } if (campaign.creatorId !== userId) { - throw new ForbiddenException('Only the campaign creator can post updates'); + throw new ForbiddenException( + 'Only the campaign creator can post updates', + ); } return this.prisma.update.create({ @@ -380,7 +384,15 @@ export class CampaignsService { const uniqueAssets = [...new Set(donations.map((d) => d.assetCode))]; const avgDonation = donations.length ? totalRaised / donations.length : 0; - return { campaignId, totalRaised, donorCount, uniqueAssets, avgDonation, donationsPerDay: [], topDonors: [] }; + return { + campaignId, + totalRaised, + donorCount, + uniqueAssets, + avgDonation, + donationsPerDay: [], + topDonors: [], + }; } private async browseCampaignsWithFullTextSearch(input: { diff --git a/src/donations/donations.module.ts b/src/donations/donations.module.ts index 235c03e..e9cc740 100644 --- a/src/donations/donations.module.ts +++ b/src/donations/donations.module.ts @@ -10,7 +10,12 @@ import { AdminTipsController } from './admin-tips.controller'; /** Module providing donation creation, verification, history, and CSV export */ @Module({ - imports: [PrismaModule, AuthModule, StellarModule, forwardRef(() => CampaignsModule)], + imports: [ + PrismaModule, + AuthModule, + StellarModule, + forwardRef(() => CampaignsModule), + ], controllers: [DonationsController, AdminTipsController], providers: [DonationsService, JwtAuthGuard], exports: [DonationsService], diff --git a/src/donations/donations.service.ts b/src/donations/donations.service.ts index 6f0be55..d0a4cdc 100644 --- a/src/donations/donations.service.ts +++ b/src/donations/donations.service.ts @@ -299,28 +299,29 @@ export class DonationsService { }); if (!campaign) throw new NotFoundException('Campaign not found'); - const skip = (page - 1) * limit; const total = await this.prisma.donation.count({ - where: { campaignId, status: 'CONFIRMED' }, - }); + const skip = (page - 1) * limit; + const total = await this.prisma.donation.count({ + where: { campaignId, status: 'CONFIRMED' }, + }); - const donations = await this.prisma.donation.findMany({ - where: { campaignId, status: 'CONFIRMED' }, - include: { donor: { select: { walletAddress: true } } }, - orderBy: { [sortBy]: order }, - skip, - take: limit, - }); + const donations = await this.prisma.donation.findMany({ + where: { campaignId, status: 'CONFIRMED' }, + include: { donor: { select: { walletAddress: true } } }, + orderBy: { [sortBy]: order }, + skip, + take: limit, + }); - const donationsWithRank = donations.map((donation, index) => ({ - rank: skip + index + 1, - walletAddress: donation.isAnonymous - ? 'Anonymous' - : (donation.donor?.walletAddress ?? 'Anonymous'), - amount: donation.amount.toString(), - assetCode: donation.assetCode, - createdAt: donation.createdAt, - txHash: donation.txHash, - })); + const donationsWithRank = donations.map((donation, index) => ({ + rank: skip + index + 1, + walletAddress: donation.isAnonymous + ? 'Anonymous' + : (donation.donor?.walletAddress ?? 'Anonymous'), + amount: donation.amount.toString(), + assetCode: donation.assetCode, + createdAt: donation.createdAt, + txHash: donation.txHash, + })); return { donations: donationsWithRank, diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 3ab8720..3a26589 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -230,11 +230,13 @@ export class MilestonesService { for (const stat of stats) { result.total += stat._count; - const status = (stat.status as string).toLowerCase() as keyof typeof result; + const status = ( + stat.status as string + ).toLowerCase() as keyof typeof result; const entry = result[status]; if (entry && typeof entry !== 'number') { entry.count = stat._count ?? 0; - entry.amount = (stat._sum.amount?.toString()) || '0'; + entry.amount = stat._sum.amount?.toString() || '0'; } } diff --git a/src/queue/queue-maintenance.service.ts b/src/queue/queue-maintenance.service.ts index 17fa6d4..fb69a32 100644 --- a/src/queue/queue-maintenance.service.ts +++ b/src/queue/queue-maintenance.service.ts @@ -21,7 +21,8 @@ export class QueueMaintenanceService implements OnModuleInit { constructor( @InjectQueue(QUEUE_EMAIL) private readonly emailQueue: Queue, - @InjectQueue(QUEUE_CONTRACT_EVENTS) private readonly contractEventsQueue: Queue, + @InjectQueue(QUEUE_CONTRACT_EVENTS) + private readonly contractEventsQueue: Queue, @InjectQueue(QUEUE_ANALYTICS) private readonly analyticsQueue: Queue, @InjectQueue(QUEUE_EXPORT) private readonly exportQueue: Queue, private readonly prisma: PrismaService, @@ -57,7 +58,9 @@ export class QueueMaintenanceService implements OnModuleInit { try { await this.pruneFailedJobs(q, name); } catch (err) { - this.logger.error(`Maintenance failed for queue ${name}: ${String(err)}`); + this.logger.error( + `Maintenance failed for queue ${name}: ${String(err)}`, + ); Sentry.captureException(err); } } @@ -80,7 +83,9 @@ export class QueueMaintenanceService implements OnModuleInit { const failed = (counts && (counts as any).failed) || 0; this.deadLetterGauge.set({ queue: name }, failed as number); } catch (err) { - this.logger.warn(`Unable to update dead-letter metric for ${name}: ${String(err)}`); + this.logger.warn( + `Unable to update dead-letter metric for ${name}: ${String(err)}`, + ); } } } @@ -103,7 +108,10 @@ export class QueueMaintenanceService implements OnModuleInit { queueName, jobId: String(job.id), payload: job.data as any, - errorMessage: (job.stacktrace && job.stacktrace.join('\n')) || job.failedReason || null, + errorMessage: + (job.stacktrace && job.stacktrace.join('\n')) || + job.failedReason || + null, failedAt: new Date(job.timestamp || Date.now()), }, }); @@ -122,7 +130,9 @@ export class QueueMaintenanceService implements OnModuleInit { await job.remove(); } } catch (err) { - this.logger.error(`Error pruning job ${job.id} from ${queueName}: ${String(err)}`); + this.logger.error( + `Error pruning job ${job.id} from ${queueName}: ${String(err)}`, + ); Sentry.captureException(err); } } diff --git a/src/stellar/soroban.service.ts b/src/stellar/soroban.service.ts index 2974ff6..e082e71 100644 --- a/src/stellar/soroban.service.ts +++ b/src/stellar/soroban.service.ts @@ -161,7 +161,9 @@ export class SorobanService { const response = await this.server.sendTransaction(finalTx); if (response.status === 'ERROR') { - throw this.parseTxResultError((response as any).errorResultXdr || (response as any).errorResult); + throw this.parseTxResultError( + (response as any).errorResultXdr || (response as any).errorResult, + ); } const txResult = await this.pollTransaction(response.hash); diff --git a/src/stellar/stellar-event.service.ts b/src/stellar/stellar-event.service.ts index d663d67..53ebce1 100644 --- a/src/stellar/stellar-event.service.ts +++ b/src/stellar/stellar-event.service.ts @@ -1,4 +1,9 @@ -import { Injectable, Inject, OnApplicationBootstrap, Logger } from '@nestjs/common'; +import { + Injectable, + Inject, + OnApplicationBootstrap, + Logger, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { Queue } from 'bull'; import { InjectQueue } from '@nestjs/bull'; @@ -228,7 +233,9 @@ export class StellarEventService implements OnApplicationBootstrap { : null; const body = event.body(); const v0 = body?.v0(); - const topics = (v0?.topics() || []).map((t: any) => scValToNative(t)); + const topics = (v0?.topics() || []).map((t: any) => + scValToNative(t), + ); const rawValue = v0?.data(); const value = rawValue ? scValToNative(rawValue) : null; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index a9b7e86..d2ef560 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -319,7 +319,8 @@ export class UsersService { _count: true, }); - const totalDonated: string = totalDonatedResult._sum?.amount?.toString() || '0'; + const totalDonated: string = + totalDonatedResult._sum?.amount?.toString() || '0'; const totalDonations: number = totalDonatedResult._count ?? 0; const averageDonation = totalDonations > 0