Source

services/CustomersService.class.js

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