CRM tools and tasks
What it is
Two things sharing one subsystem:
The activity timeline — notes, tasks, files, emails, batch emails and system events attached to a target. For supplier pages the target is the supplier key, so the CRM panel beside the supplier workspace is the supplier’s history.
The task system — the staff work queue. Tasks have an assignee (user or group), priority, status and due date, and the sidebar shows live counters for "your tasks" and "group tasks".
Where it lives
| Concern | Location |
|---|---|
Routes |
|
Components |
|
Store |
|
Services |
|
Models |
|
Counters |
|
Backends
The CRM spans three hosts, which is why CrmService is the most header-heavy service
in the app:
| Base URL | Used for |
|---|---|
|
|
|
|
|
Outbound email. |
Headers
CrmService maintains its headers by subscribing to the store in its constructor:
this.headers['X-InstanceId'] = '' + appConfig.env.instanceId; // 8001
this.headers['X-EventLogger-InstanceId'] = '' + appConfig.env.instanceId;
this.store.select(selectAuthToken).subscribe((jwt) => this.headers['Authorization'] = jwt);
this.store.select('crm').subscribe((data) => {
if (data.targetUuid) { this.headers['X-Actions-TargetUUID'] = data.targetUuid; }
});
Two consequences:
-
The current target UUID is implicit in every request. A call issued while the target is changing can be scoped to the wrong entity.
-
For the event-logger call,
listActionsEventscopies the header across:X-EventLogger-TargetUUID = X-Actions-TargetUUID, then deletes the original.
|
|
The normalised store
CrmState is a hand-built normalised store:
interface CrmStateModel {
crmItems: { [uuid: string]: CrmItem }; // the entity map
all?: string[]; // ordered uuid lists — the "streams"
pinnedNotes?: string[];
notes?: string[];
emails?: string[];
batchEmails?: string[];
tasks?: string[];
events?: string[];
files?: string[];
}
interface CrmState {
targetUuid: string;
crmCurrentState: CrmStateModel; // the active target
crmStatesList: { [targetUuid: string]: CrmStateModel }; // per-target cache
crmPendingItems: { [key: string]: CrmItemStatus }; // in-flight mutations
}
Selectors are generated per stream by a factory:
export const selectCrmAll = createTimelineStreamSelector('all');
export const selectCrmNotes = createTimelineStreamSelector('notes');
export const selectCrmFiles = createTimelineStreamSelector('files');
export const selectCrmTasks = createTimelineStreamSelector('tasks');
export const selectCrmEvents = createTimelineStreamSelector('events');
export const selectCrmEmails = createTimelineStreamSelector('emails');
export const selectCrmBatchEmails = createTimelineStreamSelector('batchEmails');
export const selectCrmPinnedNotes = createTimelineStreamSelector('pinnedNotes');
Reducer behaviour
| Action | Behaviour |
|---|---|
|
Switches |
|
Merges per key. For array values it concatenates and de-duplicates (append
semantics, for paging); for object values it spreads. |
|
Inserts into |
|
Removes from |
|
Marks |
|
Set and clear |
|
Resets only the active target’s state. |
|
|
Item types
A single activity/ feed returns mixed types, dispatched on item.type in
CrmService.listActions:
type |
Model |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
anything else |
|
CrmItem (models/crm-item.type.ts) is the union. Write shapes are separate models:
NoteDataModel, FileDataModel, EventDataModel.
Adding a type means touching the switch, the CrmItem union, CrmStateModel’s stream
list and the `DELETE_CRM_MODEL cleanup.
Timeline endpoints
| Endpoint | Notes |
|---|---|
|
The combined feed. |
|
Per-type feeds via a shared |
|
The event stream. Requests |
|
Note CRUD, including pinning. |
|
Task CRUD — see below. |
Paging uses an opaque last cursor, JSON-stringified into a query parameter, with the
next cursor returned in CrmMetaModel. Infinite scroll uses
ngx-infinite-scroll and appends via `UPDATE_CRM_MODEL’s de-duplicating merge.
Tasks
TasksService on actionsToolsUrl:
| Endpoint | Purpose |
|---|---|
|
List with filters. |
|
Create ( |
|
Read. |
|
Update. |
|
Delete one. |
|
Bulk delete — issued via |
|
Bulk partial update. |
|
A task’s own event history. |
Status and priority
export enum TaskStatuses { NEW = 'new', IN_PROGR = 'in-progr', DONE = 'done', CLOSED = 'closed' }
export enum TaskPriorities { LOW = 'low', MEDIUM = 'medium', HIGH = 'high', CRITICAL = 'critical' }
Both ship with parallel display arrays (taskStatuses, taskPriorities) mapping key
to label — note IN_PROGR displays as "Started", not "In progress".
tasks/task-next-status.directive.ts implements the forward transition, so status
progression is encoded in a directive rather than a state machine.
Task filters and counters
FilterSet (models/task-filter.model.ts) describes a named filter:
class FilterSet {
title: string;
isGroup? = false;
count?: number;
filters?: { [key: string]: any }; // UI representation
filtersQuery?: { [key: string]: any }; // query representation
subFilters?: any[];
permCode?: USER_PERMISSION | string; // filter visible only with this permission
membersCount?: number;
}
FilterDefinition splits them into predefined and custom. The /tasks route takes
a :filterId (default 0), so a filter selection is a URL, and therefore
bookmarkable and shareable.
TasksFiltersService hits tasks/filters/counts/ and
tasks/filters/counts/assignees/.
TaskCountBadgeComponent (shared/task-count-badge/) renders the sidebar counters
with filterType of currentUser or currentUserGroup. Its behaviour:
-
Reloads every
RELOAD_COUNT_INTERVAL = 60000ms. -
Listens to
documentvisibilitychangeand only reloads while the tab is visible. -
Also reloads on the
TASK_COUNTERS_UPDATEevent fromTableEventsService, whichCrmEffectsbroadcasts after a task mutation. -
Consults
FeatureFlagsService—TASKS_MID_VER_SIMPLE_QUERYswitches the counting query. -
ViewEncapsulation.None, so its styles are global.
Notes, files, comments, emails
| Item | Detail |
|---|---|
Notes |
|
Files |
|
Comments |
|
Emails |
|
Events |
|
B2B preview |
|
Attachment base |
|
action-tab-calculator.service.ts (on internalApiUrl) computes the per-tab counts
shown on the CRM panel’s tab strip.
CRM panel presentation
The panel has four states — HIDDEN, OPEN, CLOSED, FIXED (CrmStates) — held in
the layout slice and defaulted per route pattern: fixed on the supplier General tab,
closed on other supplier tabs, hidden on the supplier list. See
UI composition.
CRM_OPEN_ALL_TAB on TableEventsService lets effects switch the panel to the "All"
tab after adding an item.
Access
/tasks and /emails both deny REST_UW_ACCESS and ADVANCE_AUDITORS, and the
sidebar hides the task counters for those groups. CRM_ADMIN is the elevated CRM
permission. Filters can carry their own permCode, so a filter can be hidden from
users who could otherwise see the list.
Gotchas
| Issue | Detail |
|---|---|
Implicit target scoping |
The target UUID travels as a header set from a store subscription, not as a parameter. |
Event paging is a |
Acknowledged debt in the source. |
|
|
In-place mutation |
|
|
Do not "correct" the label without checking the training material. |
Bulk delete uses a request body |
|
Pending statuses must be cleared |
|
|
|