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
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@ COPY --from=builder /app/entrypoint.sh ./entrypoint.sh
RUN chmod +x ./entrypoint.sh
EXPOSE 3001
CMD ["./entrypoint.sh"]

FROM node:22-alpine AS migration
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY prisma ./prisma
CMD ["npm", "run", "db:migrate:deploy"]
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ All endpoints are prefixed with `/api/v1`. Swagger docs available at `/docs`.
| `GET` | `/api/v1/products` | List all active insurance products |
| `GET` | `/api/v1/policies/me?wallet=<address>` | Get policies for a wallet address |
| `GET` | `/api/v1/policies/:id` | Get a single policy by UUID |
| `PATCH` | `/api/v1/policies/:id` | Update a policy (e.g. change oracleKey, emits status change event) |
| `PATCH` | `/api/v1/policies/:id` | Update a policy's oracleKey or coverage period (timestamps in Unix seconds) |
| `POST` | `/api/v1/policies/:id/cancel` | Cancel an active policy |
| `POST` | `/api/v1/policies/buy` | Calculate premium and get a purchase quote |

Expand All @@ -76,10 +76,17 @@ All endpoints are prefixed with `/api/v1`. Swagger docs available at `/docs`.
| Method | Route | Description |
|--------|-------|-------------|
| `POST` | `/api/v1/claims/submit` | Submit a manual claim |
| `POST` | `/api/v1/claims/:id/evidence` | Upload 1–10 PNG, JPEG, WebP, or PDF evidence files (10 MB maximum per file) |
| `POST` | `/api/v1/claims/:policyId/auto` | Trigger automatic claim evaluation (keeper only) |
| `GET` | `/api/v1/claims/:id` | Get claim details by ID |
| `GET` | `/api/v1/claims/history/:wallet` | Deprecated: use `GET /api/v1/claims?wallet=...` |

The policy-state cache invalidates individual Redis entries with `DEL`. Global
invalidation walks `cache:policy:derived:*` using cursor-based `SCAN` batches
(`COUNT 100`) and deletes each batch as it is found, avoiding Redis's blocking
`KEYS` command. Each process publishes one invalidation event after the scan so
other instances clear their in-memory cache.

### Oracle

| Method | Route | Description |
Expand All @@ -100,6 +107,12 @@ All endpoints are prefixed with `/api/v1`. Swagger docs available at `/docs`.
All responses follow the shape: `{ success: boolean, data?: any, error?: string }`.
Values are returned in 7-decimal fixed point as strings (matching Stellar asset precision).

Claim evidence uploads use `multipart/form-data` with one or more `files` parts.
Uploads are limited to 10 files of at most 10 MB each, and the claim owner must
be authenticated. Evidence content and validated metadata are stored with the
claim; empty submissions, unsupported types, and file signatures that do not
match their declared types are rejected.

---

## Local Setup
Expand Down Expand Up @@ -130,10 +143,18 @@ Swagger docs at `http://localhost:3001/docs`.
### Production build

```bash
# Apply migrations as a separate deployment step before starting new app instances.
npm run db:migrate:deploy
npm run build
npm run start:prod
```

The application entrypoint never changes the database schema. Run migrations
once per deployment before rolling out the application; Prisma's PostgreSQL
advisory lock serializes concurrent migration runners. With Docker Compose,
run `docker compose --profile migrations run --rm migrate` before
`docker compose up -d api`.

---

## API versioning (#713)
Expand Down
10 changes: 10 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,16 @@ services:
redis:
condition: service_healthy

migrate:
build:
context: .
target: migration
env_file: .env
depends_on:
postgres:
condition: service_healthy
profiles: ['migrations']

postgres:
image: postgres:16-alpine
environment:
Expand Down
3 changes: 0 additions & 3 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
#!/bin/sh
set -e

echo "Running Prisma migrations..."
npx prisma migrate deploy

