Skip to content

Commit d742771

Browse files
authored
Merge pull request #77 from fleetbase/feature/session-lifecycle-events
feat: robust session lifecycle events (user.loaded, user.deauthenticated, session.authenticated)
2 parents 9769481 + d658250 commit d742771

4 files changed

Lines changed: 387 additions & 41 deletions

File tree

addon/services/current-user.js

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ import { storageFor } from 'ember-local-storage';
1010
import { debug } from '@ember/debug';
1111
import lookupUserIp from '../utils/lookup-user-ip';
1212

13+
/**
14+
* CurrentUserService
15+
*
16+
* Manages the authenticated user's identity and preferences. Extends Evented
17+
* so that any service or component can subscribe to user lifecycle events
18+
* directly on this service.
19+
*
20+
* Session lifecycle events emitted (on both this service and via EventsService
21+
* which re-broadcasts them on the universe bus for cross-engine listeners):
22+
*
23+
* user.loaded — fired after a successful login or session restore.
24+
* Payload: (user, organization, properties)
25+
*
26+
* user.updated — fired when the user record is refreshed in-session
27+
* (e.g. profile edit). Payload: (user, properties)
28+
*
29+
* user.organization_switched — fired when the user switches active org.
30+
* Payload: (organization, properties)
31+
*/
1332
export default class CurrentUserService extends Service.extend(Evented) {
1433
@service session;
1534
@service store;
@@ -18,6 +37,7 @@ export default class CurrentUserService extends Service.extend(Evented) {
1837
@service notifications;
1938
@service intl;
2039
@service events;
40+
@service universe;
2141

2242
@tracked user = { id: 'anon' };
2343
@tracked userSnapshot = { id: 'anon' };
@@ -302,14 +322,47 @@ export default class CurrentUserService extends Service.extend(Evented) {
302322
return defaultValue;
303323
}
304324

325+
/**
326+
* Sets the current user and fires all user.loaded lifecycle events.
327+
*
328+
* This is the canonical place where the authenticated user identity is
329+
* established. It fires:
330+
*
331+
* 1. `this.trigger('user.loaded', user)` — on the currentUser service
332+
* itself (Evented), for direct service-level listeners.
333+
*
334+
* 2. `this.events.trackUserLoaded(user, organization)` — on the events
335+
* service, which re-broadcasts on both the events bus and the universe
336+
* bus so cross-engine listeners (Intercom, PostHog, Attio, etc.) can
337+
* subscribe via `universe.on('user.loaded', handler)`.
338+
*
339+
* @param {Model} user
340+
*/
305341
async setUser(user) {
306342
const snapshot = await this.getUserSnapshot(user);
307343

308344
// Set current user
309345
this.set('user', user);
310346
this.set('userSnapshot', snapshot);
347+
348+
// Resolve the organization for event payload
349+
const organization = this.store.peekRecord('company', user.get('company_uuid'));
350+
351+
// 1. Trigger on the currentUser Evented bus (backward-compatible)
311352
this.trigger('user.loaded', user);
312353

354+
// 2. Fire through the events service — broadcasts on both events bus
355+
// and universe bus for cross-engine listeners
356+
if (this.events) {
357+
this.events.trackUserLoaded(user, organization);
358+
}
359+
360+
// 3. Trigger directly on universe for framework-level uniformity —
361+
// guarantees delivery to all engines on the shared bus
362+
if (this.universe) {
363+
this.universe.trigger('user.loaded', user, organization);
364+
}
365+
313366
// Set permissions
314367
this.permissions = this.getUserPermissions(user);
315368

@@ -323,4 +376,55 @@ export default class CurrentUserService extends Service.extend(Evented) {
323376
await this.loadLocale();
324377
}
325378
}
379+
380+
/**
381+
* Fires a user.updated event when the user record is refreshed in-session.
382+
* Call this after any in-session profile update to keep integrations in sync.
383+
*
384+
* @param {Model} user
385+
*/
386+
async refreshUser(user) {
387+
const snapshot = await this.getUserSnapshot(user);
388+
this.set('user', user);
389+
this.set('userSnapshot', snapshot);
390+
391+
const organization = this.store.peekRecord('company', user.get('company_uuid'));
392+
393+
this.trigger('user.updated', user);
394+
395+
if (this.events) {
396+
this.events.trackEvent('user.updated', {
397+
user_id: user?.id,
398+
organization_id: organization?.id,
399+
organization_name: organization?.name,
400+
});
401+
}
402+
403+
if (this.universe) {
404+
this.universe.trigger('user.updated', user, organization);
405+
}
406+
}
407+
408+
/**
409+
* Fires a user.organization_switched event when the user changes their
410+
* active organization. Call this after a successful org switch.
411+
*
412+
* @param {Model} organization
413+
*/
414+
switchOrganization(organization) {
415+
this.company = organization;
416+
417+
this.trigger('user.organization_switched', organization);
418+
419+
if (this.events) {
420+
this.events.trackEvent('user.organization_switched', {
421+
organization_id: organization?.id,
422+
organization_name: organization?.name,
423+
});
424+
}
425+
426+
if (this.universe) {
427+
this.universe.trigger('user.organization_switched', organization);
428+
}
429+
}
326430
}

addon/services/events.js

Lines changed: 167 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,66 @@ import Evented from '@ember/object/evented';
33
import config from 'ember-get-config';
44

55
/**
6-
* Events Service
6+
* EventsService
77
*
8-
* Provides a centralized event tracking system for Fleetbase.
9-
* This service emits standardized events on both its own event bus and the universe service,
10-
* allowing components, services, and engines to subscribe and react to application events.
8+
* Provides a centralized, standardized event tracking system for Fleetbase.
9+
*
10+
* This service is the single source of truth for all application lifecycle
11+
* events. It emits every event on two buses simultaneously:
12+
*
13+
* 1. Its own Evented bus — for direct service/component listeners:
14+
* this.events.on('user.loaded', handler)
15+
*
16+
* 2. The universe service bus — for cross-engine listeners (recommended
17+
* for use in Ember Engines / extensions):
18+
* this.universe.on('user.loaded', handler)
19+
*
20+
* ─────────────────────────────────────────────────────────────────────────────
21+
* Session Lifecycle Events
22+
* ─────────────────────────────────────────────────────────────────────────────
23+
*
24+
* session.authenticated Fired after a successful login or session restore.
25+
* Payload: (properties)
26+
*
27+
* session.invalidated Fired after the session is destroyed (logout).
28+
* Payload: (duration_seconds, properties)
29+
*
30+
* session.terminated Alias for session.invalidated — provided for
31+
* backward compatibility and semantic clarity.
32+
* Payload: (duration_seconds, properties)
33+
*
34+
* user.loaded Fired after the authenticated user record and
35+
* organization have been fully loaded into the
36+
* currentUser service. This is the canonical event
37+
* for integrations to boot (Intercom, PostHog, etc.)
38+
* Payload: (user, organization, properties)
39+
*
40+
* user.updated Fired when the user record is refreshed in-session
41+
* (e.g. after a profile edit).
42+
* Payload: (user, properties)
43+
*
44+
* user.deauthenticated Fired when the user identity is cleared on logout.
45+
* Semantic alias for session.invalidated — use this
46+
* to shut down integrations cleanly (Intercom, etc.)
47+
* Payload: (duration_seconds, properties)
48+
*
49+
* user.organization_switched Fired when the user switches their active org.
50+
* Payload: (organization, properties)
51+
*
52+
* ─────────────────────────────────────────────────────────────────────────────
53+
* Resource Events
54+
* ─────────────────────────────────────────────────────────────────────────────
55+
*
56+
* resource.created Generic resource creation.
57+
* resource.updated Generic resource update.
58+
* resource.deleted Generic resource deletion.
59+
* resource.imported Bulk import.
60+
* resource.exported Export.
61+
* resource.bulk_action Bulk action (delete, archive, etc.)
62+
* {modelName}.created Model-specific creation (e.g. order.created)
63+
* {modelName}.updated Model-specific update.
64+
* {modelName}.deleted Model-specific deletion.
65+
* {modelName}.exported Model-specific export.
1166
*
1267
* @class EventsService
1368
* @extends Service
@@ -16,6 +71,108 @@ export default class EventsService extends Service.extend(Evented) {
1671
@service universe;
1772
@service currentUser;
1873

74+
// =========================================================================
75+
// Session Lifecycle Tracking
76+
// =========================================================================
77+
78+
/**
79+
* Tracks a successful authentication (login or session restore).
80+
*
81+
* Called by SessionService.handleAuthentication().
82+
*
83+
* @param {Object} [props={}] - Additional properties to include
84+
*/
85+
trackSessionAuthenticated(props = {}) {
86+
const properties = this.#enrichProperties(props);
87+
this.#trigger('session.authenticated', properties);
88+
}
89+
90+
/**
91+
* Tracks when a user session is terminated (logout).
92+
*
93+
* Called by SessionService.handleInvalidation(). Also fires the semantic
94+
* `user.deauthenticated` event so integrations can react to the user
95+
* identity being cleared without needing to know about session internals.
96+
*
97+
* @param {Number|null} duration - Session duration in seconds (null if unknown)
98+
* @param {Object} [props={}] - Additional properties to include
99+
*/
100+
trackSessionTerminated(duration, props = {}) {
101+
const properties = this.#enrichProperties({
102+
session_duration: duration,
103+
...props,
104+
});
105+
106+
// Fire session.invalidated (technical event)
107+
this.#trigger('session.invalidated', duration, properties);
108+
109+
// Fire session.terminated (backward-compatible alias)
110+
this.#trigger('session.terminated', duration, properties);
111+
112+
// Fire user.deauthenticated (semantic event for integrations)
113+
this.#trigger('user.deauthenticated', duration, properties);
114+
}
115+
116+
/**
117+
* Tracks when the current user is loaded (session initialized).
118+
*
119+
* Called by CurrentUserService.setUser() after a successful login or
120+
* session restore. This is the canonical event for integrations to boot.
121+
*
122+
* @param {Object} user - The authenticated user model
123+
* @param {Object} organization - The user's active organization model
124+
* @param {Object} [props={}] - Additional properties to include
125+
*/
126+
trackUserLoaded(user, organization, props = {}) {
127+
const properties = this.#enrichProperties({
128+
user_id: user?.id,
129+
organization_id: organization?.id,
130+
organization_name: organization?.name,
131+
...props,
132+
});
133+
134+
this.#trigger('user.loaded', user, organization, properties);
135+
}
136+
137+
/**
138+
* Tracks when the user record is refreshed in-session (e.g. profile edit).
139+
*
140+
* Called by CurrentUserService.refreshUser().
141+
*
142+
* @param {Object} user - The updated user model
143+
* @param {Object} [props={}] - Additional properties to include
144+
*/
145+
trackUserUpdated(user, props = {}) {
146+
const properties = this.#enrichProperties({
147+
user_id: user?.id,
148+
...props,
149+
});
150+
151+
this.#trigger('user.updated', user, properties);
152+
}
153+
154+
/**
155+
* Tracks when the user switches their active organization.
156+
*
157+
* Called by CurrentUserService.switchOrganization().
158+
*
159+
* @param {Object} organization - The new active organization model
160+
* @param {Object} [props={}] - Additional properties to include
161+
*/
162+
trackOrganizationSwitched(organization, props = {}) {
163+
const properties = this.#enrichProperties({
164+
organization_id: organization?.id,
165+
organization_name: organization?.name,
166+
...props,
167+
});
168+
169+
this.#trigger('user.organization_switched', organization, properties);
170+
}
171+
172+
// =========================================================================
173+
// Resource Event Tracking
174+
// =========================================================================
175+
19176
/**
20177
* Tracks the creation of a resource
21178
*
@@ -131,38 +288,9 @@ export default class EventsService extends Service.extend(Evented) {
131288
this.#trigger('resource.bulk_action', verb, resources, firstResource, properties);
132289
}
133290

134-
/**
135-
* Tracks when the current user is loaded (session initialized)
136-
*
137-
* @param {Object} user - The user object
138-
* @param {Object} organization - The organization object
139-
* @param {Object} [props={}] - Additional properties to include
140-
*/
141-
trackUserLoaded(user, organization, props = {}) {
142-
const properties = this.#enrichProperties({
143-
user_id: user?.id,
144-
organization_id: organization?.id,
145-
organization_name: organization?.name,
146-
...props,
147-
});
148-
149-
this.#trigger('user.loaded', user, organization, properties);
150-
}
151-
152-
/**
153-
* Tracks when a user session is terminated
154-
*
155-
* @param {Number} duration - Session duration in seconds
156-
* @param {Object} [props={}] - Additional properties to include
157-
*/
158-
trackSessionTerminated(duration, props = {}) {
159-
const properties = this.#enrichProperties({
160-
session_duration: duration,
161-
...props,
162-
});
163-
164-
this.#trigger('session.terminated', duration, properties);
165-
}
291+
// =========================================================================
292+
// Generic Event Tracking
293+
// =========================================================================
166294

167295
/**
168296
* Tracks a generic custom event
@@ -190,11 +318,11 @@ export default class EventsService extends Service.extend(Evented) {
190318
// =========================================================================
191319

192320
/**
193-
* Triggers an event on both the events service and universe service
321+
* Triggers an event on both the events service and universe service.
194322
*
195323
* This dual event system allows listeners to subscribe to events on either:
196-
* - this.events.on('event.name', handler) - Local listeners
197-
* - this.universe.on('event.name', handler) - Cross-engine listeners
324+
* - this.events.on('event.name', handler) — local listeners
325+
* - this.universe.on('event.name', handler) — cross-engine listeners
198326
*
199327
* @private
200328
* @param {String} eventName - The event name

0 commit comments

Comments
 (0)