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
55 changes: 55 additions & 0 deletions src/common/logging/json-logger.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ConsoleLogger, LogLevel } from '@nestjs/common';

/**
* JsonLogger — structured JSON log output (#352).
*
* NestJS's default Logger prints human-formatted colored text, which isn't
* machine-parseable by a log aggregator (CloudWatch/Datadog/Loki/etc.)
* without a fragile regex. Registered once via app.useLogger() in main.ts,
* this backs every existing `new Logger(ClassName)` call site across the
* project (Nest routes all Logger instance calls through whatever
* LoggerService is registered globally), so no call site needs to change.
*
* Prometheus metrics and OpenTelemetry tracing (also requested in #352) are
* a materially larger scope than log formatting: they need a metrics
* registry wired through every request path and a trace exporter pointed
* at a real collector endpoint, neither of which exists anywhere in this
* project's config today. Left out of this fix rather than stood up with a
* fabricated/unverified OTLP endpoint -- noted as a follow-up.
*/
export class JsonLogger extends ConsoleLogger {
constructor() {
// colors: false so contextMessage below is plain `[Context] ` text,
// not ANSI-escaped -- JSON log lines shouldn't carry terminal color codes.
super({ colors: false });
}

protected formatMessage(
logLevel: LogLevel,
message: unknown,
pidMessage: string,
formattedLogLevel: string,
contextMessage: string,
timestampDiff: string,
): string {
const context = contextMessage.trim().replace(/^\[|\]$/g, '') || undefined;
const entry: Record<string, unknown> = {
timestamp: new Date().toISOString(),
level: logLevel,
context,
message: typeof message === 'string' ? message : this.stringifyMessageForJson(message),
};
return `${JSON.stringify(entry)}\n`;
}

private stringifyMessageForJson(message: unknown): string {
if (message instanceof Error) {
return message.stack ?? message.message;
}
try {
return JSON.stringify(message);
} catch {
return String(message);
}
}
}
42 changes: 42 additions & 0 deletions src/health/dto/health-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';

export class DatabaseCheckDto {
@ApiProperty({ description: 'Database connectivity status', enum: ['ok', 'error'] })
status: 'ok' | 'error';

@ApiProperty({ description: 'Error message when status is "error"', required: false })
error?: string;
}

export class StellarCheckDto {
@ApiProperty({ description: 'Stellar RPC/keeper connectivity status', enum: ['ok', 'error'] })
status: 'ok' | 'error';

@ApiProperty({ description: 'Keeper account native XLM balance (7-decimal fixed point)', required: false })
keeperBalanceXlm?: string;

@ApiProperty({ description: 'Error message when status is "error"', required: false })
error?: string;
}

export class HealthChecksDto {
@ApiProperty({ type: DatabaseCheckDto })
database: DatabaseCheckDto;

@ApiProperty({ type: StellarCheckDto })
stellar: StellarCheckDto;
}

