Skip to content

Commit 2814381

Browse files
authored
Merge pull request InsurNiffy#1036 from aji70/main
This PR addresses four backend infrastructure improvements to enhance data consistency, operational observability, and database performance
2 parents a25358b + 2603af8 commit 2814381

20 files changed

Lines changed: 593 additions & 41 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
-- Add evidence metadata table to store IPFS CID, file size, and MIME type
2+
CREATE TABLE "evidence_metadata" (
3+
"id" SERIAL NOT NULL,
4+
"claim_id" INTEGER NOT NULL,
5+
"cid" TEXT,
6+
"url" TEXT,
7+
"file_size_bytes" INTEGER,
8+
"mime_type" TEXT,
9+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10+
11+
CONSTRAINT "evidence_metadata_pkey" PRIMARY KEY ("id")
12+
);
13+
14+
CREATE UNIQUE INDEX "evidence_metadata_claim_id_key" ON "evidence_metadata"("claim_id");
15+
CREATE INDEX "evidence_metadata_claim_id_idx" ON "evidence_metadata"("claim_id");
16+
17+
ALTER TABLE "evidence_metadata" ADD CONSTRAINT "evidence_metadata_claim_id_fkey" FOREIGN KEY ("claim_id") REFERENCES "claims"("id") ON DELETE CASCADE ON UPDATE CASCADE;