echo "Starting application..."
exec node dist/main
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:generate": "prisma generate",
"db:studio": "prisma studio"
},
Expand Down
16 changes: 16 additions & 0 deletions prisma/migrations/20261019100000_add_claim_evidence/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE "ClaimEvidence" (
"id" TEXT NOT NULL,
"claimId" TEXT NOT NULL,
"filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"content" BYTEA NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "ClaimEvidence_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "ClaimEvidence_claimId_idx" ON "ClaimEvidence"("claimId");

ALTER TABLE "ClaimEvidence" ADD CONSTRAINT "ClaimEvidence_claimId_fkey"
FOREIGN KEY ("claimId") REFERENCES "Claim"("id") ON DELETE CASCADE ON UPDATE CASCADE;
2 changes: 2 additions & 0 deletions prisma/migrations/migration_lock.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Please do not edit this file manually.
provider = "postgresql"
14 changes: 14 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ model Claim {
submittedAt DateTime @default(now())
processedAt DateTime?
createdAt DateTime @default(now())
evidence ClaimEvidence[]

@@unique([txHash])
@@index([policyId])
Expand All @@ -80,6 +81,19 @@ model Claim {
@@index([claimant, submittedAt(sort: Desc)])
}

model ClaimEvidence {
id String @id @default(uuid())
claimId String
claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade)
filename String
mimeType String
size Int
content Bytes
createdAt DateTime @default(now())

@@index([claimId])
}

model OracleReading {
id String @id @default(uuid())
dataType String
Expand Down
40 changes: 39 additions & 1 deletion src/claims/claims.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Post, Query, Req, UseGuards, UseInterceptors, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { Body, Controller, ForbiddenException, Get, Param, ParseUUIDPipe, Post, Query, Req, UploadedFiles, UseGuards, UseInterceptors, UnauthorizedException, NotFoundException } from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';
import { CacheControlInterceptor } from '../common/interceptors/cache-control.interceptor';
import { Throttle } from '@nestjs/throttler';
import { StreamingInterceptor } from '../common/interceptors/streaming.interceptor';
Expand All @@ -10,7 +11,9 @@ import {
ApiParam,
ApiQuery,
ApiExtraModels,
ApiBody,
getSchemaPath,
ApiConsumes,
} from '@nestjs/swagger';
import { ApiErrorResponse } from '../common/swagger/api-error-responses';
import { ClaimsService } from './claims.service';
Expand All @@ -20,6 +23,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { OperatorAuthGuard } from '../auth/operator-auth.guard';
import { AuthenticatedRequest } from '../auth/authenticated-request';
import { ErrorCode } from '../common/errors/error-codes';
import { MAX_EVIDENCE_FILE_SIZE, MAX_EVIDENCE_FILES } from './evidence-validation';

@ApiTags('claims')
@Controller('claims')
Expand Down Expand Up @@ -53,6 +57,40 @@ export class ClaimsController {
return { success: true, data: { claimId, claim } };
}

/** POST /api/v1/claims/:id/evidence — attach evidence files to a claim */
@Post(':id/evidence')
@UseGuards(JwtAuthGuard)
@UseInterceptors(FilesInterceptor('files', MAX_EVIDENCE_FILES, {
limits: { fileSize: MAX_EVIDENCE_FILE_SIZE },
}))
@ApiBearerAuth()
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['files'],
properties: {
files: { type: 'array', items: { type: 'string', format: 'binary' } },
},
},
})
@ApiOperation({ summary: 'Upload evidence files for a claim' })
@ApiParam({ name: 'id', description: 'Claim UUID' })
@ApiResponse({ status: 201, description: 'Evidence files uploaded' })
@ApiErrorResponse(400, 'At least one valid evidence file is required. Accepted types: PNG, JPEG, WebP, PDF; maximum 10 files, 10 MB each.', undefined, 'At least one evidence file is required')
async uploadEvidence(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Array<{ originalname: string; mimetype: string; buffer: Buffer }>,
@Req() req: AuthenticatedRequest,
) {
const authedWallet = req.user?.walletAddress || req.wallet;
if (!authedWallet) {
throw new UnauthorizedException('Not authenticated');
}
const uploaded = await this.claims.uploadEvidence(id, authedWallet, files || []);
return { success: true, data: { files: uploaded } };
}

/** GET /api/v1/claims?wallet=... — get claim history for the authenticated wallet */
@Get()
@UseGuards(JwtAuthGuard)
Expand Down
55 changes: 55 additions & 0 deletions src/claims/claims.evidence.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { ClaimsService } from './claims.service';

jest.mock('../oracle/oracle.service', () => ({ OracleService: class OracleService {} }));

