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
1 change: 1 addition & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@nestjs/platform-socket.io": "^10.4.22",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/terminus": "^10.0.0",
"@nestjs/websockets": "^10.4.22",
"cache-manager": "^5.7.6",
"class-transformer": "^0.5.1",
Expand Down
1,017 changes: 1,007 additions & 10 deletions api/pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler"
import { AdminModule } from "./admin/admin.module"
import { AuditModule } from "./audit/audit.module"
import { GatewaysModule } from "./gateways/gateways.module"
import { HealthController } from "./health/health.controller"
import { HealthModule } from "./health/health.module"
import { TagsModule } from "./tags/tags.module"

@Module({
Expand All @@ -18,9 +18,9 @@ import { TagsModule } from "./tags/tags.module"
AdminModule,
AuditModule,
GatewaysModule,
HealthModule,
TagsModule,
],
controllers: [HealthController],
providers: [
{
provide: APP_GUARD,
Expand Down
10 changes: 5 additions & 5 deletions api/src/audit/admin-audit.controller.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { Controller, Get, Query } from "@nestjs/common"
import { AuditService } from "./audit.service"
import { PaginationQueryDto } from "../common/dto/pagination.dto"

@Controller("admin/audit-logs")
export class AdminAuditController {
constructor(private readonly auditService: AuditService) {}

@Get()
findAll(
@Query("limit") limit = "100",
@Query("offset") offset = "0",
) {
return this.auditService.findAll(parseInt(limit), parseInt(offset))
async findAll(@Query() query: PaginationQueryDto) {
const page = query.page ?? 1
const limit = query.limit ?? 20
return this.auditService.findAll(page, limit)
}
}
15 changes: 13 additions & 2 deletions api/src/audit/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,22 @@ export class AuditService {
)
}

async findAll(limit = 100, offset = 0) {
async findAll(page = 1, limit = 20) {
const offset = (page - 1) * limit
const totalResult = await this.pool.query(
"SELECT COUNT(*)::int AS total FROM audit_logs",
)
const total = totalResult.rows[0]?.total ?? 0
const { rows } = await this.pool.query(
"SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2",
[limit, offset],
)
return rows

return {
data: rows,
total,
page,
limit,
}
}
}
35 changes: 35 additions & 0 deletions api/src/common/dto/pagination.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { ApiPropertyOptional } from "@nestjs/swagger"
import { Type } from "class-transformer"
import { IsInt, IsOptional, Max, Min } from "class-validator"

export class PaginationQueryDto {
@ApiPropertyOptional({
description: "Page number to return. 1-indexed.",
example: 1,
default: 1,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be >= 1" })
page?: number = 1

@ApiPropertyOptional({
description: "Number of items per page. Maximum 100.",
example: 20,
default: 20,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be >= 1" })
@Max(100, { message: "limit must be <= 100" })
limit?: number = 20
}

export interface PaginatedResult<T> {
total: number
page: number
limit: number
data: T[]
}
21 changes: 21 additions & 0 deletions api/src/health/database.health-indicator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { HealthCheckError, HealthIndicator, HealthIndicatorResult } from "@nestjs/terminus"
import { Injectable, OnModuleDestroy } from "@nestjs/common"
import { Pool } from "pg"

@Injectable()
export class DatabaseHealthIndicator extends HealthIndicator implements OnModuleDestroy {
private readonly pool = new Pool({ connectionString: process.env.DATABASE_URL })

async isHealthy(key: string): Promise<HealthIndicatorResult> {
try {
await this.pool.query("SELECT 1")
return this.getStatus(key, true)
} catch (error) {
throw new HealthCheckError("database", error)
}
}

async onModuleDestroy(): Promise<void> {
await this.pool.end()
}
}
26 changes: 22 additions & 4 deletions api/src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Controller, Get } from "@nestjs/common"
import { Controller, Get, ServiceUnavailableException } from "@nestjs/common"
import { ApiOkResponse, ApiOperation, ApiProperty, ApiTags } from "@nestjs/swagger"
import { HealthCheckService } from "@nestjs/terminus"
import { SkipThrottle } from "@nestjs/throttler"
import { DatabaseHealthIndicator } from "./database.health-indicator"

export class HealthCheckResponseDto {
@ApiProperty({
Expand All @@ -19,15 +22,30 @@ export class HealthCheckResponseDto {
@ApiTags("health")
@Controller("health")
export class HealthController {
constructor(
private readonly healthCheckService: HealthCheckService,
private readonly databaseHealthIndicator: DatabaseHealthIndicator,
) {}

@Get()
@SkipThrottle()
@ApiOperation({
summary: "Liveness probe",
description:
"Returns a fixed `ok` status and the current server timestamp. " +
"Intended for use by load balancers and orchestrators.",
"Also verifies the database connection for liveness checks.",
})
@ApiOkResponse({ type: HealthCheckResponseDto })
check(): HealthCheckResponseDto {
return { status: "ok", timestamp: new Date().toISOString() }
async check(): Promise<HealthCheckResponseDto> {
try {
await this.healthCheckService.check([
async () => this.databaseHealthIndicator.isHealthy("database"),
])
return { status: "ok", timestamp: new Date().toISOString() }
} catch (error) {
throw new ServiceUnavailableException(
"Database connectivity check failed.",
)
}
}
}
11 changes: 11 additions & 0 deletions api/src/health/health.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common"
import { TerminusModule } from "@nestjs/terminus"
import { DatabaseHealthIndicator } from "./database.health-indicator"
import { HealthController } from "./health.controller"

@Module({
imports: [TerminusModule],
controllers: [HealthController],
providers: [DatabaseHealthIndicator],
})
export class HealthModule {}
18 changes: 2 additions & 16 deletions api/src/tags/dto/list-tags.query.dto.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,8 @@
import { Type } from "class-transformer"
import { IsInt, IsOptional, Max, Min } from "class-validator"
import { PaginationQueryDto } from "../../common/dto/pagination.dto"

/**
* Query parameters for the public `GET /tags` endpoint.
*
* `page` is 1-indexed; `limit` is capped at 100 to keep payloads bounded.
*/
export class ListTagsQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be >= 1" })
page?: number = 1

@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be >= 1" })
@Max(100, { message: "limit must be <= 100" })
limit?: number = 20
}
export class ListTagsQueryDto extends PaginationQueryDto {}
9 changes: 3 additions & 6 deletions api/src/tags/tags.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,12 @@ import {
Injectable,
NotFoundException,
} from "@nestjs/common"
import { PaginatedResult } from "../common/dto/pagination.dto"
import { TagsRepository } from "./repository/tags.repository"
import { slugify } from "./slugify"
import { Tag } from "./tag.entity"

export interface PagedTags {
items: Tag[]
page: number
limit: number
total: number
export interface PagedTags extends PaginatedResult<Tag> {
hasMore: boolean
}

Expand All @@ -22,7 +19,7 @@ export class TagsService {
list(page: number, limit: number): PagedTags {
const { items, total } = this.tags.listPaginated(page, limit)
return {
items,
data: items,
page,
limit,
total,
Expand Down
Loading