diff --git a/Dockerfile b/Dockerfile
index c36fe38..6360ec1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index 77bed26..6baf4df 100644
--- a/README.md
+++ b/README.md
@@ -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=
` | 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 |
@@ -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 |
@@ -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
@@ -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)
diff --git a/docker-compose.yml b/docker-compose.yml
index 8982410..82ae767 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/entrypoint.sh b/entrypoint.sh
index 09f1044..013d56e 100755
--- a/entrypoint.sh
+++ b/entrypoint.sh
@@ -1,8 +1,5 @@
#!/bin/sh
set -e
-echo "Running Prisma migrations..."
-npx prisma migrate deploy
-
echo "Starting application..."
exec node dist/main
diff --git a/package.json b/package.json
index eab42ea..b3051af 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/prisma/migrations/20261019100000_add_claim_evidence/migration.sql b/prisma/migrations/20261019100000_add_claim_evidence/migration.sql
new file mode 100644
index 0000000..2ebbc01
--- /dev/null
+++ b/prisma/migrations/20261019100000_add_claim_evidence/migration.sql
@@ -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;
\ No newline at end of file
diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml
new file mode 100644
index 0000000..1e632f8
--- /dev/null
+++ b/prisma/migrations/migration_lock.toml
@@ -0,0 +1,2 @@
+# Please do not edit this file manually.
+provider = "postgresql"
\ No newline at end of file
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 5aa6288..8c86367 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -70,6 +70,7 @@ model Claim {
submittedAt DateTime @default(now())
processedAt DateTime?
createdAt DateTime @default(now())
+ evidence ClaimEvidence[]
@@unique([txHash])
@@index([policyId])
@@ -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
diff --git a/src/claims/claims.controller.ts b/src/claims/claims.controller.ts
index 4cf7f4b..aca8bd5 100644
--- a/src/claims/claims.controller.ts
+++ b/src/claims/claims.controller.ts
@@ -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';
@@ -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';
@@ -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')
@@ -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)
diff --git a/src/claims/claims.evidence.spec.ts b/src/claims/claims.evidence.spec.ts
new file mode 100644
index 0000000..e787c2d
--- /dev/null
+++ b/src/claims/claims.evidence.spec.ts
@@ -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,
+ }],
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/claims/claims.service.ts b/src/claims/claims.service.ts
index 9c5fa60..19591a2 100644
--- a/src/claims/claims.service.ts
+++ b/src/claims/claims.service.ts
@@ -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';
@@ -96,6 +97,47 @@ export class ClaimsService {
}
}
+ async uploadEvidence(
+ claimId: string,
+ claimant: string,
+ files: Array<{ originalname: string; mimetype: string; buffer: Buffer }>,
+ ): Promise> {
+ 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,
+ };
+ });
+
+ 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
diff --git a/src/claims/evidence-validation.ts b/src/claims/evidence-validation.ts
index 7eb7efa..a1ddf5a 100644
--- a/src/claims/evidence-validation.ts
+++ b/src/claims/evidence-validation.ts
@@ -30,6 +30,7 @@ const ALLOWED_EVIDENCE_TYPES: Record ({ OracleService: class OracleService {} }));
+
describe('PolicyStateCacheService (#714)', () => {
let service: PolicyStateCacheService;
let mockPrisma: any;
let mockOracle: any;
let mockRedis: any;
let mockStatusEvents: any;
+ let redisStore: Map;
const mockPolicy = {
id: 'policy-uuid-1',
@@ -54,7 +57,7 @@ describe('PolicyStateCacheService (#714)', () => {
getLatestReading: jest.fn().mockResolvedValue({ ...mockReading }),
};
- const redisStore = new Map();
+ redisStore = new Map();
mockRedis = {
get: jest.fn().mockImplementation(async (key: string) => redisStore.get(key) ?? null),
set: jest.fn().mockImplementation(async (key: string, val: string) => {
@@ -66,6 +69,7 @@ describe('PolicyStateCacheService (#714)', () => {
return keys.length;
}),
keys: jest.fn().mockImplementation(async () => Array.from(redisStore.keys())),
+ scan: jest.fn(),
publish: jest.fn().mockResolvedValue(1),
duplicate: jest.fn().mockReturnValue({
on: jest.fn(),
@@ -151,6 +155,29 @@ describe('PolicyStateCacheService (#714)', () => {
});
});
+ describe('Global cache invalidation', () => {
+ it('uses incremental SCAN batches for a large Redis keyspace', async () => {
+ for (let index = 0; index < 250; index++) {
+ redisStore.set(`cache:policy:derived:${index}`, '{}');
+ }
+ const keys = Array.from(redisStore.keys());
+ mockRedis.scan.mockImplementation(async (cursor: string) => {
+ const start = Number(cursor);
+ const batch = keys.slice(start, start + 100);
+ const nextCursor = start + 100 >= keys.length ? '0' : String(start + 100);
+ return [nextCursor, batch];
+ });
+
+ await service.invalidateAll();
+
+ expect(mockRedis.scan).toHaveBeenCalledTimes(3);
+ expect(mockRedis.scan).toHaveBeenNthCalledWith(1, '0', 'MATCH', 'cache:policy:derived:*', 'COUNT', 100);
+ expect(mockRedis.keys).not.toHaveBeenCalled();
+ expect(mockRedis.del).toHaveBeenCalledTimes(3);
+ expect(redisStore.size).toBe(0);
+ });
+ });
+
describe('3. Claim change invalidates cache', () => {
it('recomputes policy state when claim is created or changed', async () => {
const now = new Date('2026-02-01T00:00:00.000Z');
diff --git a/src/claims/policy-state-cache.service.ts b/src/claims/policy-state-cache.service.ts
index aa478c0..378d899 100644
--- a/src/claims/policy-state-cache.service.ts
+++ b/src/claims/policy-state-cache.service.ts
@@ -9,6 +9,7 @@ import { compareScaledToThreshold, parseDecimalScaled, parseFixedPointBigInt } f
export const POLICY_DERIVED_STATE_TTL_SECONDS = 60;
const IN_MEMORY_MAX_ENTRIES = 2000;
const INVALIDATION_CHANNEL = 'cache:policy:invalidate';
+const REDIS_INVALIDATION_SCAN_COUNT = 100;
/**
* Time-independent state cached in Redis / memory (#714).
@@ -398,11 +399,23 @@ export class PolicyStateCacheService implements OnModuleInit, OnModuleDestroy {
this.memoryCache.clear();
this.inFlight.clear();
- if (this.redis && typeof this.redis.keys === 'function') {
+ if (this.redis) {
try {
- const keys = await this.redis.keys('cache:policy:derived:*');
- if (keys.length > 0 && typeof this.redis.del === 'function') {
- await this.redis.del(...keys);
+ if (typeof this.redis.scan === 'function') {
+ let cursor = '0';
+ do {
+ const [nextCursor, keys] = await this.redis.scan(
+ cursor,
+ 'MATCH',
+ 'cache:policy:derived:*',
+ 'COUNT',
+ REDIS_INVALIDATION_SCAN_COUNT,
+ );
+ cursor = nextCursor;
+ if (keys.length > 0 && typeof this.redis.del === 'function') {
+ await this.redis.del(...keys);
+ }
+ } while (cursor !== '0');
}
if (typeof this.redis.publish === 'function') {
await this.redis.publish(INVALIDATION_CHANNEL, JSON.stringify({ all: true }));
diff --git a/src/policy/dto/update-policy.dto.ts b/src/policy/dto/update-policy.dto.ts
index 29485b9..5833d7a 100644
--- a/src/policy/dto/update-policy.dto.ts
+++ b/src/policy/dto/update-policy.dto.ts
@@ -1,4 +1,4 @@
-import { IsOptional, IsString } from 'class-validator';
+import { IsInt, IsOptional, IsString, Min } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
/**
@@ -12,4 +12,16 @@ export class UpdatePolicyDto {
@IsOptional()
@IsString()
oracleKey?: string;
+
+ @ApiPropertyOptional({ description: 'New policy start timestamp (Unix seconds)', example: 1780272000 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ startTime?: number;
+
+ @ApiPropertyOptional({ description: 'New policy end timestamp (Unix seconds)', example: 1782864000 })
+ @IsOptional()
+ @IsInt()
+ @Min(0)
+ endTime?: number;
}
diff --git a/src/policy/policy-oracle-key-event.spec.ts b/src/policy/policy-oracle-key-event.spec.ts
index 94eb177..66db150 100644
--- a/src/policy/policy-oracle-key-event.spec.ts
+++ b/src/policy/policy-oracle-key-event.spec.ts
@@ -176,6 +176,35 @@ describe('Policy Oracle Key Change Event (#717)', () => {
expect(mockStatusEventsService.emitPolicyOracleKeyChange).not.toHaveBeenCalled();
});
+ it('rejects an endTime that is not after the effective startTime', async () => {
+ mockPrismaService.policy.findUnique.mockResolvedValue(initialPolicy);
+
+ await expect(
+ service.updatePolicy(initialPolicy.id, {
+ endTime: Math.floor(initialPolicy.startTime.getTime() / 1000),
+ }),
+ ).rejects.toThrow('endTime must be after startTime');
+
+ expect(mockPrismaService.policy.update).not.toHaveBeenCalled();
+ });
+
+ it('persists a valid partial date update and validates oracle period coverage', async () => {
+ mockPrismaService.policy.findUnique.mockResolvedValue(initialPolicy);
+ mockPrismaService.policy.update.mockResolvedValue({
+ ...initialPolicy,
+ startTime: new Date('2026-06-05T00:00:00Z'),
+ });
+
+ await service.updatePolicy(initialPolicy.id, {
+ startTime: Math.floor(new Date('2026-06-05T00:00:00Z').getTime() / 1000),
+ });
+
+ expect(mockPrismaService.policy.update).toHaveBeenCalledWith(expect.objectContaining({
+ where: { id: initialPolicy.id },
+ data: { startTime: new Date('2026-06-05T00:00:00Z') },
+ }));
+ });
+
it('throws NotFoundException for non-existent policy', async () => {
mockPrismaService.policy.findUnique.mockResolvedValue(null);
diff --git a/src/policy/policy.controller.ts b/src/policy/policy.controller.ts
index dd66ab4..3560cae 100644
--- a/src/policy/policy.controller.ts
+++ b/src/policy/policy.controller.ts
@@ -419,7 +419,7 @@ export class PolicyController {
],
},
})
- @ApiErrorResponse(400, 'Invalid oracleKey format or period mismatch.', undefined, 'Invalid oracleKey format')
+ @ApiErrorResponse(400, 'Invalid oracleKey format or period, or endTime is not after startTime.', undefined, 'Invalid policy update')
@ApiErrorResponse(401, 'Missing or invalid JWT.', undefined, 'Missing or invalid token')
@ApiErrorResponse(403, 'Policy belongs to a different wallet.', undefined, 'Policy belongs to a different wallet')
@ApiErrorResponse(404, 'No policy found for the given ID.', undefined, 'Policy not found')
diff --git a/src/policy/policy.service.ts b/src/policy/policy.service.ts
index e6106f2..9b7fd93 100644
--- a/src/policy/policy.service.ts
+++ b/src/policy/policy.service.ts
@@ -1069,10 +1069,20 @@ export class PolicyService {
throw new NotFoundException(`Policy ${id} not found`);
}
- const isOracleKeyProvided = dto.oracleKey !== undefined && dto.oracleKey !== null;
- const isOracleKeyChanged = isOracleKeyProvided && dto.oracleKey !== existing.oracleKey;
+ const startTime = dto.startTime === undefined ? existing.startTime : new Date(dto.startTime * 1000);
+ const endTime = dto.endTime === undefined ? existing.endTime : new Date(dto.endTime * 1000);
+ if (!Number.isFinite(startTime.getTime()) || !Number.isFinite(endTime.getTime())) {
+ throw new BadRequestException('startTime and endTime must be valid Unix timestamps in seconds');
+ }
+ if (endTime <= startTime) {
+ throw new BadRequestException('endTime must be after startTime');
+ }
- if (!isOracleKeyChanged) {
+ const isOracleKeyChanged = dto.oracleKey !== undefined && dto.oracleKey !== null && dto.oracleKey !== existing.oracleKey;
+ const isStartTimeChanged = startTime.getTime() !== existing.startTime.getTime();
+ const isEndTimeChanged = endTime.getTime() !== existing.endTime.getTime();
+
+ if (!isOracleKeyChanged && !isStartTimeChanged && !isEndTimeChanged) {
return {
id: String(existing.id),
productId: existing.productId,
@@ -1087,14 +1097,12 @@ export class PolicyService {
};
}
- const newOracleKey = dto.oracleKey!;
-
- // Validate oracle key format and temporal coverage
+ const newOracleKey = dto.oracleKey ?? existing.oracleKey;
const keyValidation = this.validateOracleKey(
newOracleKey,
this.mapProduct(existing.product),
- existing.startTime,
- existing.endTime,
+ startTime,
+ endTime,
);
if (!keyValidation.valid) {
throw new BadRequestException(keyValidation.reason);
@@ -1104,7 +1112,9 @@ export class PolicyService {
const updated = await this.prisma.policy.update({
where: { id },
data: {
- oracleKey: newOracleKey,
+ ...(isOracleKeyChanged ? { oracleKey: newOracleKey } : {}),
+ ...(isStartTimeChanged ? { startTime } : {}),
+ ...(isEndTimeChanged ? { endTime } : {}),
},
include: {
_count: {
@@ -1113,34 +1123,37 @@ export class PolicyService {
},
});
- const oldOracleKey = existing.oracleKey;
- const timestamp = Date.now();
-
- // Emit event ONLY AFTER database change is committed
- const event: PolicyOracleKeyChangeEvent = {
- policyId: String(updated.id),
- oldOracleKey,
- newOracleKey,
- actor: actor ?? null,
- timestamp,
- correlationId: correlationId ?? null,
- };
+ if (isOracleKeyChanged) {
+ const oldOracleKey = existing.oracleKey;
+ const event: PolicyOracleKeyChangeEvent = {
+ policyId: String(updated.id),
+ oldOracleKey,
+ newOracleKey,
+ actor: actor ?? null,
+ timestamp: Date.now(),
+ correlationId: correlationId ?? null,
+ };
- this.statusEvents.emitPolicyOracleKeyChange(event);
- this.webhooks.notifyPolicyOracleKeyChange(event).catch((err) => {
- this.logger.error(`Failed to dispatch oracle key change webhook for policy ${id}: ${err.message}`);
- });
+ this.statusEvents.emitPolicyOracleKeyChange(event);
+ this.webhooks.notifyPolicyOracleKeyChange(event).catch((err) => {
+ this.logger.error(`Failed to dispatch oracle key change webhook for policy ${id}: ${err.message}`);
+ });
+ }
- // Write audit log
+ const changes = [
+ ...(isOracleKeyChanged ? [`oracle key from "${existing.oracleKey}" to "${newOracleKey}"`] : []),
+ ...(isStartTimeChanged ? ['start time'] : []),
+ ...(isEndTimeChanged ? ['end time'] : []),
+ ];
await this.prisma.auditLog.create({
data: {
entityType: 'Policy',
- entityId: id,
+ entityId: id,
fromStatus: existing.status,
- toStatus: existing.status,
- reason: `Oracle key changed from "${oldOracleKey}" to "${newOracleKey}" by ${actor ?? 'system'}`,
+ toStatus: existing.status,
+ reason: `Updated ${changes.join(' and ')} by ${actor ?? 'system'}`,
},
- }).catch((err) => this.logger.error(`Failed to write audit log for policy ${id} oracle key update`, err));
+ }).catch((err) => this.logger.error(`Failed to write audit log for policy ${id} update`, err));
return {
id: String(updated.id),
diff --git a/src/prisma/production-startup.spec.ts b/src/prisma/production-startup.spec.ts
new file mode 100644
index 0000000..24ae7a3
--- /dev/null
+++ b/src/prisma/production-startup.spec.ts
@@ -0,0 +1,30 @@
+import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join, resolve } from 'path';
+import { execFileSync } from 'child_process';
+
+describe('production entrypoint', () => {
+ it('starts the application without running migrations', () => {
+ const tempDir = mkdtempSync(join(tmpdir(), 'parashield-startup-'));
+ const entrypoint = resolve(__dirname, '../../entrypoint.sh');
+
+ try {
+ for (const executable of ['node', 'npx']) {
+ const fakeCommand = join(tempDir, executable);
+ writeFileSync(fakeCommand, `#!/bin/sh\nprintf '${executable} invoked: %s\\n' "$*"\n`);
+ chmodSync(fakeCommand, 0o755);
+ }
+
+ const output = execFileSync('sh', [entrypoint], {
+ encoding: 'utf8',
+ env: { ...process.env, PATH: `${tempDir}:${process.env.PATH}` },
+ });
+
+ expect(output).toContain('Starting application...');
+ expect(output).toContain('node invoked: dist/main');
+ expect(output).not.toContain('npx invoked');
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+});
\ No newline at end of file