Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,14 @@ Required for this project — invoke before writing relevant code:
- `nestjs-best-practices` — before writing any NestJS module, controller, service, or guard
- `postgres-drizzle` — before writing schema changes, queries, or migrations

## API Documentation

Swagger UI is available at `/api` when `SWAGGER_ENABLED=true` (explicit opt-in; defaults to off).

- `@nestjs/swagger` CLI plugin handles automatic DTO inference — no `@ApiProperty` needed on DTO classes
- Manual annotations are required for: `@ApiTags`, `@ApiHeader`, `@ApiParam`, and error response decorators
- OpenAPI JSON spec available at `/api-json`

## CI

Two GitHub Actions workflows:
Expand Down
10 changes: 9 additions & 1 deletion nest-cli.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"plugins": [
{
"name": "@nestjs/swagger",
"options": {
"introspectComments": true
}
}
]
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"bullmq": "^5.70.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
Expand Down
73 changes: 73 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions src/common/guards/user-id-api.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { applyDecorators } from '@nestjs/common';
import { ApiHeader, ApiUnauthorizedResponse } from '@nestjs/swagger';

export const ApiUserIdHeader = () =>
applyDecorators(
ApiHeader({ name: 'x-user-id', description: 'User UUID', required: true }),
ApiUnauthorizedResponse({
description: 'Missing or invalid X-User-Id header',
}),
);
3 changes: 3 additions & 0 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Controller, Get } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';

@ApiTags('health')
@Controller('health')
export class HealthController {
@Get()
@ApiOperation({ summary: 'Health check' })
health(): { status: string } {
return { status: 'ok' };
}
Expand Down
13 changes: 13 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { Logger } from 'nestjs-pino';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module';

async function bootstrap() {
Expand All @@ -14,6 +15,18 @@ async function bootstrap() {
transform: true,
}),
);
const enableSwagger = process.env.SWAGGER_ENABLED === 'true';
if (enableSwagger) {
const config = new DocumentBuilder()
.setTitle('Wallet API')
.setDescription(
'Digital wallet POC — deposits, purchases, royalties, reports',
)
.setVersion('0.0.1')
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
}
await app.listen(3000);
}
void bootstrap();
24 changes: 24 additions & 0 deletions src/purchases/dto/purchase-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { purchaseStatusEnum } from '../../common/database/schema';

export class PurchaseResponseDto {
/** Purchase UUID */
id!: string;

/** Idempotency key UUID */
idempotencyKey!: string;

/** Buyer wallet UUID */
buyerWalletId!: string;

/** Author wallet UUID */
authorWalletId!: string;

/** Item price in integer units */
itemPrice!: number;

/** Purchase status */
status!: (typeof purchaseStatusEnum.enumValues)[number];

/** Timestamp of purchase creation */
createdAt!: Date;
}
41 changes: 38 additions & 3 deletions src/purchases/purchases.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,59 @@ import {
Controller,
Headers,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiBadRequestResponse,
ApiConflictResponse,
ApiForbiddenResponse,
ApiHeader,
ApiNotFoundResponse,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { PurchasesService } from './purchases.service';
import { UserIdGuard } from '../common/guards/user-id.guard';
import { ApiUserIdHeader } from '../common/guards/user-id-api.decorator';
import { PurchaseBodyDto } from './dto/purchase.dto';
import { UUID_REGEX } from '../common/validation/uuid';
import { PurchaseResponseDto } from './dto/purchase-response.dto';

@ApiTags('purchases')
@ApiUserIdHeader()
@Controller('purchases')
@UseGuards(UserIdGuard)
export class PurchasesController {
constructor(private readonly purchasesService: PurchasesService) {}

@Post()
@ApiOperation({ summary: 'Purchase an item' })
@ApiHeader({
name: 'idempotency-key',
description: 'UUID for idempotent request',
required: true,
})
@ApiBadRequestResponse({
description:
'Self-purchase / invalid idempotency key / author or platform wallet missing',
})
// 402 has no named @nestjs/swagger decorator; raw ApiResponse is intentional
@ApiResponse({ status: 402, description: 'Insufficient funds' })
@ApiForbiddenResponse({
description: 'Buyer wallet does not belong to authenticated user',
})
@ApiNotFoundResponse({ description: 'Buyer wallet not found' })
@ApiConflictResponse({
description: 'Idempotency conflict or deadlock — retry',
})
purchase(
@Headers('x-user-id') userId: string,
@Req() req: Request & { userId: string },
@Headers('idempotency-key') idempotencyKey: string,
@Body() dto: PurchaseBodyDto,
) {
): Promise<PurchaseResponseDto> {
if (!idempotencyKey || !UUID_REGEX.test(idempotencyKey)) {
throw new BadRequestException(
'Idempotency-Key header must be a valid UUID',
Expand All @@ -33,7 +68,7 @@ export class PurchasesController {
buyerWalletId: dto.buyerWalletId,
authorWalletId: dto.authorWalletId,
itemPrice: dto.itemPrice,
requestUserId: userId,
requestUserId: req.userId,
});
}
}
9 changes: 9 additions & 0 deletions src/reports/dto/report-request-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { reportStatusEnum } from '../../common/database/schema';

export class ReportRequestResponseDto {
/** Report job UUID */
jobId!: string;

/** Current job status */
status!: (typeof reportStatusEnum.enumValues)[number];
}
6 changes: 6 additions & 0 deletions src/reports/dto/report-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { ReportRequestResponseDto } from './report-request-response.dto';

export class ReportResponseDto extends ReportRequestResponseDto {
/** Report data when completed, null otherwise */
result!: unknown;
}
25 changes: 23 additions & 2 deletions src/reports/reports.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,45 @@ import {
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import {
ApiBadRequestResponse,
ApiCreatedResponse,
ApiNotFoundResponse,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { UserIdGuard } from '../common/guards/user-id.guard';
import { ApiUserIdHeader } from '../common/guards/user-id-api.decorator';
import { ReportRequestResponseDto } from './dto/report-request-response.dto';
import { ReportResponseDto } from './dto/report-response.dto';

@ApiTags('reports')
@ApiUserIdHeader()
@Controller('reports')
@UseGuards(UserIdGuard)
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}

@Post('financial')
requestReport(@Req() req: Request & { userId: string }) {
@ApiOperation({ summary: 'Request a financial report' })
@ApiCreatedResponse({ description: 'Report queued for generation' })
@ApiBadRequestResponse({ description: 'User does not exist' })
requestReport(
@Req() req: Request & { userId: string },
): Promise<ReportRequestResponseDto> {
return this.reportsService.requestReport(req.userId);
}

@Get('financial/:jobId')
@ApiOperation({ summary: 'Get report status and result' })
@ApiParam({ name: 'jobId', format: 'uuid' })
@ApiNotFoundResponse({ description: 'Report not found' })
getReport(
@Param('jobId', ParseUUIDPipe) jobId: string,
@Req() req: Request & { userId: string },
) {
): Promise<ReportResponseDto> {
return this.reportsService.getReport(jobId, req.userId);
}
}
Loading
Loading