Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,10 @@
},
"testEnvironment": "node",
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/src/$1"
"^src/(.*)$": "<rootDir>/src/$1",
"^ioredis$": "<rootDir>/test/mocks/ioredis.mock.ts",
"^@nestjs/bullmq$": "<rootDir>/test/mocks/nestjs-bullmq.mock.ts",
"^@nestjs/bull$": "<rootDir>/test/mocks/nestjs-bull.mock.ts"
},
"modulePaths": ["<rootDir>"]
},
Expand Down
1 change: 1 addition & 0 deletions app/backend/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Deprecated } from './common/decorators/deprecated.decorator';
export class AppController {
constructor(private readonly appService: AppService) {}

@Public()
@Get()
@Version(API_VERSIONS.V1)
@ApiOperation({
Expand Down
2 changes: 2 additions & 0 deletions app/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RolesGuard } from './auth/roles.guard';
import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guard';
import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor';
import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor';
import { PaginationInterceptor } from './common/interceptors/pagination.interceptor';
import { LoggingInterceptor } from './interceptors/logging.interceptor';
import { RequestCorrelationMiddleware } from './middleware/request-correlation.middleware';

Expand Down Expand Up @@ -54,6 +55,7 @@ import { RequestCorrelationMiddleware } from './middleware/request-correlation.m
{ provide: APP_GUARD, useClass: AdaptiveRateLimitGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: DeprecationInterceptor },
{ provide: APP_INTERCEPTOR, useClass: PaginationInterceptor },
// HttpCacheInterceptor runs closest to the route handler to
// observe the raw response body for ETag computation.
{ provide: APP_INTERCEPTOR, useClass: HttpCacheInterceptor },
Expand Down
16 changes: 14 additions & 2 deletions app/backend/src/campaigns/campaigns.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import {
Version,
} from '@nestjs/common';
import type { Response } from 'express';
import {
Pagination,
PaginationDefaults,
PaginationParams,
ApiPaginatedResponse,
} from 'src/common/decorators/pagination.decorator';
import {
ApiBody,
ApiOperation,
Expand Down Expand Up @@ -74,23 +80,29 @@ export class CampaignsController {
@Throttle({ default: { ttl: 60000, limit: 10 } }) // Limit to 10 requests per minute for this endpoint
@HttpCacheTtl(30) // Response cached for 30 seconds
@Get()
@PaginationDefaults({ default: 25, max: 100 })
@ApiOperation({
summary: 'List all campaigns',
description:
"Retrieves campaigns. NGO operators only see their own organization's campaigns.",
})
@ApiOkResponse({ description: 'List of campaigns retrieved successfully.' })
@ApiPaginatedResponse(CreateCampaignDto)
@ApiUnauthorizedResponse({
description: 'Missing or invalid authentication credentials.',
})
async list(
@Query('includeArchived', new DefaultValuePipe(false), ParseBoolPipe)
includeArchived: boolean,
@Req() req: Request,
@Pagination() pagination: PaginationParams,
) {
// Scope to ngoId for NGO role; admins/operators see all
const ngoId = req.user?.role === AppRole.ngo ? req.user.ngoId : undefined;
const campaigns = await this.campaigns.findAll(includeArchived, ngoId);
const campaigns = await this.campaigns.findAll(
includeArchived,
ngoId,
pagination,
);
return ApiResponseDto.ok(campaigns, 'Campaigns fetched successfully');
}

Expand Down
4 changes: 2 additions & 2 deletions app/backend/src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ describe('CampaignsService', () => {
});

const createArgs = prismaMock.campaign.create.mock.calls[0]?.[0];

// Clean match validation instead of strict object equivalence structures
expect(createArgs).toMatchObject({
data: {
Expand Down Expand Up @@ -159,4 +159,4 @@ describe('CampaignsService', () => {
expect(updateArgs?.data).toMatchObject({ deletedAt: expect.any(Date) });
expect(result.deletedAt).not.toBeNull();
});
});
});
29 changes: 22 additions & 7 deletions app/backend/src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreateCampaignDto } from './dto/create-campaign.dto';
import { UpdateCampaignDto } from './dto/update-campaign.dto';
import { ExportCampaignsQueryDto } from './dto/export-campaigns.dto';
import { PaginationParams } from '../common/decorators/pagination.decorator';

export interface CampaignExportRow {
id: string;
Expand Down Expand Up @@ -53,21 +54,35 @@ export class CampaignsService {
});
}

async findAll(includeArchived = false, ngoId?: string | null, page = 1, limit = 50) {
async findAll(
includeArchived = false,
ngoId?: string | null,
pagination: PaginationParams = { limit: 25 },
) {
const { limit, cursor } = pagination;
const take = limit;

const where: Prisma.CampaignWhereInput = {
deletedAt: null,
...(includeArchived ? {} : { archivedAt: null }),
...(ngoId ? { ngoId } : {}),
};
const take = Math.min(200, Math.max(1, limit));
const skip = (Math.max(1, page) - 1) * take;

return this.prisma.campaign.findMany({
const campaigns = await this.prisma.campaign.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take,
orderBy: { id: 'asc' },
take: take + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});

const hasMore = campaigns.length > take;
const items = hasMore ? campaigns.slice(0, take) : campaigns;
const nextCursor = hasMore ? items[items.length - 1].id : null;

return {
data: items,
nextCursor,
};
}

async findOne(id: string) {
Expand Down
8 changes: 1 addition & 7 deletions app/backend/src/claims/claim-export.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Controller,
Get,
Query,
Res,
Version,
} from '@nestjs/common';
import { Controller, Get, Query, Res, Version } from '@nestjs/common';
import type { Response } from 'express';
import {
ApiTags,
Expand Down
85 changes: 58 additions & 27 deletions app/backend/src/claims/claim-lifecycle.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ import {
Param,
Patch,
Request,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Request as ExpressRequest } from 'express';
import {
Pagination,
PaginationDefaults,
PaginationParams,
ApiPaginatedResponse,
} from 'src/common/decorators/pagination.decorator';
import {
ApiTags,
ApiOperation,
Expand All @@ -28,6 +36,7 @@ import { AppRole } from 'src/auth/app-role.enum';
import { InternalNotesService } from 'src/common/services/internal-notes.service';
import { CreateInternalNoteDto } from 'src/common/dto/create-internal-note.dto';
import { InternalNoteResponseDto } from 'src/common/dto/internal-note-response.dto';
import { ApiResponseDto } from '../common/dto/api-response.dto';
import { HttpCacheTtl } from 'src/common/decorators/http-cache.decorator';

@ApiTags('Onchain Proxy')
Expand Down Expand Up @@ -56,22 +65,23 @@ export class ClaimLifecycleController {
@ApiNotFoundResponse({
description: 'The specified campaign was not found.',
})
create(@Body() createClaimDto: CreateClaimDto) {
return this.claimsService.create(createClaimDto);
async create(@Body() createClaimDto: CreateClaimDto) {
const result = await this.claimsService.create(createClaimDto);
return ApiResponseDto.ok(result, 'Claim created successfully');
}

@HttpCacheTtl(30) // Response cached for 30 seconds
@Get()
@PaginationDefaults({ default: 25, max: 100 })
@ApiOperation({
operationId: 'ClaimsController_findAll_v1',
summary: 'List all claims',
description: 'Retrieves a list of all claims across all campaigns.',
})
@ApiOkResponse({
description: 'List of all claims retrieved successfully.',
})
findAll() {
return this.claimsService.findAll();
@ApiPaginatedResponse(CreateClaimDto)
async findAll(@Pagination() pagination: PaginationParams) {
const result = await this.claimsService.findAll(pagination);
return ApiResponseDto.ok(result, 'Claims fetched successfully');
}

@Get(':id')
Expand All @@ -87,12 +97,14 @@ export class ClaimLifecycleController {
@ApiNotFoundResponse({
description: 'The specified claim was not found.',
})
findOne(@Param('id') id: string) {
return this.claimsService.findOne(id);
async findOne(@Param('id') id: string) {
const result = await this.claimsService.findOne(id);
return ApiResponseDto.ok(result, 'Claim details retrieved successfully');
}

@Post(':id/verify')
@Roles(AppRole.operator, AppRole.admin)
@HttpCode(HttpStatus.OK)
@ApiOperation({
operationId: 'ClaimsController_verify_v1',
summary: 'Verify a claim',
Expand All @@ -110,12 +122,14 @@ export class ClaimLifecycleController {
@ApiNotFoundResponse({
description: 'The specified claim was not found.',
})
verify(@Param('id') id: string) {
return this.claimsService.verify(id);
async verify(@Param('id') id: string) {
const result = await this.claimsService.verify(id);
return ApiResponseDto.ok(result, 'Claim verified successfully');
}

@Post(':id/approve')
@Roles(AppRole.admin)
@HttpCode(HttpStatus.OK)
@ApiOperation({
operationId: 'ClaimsController_approve_v1',
summary: 'Approve a claim',
Expand All @@ -133,12 +147,14 @@ export class ClaimLifecycleController {
@ApiNotFoundResponse({
description: 'The specified claim was not found.',
})
approve(@Param('id') id: string) {
return this.claimsService.approve(id);
async approve(@Param('id') id: string) {
const result = await this.claimsService.approve(id);
return ApiResponseDto.ok(result, 'Claim approved successfully');
}

@Post(':id/disburse')
@Roles(AppRole.admin)
@HttpCode(HttpStatus.OK)
@ApiOperation({
operationId: 'ClaimsController_disburse_v1',
summary: 'Disburse funds for a claim',
Expand Down Expand Up @@ -180,8 +196,9 @@ export class ClaimLifecycleController {
@ApiNotFoundResponse({
description: 'The specified claim was not found.',
})
disburse(@Param('id') id: string) {
return this.claimsService.disburse(id);
async disburse(@Param('id') id: string) {
const result = await this.claimsService.disburse(id);
return ApiResponseDto.ok(result, 'Claim disbursed successfully');
}

@Patch(':id/archive')
Expand All @@ -199,8 +216,9 @@ export class ClaimLifecycleController {
@ApiNotFoundResponse({
description: 'The specified claim was not found.',
})
archive(@Param('id') id: string) {
return this.claimsService.archive(id);
async archive(@Param('id') id: string) {
const result = await this.claimsService.archive(id);
return ApiResponseDto.ok(result, 'Claim archived successfully');
}

@Post(':id/notes')
Expand All @@ -217,13 +235,19 @@ export class ClaimLifecycleController {
@ApiForbiddenResponse({
description: 'Access denied - staff role required.',
})
addNote(
async addNote(
@Param('id') id: string,
@Body() dto: CreateInternalNoteDto,
@Request() req: ExpressRequest,
) {
const authorId = req.user?.apiKeyId || req.user?.authType || 'system';
return this.internalNotesService.createNote('claim', id, authorId, dto);
const result = await this.internalNotesService.createNote(
'claim',
id,
authorId,
dto,
);
return ApiResponseDto.ok(result, 'Note added successfully');
}

@Get(':id/notes')
Expand All @@ -240,8 +264,12 @@ export class ClaimLifecycleController {
@ApiForbiddenResponse({
description: 'Access denied - staff role required.',
})
getNotes(@Param('id') id: string) {
return this.internalNotesService.findNotesByEntity('claim', id);
async getNotes(@Param('id') id: string) {
const result = await this.internalNotesService.findNotesByEntity(
'claim',
id,
);
return ApiResponseDto.ok(result, 'Notes retrieved successfully');
}

@Post(':id/cancel')
Expand All @@ -262,8 +290,9 @@ export class ClaimLifecycleController {
description: 'Access denied - operator role required.',
})
@ApiNotFoundResponse({ description: 'Claim not found.' })
cancel(@Param('id') id: string, @Body() dto: CancelClaimDto) {
return this.cancelAndReissueService.cancel(id, dto);
async cancel(@Param('id') id: string, @Body() dto: CancelClaimDto) {
const result = await this.cancelAndReissueService.cancel(id, dto);
return ApiResponseDto.ok(result, 'Claim cancelled successfully');
}

@Post(':id/reissue')
Expand Down Expand Up @@ -300,8 +329,9 @@ export class ClaimLifecycleController {
description: 'Access denied - operator role required.',
})
@ApiNotFoundResponse({ description: 'Original claim not found.' })
reissue(@Param('id') id: string, @Body() dto: ReissueClaimDto) {
return this.cancelAndReissueService.reissue(id, dto);
async reissue(@Param('id') id: string, @Body() dto: ReissueClaimDto) {
const result = await this.cancelAndReissueService.reissue(id, dto);
return ApiResponseDto.ok(result, 'Claim reissued successfully');
}

@Get(':id/reissue-history')
Expand All @@ -322,7 +352,8 @@ export class ClaimLifecycleController {
description: 'Access denied - operator role required.',
})
@ApiNotFoundResponse({ description: 'Claim not found.' })
getReissueHistory(@Param('id') id: string) {
return this.cancelAndReissueService.getReissueHistory(id);
async getReissueHistory(@Param('id') id: string) {
const result = await this.cancelAndReissueService.getReissueHistory(id);
return ApiResponseDto.ok(result, 'Reissue history retrieved successfully');
}
}
8 changes: 1 addition & 7 deletions app/backend/src/claims/claim-receipt.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
import {
Controller,
Get,
Post,
Body,
Param,
} from '@nestjs/common';
import { Controller, Get, Post, Body, Param } from '@nestjs/common';
import {
ApiTags,
ApiOperation,
Expand Down
Loading
Loading