Events

Three different "events"

Distinguish them before reading further:

Kind Where

Domain events

Business facts emitted by backend services and stored in the event logger. Queryable, visible in /manage/events and in the CRM timeline. This page.

Event rules

Stored rules that match a domain event, filter it, and run actions. Two generations. This page.

In-app events

TableEventsService’s `Subject bus, and iframe postMessage messages. See the in-app event bus and iframe messaging.

The domain event stream

Events live in the event logger, of which two generations are configured:

Base URL Role

eventLoggerUrl

v2. es-changelog (the CRM timeline’s event stream) and track (the analytics endpoint used by MonitorService).

eventRulesOldUrl

v1. event_rules/ CRUD, event_rules/history/, and a raw Elasticsearch endpoint at es against the index em_events.

Event taxonomy

manage/event-rules/event-rule.models.ts declares the vocabulary a rule can match:

export const eventTypes = [
  'analitics',            // sic — the analytics stream MonitorService writes to
  'api-event',
  'cli-event',
  'event',
  'messaging-log',
  'messaging-result',
  'task-change-event',
  'underwriting-event',
];

export const eventTargets = ['compliance', 'crm-task', 'email', 'marketplace', 'supplier'];

An event therefore has a type (which subsystem produced it) and a target type (what kind of thing it is about), plus a targetType-scoped identifier — for a supplier, the supplier key. That identifier is carried on requests as X-EventLogger-TargetUUID (or X-Actions-TargetUUID for CRM). See CRM.

'analitics' is misspelled in the source and on the wire. MonitorService sends X-EventLogger-Type: analitics. Match the misspelling.

Browser-side Elasticsearch

EventsService instantiates the elasticsearch npm client in the browser:

this.es = new Client({ host: environment.eventRulesOldUrl + 'es', log: 'warning' });

private esRequest(params) {
  return this.store.select('auth').pipe(take(1), switchMap((authState) =>
    observableFrom(this.es.search(Object.assign({
      index: 'em_events', type: 'em_events',
      headers: { 'X-Session-Token': authState.user.jwtToken },
    }, params)))));
}

query(query = { match_all: {} }, limit = 30, page = 1, sort = { created_at: 'desc' })

This is the only direct Elasticsearch client in the application, and the reason elasticsearch appears in allowedCommonJsDependencies.

Elasticsearch querying is currently disabled in the rule editor. SelectorFormComponent replaces the live query with a stub and toasts the user:

const events$ = of({ hits: { total: { value: 0 }, hits: [] } }).pipe(
  first(),
  tap(() => this.toastrService.info('Elasticsearch is disabled. No events will be shown.')),
);
// the real query is commented out, marked "Deprecated. Only for historical purposes."

So the "example matching events" preview in the rule editor always shows zero. The toElasticSearchQuery builder and EventsService.query remain in the code, unused.

/manage/events (manage/events/ with event-badge/, event-filters/, event-preview/) is the raw stream browser. The CRM timeline’s events stream comes from eventLoggerUrl es-changelog instead — and requests limit=2000000, because there is no paging on that endpoint. See CRM.

Event rules

A rule is: when an event of this type about this kind of target matches these filters, do these actions.

export interface EventRule extends Entity {   // Entity = { uuid, created_at }
  eventType: string;         // one of eventTypes
  eventTypeCode: string;
  targetType: string;        // one of eventTargets
  message: string;
  user: string;
  filters: Array<Filter>;
  actions: Array<Action>;
}

Filters

Filter is the one piece of real parsing logic in the front end. Rules are authored as expressions and stored as structured objects.

export class Filter {
  constructor(public name: string, public op: string, public val: string | number) {}
  static regex = /([a-zA-Z.]+ ) *(!=|=|<|>|>=|<=|=<|=>) *([^ ]+)/;
  static fromExpression(text) { … }   // "amount >= 100"  → Filter('amount', 'gte', '100')
  static fromJson({ name, op, val }) { … }
  toExpression() { … }                // Filter('amount','gte','100') → "amount >= 100"
}

Operator mapping:

Expression Stored op Rendered back as

=

eq

=

!=

ne

!=

<

lt

<

>

gt

>

>= or ⇒

gte

>=

⇐ or =<

lte

=<

The round trip is not lossless. >= and ⇒ both parse to gte, and ⇐ and =< both parse to lte; toExpression renders gte as >= but lte as =<. A rule saved with ⇐ reappears in the editor as =<. Cosmetic, but it makes diffs of rule text noisy.

The regex also requires a trailing space in the field-name group (([a-zA-Z.]+ )), so amount>=100 does not parse and an unparseable expression throws Can’t parse <text>. The form validator uses the same regex (Validators.pattern(Filter.regex)) so the error surfaces as invalid input rather than an exception.

Field names may contain dots (data.target_type), which is how nested event properties are addressed.

Actions

export class Action {
  static CREATE_TASK           = 'create-task';
  static SEND_EMAIL            = 'send-email';
  static SLACK_NOTIF           = 'notify-on-slack';       // ← not in actionTypes
  static EDIT_SUPPLIER         = 'edit-supplier';
  static SEND_TO_EVENT_BRIDGE  = 'push-to-event-bridge';

  type: string;
  template: string;
  to?: string;
  contextMapping: object;
  dataMapping: object;
  data: object;
  delay: number;
  delayTime: number;
  delayPeriod: number;
  delayEnabled: boolean;
}

export const actionTypes = [
  Action.CREATE_TASK,
  Action.SEND_EMAIL,
  // Action.SLACK_NOTIF,        ← commented out
  Action.EDIT_SUPPLIER,
  Action.SEND_TO_EVENT_BRIDGE,
];

Four active action types; Slack notification is defined but commented out of the selectable list. The constant remains, so existing rules of that type would still load.

Action Editor Behaviour

create-task

action-form/create-task/

Creates a CRM task. See CRM.

send-email

action-form/send-email/

Sends a templated email — the origin of most AUTO_EMAIL traffic. See emails.

edit-supplier

action-form/edit-supplier/

Mutates supplier fields.

push-to-event-bridge

action-form/send-to-event-bridge/

Publishes to AWS EventBridge, extending automation beyond this system.

Mappings

Three fields shape an action’s payload:

Field Meaning

contextMapping

{ propName → selector } — pull a value out of the event into the action’s context.

dataMapping

{ propName → value } — map event data onto action data.

data

Static data attached to the action.

RulesUtilsService builds these as form groups with an all-or-nothing validator: within a mapping pair, if any field is filled, all must be:

static addAllOrNothingValidator(group: FormGroup) {
  // for each control: if any sibling is non-empty and this one is empty → { required: true }
  // and when this one becomes non-empty, revalidate the empty siblings
}

tryAddNext appends a fresh blank row when the last row is filled — the "grow as you type" list pattern used for filters, mappings and recipients. Email recipients use emailOrEmpty: validate as an email unless blank.

Delays

An action can be delayed. The wire format is a single delay in seconds; the editor splits it into a magnitude and a unit:

export const MINUTES = 60;
export const DAYS    = 24 * 60 * 60;
export const WEEKS   = 7 * 24 * 60 * 60;

On load (RulesAddEditComponent), the seconds value is decomposed largest-unit-first:

delay > 0                → delayEnabled = true
delay % WEEKS   === 0    → delayPeriod = WEEKS,   delayTime = delay / WEEKS
else delay % DAYS === 0  → delayPeriod = DAYS,    delayTime = delay / DAYS
else                     → delayPeriod = MINUTES, delayTime = delay / MINUTES

MINUTES = 60 is the fallback, so a delay that is not a whole number of weeks or days is expressed in minutes — and a delay of, say, 90 seconds becomes delayTime = 1.5 minutes. There is no hours unit and no seconds unit. Round-tripping a delay authored outside this UI can produce fractional values.

EventBridge action naming

export const PREFIX_EVNET_RULE = 'em_event_rule_';   // sic

export function eventBridgeActionNameValidator(control: AbstractControl) {
  return control.value.substring(0, PREFIX_EVNET_RULE.length) !== PREFIX_EVNET_RULE
    ? { eventRuleNameError: true } : null;
}

EventBridge action names must begin with em_event_rule_. The constant name is misspelled (EVNET); the value is correct.

The rule editor

/manage/event-rules with:

Route Component

list

RulesListComponent

add, :eventRuleId/edit

RulesAddEditComponent — one component for both, distinguished by the parameter

:eventRuleId/history

RulesHistoryListComponent

RulesAddEditComponent composes two child forms:

selectorForm = new FormGroup({})     ← SelectorFormComponent registers itself into it
actionsForm  = new FormArray([])     ← ActionsFormComponent contributes actions

ngAfterViewInit:
  combineLatest(selector.selectorValue$, actions.value$, sendButton$)
    → { ...selector, actions }
    → isEditing ? eventsService.save(uuid, rule) : eventsService.create(rule)
    → navigate to manage/event-rules

The save is wired as a combineLatest over the two form streams and a sendButton$ subject, inside a long-lived subscription. Because combineLatest re-emits whenever any source emits, a subsequent form change after a click can trigger another save. Treat this subscription carefully if you touch the component.

selectorValue$ itself is valueChanges filtered on form.valid, debounced 500 ms and distinctUntilChanged — so only valid selector states reach the save path.

SelectorFormComponent uses ViewEncapsulation.None, so its styles are global.

Loading a rule for edit converts stored filters back to expressions (Filter.fromJson(f).toExpression()) and decomposes action delays, as above.

Rule history

EventsService.historyList(uuid, limit = 10, last?):

GET <eventRulesOldUrl>event_rules/history/
  ?limit=10
  &order_by={"field":"created_at","direction":"desc"}
  &q={"filters":[{"name":"event_rule_uuid","op":"eq","val":"<uuid>"}]}
  [&last=…]

Note the query language is the same { name, op, val } filter shape as rule filters — so Filter describes both what a rule matches and how the API is queried.

Rule CRUD

All on eventRulesOldUrl under event_rules/, each with an X-InstanceId: 8001 header:

Method Endpoint

create

POST event_rules/

get

GET event_rules/:uuid/

list

GET event_rules/

save

PUT event_rules/:uuid/

delete

DELETE event_rules/:uuid/

historyList

GET event_rules/history/

Responses are enveloped as Response<T> / ListResponse<T> ({ data, meta }). The X-InstanceId value is hardcoded '8001' in this service rather than read from env.instanceId — a divergence from CrmService and GlobalSettingsService, which use the config value (also 8001).

Event rules v2

/manage/event-rules-2 (EventRules2Component) is the beta rewrite. The component in this repository does almost nothing:

this.userService.checkPermission(USER_PERMISSION.CRM_ADMIN)
  .subscribe((permitted) => { this.hasCRMPermission = permitted; this.cd.detectChanges(); });

It resolves CRM_ADMIN and renders accordingly — the editor itself is elsewhere. The sidebar labels it "Event Rules v2.0 (beta)".

Both generations are live and reachable. Determine which one a change belongs to before starting.

Permissions

Permission Governs

INT_RULES_UPDATE

Editing event rules.

CRM_ADMIN

The v2 editor, and CRM administration generally.

/manage denies REST_UW_ACCESS and ADVANCE_AUDITORS at the parent route.

Gotchas summary

Issue Detail

Elasticsearch preview is disabled

Always zero example events, with an info toast.

'analitics' is misspelled

On the wire too.

Filter round-trip is lossy

⇐ becomes =<.

Filter regex needs a trailing space

amount>=100 does not parse.

Slack action is defined but unselectable

Commented out of actionTypes.

Delay units are minutes/days/weeks only

No hours; fractional minutes are possible.

PREFIX_EVNET_RULE is misspelled

The constant, not the value.

Two rule generations

v1 in this repo, v2 largely elsewhere.

combineLatest save

Can re-fire on later form changes.

Hardcoded instance id

'8001' literal in EventsService.