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>
194 lines
8.0 KiB
TypeScript
194 lines
8.0 KiB
TypeScript
/**
|
|
* 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.
|
|
}
|
|
}
|
|
}
|