@ikatec/digisac-api-sdk 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dist/apis/contacts/ContactsApi.cjs +70 -0
  2. package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
  3. package/dist/apis/contacts/ContactsApi.d.ts +51 -1
  4. package/dist/apis/contacts/ContactsApi.d.ts.map +1 -1
  5. package/dist/apis/contacts/ContactsApi.mjs +70 -0
  6. package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
  7. package/dist/apis/contacts/types.d.ts +141 -1
  8. package/dist/apis/contacts/types.d.ts.map +1 -1
  9. package/dist/apis/messages/MessagesApi.cjs +4 -1
  10. package/dist/apis/messages/MessagesApi.cjs.map +1 -1
  11. package/dist/apis/messages/MessagesApi.d.ts +5 -2
  12. package/dist/apis/messages/MessagesApi.d.ts.map +1 -1
  13. package/dist/apis/messages/MessagesApi.mjs +4 -1
  14. package/dist/apis/messages/MessagesApi.mjs.map +1 -1
  15. package/dist/apis/messages/types.d.ts +13 -0
  16. package/dist/apis/messages/types.d.ts.map +1 -1
  17. package/dist/apis/services/ServicesApi.cjs +8 -0
  18. package/dist/apis/services/ServicesApi.cjs.map +1 -1
  19. package/dist/apis/services/ServicesApi.d.ts +7 -1
  20. package/dist/apis/services/ServicesApi.d.ts.map +1 -1
  21. package/dist/apis/services/ServicesApi.mjs +8 -0
  22. package/dist/apis/services/ServicesApi.mjs.map +1 -1
  23. package/dist/apis/services/types.d.ts +7 -0
  24. package/dist/apis/services/types.d.ts.map +1 -1
  25. package/dist/apis/tags/TagsApi.cjs +24 -0
  26. package/dist/apis/tags/TagsApi.cjs.map +1 -1
  27. package/dist/apis/tags/TagsApi.d.ts +20 -1
  28. package/dist/apis/tags/TagsApi.d.ts.map +1 -1
  29. package/dist/apis/tags/TagsApi.mjs +24 -0
  30. package/dist/apis/tags/TagsApi.mjs.map +1 -1
  31. package/dist/apis/tags/types.d.ts +13 -0
  32. package/dist/apis/tags/types.d.ts.map +1 -1
  33. package/dist/apis/users/UsersApi.cjs +77 -0
  34. package/dist/apis/users/UsersApi.cjs.map +1 -1
  35. package/dist/apis/users/UsersApi.d.ts +59 -1
  36. package/dist/apis/users/UsersApi.d.ts.map +1 -1
  37. package/dist/apis/users/UsersApi.mjs +77 -0
  38. package/dist/apis/users/UsersApi.mjs.map +1 -1
  39. package/dist/apis/users/types.d.ts +123 -3
  40. package/dist/apis/users/types.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/apis/acceptanceTerms/AcceptanceTermsApi.test.ts +1 -1
  43. package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
  44. package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
  45. package/src/apis/contacts/ContactsApi.test.ts +182 -5
  46. package/src/apis/contacts/ContactsApi.ts +128 -0
  47. package/src/apis/contacts/types.ts +154 -1
  48. package/src/apis/me/MeApi.test.ts +3 -3
  49. package/src/apis/messages/MessagesApi.test.ts +14 -3
  50. package/src/apis/messages/MessagesApi.ts +6 -3
  51. package/src/apis/messages/types.ts +13 -0
  52. package/src/apis/payloads.test-d.ts +26 -0
  53. package/src/apis/queryFormat.test.ts +27 -3
  54. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
  55. package/src/apis/services/ServicesApi.test.ts +49 -0
  56. package/src/apis/services/ServicesApi.ts +10 -1
  57. package/src/apis/services/types.ts +9 -0
  58. package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
  59. package/src/apis/tags/TagsApi.test.ts +84 -0
  60. package/src/apis/tags/TagsApi.ts +59 -1
  61. package/src/apis/tags/types.ts +15 -0
  62. package/src/apis/terms/TermsApi.test.ts +1 -1
  63. package/src/apis/users/UsersApi.test.ts +228 -0
  64. package/src/apis/users/UsersApi.ts +152 -1
  65. package/src/apis/users/types.ts +144 -3
