feat(approvals): live pending-count badges on every Approval Center tab and the Approvals menu
The badges are pushed, not polled. ApprovalCountsService opens one Server-Sent Events stream (/approvals/counts/stream) through HttpClient so the login and tenant headers apply; the server writes a `counts` event the moment the numbers change. - Total badge on the Approvals menu, count badge on 26 Approval Center tabs (Reject Account and Terminated Account are lists, not requests: no badge). - Last numbers are kept per login in localStorage, so a page refresh shows them at once until the stream's first event makes them exact. - Stream recycles every few minutes and reconnects at once; after a failure it retries with a growing wait and reads /approvals/counts meanwhile. - NO_PROGRESS_BAR token: the background stream does not run the top progress bar. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Injectable, OnDestroy, inject } from '@angular/core';
|
||||
import { HttpClient, HttpContext, HttpDownloadProgressEvent, HttpEvent, HttpEventType } from '@angular/common/http';
|
||||
import { BehaviorSubject, Subscription } from 'rxjs';
|
||||
import { AuthenticationService } from '../core/authentication/authentication.service';
|
||||
import { SILENT_HTTP_ERROR } from '../core/http/silent-error.token';
|
||||
import { NO_PROGRESS_BAR } from '../core/progress-bar/no-progress.token';
|
||||
|
||||
/** What the server pushes: the total and, for each Approval Center tab with something waiting, its number. */
|
||||
interface ApprovalCounts {
|
||||
total: number;
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
/** After a stream drops, the first wait before reconnecting; it doubles up to the cap. */
|
||||
const RETRY_MS = 2_000;
|
||||
const RETRY_MAX_MS = 60_000;
|
||||
|
||||
/** A stream that ends sooner than this after opening is treated as a failure. */
|
||||
const MIN_STREAM_MS = 3_000;
|
||||
|
||||
/**
|
||||
* How many requests wait on each Approval Center tab, and the total for the Approvals menu.
|
||||
*
|
||||
* The numbers are pushed, not polled: the service opens one Server-Sent Events stream, GET /approvals/counts/stream, and
|
||||
* the server writes a `counts` event the moment the numbers change, so a request raised or actioned by anyone moves the
|
||||
* badge within about a second with no reloads and no traffic while nothing changes. The server answers numbers only
|
||||
* (no rows); a tab still loads its own list when it is opened, and applies its own table, status, office scope and
|
||||
* permission, so a tab the login may not see is absent.
|
||||
*
|
||||
* The stream goes through HttpClient (not EventSource, which cannot send the login's headers), so the usual
|
||||
* authentication and tenant headers apply. It is silent (see SILENT_HTTP_ERROR, NO_PROGRESS_BAR): no error popup and no
|
||||
* progress bar. The server ends a stream every few minutes and the service reconnects at once; after a failure it keeps
|
||||
* the last numbers and retries with a growing wait.
|
||||
*
|
||||
* A browser tab that is hidden keeps its stream: it is one idle connection, and the badge is right when it is shown.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApprovalCountsService implements OnDestroy {
|
||||
private http = inject(HttpClient);
|
||||
private authenticationService = inject(AuthenticationService);
|
||||
|
||||
private readonly countsSubject = new BehaviorSubject<Record<string, number>>({});
|
||||
private readonly totalSubject = new BehaviorSubject<number>(0);
|
||||
/** Pending count per tab key; a tab with nothing waiting is absent. */
|
||||
readonly counts$ = this.countsSubject.asObservable();
|
||||
/** Everything waiting across all the tabs the signed-in user can see. */
|
||||
readonly total$ = this.totalSubject.asObservable();
|
||||
|
||||
private active = false;
|
||||
private failures = 0;
|
||||
private stream: Subscription | null = null;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Whether this login reaches the Approval Center at all, so nothing is asked for everyone else. */
|
||||
get canSeeApprovals(): boolean {
|
||||
const permissions = this.authenticationService.getCredentials()?.permissions ?? [];
|
||||
return permissions.includes('ALL_FUNCTIONS') || permissions.includes('MENU_TASKS');
|
||||
}
|
||||
|
||||
/** Opens the stream (once; a second call changes nothing). */
|
||||
start(): void {
|
||||
if (this.active || !this.canSeeApprovals) return;
|
||||
this.active = true;
|
||||
// After a page refresh the last numbers show at once; the stream's first event (a moment later) makes them exact.
|
||||
this.restore();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.active = false;
|
||||
this.stream?.unsubscribe();
|
||||
this.stream = null;
|
||||
if (this.retryTimer) clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
this.failures = 0;
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stop();
|
||||
}
|
||||
|
||||
private connect(): void {
|
||||
if (!this.active) return;
|
||||
// Text already handled, so each progress event is only read from where the last one ended.
|
||||
let consumed = 0;
|
||||
const startedAt = Date.now();
|
||||
this.stream = this.http
|
||||
.get('/approvals/counts/stream', {
|
||||
observe: 'events',
|
||||
reportProgress: true,
|
||||
responseType: 'text',
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
|
||||
})
|
||||
.subscribe({
|
||||
next: (event: HttpEvent<string>) => {
|
||||
if (event.type !== HttpEventType.DownloadProgress) return;
|
||||
const text = (event as HttpDownloadProgressEvent).partialText ?? '';
|
||||
consumed = this.read(text, consumed);
|
||||
},
|
||||
// The server closes a stream after a few minutes: connect again straight away. One that ended almost at once
|
||||
// was not a stream at all (a proxy answering for it, say), so that is a failure and backs off.
|
||||
complete: () => (Date.now() - startedAt < MIN_STREAM_MS ? this.retryLater() : this.connect()),
|
||||
error: () => this.retryLater()
|
||||
});
|
||||
}
|
||||
|
||||
private retryLater(): void {
|
||||
this.failures += 1;
|
||||
// While the stream is down (an older server without it, a proxy that blocks it) the badge is still kept right by a
|
||||
// plain read each time the retry comes round; the stream takes over again as soon as it connects.
|
||||
this.http
|
||||
.get<ApprovalCounts>('/approvals/counts', {
|
||||
params: { fresh: 'true' },
|
||||
context: new HttpContext().set(SILENT_HTTP_ERROR, true).set(NO_PROGRESS_BAR, true)
|
||||
})
|
||||
.subscribe({
|
||||
next: (answer) => {
|
||||
this.countsSubject.next(answer.counts ?? {});
|
||||
this.totalSubject.next(answer.total ?? 0);
|
||||
this.remember(answer);
|
||||
},
|
||||
error: () => undefined
|
||||
});
|
||||
const wait = Math.min(RETRY_MAX_MS, RETRY_MS * 2 ** (this.failures - 1));
|
||||
this.retryTimer = setTimeout(() => this.connect(), wait);
|
||||
}
|
||||
|
||||
/** Reads every complete event after `from` (events end with a blank line) and returns how far it got. */
|
||||
private read(text: string, from: number): number {
|
||||
let position = from;
|
||||
let end = text.indexOf('\n\n', position);
|
||||
while (end !== -1) {
|
||||
this.handle(text.slice(position, end));
|
||||
position = end + 2;
|
||||
end = text.indexOf('\n\n', position);
|
||||
}
|
||||
return position;
|
||||
}
|
||||
|
||||
private handle(block: string): void {
|
||||
let name = 'message';
|
||||
const data: string[] = [];
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('event:')) name = line.slice(6).trim();
|
||||
else if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
// Comment lines (the server's keep-alive pings) carry no event and are ignored.
|
||||
if (name !== 'counts' || data.length === 0) return;
|
||||
try {
|
||||
const answer = JSON.parse(data.join('\n')) as ApprovalCounts;
|
||||
this.failures = 0;
|
||||
this.countsSubject.next(answer.counts ?? {});
|
||||
this.totalSubject.next(answer.total ?? 0);
|
||||
this.remember(answer);
|
||||
} catch {
|
||||
// A damaged event is skipped; the next change sends the numbers again.
|
||||
}
|
||||
}
|
||||
|
||||
/** Storage is per login, so one user's numbers are never shown to the next person on the same browser. */
|
||||
private storageKey(): string {
|
||||
return `approvalCounts:${this.authenticationService.getCredentials()?.username ?? ''}`;
|
||||
}
|
||||
|
||||
private remember(answer: ApprovalCounts): void {
|
||||
try {
|
||||
localStorage.setItem(this.storageKey(), JSON.stringify({ total: answer.total, counts: answer.counts }));
|
||||
} catch {
|
||||
// Storage can be blocked or full; the badge simply starts empty after a refresh.
|
||||
}
|
||||
}
|
||||
|
||||
private restore(): void {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(this.storageKey()) ?? 'null') as ApprovalCounts | null;
|
||||
if (saved && typeof saved.total === 'number' && saved.counts) {
|
||||
this.countsSubject.next(saved.counts);
|
||||
this.totalSubject.next(saved.total);
|
||||
}
|
||||
} catch {
|
||||
// Unreadable or missing: start empty and wait for the first event.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user