/** * 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; } /** 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>({}); private readonly totalSubject = new BehaviorSubject(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 | 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) => { 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('/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. } } }