PageSourceSearch

https://smrtbld.ai/js/realtime/JobCostingHubClient.js

js smrtbld.ai collected 2026-10-05 15:08:21 UTC 6,446 bytes, 144 lines download raw bytes

1/**
2 * Job Costing real-time client.
3 *
4 * Reuses the existing NotificationHub backend (the project + activity-log
5 * groups already live there). Adds a Job-Costing-specific group + event:
6 *   client → JoinJobCostingProjectGroup(projectId)
7 *   server → emits 'JobCostingMutation' on every audit-log entry
8 *
9 * Phase 3.5d — multi-user awareness. When User A edits a cost code, User B
10 * (viewing the same project) sees the row pulse + the entry prepended to
11 * the activity log drawer in real time.
12 */
13
14import { SignalRBaseClient } from './SignalRBaseClient.js';
15
16class JobCostingHubClient extends SignalRBaseClient {
17    constructor() {
18        super('/hubs/notifications');
19        this._listeners = new Set();
20        this._joinedProjectId = null;
21    }
22
23    registerHubEvents(connection) {
24        connection.on('JobCostingMutation', (payload) => this._dispatch(payload));
25    }
26
27    /**
28     * Refuse to connect once the session boundary has closed.
29     *
30     * This is the enforcement point, not getJobCostingHub(): the getter can only decide WHICH
31     * client you get, and both mount-time consumers call start() on whatever it hands back
32     * (accounting-cost-analysis.js:1237, accounting-progress-applications.js:192). Without
33     * this override a post-teardown mount opens /hubs/notifications on a client that was never
34     * assigned to _instance — carrying the outgoing identity if the token is still live, and
35     * otherwise 401ing into _scheduleReconnect's ten backoff retries from the login page with
36     * nothing able to stop it. It also closes the reconnect path: the retry timer runs under a generation that stop() bumps (CA-547).
37     *
38     * NOTE: `_boundaryClosed` is declared at module scope BELOW this class. That is safe —
39     * this body only ever runs after module evaluation — so do not "fix" it by hoisting the
40     * declaration above the class.
41     */
42    async start() {
43        if (_boundaryClosed) return false;
44        return super.start();
45    }
46
47    onEstablished() {
48        // Rejoin on connect AND every reconnect (CA-547).
49        if (this._joinedProjectId) {
50            this.invoke('JoinJobCostingProjectGroup', Number(this._joinedProjectId)).catch(() => {});
51        }
52    }
53
54    onMaxRetriesExceeded() {
55        // Silent 60 s retry like the app-lifetime hubs — no indicator exists (MCF-5467); stop() clears it (CA-547).
56        this.retryTimeout = setTimeout(() => this.retryConnection(), 60000);
57    }
58
59    /**
60     * Subscribe to mutations for a specific project. Idempotent — calling
61     * with the same projectId twice is a no-op; calling with a different
62     * projectId leaves the old group first.
63     */
64    async joinProject(projectId) {
65        if (!projectId) return;
66        if (this._joinedProjectId === projectId) return;
67        if (this._joinedProjectId) {
68            try { await this.invoke('LeaveJobCostingProjectGroup', Number(this._joinedProjectId)); } catch { /* ignore */ }
69        }
70        this._joinedProjectId = projectId;
71        try { await this.invoke('JoinJobCostingProjectGroup', Number(projectId)); } catch { /* swallow — caller can retry */ }
72    }
73
74    async leaveCurrentProject() {
75        if (!this._joinedProjectId) return;
76        const id = this._joinedProjectId;
77        this._joinedProjectId = null;
78        try { await this.invoke('LeaveJobCostingProjectGroup', Number(id)); } catch { /* ignore */ }
79    }
80
81    /** Subscribe to incoming mutation events. Returns an unsubscribe function. */
82    onMutation(callback) {
83        this._listeners.add(callback);
84        return () => this._listeners.delete(callback);
85    }
86
87    _dispatch(payload) {
88        for (const cb of this._listeners) {
89            try { cb(payload); } catch (err) { console.error('[JobCostingHub] listener error', err); }
90        }
91    }
92}
93
94// Singleton — one connection per browser tab is plenty.
95let _instance = null;
96
97// Closed at the session boundary and re-opened by loadAppShell. On the 401-preempt path
98// handleTransition keeps mounting a page AFTER the teardown ran, so without this a
99// getJobCostingHub() from that partial mount would build a fresh client and open a new
100// socket carrying the OUTGOING session's token (SignalR fixes identity at build time).
101// handleTransition then disposes the partial owner, but both accounting destroys call
102// leaveCurrentProject() only — never stop() — so that socket would survive.
103let _boundaryClosed = false;
104
105export function getJobCostingHub() {
106    // A throwaway satisfies the destroy-time callers (leaveCurrentProject() returns
107    // immediately with _joinedProjectId null) and cannot connect for the mount-time ones,
108    // because start() refuses while the boundary is closed. The refusal lives on the class,
109    // NOT here — this getter cannot stop a caller starting the client it hands back.
110    if (_boundaryClosed) return new JobCostingHubClient();
111    if (!_instance) _instance = new JobCostingHubClient();
112    return _instance;
113}
114
115/** Re-open the boundary for a new authenticated session. Called from loadAppShell. */
116export function resetJobCostingHubBoundary() {
117    _boundaryClosed = false;
118}
119
120/**
121 * Stop and discard the connection at the authenticated session boundary.
122 *
123 * SignalR reads accessTokenFactory when a connection is BUILT (SignalRBaseClient:132-134),
124 * so an already-open socket keeps the identity established at connect. Left running across
125 * a logout it would carry the previous user into the next session, and that session's
126 * JoinJobCostingProjectGroup invocations would ride it. Called from
127 * teardownAuthenticatedServices (router.js) — mirrors stopMessageHub (MessageHubClient:264-269).
128 *
129 * _joinedProjectId and the listener set live on the subclass, so a bare stop() would leave
130 * them set and onEstablished() would re-join the previous project group on the next connect.
131 * Both are cleared first. The base stop() cannot throw — its only throwing call is
132 * try/caught (SignalRBaseClient:222-226) and _setState guards its callback (:83-89).
133 */
134export async function stopJobCostingHub() {
135    // Closed FIRST and unconditionally: the common case is that no Job Costing page was ever
136    // opened, and the boundary must still refuse a post-teardown build.
137    _boundaryClosed = true;
138    if (!_instance) return;
139
140    _instance._joinedProjectId = null;
141    _instance._listeners.clear();
142    await _instance.stop();
143    _instance = null;
144}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.