@@ -4,7 +4,7 @@ import type { Service } from '../services/types'
4
4
  import type { Tag } from '../tags/types'
5
5
  import type { Ticket } from '../tickets/types'
6
6
  import type { User } from '../users/types'
7
- import type { ListQuery } from '../../core/types'
7
+ import type { IncludeItem, ListQuery, WhereClause } from '../../core/types'
8
8
 
9
9
  export type ContactStatus = 'online' | 'offline' | 'absent'
10
10
  export type ContactOrigin = 'api' | 'app' | 'campaign' | 'driver' | 'import' | 'web'
@@ -204,3 +204,156 @@ export type TransferTicketPayload = {
204
204
  kanbanCardSubject?: string
205
205
  initialMessage?: string
206
206
  }
207
+
208
+ /**
209
+ * One item of `POST /contacts/many`. The backend matches existing contacts by service and
210
+ * number (or e-mail) and updates them; the others are created. Items repeating a number of
211
+ * the same batch are dropped (the first one wins).
212
+ */
213
+ export type CreateManyContactsItem = {
214
+ serviceId: string
215
+ name: string
216
+ /** Phone number with country code, or the e-mail address for e-mail connections. */
217
+ number: string
218
+ internalName?: string
219
+ personId?: string
220
+ defaultDepartmentId?: string
221
+ defaultUserId?: string
222
+ tagIds?: string[]
223
+ /** When `true`, the given `tagIds` are merged into the existing ones instead of replacing them. */
224
+ mergeRelations?: boolean
225
+ /**
226
+ * Only `data.number` is read, as a fallback when `number` is empty; the backend rebuilds
227
+ * `data` from the number.
228
+ */
229
+ data?: {
230
+ number: string
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Selects a set of contacts by filter. Used by `DELETE /contacts/many` and by the tag
236
+ * attach/detach routes. The backend always scopes the filter to the current account.
237
+ *
238
+ * `where` is required on purpose: the backend applies the operation to every contact the
239
+ * filter matches, so an empty filter would reach every contact of the account.
240
+ */
241
+ export type ContactsSelection = {
242
+ where: WhereClause<Contact>
243
+ include?: IncludeItem<Contact>[]
244
+ customFilter?: Record<string, unknown>
245
+ }
246
+
247
+ /**
248
+ * CSV layout of the contact export and import, as in `exportTemplate`:
249
+ * - `'colon'`: `;` as the column delimiter (any other value uses `,`);
250
+ * - `'google'`: phone columns as DDI + Phone ("Telefone") instead of DDI + Number.
251
+ *
252
+ * E-mail and webchat connections ignore it for the contact columns (Email / Email + Number).
253
+ */
254
+ export type ContactsCsvType = 'colon' | 'google' | (string & {})
255
+
256
+ /** Body for `POST /contacts/export/csv`. */
257
+ export type ExportCsvPayload = Omit<ListQuery<Contact>, 'page' | 'perPage'> & {
258
+ serviceType: string
259
+ type: ContactsCsvType
260
+ }
261
+
262
+ /**
263
+ * Body for `POST /contacts/import-contacts`. The CSV must be uploaded first (see `FilesApi`);
264
+ * the header line is checked against the template of the service type and language.
265
+ */
266
+ export type ImportContactsPayload = {
267
+ service: Pick<Service, 'id' | 'accountId' | 'type'>
268
+ file: {
269
+ id: string
270
+ name: string
271
+ mimetype: string
272
+ /** Sent by the web app; the backend reads only `id`. */
273
+ urlToUpload?: string
274
+ }
275
+ csvHeaderLine: string[]
276
+ type: ContactsCsvType
277
+ tagsIds?: string[]
278
+ defaultDepartmentId?: string
279
+ defaultUserId?: string
280
+ }
281
+
282
+ /** Body for `POST /contacts/:id/block`. Blocking a contact closes its open ticket. */
283
+ export type BlockContactPayload = {
284
+ block: boolean
285
+ byUserId?: string
286
+ description?: string
287
+ }
288
+
289
+ /** Body for `POST /contacts/:id/sync`. */
290
+ export type SyncContactPayload = {
291
+ forceSync?: boolean
292
+ }
293
+
294
+ export type ContactMediaType = 'media' | 'document' | 'link'
295
+
296
+ /**
297
+ * Query for `GET /contacts/:id/media`. Cursor-based: pass the previous page's `nextCursor`
298
+ * as `where.cursor`.
299
+ */
300
+ export type ContactMediaQuery = {
301
+ where?: {
302
+ /** `'media'` = images and videos. Omitted = images, videos and documents. */
303
+ type?: ContactMediaType
304
+ /** Case-insensitive filter by file name (ignored for links). */
305
+ name?: string
306
+ cursor?: string
307
+ }
308
+ perPage?: number
309
+ order?: [string, 'ASC' | 'DESC' | 'asc' | 'desc'][]
310
+ }
311
+
312
+ /**
313
+ * One item of `GET /contacts/:id/media`. When the user cannot see the message, the
314
+ * identifying fields come as `'UNAUTHORIZED_MESSAGE'` and `isAuthorized` is `false`.
315
+ */
316
+ export type ContactMediaItem = {
317
+ id: string
318
+ /** ID of the message that carries the file or link. */
319
+ attachedId: string
320
+ /** File name; absent for links. */
321
+ name?: string | null
322
+ /** Only for files. */
323
+ mimetype?: string
324
+ createdAt: string
325
+ updatedAt: string
326
+ accountId: string
327
+ url: string | null
328
+ /** Only for files (filled for videos). */
329
+ previewUrl?: string | null
330
+ isAuthorized: boolean
331
+ /** Only for `type: 'link'`. */
332
+ links?: string[]
333
+ }
334
+
335
+ export type ContactMediaPage = {
336
+ data: ContactMediaItem[]
337
+ limit: number
338
+ hasNextPage: boolean
339
+ nextCursor: string | null
340
+ }
341
+
342
+ /** Body for `POST /contacts/ticket/bulk-transfer`. */
343
+ export type BulkTransferTicketsPayload = {
344
+ departmentId?: string
345
+ /** `null` removes the assigned user. */
346
+ userId?: string | null
347
+ comments?: string
348
+ /** IDs of the tickets to transfer. Ignored when `allContactsSelected` is `true`. */
349
+ ticketsSelectedId?: string[]
350
+ allContactsSelected?: boolean
351
+ /** Contact filter used with `allContactsSelected`. */
352
+ query?: ListQuery<Contact>
353
+ }
354
+
355
+ export type BulkTransferTicketsResult = {
356
+ message: string
357
+ /** IDs of the tickets actually transferred (the ones the target user can access). */
358
+ ticketsSelectedId: string[]
359
+ }
@@ -34,15 +34,15 @@ const user: User = {
34
34
  email: 'alice@example.com',
35
35
  phoneNumber: null,
36
36
  branch: null,
37
- isSuperAdmin: false,
38
37
  isClientUser: false,
39
- active: true,
40
38
  language: 'en',
41
39
  isFirstLogin: false,
42
40
  status: 'online',
43
41
  clientsStatus: null,
44
- offlineAt: null,
45
42
  isActiveInternalChat: true,
43
+ internalChatToken: null,
44
+ noteReference: false,
45
+ data: {},
46
46
  passwordExpiresAt: null,
47
47
  otpAuthActive: false,
48
48
  preferences: {},
@@ -45,6 +45,17 @@ const message: Message = {
45
45
  obfuscated: false,
46
46
  reactionParentMessageId: null,
47
47
  hsmId: null,
48
+ isFromBot: false,
49
+ accountId: 'acc-1',
50
+ serviceId: 'svc-1',
51
+ contactId: 'ctt-1',
52
+ fromId: null,
53
+ toId: 'ctt-1',
54
+ userId: 'usr-1',
55
+ ticketId: 'tkt-1',
56
+ ticketUserId: 'usr-1',
57
+ ticketDepartmentId: 'dep-1',
58
+ quotedMessageId: null,
48
59
  createdAt: '2024-01-01T00:00:00Z',
49
60
  updatedAt: '2024-01-01T00:00:00Z',
50
61
  deletedAt: null,
@@ -85,7 +96,7 @@ describe('MessagesApi', () => {
85
96
  it('serializes query parameters', async () => {
86
97
  vi.mocked(client.get).mockResolvedValue(paginated)
87
98
  await api.getMany({ page: 2, perPage: 5 })
88
- const url = vi.mocked(client.get).mock.calls[0][0] as string
99
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
89
100
  expect(url).toContain('/messages?')
90
101
  expect(decodeURIComponent(url)).toContain('"page":2')
91
102
  expect(decodeURIComponent(url)).toContain('"perPage":5')
@@ -382,7 +393,7 @@ describe('MessagesApi', () => {
382
393
  it('appends limit query parameter when provided', async () => {
383
394
  vi.mocked(client.get).mockResolvedValue({ outOfRange: true })
384
395
  await api.outOfRange('msg-1', 50)
385
- const url = vi.mocked(client.get).mock.calls[0][0] as string
396
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
386
397
  expect(url).toContain('/messages/msg-1/out-of-range?')
387
398
  expect(url).toContain('limit=50')
388
399
  })
@@ -426,7 +437,7 @@ describe('MessagesApi', () => {
426
437
  it('appends limit query parameter when provided', async () => {
427
438
  vi.mocked(client.get).mockResolvedValue([])
428
439
  await api.around('msg-1', { limit: 10 })
429
- const url = vi.mocked(client.get).mock.calls[0][0] as string
440
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
430
441
  expect(url).toContain('/messages/msg-1/around?')
431
442
  expect(url).toContain('limit=10')
432
443
  })
@@ -101,9 +101,12 @@ export class MessagesApi extends BaseCrudApi<Message, CreateMessagePayload, Upda
101
101
  )
102
102
  }
103
103
 
104
- /** Triggers an AI-generated summary for the conversation associated with the given message. */
105
- summarize(id: string, headers?: Record<string, string>): Promise<void> {
106
- return this.client.post<void>(`${this.basePath}/${id}/summarize`, undefined, headers)
104
+ /**
105
+ * Triggers an AI-generated summary for the conversation associated with the given message.
106
+ * Resolves to `'OK'`.
107
+ */
108
+ summarize(id: string, headers?: Record<string, string>): Promise<string> {
109
+ return this.client.post<string>(`${this.basePath}/${id}/summarize`, undefined, headers)
107
110
  }
108
111
 
109
112
  /** Checks whether a message is out of the visible range (i.e. there are more than `limit` messages after it). */
@@ -113,6 +113,19 @@ export type Message = {
113
113
  transcribeError?: boolean
114
114
  reactionParentMessageId: string | null
115
115
  hsmId: string | null
116
+ isFromBot: boolean
117
+ accountId: string
118
+ serviceId: string
119
+ contactId: string
120
+ /** The contact that sent the message; `null` when it is from the account. */
121
+ fromId: string | null
122
+ toId: string | null
123
+ /** The user that sent the message; `null` for a received one. */
124
+ userId: string | null
125
+ ticketId: string | null
126
+ ticketUserId: string | null
127
+ ticketDepartmentId: string | null
128
+ quotedMessageId: string | null
116
129
  createdAt: string
117
130
  updatedAt: string
118
131
  deletedAt: string | null
@@ -1,8 +1,11 @@
1
1
  import { expectError } from 'tsd'
2
2
  import type { ApiClient } from '../core/ApiClient'
3
+ import { ContactsApi } from './contacts/ContactsApi'
3
4
  import { PersonalAccessTokensApi } from './personalAccessTokens/PersonalAccessTokensApi'
4
5
  import { RolesApi } from './roles/RolesApi'
6
+ import { TagsApi } from './tags/TagsApi'
5
7
  import { TimetableApi } from './timetable/TimetableApi'
8
+ import { UsersApi } from './users/UsersApi'
6
9
 
7
10
  declare const client: ApiClient
8
11
 
@@ -37,3 +40,26 @@ tokens.create({ name: 'CI', expiresIn: '90d' })
37
40
  expectError(tokens.create({ name: 'CI', accessTokenExpiresAt: '2030-01-01T00:00:00Z' }))
38
41
  expectError(tokens.create({ name: 'CI', expiresIn: '7d' }))
39
42
  expectError(tokens.updateById('t-1', { scope: '*' }))
43
+
44
+ // ── users: the dedicated archive route takes a boolean, not a date ──
45
+
46
+ const users = new UsersApi(client)
47
+ users.archiveWithValidation('u-1', { archive: true })
48
+ expectError(users.archiveWithValidation('u-1', { archivedAt: '2030-01-01T00:00:00Z' }))
49
+ expectError(users.exportCsv({ filter: 'deleted' }))
50
+
51
+ // ── contacts: /many takes an array, the bulk delete a selection ─────
52
+
53
+ const contacts = new ContactsApi(client)
54
+ contacts.createMany([{ serviceId: 's-1', name: 'Alice', number: '5511999990001' }])
55
+ expectError(contacts.createMany({ serviceId: 's-1', name: 'Alice', number: '5511999990001' }))
56
+ expectError(contacts.getMedia('c-1', { where: { type: 'audio' } }))
57
+
58
+ // ── contact selections: an empty filter would reach every contact of the account ─
59
+
60
+ contacts.deleteMany({ where: { id: 'c-1' } })
61
+ expectError(contacts.deleteMany({}))
62
+ const tags = new TagsApi(client)
63
+ tags.attachContacts('t-1', { where: { id: { $in: ['c-1', 'c-2'] } } })
64
+ expectError(tags.attachContacts('t-1', {}))
65
+ expectError(tags.detachContacts('t-1', { include: ['tags'] }))
@@ -32,6 +32,8 @@ function createMockClient(queryFormat?: QueryFormat): ApiClient {
32
32
 
33
33
  const listQuery = { where: { name: 'x' }, page: 2, perPage: 5 }
34
34
  const getQuery = { attributes: ['id'] }
35
+ /** `Permission.name` is a union of the catalog names, so the permissions case filters by a real one. */
36
+ const permissionsQuery = { where: { name: 'chat' as const }, page: 2, perPage: 5 }
35
37
 
36
38
  type Case = {
37
39
  name: string
@@ -69,6 +71,27 @@ const cases: Case[] = [
69
71
  return (f ? api.withQueryFormat(f) : api).count(listQuery)
70
72
  },
71
73
  },
74
+ {
75
+ name: 'ContactsApi.getMedia',
76
+ path: '/contacts/c1/media',
77
+ query: { where: { type: 'document' }, perPage: 10 },
78
+ call: (client, f) => {
79
+ const api = new ContactsApi(client)
80
+ return (f ? api.withQueryFormat(f) : api).getMedia('c1', {
81
+ where: { type: 'document' },
82
+ perPage: 10,
83
+ })
84
+ },
85
+ },
86
+ {
87
+ name: 'ContactsApi.createMany',
88
+ path: '/contacts/many',
89
+ query: { include: ['tags'] },
90
+ call: (client, f) => {
91
+ const api = new ContactsApi(client)
92
+ return (f ? api.withQueryFormat(f) : api).createMany([], { include: ['tags'] })
93
+ },
94
+ },
72
95
  {
73
96
  name: 'ContactsApi.forward',
74
97
  path: '/contacts/forward',
@@ -199,10 +222,10 @@ const cases: Case[] = [
199
222
  {
200
223
  name: 'PermissionsApi.getMany',
201
224
  path: '/permissions',
202
- query: listQuery,
225
+ query: permissionsQuery,
203
226
  call: (client, f) => {
204
227
  const api = new PermissionsApi(client)
205
- return (f ? api.withQueryFormat(f) : api).getMany(listQuery)
228
+ return (f ? api.withQueryFormat(f) : api).getMany(permissionsQuery)
206
229
  },
207
230
  },
208
231
  {
@@ -255,7 +278,8 @@ const cases: Case[] = [
255
278
  const jsonUrl = (path: string, query: object) =>
256
279
  `${path}?query=${encodeURIComponent(JSON.stringify(query))}`
257
280
  const qsUrl = (path: string, query: object) => `${path}?${qs.stringify(query)}`
258
- const calledUrl = (client: ApiClient) => vi.mocked(client.get).mock.calls.at(0)?.at(0)
281
+ const calledUrl = (client: ApiClient) =>
282
+ vi.mocked(client.get).mock.calls.at(0)?.at(0) ?? vi.mocked(client.post).mock.calls.at(0)?.at(0)
259
283
 
260
284
  describe.each(cases)('$name', ({ path, query, call }) => {
261
285
  it("sends JSON with the default client format ('json')", async () => {
@@ -61,7 +61,7 @@ describe('ServiceAccessManagementApi', () => {
61
61
  it('serializes query parameters', async () => {
62
62
  vi.mocked(client.get).mockResolvedValue(paginated)
63
63
  await api.getMany({ page: 2, perPage: 5 })
64
- const url = vi.mocked(client.get).mock.calls[0][0] as string
64
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
65
65
  expect(url).toContain('/service-access-management?')
66
66
  expect(decodeURIComponent(url)).toContain('"page":2')
67
67
  })
@@ -76,7 +76,7 @@ describe('ServiceAccessManagementApi', () => {
76
76
  it('returns unpaginated array when paginate: false', async () => {
77
77
  vi.mocked(client.get).mockResolvedValue([record])
78
78
  const result = await api.getMany({ paginate: false })
79
- const url = vi.mocked(client.get).mock.calls[0][0] as string
79
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
80
80
  expect(decodeURIComponent(url)).toContain('"paginate":false')
81
81
  expect(result).toEqual([record])
82
82
  })
@@ -0,0 +1,49 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { ServicesApi } from './ServicesApi'
3
+ import type { ApiClient } from '../../core/ApiClient'
4
+ import type { ServiceLimits } from './types'
5
+
6
+ // ── Mock client factory ──────────────────────────────────────────────
7
+
8
+ function createMockClient(): ApiClient {
9
+ return {
10
+ setAccessToken: vi.fn().mockReturnThis(),
11
+ request: vi.fn(),
12
+ get: vi.fn(),
13
+ post: vi.fn(),
14
+ put: vi.fn(),
15
+ patch: vi.fn(),
16
+ delete: vi.fn(),
17
+ }
18
+ }
19
+
20
+ // ── Tests ────────────────────────────────────────────────────────────
21
+
22
+ describe('ServicesApi', () => {
23
+ let client: ReturnType<typeof createMockClient>
24
+ let api: ServicesApi
25
+
26
+ beforeEach(() => {
27
+ client = createMockClient()
28
+ api = new ServicesApi(client)
29
+ })
30
+
31
+ describe('getLimits', () => {
32
+ it('calls client.get with /services/limits', async () => {
33
+ const limits: ServiceLimits = {
34
+ whatsapp: { used: 1, total: 80, available: 79 },
35
+ telegram: { used: 0, total: 0, available: 0 },
36
+ }
37
+ vi.mocked(client.get).mockResolvedValue(limits)
38
+ const result = await api.getLimits()
39
+ expect(client.get).toHaveBeenCalledWith('/services/limits', undefined)
40
+ expect(result).toEqual(limits)
41
+ })
42
+
43
+ it('forwards custom headers', async () => {
44
+ const headers = { 'X-Custom': 'value' }
45
+ await api.getLimits(headers)
46
+ expect(client.get).toHaveBeenCalledWith('/services/limits', headers)
47
+ })
48
+ })
49
+ })
@@ -1,6 +1,6 @@
1
1
  import type { ApiClient } from '../../core/ApiClient'
2
2
  import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
3
- import type { Service, CreateServicePayload, UpdateServicePayload } from './types'
3
+ import type { Service, CreateServicePayload, ServiceLimits, UpdateServicePayload } from './types'
4
4
 
5
5
  export class ServicesApi extends ArchivableCrudApi<
6
6
  Service,
@@ -10,4 +10,13 @@ export class ServicesApi extends ArchivableCrudApi<
10
10
  constructor(client: ApiClient) {
11
11
  super(client, '/services')
12
12
  }
13
+
14
+ /**
15
+ * Returns, for each connection type of the account plan, how many connections are used,
16
+ * allowed and still available.
17
+ * @permissions services.view
18
+ */
19
+ getLimits(headers?: Record<string, string>): Promise<ServiceLimits> {
20
+ return this.client.get<ServiceLimits>(`${this.basePath}/limits`, headers)
21
+ }
13
22
  }
@@ -221,3 +221,12 @@ export type UpdateServicePayload = {
221
221
  botId?: string | null
222
222
  archivedAt?: string | null
223
223
  }
224
+
225
+ export type ServiceLimit = {
226
+ used: number
227
+ total: number
228
+ available: number
229
+ }
230
+
231
+ /** Connections used and allowed by the account plan, by connection type. */
232
+ export type ServiceLimits = Partial<Record<ServiceType, ServiceLimit>>
@@ -64,7 +64,7 @@ describe('StickerUsersApi', () => {
64
64
  it('serializes query parameters', async () => {
65
65
  vi.mocked(client.get).mockResolvedValue(paginated)
66
66
  await api.getMany({ page: 2, perPage: 5 })
67
- const url = vi.mocked(client.get).mock.calls[0][0] as string
67
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
68
68
  expect(url).toContain('/sticker-users?')
69
69
  expect(decodeURIComponent(url)).toContain('"page":2')
70
70
  })
@@ -0,0 +1,84 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { TagsApi } from './TagsApi'
3
+ import type { ApiClient } from '../../core/ApiClient'
4
+
5
+ // ── Mock client factory ──────────────────────────────────────────────
6
+
7
+ function createMockClient(): ApiClient {
8
+ return {
9
+ setAccessToken: vi.fn().mockReturnThis(),
10
+ request: vi.fn(),
11
+ get: vi.fn(),
12
+ post: vi.fn(),
13
+ put: vi.fn(),
14
+ patch: vi.fn(),
15
+ delete: vi.fn(),
16
+ }
17
+ }
18
+
19
+ const headers = { 'X-Custom': 'value' }
20
+
21
+ // ── Tests ────────────────────────────────────────────────────────────
22
+
23
+ describe('TagsApi', () => {
24
+ let client: ReturnType<typeof createMockClient>
25
+ let api: TagsApi
26
+
27
+ beforeEach(() => {
28
+ client = createMockClient()
29
+ api = new TagsApi(client)
30
+ })
31
+
32
+ describe('attachContacts', () => {
33
+ it('calls client.post with /tags/:id/contacts and the selection', async () => {
34
+ vi.mocked(client.post).mockResolvedValue('OK')
35
+ const body = { where: { id: { $in: ['c1', 'c2'] } } }
36
+ const result = await api.attachContacts('t1', body)
37
+ expect(client.post).toHaveBeenCalledWith('/tags/t1/contacts', body, undefined)
38
+ expect(result).toBe('OK')
39
+ })
40
+
41
+ it('resolves to false when no link changed', async () => {
42
+ vi.mocked(client.post).mockResolvedValue(false)
43
+ await expect(api.attachContacts('t1', { where: { id: 'c1' } })).resolves.toBe(false)
44
+ })
45
+
46
+ it('forwards custom headers', async () => {
47
+ await api.attachContacts('t1', { where: { id: 'c1' } }, headers)
48
+ expect(client.post).toHaveBeenCalledWith(
49
+ '/tags/t1/contacts',
50
+ { where: { id: 'c1' } },
51
+ headers,
52
+ )
53
+ })
54
+ })
55
+
56
+ describe('detachContacts', () => {
57
+ it('calls client.request with DELETE /tags/:id/contacts and the selection', async () => {
58
+ vi.mocked(client.request).mockResolvedValue('OK')
59
+ const body = { where: { id: 'c1' } }
60
+ const result = await api.detachContacts('t1', body, headers)
61
+ expect(client.request).toHaveBeenCalledWith('DELETE', '/tags/t1/contacts', body, headers)
62
+ expect(result).toBe('OK')
63
+ })
64
+ })
65
+
66
+ describe('attachManyToDepartments', () => {
67
+ it('calls client.post with /tags/attach-many-departments and body', async () => {
68
+ vi.mocked(client.post).mockResolvedValue('OK')
69
+ const body = { ids: ['t1', 't2'], departmentIds: ['d1'] }
70
+ const result = await api.attachManyToDepartments(body)
71
+ expect(client.post).toHaveBeenCalledWith('/tags/attach-many-departments', body, undefined)
72
+ expect(result).toBe('OK')
73
+ })
74
+ })
75
+
76
+ describe('error propagation', () => {
77
+ it('propagates errors from attachManyToDepartments', async () => {
78
+ vi.mocked(client.post).mockRejectedValue(new Error('Invalid tag ids'))
79
+ await expect(api.attachManyToDepartments({ ids: [], departmentIds: [] })).rejects.toThrow(
80
+ 'Invalid tag ids',
81
+ )
82
+ })
83
+ })
84
+ })
@@ -1,9 +1,67 @@
1
1
  import type { ApiClient } from '../../core/ApiClient'
2
2
  import { BaseCrudApi } from '../../core/BaseCrudApi'
3
- import type { Tag, CreateTagPayload, UpdateTagPayload } from './types'
3
+ import type { ContactsSelection } from '../contacts/types'
4
+ import type {
5
+ AttachTagsToDepartmentsPayload,
6
+ CreateTagPayload,
7
+ Tag,
8
+ TagContactsLinkResult,
9
+ UpdateTagPayload,
10
+ } from './types'
4
11
 
5
12
  export class TagsApi extends BaseCrudApi<Tag, CreateTagPayload, UpdateTagPayload> {
6
13
  constructor(client: ApiClient) {
7
14
  super(client, '/tags')
8
15
  }
16
+
17
+ /**
18
+ * Adds the tag to every contact of the account that matches the selection. Resolves to `'OK'`,
19
+ * or `false` when every contact already had the tag.
20
+ * @permissions tags.view
21
+ */
22
+ attachContacts(
23
+ id: string,
24
+ body: ContactsSelection,
25
+ headers?: Record<string, string>,
26
+ ): Promise<TagContactsLinkResult> {
27
+ return this.client.post<TagContactsLinkResult, ContactsSelection>(
28
+ `${this.basePath}/${id}/contacts`,
29
+ body,
30
+ headers,
31
+ )
32
+ }
33
+
34
+ /**
35
+ * Removes the tag from every contact of the account that matches the selection. Resolves to
36
+ * `'OK'`, or `false` when no contact had the tag.
37
+ * @permissions tags.view OR tags.destroy
38
+ */
39
+ detachContacts(
40
+ id: string,
41
+ body: ContactsSelection,
42
+ headers?: Record<string, string>,
43
+ ): Promise<TagContactsLinkResult> {
44
+ return this.client.request<TagContactsLinkResult, ContactsSelection>(
45
+ 'DELETE',
46
+ `${this.basePath}/${id}/contacts`,
47
+ body,
48
+ headers,
49
+ )
50
+ }
51
+
52
+ /**
53
+ * Links several tags to the given departments. Resolves to `'OK'`. A tag without departments
54
+ * is already available to all of them and is left unchanged.
55
+ * @permissions tags.view
56
+ */
57
+ attachManyToDepartments(
58
+ body: AttachTagsToDepartmentsPayload,
59
+ headers?: Record<string, string>,
60
+ ): Promise<string> {
61
+ return this.client.post<string, AttachTagsToDepartmentsPayload>(
62
+ `${this.basePath}/attach-many-departments`,
63
+ body,
64
+ headers,
65
+ )
66
+ }
9
67
  }
@@ -27,3 +27,18 @@ export type UpdateTagPayload = {
27
27
  label?: string
28
28
  backgroundColor?: string | null
29
29
  }
30
+
31
+ /**
32
+ * Result of linking or unlinking a tag and the contacts of a selection: `'OK'` when at least one
33
+ * link changed, `false` when nothing changed (every contact already had, or did not have, the tag).
34
+ */
35
+ export type TagContactsLinkResult = 'OK' | false
36
+
37
+ /**
38
+ * Body for `POST /tags/attach-many-departments`. An empty `departmentIds` makes the tags
39
+ * available to every department.
40
+ */
41
+ export type AttachTagsToDepartmentsPayload = {
42
+ ids: string[]
43
+ departmentIds: string[]
44
+ }
@@ -66,7 +66,7 @@ describe('TermsApi', () => {
66
66
  it('serializes the query', async () => {
67
67
  vi.mocked(client.get).mockResolvedValue(term)
68
68
  await api.getById('term-1', { attributes: ['id', 'version'] })
69
- const url = vi.mocked(client.get).mock.calls[0][0] as string
69
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
70
70
  expect(url).toContain('/terms/term-1?')
71
71
  expect(url).toContain('attributes')
72
72
  })