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