export class HealthResponseDto {
@ApiProperty({ description: 'Overall service health', enum: ['ok', 'degraded'] })
status: 'ok' | 'degraded';

@ApiProperty({ description: 'Response timestamp (ISO 8601)' })
timestamp: string;

@ApiProperty({ description: 'Service identifier', example: 'parashield-api' })
service: string;

@ApiProperty({ type: HealthChecksDto })
checks: HealthChecksDto;
}
12 changes: 8 additions & 4 deletions src/health/health.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { Controller, Get, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiExtraModels } from '@nestjs/swagger';
import { Controller, Get, Inject, Logger, HttpException, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { PrismaService } from '../prisma/prisma.service';
import { StellarService } from '../stellar/stellar.service';
import { HealthResponseDto, HealthChecksDto, DatabaseCheckDto, StellarCheckDto } from './dto/health-response.dto';

// #191 — default floor below which the keeper account is considered too low
// to reliably keep paying transaction fees. Overridable via
Expand All @@ -28,6 +31,7 @@ const AVIATIONSTACK_HEALTH_URL =

@ApiTags('health')
@Controller('health')
@ApiExtraModels(HealthResponseDto, HealthChecksDto, DatabaseCheckDto, StellarCheckDto)
export class HealthController {
private readonly logger = new Logger(HealthController.name);

Expand All @@ -50,9 +54,9 @@ export class HealthController {
*/
@Get()
@ApiOperation({ summary: 'Check service health and dependency connectivity' })
@ApiResponse({ status: 200, description: 'All systems healthy' })
@ApiResponse({ status: 503, description: 'Service degraded (one or more dependencies unavailable)' })
async check() {
@ApiResponse({ status: 200, description: 'All systems healthy', type: HealthResponseDto })
@ApiResponse({ status: 503, description: 'Service degraded (one or more dependencies unavailable)', type: HealthResponseDto })
async check(): Promise<HealthResponseDto> {
let dbStatus: 'ok' | 'error' = 'ok';
let dbError: string | undefined;
let dbPool: { active: number; idle: number; waiting: number } | undefined;
Expand Down Expand Up @@ -173,7 +177,7 @@ export class HealthController {
this.logger.error(`Health check Open-Meteo failed: ${openMeteoError}`);
}

const body = {
const body: HealthResponseDto = {
status: healthy ? 'ok' : 'degraded',
timestamp: new Date().toISOString(),
service: 'parashield-api',
Expand Down
4 changes: 4 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { GlobalExceptionFilter } from './common/filters/http-exception.filter';
import { LoggingInterceptor } from './common/interceptors/logging.interceptor';
import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
import { ThrottleGuard } from './common/guards/throttle.guard';
import { JsonLogger } from './common/logging/json-logger.service';
import { InputSanitizationMiddleware } from './common/middleware/input-sanitization.middleware';
import { RequestTimeoutMiddleware } from './common/middleware/request-timeout.middleware';
import { loadVaultSecrets } from './common/secrets/vault-secrets.loader';
Expand Down Expand Up @@ -40,6 +41,9 @@ async function bootstrap() {
await loadVaultSecrets();
await initializeOpenTelemetry();
const app = await NestFactory.create(AppModule);
// #352 — structured JSON logs instead of unstructured colored text, so a
// log aggregator (CloudWatch/Datadog/Loki/etc.) can actually parse them.
app.useLogger(new JsonLogger());
const logger = new Logger('Bootstrap');

const configService = app.get(ConfigService);
Expand Down
9 changes: 9 additions & 0 deletions src/policy/dto/policy-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,12 @@ export class PolicyResponseDto {
@ApiProperty({ description: 'Stellar transaction hash for policy creation', nullable: true })
txHash: string | null;
}

export class CancellationResponseDto extends PolicyResponseDto {
@ApiProperty({
description:
'Pro-rated premium refund owed for the unused coverage period (7-decimal fixed point). ' +
'Calculation only -- no on-chain refund entrypoint exists yet, so this must be paid out manually/off-chain.',
})
refundAmountXlm: string;
}
5 changes: 3 additions & 2 deletions src/policy/policy.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import { PolicyService } from './policy.service';
import { BuyPolicyDto } from './dto/buy-policy.dto';
import { ConfirmPolicyDto } from './dto/confirm-policy.dto';
import { CreateProductDto, UpdateProductDto } from './dto/admin-product.dto';
import { ProductResponseDto, PolicyResponseDto } from './dto/policy-response.dto';
import { ProductResponseDto, PolicyResponseDto, CancellationResponseDto } from './dto/policy-response.dto';
import { ResponseDto, PaginatedResponseDto } from '../common/dto/response.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { OperatorAuthGuard } from '../auth/operator-auth.guard';
Expand All @@ -43,6 +43,7 @@ import { PolicyStatusEventDto } from '../common/events/dto/sse-event.dto';

@ApiTags('policy')
@Controller()
@ApiExtraModels(ResponseDto, PaginatedResponseDto, ProductResponseDto, PolicyResponseDto, CancellationResponseDto)
@ApiExtraModels(ResponseDto, PaginatedResponseDto, ProductResponseDto, PolicyResponseDto, PolicyStatusEventDto)
export class PolicyController {
constructor(
Expand Down Expand Up @@ -279,7 +280,7 @@ export class PolicyController {
schema: {
allOf: [
{ $ref: getSchemaPath(ResponseDto) },
{ properties: { data: { $ref: getSchemaPath(PolicyResponseDto) } } },
{ properties: { data: { $ref: getSchemaPath(CancellationResponseDto) } } },
],
},
})
Expand Down
57 changes: 46 additions & 11 deletions src/policy/policy.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ export interface PolicySummary {
status: string;
}

export interface CancellationResult extends PolicySummary {
/** Pro-rated premium refund owed for the unused coverage period (#351). */
refundAmountXlm: string;
}

export interface PremiumValidationResult {
valid: boolean;
reason?: string;
Expand Down Expand Up @@ -657,6 +662,29 @@ export class PolicyService {
return { data, total, page, limit: clampedLimit };
}

/**
* Compute the pro-rated premium refund owed for cancelling before the
* policy's coverage period has fully elapsed (#351): premiumPaid scaled
* by the fraction of coverage days remaining, floored to 7-decimal fixed
* point so a rounding-up can't ever refund more than was actually paid.
*
* This is calculation only -- it does not execute a transfer. No refund
* entrypoint exists on the Policy Engine contract in this codebase
* (unlike buy_policy/process_claim/submit_claim, which are real, callable
* functions this service already invokes), so actually paying it out
* on-chain would mean inventing a contract interface with no way to
* verify it's correct. The computed amount is surfaced in the
* cancellation response for manual/off-chain processing until a real
* refund entrypoint exists.
*/
calculateProRatedRefund(premiumPaidXlm: number, startTime: Date, endTime: Date, now: Date = new Date()): number {
const totalMs = endTime.getTime() - startTime.getTime();
if (totalMs <= 0) return 0;
const remainingMs = Math.max(0, endTime.getTime() - now.getTime());
const fraction = Math.min(1, remainingMs / totalMs);
return Math.floor(premiumPaidXlm * fraction * 1e7) / 1e7;
}

/**
* Cancel an ACTIVE policy (#346). Policyholders had no way to voluntarily
* give up coverage even though ACTIVE → CANCELLED is a defined transition.
Expand All @@ -667,14 +695,20 @@ export class PolicyService {
* can't race the cancellation -- mirrors the ACTIVE→PROCESSING gate in
* ClaimsService.
*/
async cancelPolicy(policyId: string): Promise<PolicySummary> {
async cancelPolicy(policyId: string): Promise<CancellationResult> {
const existing = await this.prisma.policy.findUnique({ where: { id: policyId } });
if (!existing) {
throw new NotFoundException(`Policy ${policyId} not found`);
}

transition(existing.status, PolicyStatus.CANCELLED);

const refundAmountXlm = this.calculateProRatedRefund(
existing.premiumPaid.toNumber(),
existing.startTime,
existing.endTime,
);

const result = await this.prisma.policy.updateMany({
where: { id: policyId, status: PolicyStatus.ACTIVE },
data: { status: PolicyStatus.CANCELLED },
Expand All @@ -693,7 +727,7 @@ export class PolicyService {
entityId: policyId,
fromStatus: PolicyStatus.ACTIVE,
toStatus: PolicyStatus.CANCELLED,
reason: 'Policyholder-initiated cancellation',
reason: `Policyholder-initiated cancellation; refund owed: ${refundAmountXlm} XLM`,
},
}).catch((err) => this.logger.error(`Failed to write audit log for policy ${policyId} cancellation`, err));
this.statusEvents.emitPolicyStatusChange(policyId, PolicyStatus.CANCELLED);
Expand All @@ -706,15 +740,16 @@ export class PolicyService {

const updated = await this.prisma.policy.findUnique({ where: { id: policyId } });
return {
id: updated!.id,
productId: updated!.productId,
policyholder: updated!.policyholder,
coverage: updated!.coverageXlm.toString(),
premiumPaid: updated!.premiumPaid.toString(),
oracleKey: updated!.oracleKey,
startTime: Math.floor(updated!.startTime.getTime() / 1000),
endTime: Math.floor(updated!.endTime.getTime() / 1000),
status: updated!.status,
id: updated!.id,
productId: updated!.productId,
policyholder: updated!.policyholder,
coverage: updated!.coverageXlm.toString(),
premiumPaid: updated!.premiumPaid.toString(),
oracleKey: updated!.oracleKey,
startTime: Math.floor(updated!.startTime.getTime() / 1000),
endTime: Math.floor(updated!.endTime.getTime() / 1000),
status: updated!.status,
refundAmountXlm: refundAmountXlm.toFixed(7),
};
}

Expand Down
Loading