Skip to content

Commit 7456124

Browse files
authored
Merge pull request #256 from darcszn/feat/claim-status-notifications
feat: claim status notifications with permission UX, polling backoff, and SSE
2 parents b4403f3 + d804679 commit 7456124

8 files changed

Lines changed: 759 additions & 18 deletions

File tree

backend/src/claims/claims.controller.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import {
1010
HttpCode,
1111
HttpStatus,
1212
Body,
13+
Res,
14+
BadRequestException,
1315
} from '@nestjs/common';
1416
import {
1517
ApiTags,
@@ -19,6 +21,7 @@ import {
1921
ApiQuery,
2022
} from '@nestjs/swagger';
2123
import { Throttle } from '@nestjs/throttler';
24+
import type { Response } from 'express';
2225
import { ClaimsService } from './claims.service';
2326
import { ClaimsListResponseDto, ClaimDetailResponseDto } from './dto/claim.dto';
2427
import { BuildClaimTransactionDto } from './dto/build-claim-transaction.dto';
@@ -28,6 +31,9 @@ import { WalletAddress } from '../auth/decorators/wallet-address.decorator';
2831
import { RateLimitGuard } from '../rate-limit/rate-limit.guard';
2932
import { MAX_LIMIT, DEFAULT_LIMIT } from '../helpers/pagination';
3033

34+
/** Maximum claim IDs accepted per status-poll or SSE subscription. */
35+
const MAX_WATCH_IDS = 50;
36+
3137
@ApiTags('claims')
3238
@Controller('claims')
3339
export class ClaimsController {
@@ -122,4 +128,65 @@ export class ClaimsController {
122128
async submitTransaction(@Body() dto: SubmitTransactionDto) {
123129
return this.claimsService.submitTransaction(dto.transactionXdr);
124130
}
125-
}
131+
132+
// ── Claim status polling (for watched claims) ────────────────────────────
133+
134+
/**
135+
* GET /api/claims/status?claimId=1&claimId=2
136+
* Returns the current status for up to MAX_WATCH_IDS claim IDs.
137+
* Used by the frontend polling loop (useClaimWatcher).
138+
* Latency: indexer lag + cache TTL, typically < 30 s on Mainnet.
139+
*/
140+
@Get('status')
141+
@Throttle({ default: { limit: 30, ttl: 60_000 } })
142+
@ApiOperation({ summary: 'Poll current status for a set of watched claim IDs' })
143+
@ApiQuery({ name: 'claimId', required: true, isArray: true, type: String })
144+
@ApiResponse({ status: 200, description: 'Array of { claimId, status, updatedAt }' })
145+
async getClaimStatuses(
146+
@Query('claimId') claimId: string | string[],
147+
): Promise<{ claimId: string; status: string; updatedAt: string }[]> {
148+
const ids = (Array.isArray(claimId) ? claimId : [claimId]).slice(0, MAX_WATCH_IDS);
149+
if (ids.length === 0) throw new BadRequestException('At least one claimId is required.');
150+
return this.claimsService.getClaimStatuses(ids);
151+
}
152+
153+
// ── SSE stream for claim status changes ──────────────────────────────────
154+
155+
/**
156+
* GET /api/claims/status/stream?claimId=1&claimId=2
157+
* Server-Sent Events stream that pushes status-change events for watched claims.
158+
* Falls back gracefully — clients use polling if SSE is unavailable.
159+
* Max latency: indexer lag + push delay, typically < 15 s on Mainnet.
160+
*/
161+
@Get('status/stream')
162+
@Throttle({ default: { limit: 5, ttl: 60_000 } })
163+
@ApiOperation({ summary: 'SSE stream for watched claim status changes' })
164+
@ApiQuery({ name: 'claimId', required: true, isArray: true, type: String })
165+
@ApiResponse({ status: 200, description: 'text/event-stream' })
166+
streamClaimStatuses(
167+
@Query('claimId') claimId: string | string[],
168+
@Res() res: Response,
169+
): void {
170+
const ids = (Array.isArray(claimId) ? claimId : [claimId]).slice(0, MAX_WATCH_IDS);
171+
172+
res.setHeader('Content-Type', 'text/event-stream');
173+
res.setHeader('Cache-Control', 'no-cache');
174+
res.setHeader('Connection', 'keep-alive');
175+
res.setHeader('X-Accel-Buffering', 'no'); // disable nginx buffering
176+
res.flushHeaders();
177+
178+
const send = (data: object) => {
179+
res.write(`data: ${JSON.stringify(data)}\n\n`);
180+
};
181+
182+
// Send a heartbeat every 25 s to keep the connection alive through proxies.
183+
const heartbeat = setInterval(() => res.write(': heartbeat\n\n'), 25_000);
184+
185+
// Subscribe to status changes for the requested claim IDs.
186+
const unsubscribe = this.claimsService.subscribeToStatusChanges(ids, send);
187+
188+
res.on('close', () => {
189+
clearInterval(heartbeat);
190+
unsubscribe();
191+
});
192+
}

backend/src/claims/claims.service.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,4 +323,74 @@ export class ClaimsService {
323323

324324
return result;
325325
}
326+
327+
// ── Claim status polling & SSE ───────────────────────────────────────────
328+
329+
/**
330+
* Returns the current status for a set of claim IDs.
331+
* Used by the frontend polling loop (GET /api/claims/status).
332+
*/
333+
async getClaimStatuses(
334+
claimIds: string[],
335+
): Promise<{ claimId: string; status: string; updatedAt: string }[]> {
336+
const numericIds = claimIds.map(Number).filter((n) => !isNaN(n));
337+
if (numericIds.length === 0) return [];
338+
339+
const claims = await this.prisma.claim.findMany({
340+
where: { id: { in: numericIds } },
341+
select: { id: true, status: true, updatedAt: true },
342+
});
343+
344+
return claims.map((c) => ({
345+
claimId: String(c.id),
346+
status: c.status.toLowerCase(),
347+
updatedAt: c.updatedAt.toISOString(),
348+
}));
349+
}
350+
351+
/**
352+
* Subscribes a SSE client to status changes for the given claim IDs.
353+
* Returns an unsubscribe function to call when the client disconnects.
354+
*
355+
* Implementation: lightweight in-process pub/sub via a Map of listeners.
356+
* In a multi-instance deployment, replace with a Redis pub/sub channel.
357+
*/
358+
subscribeToStatusChanges(
359+
claimIds: string[],
360+
send: (data: object) => void,
361+
): () => void {
362+
const idSet = new Set(claimIds);
363+
364+
const listener = (update: { claimId: string; status: string; updatedAt: string }) => {
365+
if (idSet.has(update.claimId)) {
366+
send(update);
367+
}
368+
};
369+
370+
ClaimsService.statusListeners.add(listener);
371+
return () => ClaimsService.statusListeners.delete(listener);
372+
}
373+
374+
/**
375+
* Publishes a status-change event to all active SSE subscribers.
376+
* Call this from the indexer or queue consumer whenever a claim status changes.
377+
*/
378+
static publishStatusChange(update: {
379+
claimId: string;
380+
status: string;
381+
updatedAt: string;
382+
}): void {
383+
for (const listener of ClaimsService.statusListeners) {
384+
try {
385+
listener(update);
386+
} catch {
387+
// Ignore errors from individual listeners (e.g. closed connections).
388+
}
389+
}
390+
}
391+
392+
// In-process listener registry. Replace with Redis pub/sub for multi-instance.
393+
private static readonly statusListeners = new Set<
394+
(update: { claimId: string; status: string; updatedAt: string }) => void
395+
>();
326396
}

docs/claim-notification-latency.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Claim Status Notification Latency
2+
3+
## Overview
4+
5+
This document describes the expected end-to-end latency between an on-chain
6+
claim status change and a user receiving a browser notification or in-app toast.
7+
8+
---
9+
10+
## Latency Budget (Mainnet)
11+
12+
| Stage | Typical | Worst-case |
13+
|---|---|---|
14+
| Ledger close (Stellar Mainnet) | ~5 s | ~10 s |
15+
| Indexer ingestion lag | 1–3 ledgers (~5–15 s) | ~30 s during re-index |
16+
| Backend cache TTL (`CACHE_TTL_SECONDS`) | 0–60 s | 60 s |
17+
| Frontend polling interval (base) | 15 s | 60 s (after 2 failures) |
18+
| **Total (polling path)** | **~35 s** | **~150 s** |
19+
| SSE push delay (when SSE is active) | < 1 s after indexer ingestion | ~30 s |
20+
| **Total (SSE path)** | **~20 s** | **~60 s** |
21+
22+
> **Agreed maximum latency (SLO):** status changes surface within **2 minutes**
23+
> under normal operating conditions on Mainnet.
24+
25+
---
26+
27+
## Polling vs SSE
28+
29+
- The frontend attempts an SSE connection to `GET /api/claims/status/stream`
30+
first (`useRealtimeTallies` for tallies; `useClaimWatcher` for status).
31+
- If SSE is unavailable or errors, the client falls back to polling
32+
`GET /api/claims/status` with exponential backoff (base 15 s, cap 60 s).
33+
- Polling is **paused** when `document.visibilityState === "hidden"` (Page
34+
Visibility API) to reduce battery drain on mobile browsers.
35+
- Polling resumes immediately when the tab becomes visible again.
36+
37+
---
38+
39+
## Battery Impact
40+
41+
- Base polling interval is 15 s (vs 5 s for tally updates) to reduce wake-ups.
42+
- Backoff caps at 60 s after consecutive failures — no tight retry loops.
43+
- Tab-hidden pause eliminates background polling entirely on mobile.
44+
- SSE keeps a single persistent connection instead of repeated HTTP requests;
45+
a 25 s heartbeat comment keeps it alive through proxies without extra data.
46+
47+
---
48+
49+
## Indexer Lag
50+
51+
The backend indexer may lag behind the chain by 1–3 ledgers (~5–15 s) under
52+
normal conditions. During re-indexing or RPC downtime this can extend to ~30 s.
53+
The `ConsistencyMetadataDto.isStale` flag is set when `indexerLag > 5 ledgers`.
54+
55+
For trust-critical views (e.g. claim detail page), use chain reads via Soroban
56+
simulation which always reflect the current ledger state.
57+
58+
---
59+
60+
## Multi-instance Deployment Note
61+
62+
The current SSE implementation uses an in-process listener registry
63+
(`ClaimsService.statusListeners`). In a multi-instance deployment, replace this
64+
with a Redis pub/sub channel so all instances can push to all connected clients.
65+
See `backend/src/claims/claims.service.ts``subscribeToStatusChanges`.

frontend/src/components/claims/ClaimsBoard.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"use client";
22

33
// Feature: claims-board
4-
// Requirements: 1.1, 1.2, 1.3, 1.4, 4.1, 4.3, 6.1, 6.5, 9.2
4+
// Requirements: 1.1, 1.2, 1.3, 1.4, 4.1, 4.3, 6.1, 6.5, 9.2, 10.x
55

66
import React, { useState, useCallback, useRef, useEffect } from "react";
77

@@ -86,6 +86,22 @@ export function ClaimsBoard() {
8686
// ── Notifications (Req 10.1, 10.2, 10.3) ─────────────────────────────────
8787
useNotifications(localClaims, filters);
8888

89+
// ── Claim status change notifications ────────────────────────────────────
90+
const [notifEnabled, setNotifEnabled] = useState(false);
91+
// Read from localStorage after mount (avoids SSR mismatch).
92+
useEffect(() => {
93+
setNotifEnabled(getClaimNotificationsEnabled());
94+
}, []);
95+
96+
const { notify } = useClaimStatusNotifications(notifEnabled);
97+
98+
// Watch all currently visible claim IDs for status changes.
99+
useClaimWatcher({
100+
claimIds: claimIds,
101+
onStatusChange: notify,
102+
enabled: notifEnabled,
103+
});
104+
89105
// ── Real-time tally updates (Req 6.1, 6.5) ────────────────────────────────
90106
const claimIds = localClaims.map((c) => c.claim_id);
91107

@@ -118,6 +134,19 @@ export function ClaimsBoard() {
118134
// ── Render ────────────────────────────────────────────────────────────────
119135
return (
120136
<div className="flex flex-col gap-4">
137+
{/* Notification permission banner — shown once, never nagged */}
138+
<NotificationPermissionBanner
139+
onDismiss={() => setNotifEnabled(getClaimNotificationsEnabled())}
140+
/>
141+
142+
{/* Settings toggle (inline; move to a settings page as needed) */}
143+
<ClaimNotificationsToggle
144+
enabled={notifEnabled}
145+
onChange={(v) => {
146+
setClaimNotificationsEnabled(v);
147+
setNotifEnabled(v);
148+
}}
149+
/>
121150
{/* Re-auth prompt (Req 4.3) */}
122151
{showReauthPrompt && (
123152
<div

0 commit comments

Comments
 (0)