describe('ClaimsService evidence upload', () => {
const claim = { id: 'claim-1', claimant: 'wallet-1' };
const prisma = {
claim: { findUnique: jest.fn().mockResolvedValue(claim) },
claimEvidence: { createMany: jest.fn().mockResolvedValue({ count: 1 }) },
};
const service = new ClaimsService(
{} as any,
{} as any,
{} as any,
{} as any,
prisma as any,
{} as any,
{} as any,
);

beforeEach(() => {
jest.clearAllMocks();
prisma.claim.findUnique.mockResolvedValue(claim);
});

it('rejects an empty upload with a clear validation error', async () => {
await expect(service.uploadEvidence('claim-1', 'wallet-1', [])).rejects.toThrow(
new BadRequestException('At least one evidence file is required'),
);

expect(prisma.claim.findUnique).not.toHaveBeenCalled();
expect(prisma.claimEvidence.createMany).not.toHaveBeenCalled();
});

it('validates and persists uploaded evidence for the claim owner', async () => {
const buffer = Buffer.from('%PDF-1.7 evidence');

await expect(service.uploadEvidence('claim-1', 'wallet-1', [{
originalname: 'receipt.pdf',
mimetype: 'application/pdf',
buffer,
}])).resolves.toEqual([{ filename: 'receipt.pdf', mimeType: 'application/pdf', size: buffer.length }]);

expect(prisma.claimEvidence.createMany).toHaveBeenCalledWith({
data: [{
claimId: 'claim-1',
filename: 'receipt.pdf',
mimeType: 'application/pdf',
size: buffer.length,
content: buffer,
}],
});
});
});
42 changes: 42 additions & 0 deletions src/claims/claims.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { compareScaledToThreshold, parseDecimalScaled, parseFixedPointBigInt } f
import { financialBatch, financialTransaction, isSerializationFailure } from '../prisma/financial-transaction';
import { PolicyStateCacheService, DerivedPolicyState } from './policy-state-cache.service';
import { readClaimEvaluationSnapshot } from './claim-evaluation-snapshot';
import { MAX_EVIDENCE_FILES, validateEvidenceFile } from './evidence-validation';

export type ClaimResult = 'Paid' | 'Rejected' | 'Expired' | 'AlreadyClaimed' | 'AlreadyProcessed' | 'PolicyNotActive' | 'PendingFinalPeriod';

Expand Down Expand Up @@ -96,6 +97,47 @@ export class ClaimsService {
}
}

async uploadEvidence(
claimId: string,
claimant: string,
files: Array<{ originalname: string; mimetype: string; buffer: Buffer }>,
): Promise<Array<{ filename: string; mimeType: string; size: number }>> {
if (!files?.length) {
throw new BadRequestException('At least one evidence file is required');
}
if (files.length > MAX_EVIDENCE_FILES) {
throw new BadRequestException(`A maximum of ${MAX_EVIDENCE_FILES} evidence files may be uploaded`);
}

const claim = await this.prisma.claim.findUnique({
where: { id: claimId },
select: { id: true, claimant: true },
});
if (!claim) {
throw new NotFoundException('Claim not found');
}
if (claim.claimant !== claimant) {
throw new ForbiddenException('Claim belongs to a different wallet');
}

const evidence = files.map((file) => {
const validation = validateEvidenceFile(file.mimetype, file.buffer);
if (!validation.valid) {
throw new BadRequestException(validation.error);
}
return {
claimId,
filename: file.originalname,
mimeType: file.mimetype,
size: file.buffer.length,
content: Buffer.from(file.buffer) as Buffer<ArrayBuffer>,
};
});

await this.prisma.claimEvidence.createMany({ data: evidence });
return evidence.map(({ filename, mimeType, size }) => ({ filename, mimeType, size }));
}

// #350 — builds an auditLog.create() operation to append to a
// $transaction([...]) array alongside the status-changing write itself,
// so the two commit together and the audit trail can't fall out of sync
Expand Down
1 change: 1 addition & 0 deletions src/claims/evidence-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const ALLOWED_EVIDENCE_TYPES: Record<string, { mime: string; magicBytes: number[
export const ALLOWED_EVIDENCE_MIME_TYPES = Object.keys(ALLOWED_EVIDENCE_TYPES);

export const MAX_EVIDENCE_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export const MAX_EVIDENCE_FILES = 10;

export interface EvidenceValidationResult {
valid: boolean;
Expand Down
Loading