backend/prisma/schema.prisma

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ model Claim {
4040
policy Policy @relation(fields: [policyId], references: [id])
4141
votes Vote[]
4242
comments ClaimComment[]
43+
evidenceMetadata EvidenceMetadata?
4344
4445
@@index([status])
4546
@@index([severity])
@@ -389,6 +390,22 @@ model ClaimComment {
389390
@@map("claim_comments")
390391
}
391392

393+
model EvidenceMetadata {
394+
id Int @id @default(autoincrement())
395+
claimId Int @map("claim_id")
396+
cid String? /// IPFS Content Identifier
397+
url String? /// Gateway URL
398+
fileSizeBytes Int? @map("file_size_bytes")
399+
mimeType String? @map("mime_type")
400+
createdAt DateTime @default(now()) @map("created_at")
401+
402+
claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade)
403+
404+
@@unique([claimId])
405+
@@index([claimId])
406+
@@map("evidence_metadata")
407+
}
408+
392409
/// Tracks voters registered on-chain via admin governance actions.
393410
model RegisteredVoter {
394411
walletAddress String @id @map("wallet_address")

backend/src/auth/nonce.store.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import Redis from 'ioredis';
13-
import { config } from '../config/env';
13+
import { buildRedisConfig } from '../redis/config';
1414

1515
export interface NonceStore {
1616
set(nonce: string, data: string, ttlSeconds: number): Promise<void>;
@@ -76,7 +76,14 @@ export async function getNonceStore(): Promise<NonceStore> {
7676
if (_store) return _store;
7777

7878
try {
79-
const redis = new Redis(config.redis.url, {
79+
const cfg = buildRedisConfig();
80+
const redis = new Redis({
81+
host: cfg.host,
82+
port: cfg.port,
83+
password: cfg.password,
84+
tls: cfg.tls ? {} : undefined,
85+
db: cfg.db,
86+
keyPrefix: cfg.keyPrefix,
8087
lazyConnect: true,
8188
enableOfflineQueue: false,
8289
connectTimeout: 2000,

backend/src/cache/redis.service.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
22
import { ConfigService } from '@nestjs/config';
33
import Redis from 'ioredis';
4-
5-
6-
4+
import { buildRedisConfig } from '../redis/config';
75

86

97
@Injectable()
@@ -12,8 +10,14 @@ export class RedisService implements OnModuleDestroy {
1210
private readonly logger = new Logger(RedisService.name);
1311

1412
constructor(private readonly configService: ConfigService) {
15-
const redisUrl = this.configService.get<string>('REDIS_URL', 'redis://localhost:6379');
16-
this.client = new Redis(redisUrl, {
13+
const cfg = buildRedisConfig();
14+
this.client = new Redis({
15+
host: cfg.host,
16+
port: cfg.port,
17+
password: cfg.password,
18+
tls: cfg.tls ? {} : undefined,
19+
db: cfg.db,
20+
keyPrefix: cfg.keyPrefix,
1721
lazyConnect: true,
1822
retryStrategy: (times) => {
1923
if (times > 3) {

backend/src/claims/claim-view.mapper.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const SECONDS_PER_LEDGER = 5;
2020
export type ClaimWithVotes = Prisma.ClaimGetPayload<{
2121
include: {
2222
votes: { select: { vote: true } };
23+
evidenceMetadata: true;
2324
};
2425
}>;
2526

@@ -141,6 +142,9 @@ export class ClaimViewMapper {
141142
evidence: {
142143
gatewayUrl: sanitizedHash ? `${this.ipfsGateway}/ipfs/${sanitizedHash}` : '',
143144
hash: sanitizedHash,
145+
cid: claim.evidenceMetadata?.cid ?? null,
146+
fileSizeBytes: claim.evidenceMetadata?.fileSizeBytes ?? null,
147+
mimeType: claim.evidenceMetadata?.mimeType ?? null,
144148
} as SanitizedEvidenceDto,
145149
consistency: {
146150
isFinalized: claim.isFinalized,

backend/src/claims/claims.controller.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,21 @@ export class ClaimsController {
157157
return this.claimsService.getClaimById(id, walletAddress);
158158
}
159159

160+
@Post(':id/evidence/metadata')
161+
@UseGuards(JwtAuthGuard)
162+
@HttpCode(HttpStatus.OK)
163+
@ApiBearerAuth()
164+
@ApiOperation({ summary: 'Store evidence metadata for a claim' })
165+
@ApiResponse({ status: 200, description: 'Metadata stored successfully' })
166+
@ApiResponse({ status: 404, description: 'Claim not found' })
167+
async storeEvidenceMetadata(
168+
@Param('id', ParseIntPipe) id: number,
169+
@Body() dto: { cid?: string; url?: string; fileSizeBytes?: number; mimeType?: string }
170+
): Promise<{ success: boolean }> {
171+
await this.claimsService.storeEvidenceMetadata(id, dto);
172+
return { success: true };
173+
}
174+
160175
@Get(':id/evidence/:index')
161176
@UseGuards(JwtAuthGuard)
162177
@ApiBearerAuth()

backend/src/claims/claims.service.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
33
import { Prisma } from '@prisma/client';
44
import { SorobanService } from '../rpc/soroban.service';
55
import { PrismaService } from '../prisma/prisma.service';
6+
import { PrismaReplicaService } from '../prisma/prisma-replica.service';
67
import { RedisService } from '../cache/redis.service';
78
import { TenantContextService } from '../tenant/tenant-context.service';
89
import { claimTenantWhere, assertTenantOwnership } from '../tenant/tenant-filter.helper';
@@ -34,6 +35,7 @@ export class ClaimsService {
3435

3536
constructor(
3637
private readonly prisma: PrismaService,
38+
private readonly prismaReplica: PrismaReplicaService,
3739
private readonly redis: RedisService,
3840
private readonly claimViewMapper: ClaimViewMapper,
3941
private readonly config: ConfigService,
@@ -47,6 +49,11 @@ export class ClaimsService {
4749
this.indexerNetwork = this.config.get<string>('STELLAR_NETWORK', 'testnet');
4850
}
4951

52+
/** Get the appropriate client for reads — replica if enabled, otherwise primary. */
53+
private getReadClient() {
54+
return this.prismaReplica.isEnabled() ? this.prismaReplica : this.prisma;
55+
}
56+
5057
async listClaims(params: ListClaimsParams): Promise<ClaimsListResponseDto> {
5158
const { after, status } = params;
5259
const limit = clampLimit(params.limit);
@@ -66,16 +73,18 @@ export class ClaimsService {
6673
...(keysetWhere ?? {}),
6774
});
6875

76+
const readClient = this.getReadClient();
6977
const [claims, total] = await Promise.all([
70-
this.prisma.claim.findMany({
78+
readClient.claim.findMany({
7179
where,
7280
include: {
7381
votes: { where: { deletedAt: null }, select: { vote: true } },
82+
evidenceMetadata: true,
7483
},
7584
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
7685
take: limit,
7786
}),
78-
this.prisma.claim.count({ where: claimTenantWhere(tenantId, statusFilter) }),
87+
readClient.claim.count({ where: claimTenantWhere(tenantId, statusFilter) }),
7988
]);
8089

8190
return {
@@ -106,7 +115,8 @@ export class ClaimsService {
106115
const tenantId = this.tenantCtx.tenantId;
107116
const lastLedger = await this.getLastLedger();
108117

109-
const votedClaimIds = await this.prisma.vote.findMany({
118+
const readClient = this.getReadClient();
119+
const votedClaimIds = await readClient.vote.findMany({
110120
where: { voterAddress: walletAddress.toLowerCase(), deletedAt: null },
111121
select: { claimId: true },
112122
});
@@ -119,11 +129,12 @@ export class ClaimsService {
119129
});
120130

121131
const [allOpen, page] = await Promise.all([
122-
this.prisma.claim.count({ where: baseWhere }),
123-
this.prisma.claim.findMany({
132+
readClient.claim.count({ where: baseWhere }),
133+
readClient.claim.findMany({
124134
where: { ...baseWhere, ...(keysetWhere ?? {}) },
125135
include: {
126136
votes: { where: { deletedAt: null }, select: { vote: true } },
137+
evidenceMetadata: true,
127138
},
128139
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
129140
take: limit,
@@ -163,13 +174,15 @@ export class ClaimsService {
163174
}
164175

165176
const lastLedger = await this.getLastLedger();
166-
const claim = await this.prisma.claim.findFirst({
177+
const readClient = this.getReadClient();
178+
const claim = await readClient.claim.findFirst({
167179
where: claimTenantWhere(tenantId, { id }),
168180
include: {
169181
votes: {
170182
where: { deletedAt: null },
171183
select: { vote: true },
172184
},
185+
evidenceMetadata: true,
173186
},
174187
});
175188

@@ -214,7 +227,8 @@ export class ClaimsService {
214227
}
215228

216229
const lastLedger = await this.getLastLedger();
217-
const claims = await this.prisma.claim.findMany({
230+
const readClient = this.getReadClient();
231+
const claims = await readClient.claim.findMany({
218232
where: claimTenantWhere(tenantId, {
219233
policyId: { in: uniquePolicyIds },
220234
}),
@@ -223,6 +237,7 @@ export class ClaimsService {
223237
where: { deletedAt: null },
224238
select: { vote: true },
225239
},
240+
evidenceMetadata: true,
226241
},
227242
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
228243
});
@@ -303,6 +318,45 @@ export class ClaimsService {
303318
this.logger.log(`Cache invalidated for claim ${claimId || 'all'}`);
304319
}
305320

321+
/**
322+
* Store evidence metadata for a claim
323+
*/
324+
async storeEvidenceMetadata(
325+
claimId: number,
326+
metadata: { cid?: string; url?: string; fileSizeBytes?: number; mimeType?: string }
327+
): Promise<void> {
328+
const tenantId = this.tenantCtx.tenantId;
329+
330+
// Verify claim exists and belongs to tenant
331+
const claim = await this.prisma.claim.findFirst({
332+
where: claimTenantWhere(tenantId, { id: claimId }),
333+
select: { id: true },
334+
});
335+
336+
if (!claim) {
337+
throw new NotFoundException(`Claim with ID ${claimId} not found`);
338+
}
339+
340+
await this.prisma.evidenceMetadata.upsert({
341+
where: { claimId },
342+
create: {
343+
claimId,
344+
cid: metadata.cid,
345+
url: metadata.url,
346+
fileSizeBytes: metadata.fileSizeBytes,
347+
mimeType: metadata.mimeType,
348+
},
349+
update: {
350+
cid: metadata.cid,
351+
url: metadata.url,
352+
fileSizeBytes: metadata.fileSizeBytes,
353+
mimeType: metadata.mimeType,
354+
},
355+
});
356+
357+
await this.invalidateCache(claimId);
358+
}
359+
306360
/**
307361
* Build an unsigned file_claim transaction
308362
*/

backend/src/claims/dto/claim.dto.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,24 @@ export class SanitizedEvidenceDto {
216216
@IsString()
217217
@Matches(/^https?:\/\/.+/i)
218218
cachedUrl?: string;
219+
220+
@ApiPropertyOptional({ description: 'IPFS Content Identifier (CID)' })
221+
@Expose()
222+
@IsOptional()
223+
@IsString()
224+
cid?: string | null;
225+
226+
@ApiPropertyOptional({ description: 'File size in bytes' })
227+
@Expose()
228+
@IsOptional()
229+
@IsNumber()
230+
fileSizeBytes?: number | null;
231+
232+
@ApiPropertyOptional({ description: 'MIME type of the evidence file' })
233+
@Expose()
234+
@IsOptional()
235+
@IsString()
236+
mimeType?: string | null;
219237
}
220238

221239
export class ConsistencyMetadataDto {

backend/src/claims/dto/evidence-upload.dto.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,6 @@ export const EVIDENCE_UPLOAD_RATE_LIMIT_WINDOW_SECONDS_DEFAULT = 3600;
1919
export interface EvidenceUploadResponseDto {
2020
cid: string;
2121
gatewayUrl: string;
22+
fileSizeBytes?: number;
23+
mimeType?: string;
2224
}

backend/src/claims/services/evidence-upload.service.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,12 @@ export class EvidenceUploadService {
6060
size: file.size,
6161
});
6262

63-
return { cid, gatewayUrl };
63+
return {
64+
cid,
65+
gatewayUrl,
66+
fileSizeBytes: file.size,
67+
mimeType: file.mimetype,
68+
};
6469
}
6570

6671
private validateFile(file: Express.Multer.File): void {

0 commit comments

Comments
 (0)