diff --git a/addon/services/current-user.js b/addon/services/current-user.js index d8fdda5..fafe337 100644 --- a/addon/services/current-user.js +++ b/addon/services/current-user.js @@ -10,6 +10,25 @@ import { storageFor } from 'ember-local-storage'; import { debug } from '@ember/debug'; import lookupUserIp from '../utils/lookup-user-ip'; +/** + * CurrentUserService + * + * Manages the authenticated user's identity and preferences. Extends Evented + * so that any service or component can subscribe to user lifecycle events + * directly on this service. + * + * Session lifecycle events emitted (on both this service and via EventsService + * which re-broadcasts them on the universe bus for cross-engine listeners): + * + * user.loaded — fired after a successful login or session restore. + * Payload: (user, organization, properties) + * + * user.updated — fired when the user record is refreshed in-session + * (e.g. profile edit). Payload: (user, properties) + * + * user.organization_switched — fired when the user switches active org. + * Payload: (organization, properties) + */ export default class CurrentUserService extends Service.extend(Evented) { @service session; @service store; @@ -18,6 +37,7 @@ export default class CurrentUserService extends Service.extend(Evented) { @service notifications; @service intl; @service events; + @service universe; @tracked user = { id: 'anon' }; @tracked userSnapshot = { id: 'anon' }; @@ -302,14 +322,47 @@ export default class CurrentUserService extends Service.extend(Evented) { return defaultValue; } + /** + * Sets the current user and fires all user.loaded lifecycle events. + * + * This is the canonical place where the authenticated user identity is + * established. It fires: + * + * 1. `this.trigger('user.loaded', user)` — on the currentUser service + * itself (Evented), for direct service-level listeners. + * + * 2. `this.events.trackUserLoaded(user, organization)` — on the events + * service, which re-broadcasts on both the events bus and the universe + * bus so cross-engine listeners (Intercom, PostHog, Attio, etc.) can + * subscribe via `universe.on('user.loaded', handler)`. + * + * @param {Model} user + */ async setUser(user) { const snapshot = await this.getUserSnapshot(user); // Set current user this.set('user', user); this.set('userSnapshot', snapshot); + + // Resolve the organization for event payload + const organization = this.store.peekRecord('company', user.get('company_uuid')); + + // 1. Trigger on the currentUser Evented bus (backward-compatible) this.trigger('user.loaded', user); + // 2. Fire through the events service — broadcasts on both events bus + // and universe bus for cross-engine listeners + if (this.events) { + this.events.trackUserLoaded(user, organization); + } + + // 3. Trigger directly on universe for framework-level uniformity — + // guarantees delivery to all engines on the shared bus + if (this.universe) { + this.universe.trigger('user.loaded', user, organization); + } + // Set permissions this.permissions = this.getUserPermissions(user); @@ -323,4 +376,55 @@ export default class CurrentUserService extends Service.extend(Evented) { await this.loadLocale(); } } + + /** + * Fires a user.updated event when the user record is refreshed in-session. + * Call this after any in-session profile update to keep integrations in sync. + * + * @param {Model} user + */ + async refreshUser(user) { + const snapshot = await this.getUserSnapshot(user); + this.set('user', user); + this.set('userSnapshot', snapshot); + + const organization = this.store.peekRecord('company', user.get('company_uuid')); + + this.trigger('user.updated', user); + + if (this.events) { + this.events.trackEvent('user.updated', { + user_id: user?.id, + organization_id: organization?.id, + organization_name: organization?.name, + }); + } + + if (this.universe) { + this.universe.trigger('user.updated', user, organization); + } + } + + /** + * Fires a user.organization_switched event when the user changes their + * active organization. Call this after a successful org switch. + * + * @param {Model} organization + */ + switchOrganization(organization) { + this.company = organization; + + this.trigger('user.organization_switched', organization); + + if (this.events) { + this.events.trackEvent('user.organization_switched', { + organization_id: organization?.id, + organization_name: organization?.name, + }); + } + + if (this.universe) { + this.universe.trigger('user.organization_switched', organization); + } + } } diff --git a/addon/services/events.js b/addon/services/events.js index c239310..09fdb2f 100644 --- a/addon/services/events.js +++ b/addon/services/events.js @@ -3,11 +3,66 @@ import Evented from '@ember/object/evented'; import config from 'ember-get-config'; /** - * Events Service + * EventsService * - * Provides a centralized event tracking system for Fleetbase. - * This service emits standardized events on both its own event bus and the universe service, - * allowing components, services, and engines to subscribe and react to application events. + * Provides a centralized, standardized event tracking system for Fleetbase. + * + * This service is the single source of truth for all application lifecycle + * events. It emits every event on two buses simultaneously: + * + * 1. Its own Evented bus — for direct service/component listeners: + * this.events.on('user.loaded', handler) + * + * 2. The universe service bus — for cross-engine listeners (recommended + * for use in Ember Engines / extensions): + * this.universe.on('user.loaded', handler) + * + * ───────────────────────────────────────────────────────────────────────────── + * Session Lifecycle Events + * ───────────────────────────────────────────────────────────────────────────── + * + * session.authenticated Fired after a successful login or session restore. + * Payload: (properties) + * + * session.invalidated Fired after the session is destroyed (logout). + * Payload: (duration_seconds, properties) + * + * session.terminated Alias for session.invalidated — provided for + * backward compatibility and semantic clarity. + * Payload: (duration_seconds, properties) + * + * user.loaded Fired after the authenticated user record and + * organization have been fully loaded into the + * currentUser service. This is the canonical event + * for integrations to boot (Intercom, PostHog, etc.) + * Payload: (user, organization, properties) + * + * user.updated Fired when the user record is refreshed in-session + * (e.g. after a profile edit). + * Payload: (user, properties) + * + * user.deauthenticated Fired when the user identity is cleared on logout. + * Semantic alias for session.invalidated — use this + * to shut down integrations cleanly (Intercom, etc.) + * Payload: (duration_seconds, properties) + * + * user.organization_switched Fired when the user switches their active org. + * Payload: (organization, properties) + * + * ───────────────────────────────────────────────────────────────────────────── + * Resource Events + * ───────────────────────────────────────────────────────────────────────────── + * + * resource.created Generic resource creation. + * resource.updated Generic resource update. + * resource.deleted Generic resource deletion. + * resource.imported Bulk import. + * resource.exported Export. + * resource.bulk_action Bulk action (delete, archive, etc.) + * {modelName}.created Model-specific creation (e.g. order.created) + * {modelName}.updated Model-specific update. + * {modelName}.deleted Model-specific deletion. + * {modelName}.exported Model-specific export. * * @class EventsService * @extends Service @@ -16,6 +71,108 @@ export default class EventsService extends Service.extend(Evented) { @service universe; @service currentUser; + // ========================================================================= + // Session Lifecycle Tracking + // ========================================================================= + + /** + * Tracks a successful authentication (login or session restore). + * + * Called by SessionService.handleAuthentication(). + * + * @param {Object} [props={}] - Additional properties to include + */ + trackSessionAuthenticated(props = {}) { + const properties = this.#enrichProperties(props); + this.#trigger('session.authenticated', properties); + } + + /** + * Tracks when a user session is terminated (logout). + * + * Called by SessionService.handleInvalidation(). Also fires the semantic + * `user.deauthenticated` event so integrations can react to the user + * identity being cleared without needing to know about session internals. + * + * @param {Number|null} duration - Session duration in seconds (null if unknown) + * @param {Object} [props={}] - Additional properties to include + */ + trackSessionTerminated(duration, props = {}) { + const properties = this.#enrichProperties({ + session_duration: duration, + ...props, + }); + + // Fire session.invalidated (technical event) + this.#trigger('session.invalidated', duration, properties); + + // Fire session.terminated (backward-compatible alias) + this.#trigger('session.terminated', duration, properties); + + // Fire user.deauthenticated (semantic event for integrations) + this.#trigger('user.deauthenticated', duration, properties); + } + + /** + * Tracks when the current user is loaded (session initialized). + * + * Called by CurrentUserService.setUser() after a successful login or + * session restore. This is the canonical event for integrations to boot. + * + * @param {Object} user - The authenticated user model + * @param {Object} organization - The user's active organization model + * @param {Object} [props={}] - Additional properties to include + */ + trackUserLoaded(user, organization, props = {}) { + const properties = this.#enrichProperties({ + user_id: user?.id, + organization_id: organization?.id, + organization_name: organization?.name, + ...props, + }); + + this.#trigger('user.loaded', user, organization, properties); + } + + /** + * Tracks when the user record is refreshed in-session (e.g. profile edit). + * + * Called by CurrentUserService.refreshUser(). + * + * @param {Object} user - The updated user model + * @param {Object} [props={}] - Additional properties to include + */ + trackUserUpdated(user, props = {}) { + const properties = this.#enrichProperties({ + user_id: user?.id, + ...props, + }); + + this.#trigger('user.updated', user, properties); + } + + /** + * Tracks when the user switches their active organization. + * + * Called by CurrentUserService.switchOrganization(). + * + * @param {Object} organization - The new active organization model + * @param {Object} [props={}] - Additional properties to include + */ + trackOrganizationSwitched(organization, props = {}) { + const properties = this.#enrichProperties({ + organization_id: organization?.id, + organization_name: organization?.name, + ...props, + }); + + this.#trigger('user.organization_switched', organization, properties); + } + + // ========================================================================= + // Resource Event Tracking + // ========================================================================= + /** * Tracks the creation of a resource * @@ -131,38 +288,9 @@ export default class EventsService extends Service.extend(Evented) { this.#trigger('resource.bulk_action', verb, resources, firstResource, properties); } - /** - * Tracks when the current user is loaded (session initialized) - * - * @param {Object} user - The user object - * @param {Object} organization - The organization object - * @param {Object} [props={}] - Additional properties to include - */ - trackUserLoaded(user, organization, props = {}) { - const properties = this.#enrichProperties({ - user_id: user?.id, - organization_id: organization?.id, - organization_name: organization?.name, - ...props, - }); - - this.#trigger('user.loaded', user, organization, properties); - } - - /** - * Tracks when a user session is terminated - * - * @param {Number} duration - Session duration in seconds - * @param {Object} [props={}] - Additional properties to include - */ - trackSessionTerminated(duration, props = {}) { - const properties = this.#enrichProperties({ - session_duration: duration, - ...props, - }); - - this.#trigger('session.terminated', duration, properties); - } + // ========================================================================= + // Generic Event Tracking + // ========================================================================= /** * Tracks a generic custom event @@ -190,11 +318,11 @@ export default class EventsService extends Service.extend(Evented) { // ========================================================================= /** - * Triggers an event on both the events service and universe service + * Triggers an event on both the events service and universe service. * * This dual event system allows listeners to subscribe to events on either: - * - this.events.on('event.name', handler) - Local listeners - * - this.universe.on('event.name', handler) - Cross-engine listeners + * - this.events.on('event.name', handler) — local listeners + * - this.universe.on('event.name', handler) — cross-engine listeners * * @private * @param {String} eventName - The event name diff --git a/addon/services/session.js b/addon/services/session.js index f78f4fb..2913941 100644 --- a/addon/services/session.js +++ b/addon/services/session.js @@ -5,11 +5,42 @@ import { later } from '@ember/runloop'; import { debug } from '@ember/debug'; import getWithDefault from '../utils/get-with-default'; +/** + * SessionService + * + * Extends ember-simple-auth's session service to add: + * + * - Proper lifecycle event firing through the EventsService (which + * re-broadcasts on both the events bus and the universe bus). + * - Session start timestamp tracking for duration calculation. + * + * Session lifecycle events fired (via EventsService → universe bus): + * + * session.authenticated — fired after a successful login/session restore. + * Payload: (properties) + * + * session.invalidated — fired after the session is destroyed (logout). + * Payload: (duration_seconds, properties) + * + * user.deauthenticated — alias for session.invalidated, provided as a + * semantic convenience for integrations that want + * to react to the user identity being cleared + * (e.g. Intercom shutdown, PostHog reset). + * Payload: (duration_seconds, properties) + * + * All events are fired on both the EventsService Evented bus and the universe + * service, so listeners can use either: + * + * this.events.on('session.authenticated', handler) + * this.universe.on('session.authenticated', handler) ← recommended for engines + */ export default class SessionService extends SimpleAuthSessionService { @service router; @service currentUser; @service fetch; @service notifications; + @service events; + @service universe; /** * Set where to transition to @@ -25,6 +56,14 @@ export default class SessionService extends SimpleAuthSessionService { */ @tracked _isOnboarding = false; + /** + * Timestamp (ms) when the session was authenticated. + * Used to calculate session duration on invalidation. + * + * @var {Number|null} + */ + @tracked _sessionStartedAt = null; + /** * Set this as onboarding. * @@ -44,11 +83,20 @@ export default class SessionService extends SimpleAuthSessionService { } /** - * Overwrite the handle authentication method + * Overwrite the handle authentication method. + * + * Fires `session.authenticated` through the events service so that + * integrations (Intercom, PostHog, etc.) can react to a successful login. * * @void */ async handleAuthentication() { + // Record session start time for duration tracking on logout + this._sessionStartedAt = Date.now(); + + // Fire session.authenticated event + this._fireSessionEvent('session.authenticated'); + if (this._isOnboarding) { return; } @@ -76,6 +124,37 @@ export default class SessionService extends SimpleAuthSessionService { removeLoaderNode(); } + /** + * Extends the parent handleInvalidation method. + * + * IMPORTANT: super.handleInvalidation(routeAfterInvalidation) is called + * first to preserve the ember-simple-auth behaviour of redirecting the + * user to the login page (via handleSessionInvalidated). Our event + * firing happens after so it cannot interfere with the redirect. + * + * @param {String} routeAfterInvalidation - Passed through from ember-simple-auth + * @void + */ + handleInvalidation(routeAfterInvalidation) { + // 1. Always call super first — this performs the actual post-logout + // redirect/reload that ember-simple-auth is responsible for. + super.handleInvalidation(routeAfterInvalidation); + + const durationSeconds = this._sessionStartedAt ? Math.round((Date.now() - this._sessionStartedAt) / 1000) : null; + + // 2. Fire session lifecycle events for integrations (Intercom, PostHog, etc.) + if (this.events) { + this.events.trackSessionTerminated(durationSeconds); + } + + // 3. Fire user.deauthenticated directly on universe for framework-level + // uniformity — engines can listen without needing the events service. + this._fireSessionEvent('user.deauthenticated', { session_duration: durationSeconds }); + + // Reset session start time + this._sessionStartedAt = null; + } + /** * Loads the current authenticated user * @@ -217,4 +296,39 @@ export default class SessionService extends SimpleAuthSessionService { throw new Error(error.message); }); } + + // ========================================================================= + // Private helpers + // ========================================================================= + + /** + * Fires a named session lifecycle event on both the events service and + * directly on the universe service. + * + * Firing on universe directly (in addition to via events service) ensures + * that all engines and extensions receive the event on the shared framework- + * level bus regardless of whether the events service has fully initialised. + * + * Listeners can subscribe via: + * this.universe.on('session.authenticated', handler) + * this.universe.on('user.deauthenticated', handler) + * this.events.on('session.authenticated', handler) + * + * @private + * @param {String} eventName + * @param {Object} [extraProps={}] + */ + _fireSessionEvent(eventName, extraProps = {}) { + // 1. Fire through the events service (dual-broadcasts on events + universe bus) + if (this.events) { + this.events.trackEvent(eventName, extraProps); + } + + // 2. Also trigger directly on universe for framework-level uniformity — + // ensures the event reaches all engines even if events service is + // not yet available or not injected in a given engine context. + if (this.universe) { + this.universe.trigger(eventName, extraProps); + } + } } diff --git a/package.json b/package.json index 9f37fbd..e57e8d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fleetbase/ember-core", - "version": "0.3.15", + "version": "0.3.16", "description": "Provides all the core services, decorators and utilities for building a Fleetbase extension for the Console.", "keywords": [ "fleetbase-core",