import ScenidCloudService from './ScenidCloudService.class.js'
const _groupCache = new Map() // serviceUrl → { byName, fetchedAt, promise }
const GROUP_CACHE_TTL = 5 * 60 * 1000
const GROUP_CACHE_FRESH_GUARD = 10 * 1000
const isGroupCacheStale = entry => !entry || (Date.now() - entry.fetchedAt) >= GROUP_CACHE_TTL
const isGroupCacheFresh = entry => entry && (Date.now() - entry.fetchedAt) < GROUP_CACHE_FRESH_GUARD
/**
* @typedef {Object} FieldMappingObject
* @property {string[]} [include]
* @property {string[]} [exclude]
*/
const buildFieldMapping = fields => {
if (!fields) return ''
if (Array.isArray(fields)) return `?fields=${fields.join(',')}`
const parts = []
if (fields.include) parts.push(`include=${fields.include.join(',')}`)
if (fields.exclude) parts.push(`exclude=${fields.exclude.join(',')}`)
return parts.length ? `?${parts.join('&')}` : ''
}
/**
* Client for the Scenid CRM Service (Customers).
*
* Manages the entity graph: Organisational Units (OUs), Persons, and Groups,
* plus generic entity lookup and list operations.
*
* Obtained via `sdk.asAdmin().Customers()` or `sdk.asUser(idToken).Customers()`.
*
* @extends ScenidCloudService
*
* @example
* const customers = sdk.asAdmin().Customers()
*
* const { result } = await customers.Entity.get('person-abc123')
* const { result: ou } = await customers.OU.create({ data: { name: 'Engineering' } }, { return: true })
*/
class CustomersService extends ScenidCloudService {
/**
* @param {string|function(): Promise.<string>} tokenSource
* @param {string} serviceUrl
*/
constructor (tokenSource, serviceUrl) {
super(tokenSource, serviceUrl)
/**
* Single-entity lookup by `humanId`.
* @namespace
*/
this.Entity = {
/**
* Fetches a single entity by its human-readable ID.
*
* @param {string} humanId - The entity's human-readable identifier.
* @param {string[] | FieldMappingObject} [fields] - Restrict the returned fields.
* Pass an array for an include-only list, or an object with `include`/`exclude` keys.
* @returns {Promise<ServiceResponse>}
* @throws {Error} `customers/missing-humanId` – `humanId` was not provided.
*
* @example
* // Fetch all fields
* const { result } = await customers.Entity.get('person-abc123')
*
* // Fetch only specific fields
* const { result } = await customers.Entity.get('person-abc123', ['name', 'email'])
*
* // Exclude fields
* const { result } = await customers.Entity.get('person-abc123', { exclude: ['internalNotes'] })
*/
get: (humanId, fields) => {
if (!humanId) throw new Error('customers/missing-humanId')
return this.get(`/api/v2/entity/${humanId}${buildFieldMapping(fields)}`)
}
}
/**
* Bulk entity queries.
* @namespace
*/
this.Entities = {
/**
* Queries entities of a given type with optional filtering, full-text search, and field projection.
*
* **entityType** (required) — selects which entity collection to query:
* - `'ou'` — Organisational Units
* - `'person'` — Persons
* - `'group'` — Groups
*
* **filterModel** — structured filter applied on top of the full-text search.
* Each item targets a field and an operator:
*
* ```js
* filterModel: {
* logicOperator: 'and', // 'and' (default) or 'or'
* items: [
* { field: 'data.active', fieldType: 'boolean', operator: 'equals', value: true },
* { field: 'data.name', operator: 'contains', value: 'GmbH' }
* ]
* }
* ```
*
* String operators: `contains`, `doesNotContain`, `startsWith`, `endsWith`, `equals`, `isEmpty`, `isNotEmpty`.
* Boolean operators: `equals`, `isEmpty`, `isNotEmpty`.
*
* Special virtual fields for relationship filtering:
* - `__GROUPS__` — filter by group membership (value: `humanId` or array of `humanId`)
* - `__MEMBERS__` — filter by group members
* - `__EMPLOYEES__` — filter by employee `humanId`
* - `__EMPLOYMENTS__` — filter by employment OU `humanId`
* - `__ROLES__` — filter by role name across employees/employments
*
* Relationship operators: `includes` (default) or `excludes`.
*
* Special field aliases:
* - `originalHumanId` → resolves to `humanId`
* - `contacts.<type>` → resolves to the correct nested contact path (`email`, `phone`, `mobile`, `fax`)
* - `data.bankHistory` → resolves to `data.bankHistory.iban`
*
* @param {Object} params
* @param {'ou'|'person'|'group'} params.entityType - Entity collection to query. Required.
* @param {string[]|FieldMappingObject} [params.fields] - Fields to return.
* Always includes `entityType`, `id`, `humanId`, and common name fields.
* Pass an array to add extra includes, or an object with `include`/`exclude` keys.
* @param {Object} [params.filterModel] - Structured filter.
* @param {string} [params.quickFilter] - Free-text search string matched against ngram-indexed fields.
* @param {boolean} [params.searchSubEntities] - When `true`, extends `quickFilter` to also search
* nested employees (for OUs), employments (for persons), and members (for groups).
* @param {boolean} [params.hideInactive] - When `true`, excludes entities where `data.active` is not `true`.
* @param {boolean} [params.idsOnly] - When `true`, returns only `humanId` and `pathSegments.humanId` per entity.
* @param {Object} [params.sort] - Sort configuration forwarded to Elasticsearch.
* @returns {Promise<ServiceResponse>}
*
* @example
* // All active persons whose name contains "Müller"
* const { result } = await customers.Entities.list({
* entityType: 'person',
* hideInactive: true,
* filterModel: {
* items: [{ field: 'data.lastName', operator: 'contains', value: 'Müller' }]
* }
* })
*
* @example
* // OUs that belong to a specific group, with free-text search
* const { result } = await customers.Entities.list({
* entityType: 'ou',
* quickFilter: 'Engineering',
* filterModel: {
* items: [{ field: '__GROUPS__', operator: 'includes', value: 'group-abc123' }]
* }
* })
*/
list: params => this.post('/api/v2/entities/list', params)
}
/**
* Organisational Unit (OU) operations.
* @namespace
*/
this.OU = {
/**
* Creates a new Organisational Unit.
*
* @param {Object} body
* @param {Object} body.data - OU field values.
* @param {string} [body.parent] - `humanId` of the parent OU.
* @param {string[]} [body.groups] - Group names to add the OU to immediately. Names are resolved to humanIds automatically.
* @param {Array<{humanId: string, roles: Array<{role: string, status: 'active'|'paused'|'separated'|'retired'|'inactive'}>}>} [body.employees]
* Persons to add as employees. Each entry must include `humanId` and a non-empty `roles` array.
* Each role entry requires `role` (string) and `status` (one of `'active'`, `'paused'`, `'separated'`, `'retired'`, `'inactive'`).
* @param {Object} [options]
* @param {boolean} [options.return] - When `true`, returns the created entity instead of `{ result: 'OK' }`.
* @returns {Promise<ServiceResponse>}
*
* @example
* const { result } = await customers.OU.create(
* {
* data: { name: 'Engineering', costCenter: 'CC-42' },
* parent: 'ou-root',
* groups: ['group-admins'],
* employees: [{ humanId: 'person-ada', roles: [{ role: 'Engineer', status: 'active' }] }]
* },
* { return: true }
* )
*/
create: async (body, options = {}) => {
const req = { ...body }
if (req.groups?.length) req.groups = await this._resolveGroups(req.groups)
if (options.return) req.return = true
return this.post('/api/v2/entity/ou', req)
},
/**
* Updates an existing Organisational Unit.
*
* @param {string} humanId - The OU's human-readable identifier.
* @param {Object} body - Update payload (e.g. `{ data: { name: 'New Name' } }`).
* @returns {Promise<ServiceResponse>}
*/
update: async (humanId, body) => {
const req = { ...body }
if (req.groups?.length) req.groups = await this._resolveGroups(req.groups)
return this.put(`/api/v2/entity/ou/${humanId}`, req)
},
/**
* Deletes an Organisational Unit.
*
* @param {string} humanId - The OU's human-readable identifier.
* @returns {Promise<ServiceResponse>}
*/
delete: humanId => this.delete(`/api/v2/entity/ou/${humanId}`),
/**
* Moves an OU to a new parent, or removes its parent entirely.
*
* @param {string} humanId - The OU to move.
* @param {string} [targetHumanId] - The new parent OU's `humanId`.
* Omit to detach from the current parent (makes the OU root-level).
* @returns {Promise<ServiceResponse>}
*
* @example
* await customers.OU.move('ou-engineering', 'ou-product') // reparent
* await customers.OU.move('ou-engineering') // remove parent
*/
move: (humanId, targetHumanId) => {
if (targetHumanId) return this.put(`/api/v2/entity/ou/${humanId}/parent/${targetHumanId}`)
return this.delete(`/api/v2/entity/ou/${humanId}/parent`)
},
/**
* Adds or updates a person's employment in an OU.
*
* @param {string} ouHumanId - The OU's human-readable identifier.
* @param {string} personHumanId - The person's human-readable identifier.
* @param {Array<{role: string, status: 'active'|'paused'|'separated'|'retired'|'inactive'}>} roles
* Required. Each entry needs `role` (string) and `status`.
* @param {Object} [options]
* @param {boolean} [options.return] - When `true`, returns the updated person entity.
* @returns {Promise<ServiceResponse>}
*
* @example
* await customers.OU.upsertEmployee('ou-engineering', 'person-ada', [
* { role: 'Engineer', status: 'active' }
* ])
*/
upsertEmployee: (ouHumanId, personHumanId, roles, options = {}) => {
const body = { data: { roles } }
if (options.return) body.return = true
return this.post(`/api/v2/entity/ou/${ouHumanId}/employees/${personHumanId}`, body)
},
/**
* Removes a person from an OU's employee list.
*
* @param {string} ouHumanId - The OU's human-readable identifier.
* @param {string} personHumanId - The person's human-readable identifier.
* @returns {Promise<ServiceResponse>}
*/
removeEmployee: (ouHumanId, personHumanId) =>
this.delete(`/api/v2/entity/ou/${ouHumanId}/employees/${personHumanId}`)
}
/**
* Person entity operations.
* @namespace
*/
this.Person = {
/**
* Creates a new Person entity.
*
* @param {Object} body
* @param {Object} body.data - Person field values.
* @param {string[]} [body.groups] - Group names to add the person to. Names are resolved to humanIds automatically.
* @param {Array<{humanId: string, roles: Array<{role: string, status: 'active'|'paused'|'separated'|'retired'|'inactive'}>}>} [body.employments]
* OUs to immediately employ this person in. Each entry must include `humanId` (the OU's human-readable ID)
* and a non-empty `roles` array. Each role entry requires `role` (string) and `status`
* (one of `'active'`, `'paused'`, `'separated'`, `'retired'`, `'inactive'`).
* @param {Object} [options]
* @param {boolean} [options.return] - When `true`, returns the created entity.
* @returns {Promise<ServiceResponse>}
*
* @example
* const { result } = await customers.Person.create(
* {
* data: { firstName: 'Ada', lastName: 'Lovelace' },
* groups: ['group-admins'],
* employments: [{ humanId: 'ou-engineering', roles: [{ role: 'Engineer', status: 'active' }] }]
* },
* { return: true }
* )
*/
create: async (body, options = {}) => {
const req = { ...body }
if (req.groups?.length) req.groups = await this._resolveGroups(req.groups)
if (options.return) req.return = true
return this.post('/api/v2/entity/person', req)
},
/**
* Updates an existing Person entity.
*
* @param {string} humanId - The person's human-readable identifier.
* @param {Object} body - Update payload.
* @returns {Promise<ServiceResponse>}
*/
update: async (humanId, body) => {
const req = { ...body }
if (req.groups?.length) req.groups = await this._resolveGroups(req.groups)
return this.put(`/api/v2/entity/person/${humanId}`, req)
},
/**
* Deletes a Person entity.
*
* @param {string} humanId - The person's human-readable identifier.
* @returns {Promise<ServiceResponse>}
*/
delete: humanId => this.delete(`/api/v2/entity/person/${humanId}`),
/**
* Adds or updates an employment relationship between a person and an OU.
*
* @param {string} personHumanId - The person's human-readable identifier.
* @param {string} ouHumanId - The OU's human-readable identifier.
* @param {Array<{role: string, status: 'active'|'paused'|'separated'|'retired'|'inactive'}>} roles
* Required. Each entry needs `role` (string) and `status`.
* @param {Object} [options]
* @param {boolean} [options.return] - When `true`, returns the updated person entity.
* @returns {Promise<ServiceResponse>}
*
* @example
* await customers.Person.upsertEmployment('person-ada', 'ou-engineering', [
* { role: 'Engineer', status: 'active' }
* ])
*/
upsertEmployment: (personHumanId, ouHumanId, roles, options = {}) => {
const body = { data: { roles } }
if (options.return) body.return = true
return this.post(`/api/v2/entity/person/${personHumanId}/employments/${ouHumanId}`, body)
},
/**
* Removes an employment relationship from a person.
*
* @param {string} personHumanId - The person's human-readable identifier.
* @param {string} ouHumanId - The OU's human-readable identifier.
* @returns {Promise<ServiceResponse>}
*/
removeEmployment: (personHumanId, ouHumanId) =>
this.delete(`/api/v2/entity/person/${personHumanId}/employments/${ouHumanId}`)
}
/**
* Group entity operations.
* @namespace
*/
this.Group = {
/**
* Creates a new Group entity.
*
* @param {Object} data - Group field values.
* @param {Object} [options]
* @param {string[]} [options.members] - `humanId` list of initial members (persons or OUs).
* @param {boolean} [options.return] - When `true`, returns the created entity.
* @returns {Promise<ServiceResponse>}
*
* @example
* const { result } = await customers.Group.create(
* { name: 'Admins' },
* { members: ['person-ada', 'ou-engineering'], return: true }
* )
*/
create: (data, options = {}) => {
const body = { data }
if (options.members?.length) body.members = options.members
if (options.return) body.return = true
return this.post('/api/v2/entity/group', body)
},
/**
* Updates an existing Group entity.
*
* @param {string} humanId - The group's human-readable identifier.
* @param {Object} body - Update payload.
* @returns {Promise<ServiceResponse>}
*/
update: (humanId, body) => this.put(`/api/v2/entity/group/${humanId}`, body),
/**
* Deletes a Group entity.
*
* @param {string} humanId - The group's human-readable identifier.
* @returns {Promise<ServiceResponse>}
*/
delete: humanId => this.delete(`/api/v2/entity/group/${humanId}`),
/**
* Adds or updates a member in a group.
*
* @param {string} groupHumanId - The group's human-readable identifier.
* @param {string} targetHumanId - The member's `humanId` (person or OU).
* @param {Object} [body] - Optional membership metadata.
* @returns {Promise<ServiceResponse>}
*/
upsertMember: (groupHumanId, targetHumanId, body) =>
this.post(`/api/v2/entity/group/${groupHumanId}/members/${targetHumanId}`, body),
/**
* Removes a member from a group.
*
* @param {string} groupHumanId - The group's human-readable identifier.
* @param {string} targetHumanId - The member's `humanId`.
* @returns {Promise<ServiceResponse>}
*/
removeMember: (groupHumanId, targetHumanId) =>
this.delete(`/api/v2/entity/group/${groupHumanId}/members/${targetHumanId}`)
}
/**
* Journal entry operations.
* @namespace
*/
this.Journal = {
/**
* Creates journal entries on one or more entities.
*
* @param {string[]} humanIds - One or more entity human-readable identifiers.
* @param {Object} entry - The journal entry data.
* @param {string} entry.channel - One of NOTE, PHONE, MOBILE, FAX, EMAIL, CONVERSATION, CALENDAR_EVENT, FILE, JIRA, MAIL
* @param {string} [entry.direction] - One of UNKNOWN, INCOMING, OUTGOING. Only valid for PHONE, MOBILE, FAX, EMAIL, MAIL.
* @param {string} entry.text - Entry text (required).
* @returns {Promise<ServiceResponse>} Array of created journal docs.
*
* @example
* await customers.Journal.create(['person-abc123'], { channel: 'NOTE', text: 'Spoke to client.' })
* await customers.Journal.create(['person-abc123', 'ou-xyz'], { channel: 'PHONE', direction: 'INCOMING', text: 'Called in.' })
*/
create: (humanIds, entry) => {
if (!Array.isArray(humanIds) || humanIds.length === 0) throw new Error('customers/missing-humanIds')
return this.post('/api/v2/journal', { humanIds, entry })
}
}
}
async _refreshGroupCache () {
const existing = _groupCache.get(this.serviceUrl)
if (existing?.promise) {
await existing.promise
return
}
let resolve
const promise = new Promise(r => { resolve = r })
_groupCache.set(this.serviceUrl, { ...(existing ?? {}), promise })
try {
const { result } = await this.post('/api/v2/entities/list', { entityType: 'group', fields: ['data.name'] })
const groups = Array.isArray(result) ? result : []
const byName = new Map()
groups.forEach(g => { if (g.data?.name) byName.set(g.data.name, g.humanId) })
_groupCache.set(this.serviceUrl, { byName, fetchedAt: Date.now(), promise: undefined })
resolve()
} catch (e) {
_groupCache.delete(this.serviceUrl)
resolve()
throw e
}
}
async _resolveGroup (name) {
const cache = _groupCache.get(this.serviceUrl)
if (!isGroupCacheStale(cache)) {
if (cache.byName.has(name)) return cache.byName.get(name)
if (!isGroupCacheFresh(cache)) {
await this._refreshGroupCache()
const fresh = _groupCache.get(this.serviceUrl)
if (fresh?.byName.has(name)) return fresh.byName.get(name)
}
throw new Error('customers/group-not-found')
}
await this._refreshGroupCache()
const fresh = _groupCache.get(this.serviceUrl)
if (fresh?.byName.has(name)) return fresh.byName.get(name)
throw new Error('customers/group-not-found')
}
async _resolveGroups (groups) {
const humanIds = await Promise.all(groups.map(name => this._resolveGroup(name)))
return humanIds.map(humanId => ({ humanId }))
}
}
export default CustomersService
Source