Skip to content

Commit 799375d

Browse files
authored
feat: implement backend improvements and fixes for issues 209, 210, 218, 231 (#367)
1 parent b3701ec commit 799375d

8 files changed

Lines changed: 245 additions & 26 deletions

File tree

prisma/schema.prisma

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,19 +94,33 @@ model Dispute {
9494
@@index([escrowId])
9595
}
9696

97+
enum NotificationStatus {
98+
PENDING
99+
SENT
100+
FAILED
101+
}
102+
97103
model Notification {
98-
id String @id @default(uuid())
104+
id String @id @default(uuid())
99105
escrowId String
100106
type String
101107
channel String
102108
recipientAddress String
103109
message String
104-
createdAt DateTime @default(now())
105-
escrow Escrow @relation(fields: [escrowId], references: [id], onDelete: Cascade)
110+
status NotificationStatus @default(PENDING)
111+
retryCount Int @default(0)
112+
sentAt DateTime?
113+
failedAt DateTime?
114+
lastError String?
115+
createdAt DateTime @default(now())
116+
updatedAt DateTime @updatedAt
117+
escrow Escrow @relation(fields: [escrowId], references: [id], onDelete: Cascade)
106118
107119
@@index([escrowId])
120+
@@index([status, createdAt])
108121
}
109122

123+
110124
model VendorProfile {
111125
address String @id
112126
businessName String

src/escrow/escrow.controller.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import {
1111
HttpCode,
1212
HttpStatus,
1313
Query,
14+
Headers,
15+
BadRequestException,
1416
} from '@nestjs/common';
1517
import {
1618
ApiTags,
@@ -62,8 +64,15 @@ export class EscrowController {
6264
@HttpCode(HttpStatus.CREATED)
6365
@UseGuards(JwtGuard)
6466
@Throttle({ public: { limit: 10, ttl: 60000 } })
65-
createEscrow(@Body() dto: CreateEscrowDto, @CurrentUser() user: AuthUser) {
66-
return this.escrowService.createEscrow(dto, user.address);
67+
createEscrow(
68+
@Body() dto: CreateEscrowDto,
69+
@CurrentUser() user: AuthUser,
70+
@Headers('idempotency-key') idempotencyKey?: string,
71+
) {
72+
if (!idempotencyKey) {
73+
throw new BadRequestException('Idempotency-Key header required');
74+
}
75+
return this.escrowService.createIdempotent(idempotencyKey, dto, user.address);
6776
}
6877

6978
/**

src/escrow/escrow.service.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,30 @@ export class EscrowService {
156156
};
157157
}
158158

159+
/** Wrapper for createEscrow that ensures idempotency via Redis caching. */
160+
async createIdempotent(
161+
idempotencyKey: string,
162+
dto: CreateEscrowDto,
163+
vendorAddress: string,
164+
): Promise<EscrowWithPaymentUrl> {
165+
const cacheKey = `idempotency:${idempotencyKey}`;
166+
if (this.cacheService) {
167+
const cached = await this.cacheService.get<EscrowWithPaymentUrl>(cacheKey);
168+
if (cached) {
169+
return cached;
170+
}
171+
}
172+
173+
const result = await this.createEscrow(dto, vendorAddress);
174+
175+
if (this.cacheService) {
176+
// Cache for 24 hours (86400 seconds)
177+
await this.cacheService.set(cacheKey, result, 86400);
178+
}
179+
180+
return result;
181+
}
182+
159183
/** Loads an escrow by ID or raises a typed not-found error. */
160184
async findById(id: string): Promise<EscrowRecord> {
161185
try {

src/notifications/notification-retry-queue.service.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import * as crypto from 'crypto';
3333
import type { ConnectionOptions } from 'bullmq';
3434

35+
import { PrismaService } from '../prisma/prisma.service';
3536
import {
3637
NotificationRetryJobData,
3738
NotificationRetryBackoff,
@@ -98,6 +99,7 @@ export class NotificationRetryQueueService
9899

99100
constructor(
100101
@Optional() options?: CommonOptions,
102+
@Optional() private readonly prisma?: PrismaService,
101103
) {
102104
if (options?.backoff) this.options.backoff = options.backoff;
103105
this.options.deadLetterSink = options?.deadLetterSink;
@@ -156,9 +158,29 @@ export class NotificationRetryQueueService
156158
for (let attempt = 1; attempt <= attempts; attempt++) {
157159
try {
158160
await dispatcher.dispatch(job);
161+
if (job.notificationId && this.prisma) {
162+
await this.prisma.notification.update({
163+
where: { id: job.notificationId },
164+
data: {
165+
status: 'SENT',
166+
sentAt: new Date(),
167+
retryCount: attempt - 1,
168+
},
169+
}).catch(err => this.logger.error('Failed to update notification status to SENT', err));
170+
}
159171
return;
160172
} catch (err) {
161173
lastError = err;
174+
if (job.notificationId && this.prisma) {
175+
await this.prisma.notification.update({
176+
where: { id: job.notificationId },
177+
data: {
178+
retryCount: attempt,
179+
failedAt: new Date(),
180+
lastError: err instanceof Error ? err.message : String(err),
181+
},
182+
}).catch(dbErr => this.logger.error('Failed to update notification status to FAILED/PENDING', dbErr));
183+
}
162184
if (attempt >= attempts) break;
163185
const delay = computeBackoffDelay(attempt + 1, this.options.backoff);
164186
this.logger.warn(
@@ -168,6 +190,14 @@ export class NotificationRetryQueueService
168190
await this.sleep(delay);
169191
}
170192
}
193+
if (job.notificationId && this.prisma) {
194+
await this.prisma.notification.update({
195+
where: { id: job.notificationId },
196+
data: {
197+
status: 'FAILED',
198+
},
199+
}).catch(err => this.logger.error('Failed to update notification status to FAILED', err));
200+
}
171201
await this.recordDeadLetter(job, attempts, lastError);
172202
}
173203

@@ -242,17 +272,48 @@ export class NotificationRetryQueueService
242272
`No dispatcher registered for channel ${job.data.channel}`,
243273
);
244274
}
245-
await dispatcher.dispatch(job.data);
275+
try {
276+
await dispatcher.dispatch(job.data);
277+
if (job.data.notificationId && this.prisma) {
278+
await this.prisma.notification.update({
279+
where: { id: job.data.notificationId },
280+
data: {
281+
status: 'SENT',
282+
sentAt: new Date(),
283+
retryCount: job.attemptsMade,
284+
},
285+
}).catch(err => this.logger.error('Failed to update notification status to SENT in worker', err));
286+
}
287+
} catch (err) {
288+
if (job.data.notificationId && this.prisma) {
289+
await this.prisma.notification.update({
290+
where: { id: job.data.notificationId },
291+
data: {
292+
retryCount: job.attemptsMade + 1,
293+
failedAt: new Date(),
294+
lastError: err instanceof Error ? err.message : String(err),
295+
},
296+
}).catch(dbErr => this.logger.error('Failed to update notification status on error in worker', dbErr));
297+
}
298+
throw err;
299+
}
246300
},
247301
{ connection },
248302
);
249303
this.bullWorker.on('failed', async (job, error) => {
250-
if (
251-
!job ||
252-
job.attemptsMade < (job.opts.attempts ?? this.options.backoff.attempts)
253-
) {
304+
if (!job) return;
305+
const attemptsAllowed = job.opts.attempts ?? this.options.backoff.attempts;
306+
if (job.attemptsMade < attemptsAllowed) {
254307
return;
255308
}
309+
if (job.data.notificationId && this.prisma) {
310+
await this.prisma.notification.update({
311+
where: { id: job.data.notificationId },
312+
data: {
313+
status: 'FAILED',
314+
},
315+
}).catch(err => this.logger.error('Failed to update notification status to FAILED in worker', err));
316+
}
256317
await this.recordDeadLetter(
257318
job.data,
258319
job.attemptsMade,

src/notifications/notification-retry-queue.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface NotificationRetryJobData {
2222
recipientAddress: string;
2323
/** Optional correlation id so cross-service log lines stitch together. */
2424
requestId?: string;
25+
/** Optional database record id for tracking. */
26+
notificationId?: string;
2527
}
2628

2729
/**

0 commit comments

Comments
 (0)