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.