Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions addon/services/current-user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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' };
Expand Down Expand Up @@ -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);

Expand All @@ -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);
}
}
}
206 changes: 167 additions & 39 deletions addon/services/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
*
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading