@ikatec/digisac-api-sdk 4.3.0 → 4.4.0

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 (54) 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/services/ServicesApi.cjs +8 -0
  10. package/dist/apis/services/ServicesApi.cjs.map +1 -1
  11. package/dist/apis/services/ServicesApi.d.ts +7 -1
  12. package/dist/apis/services/ServicesApi.d.ts.map +1 -1
  13. package/dist/apis/services/ServicesApi.mjs +8 -0
  14. package/dist/apis/services/ServicesApi.mjs.map +1 -1
  15. package/dist/apis/services/types.d.ts +7 -0
  16. package/dist/apis/services/types.d.ts.map +1 -1
  17. package/dist/apis/tags/TagsApi.cjs +23 -0
  18. package/dist/apis/tags/TagsApi.cjs.map +1 -1
  19. package/dist/apis/tags/TagsApi.d.ts +19 -1
  20. package/dist/apis/tags/TagsApi.d.ts.map +1 -1
  21. package/dist/apis/tags/TagsApi.mjs +23 -0
  22. package/dist/apis/tags/TagsApi.mjs.map +1 -1
  23. package/dist/apis/tags/types.d.ts +8 -0
  24. package/dist/apis/tags/types.d.ts.map +1 -1
  25. package/dist/apis/users/UsersApi.cjs +77 -0
  26. package/dist/apis/users/UsersApi.cjs.map +1 -1
  27. package/dist/apis/users/UsersApi.d.ts +59 -1
  28. package/dist/apis/users/UsersApi.d.ts.map +1 -1
  29. package/dist/apis/users/UsersApi.mjs +77 -0
  30. package/dist/apis/users/UsersApi.mjs.map +1 -1
  31. package/dist/apis/users/types.d.ts +102 -0
  32. package/dist/apis/users/types.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/apis/acceptanceTerms/AcceptanceTermsApi.test.ts +1 -1
  35. package/src/apis/authHistory/AuthHistoryApi.test.ts +1 -1
  36. package/src/apis/campaigns/CampaignsApi.test.ts +1 -1
  37. package/src/apis/contacts/ContactsApi.test.ts +182 -5
  38. package/src/apis/contacts/ContactsApi.ts +128 -0
  39. package/src/apis/contacts/types.ts +154 -1
  40. package/src/apis/messages/MessagesApi.test.ts +3 -3
  41. package/src/apis/payloads.test-d.ts +26 -0
  42. package/src/apis/queryFormat.test.ts +27 -3
  43. package/src/apis/serviceAccessManagement/ServiceAccessManagementApi.test.ts +2 -2
  44. package/src/apis/services/ServicesApi.test.ts +49 -0
  45. package/src/apis/services/ServicesApi.ts +10 -1
  46. package/src/apis/services/types.ts +9 -0
  47. package/src/apis/stickerUsers/StickerUsersApi.test.ts +1 -1
  48. package/src/apis/tags/TagsApi.test.ts +79 -0
  49. package/src/apis/tags/TagsApi.ts +57 -1
  50. package/src/apis/tags/types.ts +9 -0
  51. package/src/apis/terms/TermsApi.test.ts +1 -1
  52. package/src/apis/users/UsersApi.test.ts +228 -0
  53. package/src/apis/users/UsersApi.ts +152 -1
  54. package/src/apis/users/types.ts +121 -0
@@ -2,12 +2,20 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { ContactsApi } from './ContactsApi'
3
3
  import type { ApiClient } from '../../core/ApiClient'
4
4
  import type {
5
+ BlockContactPayload,
6
+ BulkTransferTicketsPayload,
7
+ BulkTransferTicketsResult,
5
8
  Contact,
9
+ ContactMediaPage,
10
+ ContactsSelection,
6
11
  CreateContactPayload,
12
+ CreateManyContactsItem,
13
+ ExportCsvPayload,
7
14
  ExportTemplatePayload,
8
15
  CountContactsQuery,
9
16
  ExistsContactsParams,
10
17
  CountMediaResult,
18
+ ImportContactsPayload,
11
19
  } from './types'
12
20
  import type { Paginated } from '../../core/types'
13
21
 
@@ -101,7 +109,7 @@ describe('ContactsApi', () => {
101
109
  it('serializes query parameters', async () => {
102
110
  vi.mocked(client.get).mockResolvedValue(paginated)
103
111
  await api.getMany({ page: 2, perPage: 5 })
104
- const url = vi.mocked(client.get).mock.calls[0][0] as string
112
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
105
113
  expect(url).toContain('/contacts?')
106
114
  expect(decodeURIComponent(url)).toContain('"page":2')
107
115
  })
@@ -225,7 +233,7 @@ describe('ContactsApi', () => {
225
233
  vi.mocked(client.get).mockResolvedValue({ count: 10 })
226
234
  const query: CountContactsQuery = { where: { name: 'Alice' } }
227
235
  await api.count(query)
228
- const url = vi.mocked(client.get).mock.calls[0][0] as string
236
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
229
237
  expect(url).toContain('/contacts/count?')
230
238
  expect(url).toContain('where')
231
239
  })
@@ -270,7 +278,7 @@ describe('ContactsApi', () => {
270
278
  it('serializes query parameters', async () => {
271
279
  vi.mocked(client.get).mockResolvedValue(paginated)
272
280
  await api.forward({ page: 1, perPage: 10 })
273
- const url = vi.mocked(client.get).mock.calls[0][0] as string
281
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
274
282
  expect(url).toContain('/contacts/forward?')
275
283
  })
276
284
 
@@ -289,7 +297,7 @@ describe('ContactsApi', () => {
289
297
  vi.mocked(client.get).mockResolvedValue({ '1234567890': true })
290
298
  const params: ExistsContactsParams = { serviceId: 'svc-1', numbers: ['1234567890'] }
291
299
  const result = await api.exists(params)
292
- const url = vi.mocked(client.get).mock.calls[0][0] as string
300
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
293
301
  expect(url).toContain('/contacts/exists?')
294
302
  expect(url).toContain('serviceId=svc-1')
295
303
  expect(result).toEqual({ '1234567890': true })
@@ -299,7 +307,7 @@ describe('ContactsApi', () => {
299
307
  vi.mocked(client.get).mockResolvedValue({})
300
308
  const headers = { 'X-Custom': 'h' }
301
309
  await api.exists({ serviceId: 'svc-1', numbers: [] }, headers)
302
- expect(vi.mocked(client.get).mock.calls[0][1]).toEqual(headers)
310
+ expect(vi.mocked(client.get).mock.calls[0]?.[1]).toEqual(headers)
303
311
  })
304
312
  })
305
313
 
@@ -365,6 +373,175 @@ describe('ContactsApi', () => {
365
373
  })
366
374
  })
367
375
 
376
+ // ── createMany ───────────────────────────────────────────────────
377
+
378
+ describe('createMany', () => {
379
+ const items: CreateManyContactsItem[] = [
380
+ { serviceId: 'svc-1', name: 'Alice', number: '5511999990001' },
381
+ { serviceId: 'svc-1', name: 'Bob', number: '5511999990002', tagIds: ['t1'] },
382
+ ]
383
+
384
+ it('calls client.post with /contacts/many and the array body', async () => {
385
+ vi.mocked(client.post).mockResolvedValue([contact])
386
+ const result = await api.createMany(items)
387
+ expect(client.post).toHaveBeenCalledWith('/contacts/many', items, undefined)
388
+ expect(result).toEqual([contact])
389
+ })
390
+
391
+ it('serializes the query and forwards custom headers', async () => {
392
+ const headers = { 'X-Custom': 'value' }
393
+ await api.createMany(items, { include: ['tags'] }, headers)
394
+ const [url, body, sentHeaders] = vi.mocked(client.post).mock.calls[0] ?? []
395
+ expect(decodeURIComponent(url as string)).toBe('/contacts/many?query={"include":["tags"]}')
396
+ expect(body).toBe(items)
397
+ expect(sentHeaders).toBe(headers)
398
+ })
399
+ })
400
+
401
+ // ── deleteMany ───────────────────────────────────────────────────
402
+
403
+ describe('deleteMany', () => {
404
+ it('calls client.request with DELETE /contacts/many and the selection', async () => {
405
+ vi.mocked(client.request).mockResolvedValue(true)
406
+ const body: ContactsSelection = { where: { id: { $in: ['c1', 'c2'] } } }
407
+ const result = await api.deleteMany(body)
408
+ expect(client.request).toHaveBeenCalledWith('DELETE', '/contacts/many', body, undefined)
409
+ expect(result).toBe(true)
410
+ })
411
+
412
+ it('forwards custom headers', async () => {
413
+ const headers = { 'X-Custom': 'value' }
414
+ await api.deleteMany({ where: { id: 'c1' } }, headers)
415
+ expect(client.request).toHaveBeenCalledWith(
416
+ 'DELETE',
417
+ '/contacts/many',
418
+ { where: { id: 'c1' } },
419
+ headers,
420
+ )
421
+ })
422
+ })
423
+
424
+ // ── exportCsv ────────────────────────────────────────────────────
425
+
426
+ describe('exportCsv', () => {
427
+ it('calls client.post with /contacts/export/csv and resolves to the CSV text', async () => {
428
+ const csv = 'Nome,DDI,Número\nAlice,55,11999990001\n'
429
+ vi.mocked(client.post).mockResolvedValue(csv)
430
+ const body: ExportCsvPayload = {
431
+ serviceType: 'whatsapp',
432
+ type: 'comma',
433
+ where: { id: 'cnt-1' },
434
+ }
435
+ const result = await api.exportCsv(body)
436
+ expect(client.post).toHaveBeenCalledWith('/contacts/export/csv', body, undefined)
437
+ expect(result).toBe(csv)
438
+ })
439
+ })
440
+
441
+ // ── importCsv ────────────────────────────────────────────────────
442
+
443
+ describe('importCsv', () => {
444
+ it('calls client.post with /contacts/import-contacts and body', async () => {
445
+ const body: ImportContactsPayload = {
446
+ service: { id: 'svc-1', accountId: 'acc-1', type: 'whatsapp' },
447
+ file: { id: 'f1', name: 'contacts.csv', mimetype: 'text/csv', urlToUpload: 'https://s3' },
448
+ csvHeaderLine: ['Nome', 'DDI', 'Número'],
449
+ type: 'comma',
450
+ tagsIds: ['t1'],
451
+ }
452
+ vi.mocked(client.post).mockResolvedValue('OK')
453
+ const headers = { 'X-Custom': 'value' }
454
+ const result = await api.importCsv(body, headers)
455
+ expect(client.post).toHaveBeenCalledWith('/contacts/import-contacts', body, headers)
456
+ expect(result).toBe('OK')
457
+ })
458
+ })
459
+
460
+ // ── sync ─────────────────────────────────────────────────────────
461
+
462
+ describe('sync', () => {
463
+ it('calls client.post with /contacts/:id/sync and an empty body by default', async () => {
464
+ vi.mocked(client.post).mockResolvedValue('OK')
465
+ const result = await api.sync('c1')
466
+ expect(client.post).toHaveBeenCalledWith('/contacts/c1/sync', {}, undefined)
467
+ expect(result).toBe('OK')
468
+ })
469
+
470
+ it('sends forceSync and forwards custom headers', async () => {
471
+ const headers = { 'X-Custom': 'value' }
472
+ await api.sync('c1', { forceSync: true }, headers)
473
+ expect(client.post).toHaveBeenCalledWith('/contacts/c1/sync', { forceSync: true }, headers)
474
+ })
475
+ })
476
+
477
+ // ── block ────────────────────────────────────────────────────────
478
+
479
+ describe('block', () => {
480
+ it('calls client.post with /contacts/:id/block and body', async () => {
481
+ vi.mocked(client.post).mockResolvedValue({ ...contact, block: true })
482
+ const body: BlockContactPayload = { block: true, description: 'spam' }
483
+ const result = await api.block('c1', body)
484
+ expect(client.post).toHaveBeenCalledWith('/contacts/c1/block', body, undefined)
485
+ expect(result.block).toBe(true)
486
+ })
487
+ })
488
+
489
+ // ── markRead ─────────────────────────────────────────────────────
490
+
491
+ describe('markRead', () => {
492
+ it('calls client.post with /contacts/:id/mark-read and no body', async () => {
493
+ vi.mocked(client.post).mockResolvedValue('OK')
494
+ const headers = { 'X-Custom': 'value' }
495
+ const result = await api.markRead('c1', headers)
496
+ expect(client.post).toHaveBeenCalledWith('/contacts/c1/mark-read', undefined, headers)
497
+ expect(result).toBe('OK')
498
+ })
499
+ })
500
+
501
+ // ── getMedia ─────────────────────────────────────────────────────
502
+
503
+ describe('getMedia', () => {
504
+ const page: ContactMediaPage = { data: [], limit: 50, hasNextPage: false, nextCursor: null }
505
+
506
+ it('calls client.get with /contacts/:id/media when no query is provided', async () => {
507
+ vi.mocked(client.get).mockResolvedValue(page)
508
+ const result = await api.getMedia('c1')
509
+ expect(client.get).toHaveBeenCalledWith('/contacts/c1/media', undefined)
510
+ expect(result).toEqual(page)
511
+ })
512
+
513
+ it('serializes the type, cursor and perPage', async () => {
514
+ await api.getMedia('c1', {
515
+ where: { type: 'document', cursor: '2026-01-01T00:00:00Z' },
516
+ perPage: 10,
517
+ })
518
+ const url = decodeURIComponent(vi.mocked(client.get).mock.calls[0]?.[0] as string)
519
+ expect(url).toBe(
520
+ '/contacts/c1/media?query={"where":{"type":"document","cursor":"2026-01-01T00:00:00Z"},"perPage":10}',
521
+ )
522
+ })
523
+ })
524
+
525
+ // ── bulkTransferTickets ──────────────────────────────────────────
526
+
527
+ describe('bulkTransferTickets', () => {
528
+ it('calls client.post with /contacts/ticket/bulk-transfer and body', async () => {
529
+ const response: BulkTransferTicketsResult = {
530
+ message: 'Transferência realizada com sucesso',
531
+ ticketsSelectedId: ['t1'],
532
+ }
533
+ vi.mocked(client.post).mockResolvedValue(response)
534
+ const body: BulkTransferTicketsPayload = {
535
+ departmentId: 'd1',
536
+ userId: null,
537
+ ticketsSelectedId: ['t1'],
538
+ }
539
+ const result = await api.bulkTransferTickets(body)
540
+ expect(client.post).toHaveBeenCalledWith('/contacts/ticket/bulk-transfer', body, undefined)
541
+ expect(result).toEqual(response)
542
+ })
543
+ })
544
+
368
545
  // ── Error propagation ────────────────────────────────────────────
369
546
 
370
547
  describe('error propagation', () => {
@@ -3,12 +3,22 @@ import type { ApiClient } from '../../core/ApiClient'
3
3
  import { BaseCrudApi } from '../../core/BaseCrudApi'
4
4
  import type { ListQuery, Paginated } from '../../core/types'
5
5
  import type {
6
+ BlockContactPayload,
7
+ BulkTransferTicketsPayload,
8
+ BulkTransferTicketsResult,
6
9
  Contact,
10
+ ContactMediaPage,
11
+ ContactMediaQuery,
12
+ ContactsSelection,
7
13
  CountContactsQuery,
8
14
  CountMediaResult,
9
15
  CreateContactPayload,
16
+ CreateManyContactsItem,
10
17
  ExistsContactsParams,
18
+ ExportCsvPayload,
11
19
  ExportTemplatePayload,
20
+ ImportContactsPayload,
21
+ SyncContactPayload,
12
22
  TransferTicketPayload,
13
23
  UpdateContactPayload,
14
24
  } from './types'
@@ -120,4 +130,122 @@ export class ContactsApi extends BaseCrudApi<Contact, CreateContactPayload, Upda
120
130
  headers,
121
131
  )
122
132
  }
133
+
134
+ /**
135
+ * Creates or updates several contacts in one call. Existing contacts (same service and
136
+ * number or e-mail) are updated; the others are created. Returns the resulting contacts.
137
+ * @permissions contacts.view OR contacts.update OR contacts.create
138
+ */
139
+ createMany(
140
+ body: CreateManyContactsItem[],
141
+ query?: ListQuery<Contact>,
142
+ headers?: Record<string, string>,
143
+ ): Promise<Contact[]> {
144
+ const queryString = this.toQueryString(query)
145
+ return this.client.post<Contact[], CreateManyContactsItem[]>(
146
+ `${this.basePath}/many${queryString}`,
147
+ body,
148
+ headers,
149
+ )
150
+ }
151
+
152
+ /**
153
+ * Deletes every contact of the account that matches the selection.
154
+ * @permissions contacts.view OR contacts.destroy
155
+ */
156
+ deleteMany(body: ContactsSelection, headers?: Record<string, string>): Promise<boolean> {
157
+ return this.client.request<boolean, ContactsSelection>(
158
+ 'DELETE',
159
+ `${this.basePath}/many`,
160
+ body,
161
+ headers,
162
+ )
163
+ }
164
+
165
+ /**
166
+ * Exports the contacts that match the filter as CSV. Resolves to the CSV text.
167
+ * @permissions contacts.view AND contacts.export
168
+ */
169
+ exportCsv(body: ExportCsvPayload, headers?: Record<string, string>): Promise<string> {
170
+ return this.client.post<string, ExportCsvPayload>(`${this.basePath}/export/csv`, body, headers)
171
+ }
172
+
173
+ /**
174
+ * Starts the import of a CSV file previously uploaded through `FilesApi`. The import runs
175
+ * in the background; the header line is validated against the service type template.
176
+ * Resolves to `'OK'`.
177
+ * @permissions contacts.view OR contacts.update OR contacts.create
178
+ */
179
+ importCsv(body: ImportContactsPayload, headers?: Record<string, string>): Promise<string> {
180
+ return this.client.post<string, ImportContactsPayload>(
181
+ `${this.basePath}/import-contacts`,
182
+ body,
183
+ headers,
184
+ )
185
+ }
186
+
187
+ /**
188
+ * Marks the contact as read and, when needed, syncs it with the messaging service.
189
+ * Resolves to `'OK'`.
190
+ * @permissions contacts.view OR contacts.update
191
+ */
192
+ sync(id: string, body?: SyncContactPayload, headers?: Record<string, string>): Promise<string> {
193
+ return this.client.post<string, SyncContactPayload>(
194
+ `${this.basePath}/${id}/sync`,
195
+ body ?? {},
196
+ headers,
197
+ )
198
+ }
199
+
200
+ /**
201
+ * Blocks or unblocks a contact. Blocking closes the contact's open ticket. Requires the
202
+ * block list to be active on the account.
203
+ * @permissions contacts.block
204
+ */
205
+ block(id: string, body: BlockContactPayload, headers?: Record<string, string>): Promise<Contact> {
206
+ return this.client.post<Contact, BlockContactPayload>(
207
+ `${this.basePath}/${id}/block`,
208
+ body,
209
+ headers,
210
+ )
211
+ }
212
+
213
+ /**
214
+ * Resets the contact's unread counter. Resolves to `'OK'`.
215
+ * @permissions contacts.view OR contacts.update
216
+ */
217
+ markRead(id: string, headers?: Record<string, string>): Promise<string> {
218
+ return this.client.post<string>(`${this.basePath}/${id}/mark-read`, undefined, headers)
219
+ }
220
+
221
+ /**
222
+ * Returns the files and links exchanged with a contact, cursor-paginated.
223
+ * @permissions contacts.view
224
+ */
225
+ getMedia(
226
+ contactId: string,
227
+ query?: ContactMediaQuery,
228
+ headers?: Record<string, string>,
229
+ ): Promise<ContactMediaPage> {
230
+ const queryString = this.toQueryString(query)
231
+ return this.client.get<ContactMediaPage>(
232
+ `${this.basePath}/${contactId}/media${queryString}`,
233
+ headers,
234
+ )
235
+ }
236
+
237
+ /**
238
+ * Transfers several tickets at once to a department and/or user.
239
+ * @permissions tickets.transfer.all.bulk
240
+ */
241
+ bulkTransferTickets(
242
+ body: BulkTransferTicketsPayload,
243
+ headers?: Record<string, string>,
244
+ ): Promise<BulkTransferTicketsResult> {
245
+ return this.client.post<BulkTransferTicketsResult, BulkTransferTicketsPayload>(
246
+ `${this.basePath}/ticket/bulk-transfer`,
247
+ body,
248
+ headers,
249
+ )
250
+ }
123
251
  }
@@ -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
+ }
@@ -85,7 +85,7 @@ describe('MessagesApi', () => {
85
85
  it('serializes query parameters', async () => {
86
86
  vi.mocked(client.get).mockResolvedValue(paginated)
87
87
  await api.getMany({ page: 2, perPage: 5 })
88
- const url = vi.mocked(client.get).mock.calls[0][0] as string
88
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
89
89
  expect(url).toContain('/messages?')
90
90
  expect(decodeURIComponent(url)).toContain('"page":2')
91
91
  expect(decodeURIComponent(url)).toContain('"perPage":5')
@@ -382,7 +382,7 @@ describe('MessagesApi', () => {
382
382
  it('appends limit query parameter when provided', async () => {
383
383
  vi.mocked(client.get).mockResolvedValue({ outOfRange: true })
384
384
  await api.outOfRange('msg-1', 50)
385
- const url = vi.mocked(client.get).mock.calls[0][0] as string
385
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
386
386
  expect(url).toContain('/messages/msg-1/out-of-range?')
387
387
  expect(url).toContain('limit=50')
388
388
  })
@@ -426,7 +426,7 @@ describe('MessagesApi', () => {
426
426
  it('appends limit query parameter when provided', async () => {
427
427
  vi.mocked(client.get).mockResolvedValue([])
428
428
  await api.around('msg-1', { limit: 10 })
429
- const url = vi.mocked(client.get).mock.calls[0][0] as string
429
+ const url = vi.mocked(client.get).mock.calls[0]?.[0] as string
430
430
  expect(url).toContain('/messages/msg-1/around?')
431
431
  expect(url).toContain('limit=10')
432
432
  })
@@ -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 () => {