@ikatec/digisac-api-sdk 2.1.1 → 2.2.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.
- package/dist/apis/contactBlockLists/types.d.ts +2 -1
- package/dist/apis/contactBlockLists/types.d.ts.map +1 -1
- package/dist/apis/contacts/ContactsApi.cjs +8 -0
- package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
- package/dist/apis/contacts/ContactsApi.d.ts +7 -1
- package/dist/apis/contacts/ContactsApi.d.ts.map +1 -1
- package/dist/apis/contacts/ContactsApi.mjs +8 -0
- package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
- package/dist/apis/contacts/types.d.ts +16 -0
- package/dist/apis/contacts/types.d.ts.map +1 -1
- package/dist/apis/departments/DepartmentsApi.cjs +2 -2
- package/dist/apis/departments/DepartmentsApi.cjs.map +1 -1
- package/dist/apis/departments/DepartmentsApi.d.ts +2 -2
- package/dist/apis/departments/DepartmentsApi.d.ts.map +1 -1
- package/dist/apis/departments/DepartmentsApi.mjs +2 -2
- package/dist/apis/departments/DepartmentsApi.mjs.map +1 -1
- package/dist/apis/departments/types.d.ts +1 -0
- package/dist/apis/departments/types.d.ts.map +1 -1
- package/dist/apis/kanbanBoards/types.d.ts +2 -0
- package/dist/apis/kanbanBoards/types.d.ts.map +1 -1
- package/dist/apis/services/ServicesApi.cjs +2 -2
- package/dist/apis/services/ServicesApi.cjs.map +1 -1
- package/dist/apis/services/ServicesApi.d.ts +2 -2
- package/dist/apis/services/ServicesApi.d.ts.map +1 -1
- package/dist/apis/services/ServicesApi.mjs +2 -2
- package/dist/apis/services/ServicesApi.mjs.map +1 -1
- package/dist/apis/services/types.d.ts +1 -0
- package/dist/apis/services/types.d.ts.map +1 -1
- package/dist/apis/tags/types.d.ts +2 -0
- package/dist/apis/tags/types.d.ts.map +1 -1
- package/dist/apis/ticketTopics/TicketTopicsApi.cjs +17 -0
- package/dist/apis/ticketTopics/TicketTopicsApi.cjs.map +1 -1
- package/dist/apis/ticketTopics/TicketTopicsApi.d.ts +11 -0
- package/dist/apis/ticketTopics/TicketTopicsApi.d.ts.map +1 -1
- package/dist/apis/ticketTopics/TicketTopicsApi.mjs +17 -0
- package/dist/apis/ticketTopics/TicketTopicsApi.mjs.map +1 -1
- package/dist/apis/tickets/TicketsApi.cjs +11 -0
- package/dist/apis/tickets/TicketsApi.cjs.map +1 -1
- package/dist/apis/tickets/TicketsApi.d.ts +10 -1
- package/dist/apis/tickets/TicketsApi.d.ts.map +1 -1
- package/dist/apis/tickets/TicketsApi.mjs +11 -0
- package/dist/apis/tickets/TicketsApi.mjs.map +1 -1
- package/dist/apis/tickets/types.d.ts +18 -0
- package/dist/apis/tickets/types.d.ts.map +1 -1
- package/dist/apis/users/UsersApi.cjs +2 -2
- package/dist/apis/users/UsersApi.cjs.map +1 -1
- package/dist/apis/users/UsersApi.d.ts +2 -2
- package/dist/apis/users/UsersApi.d.ts.map +1 -1
- package/dist/apis/users/UsersApi.mjs +2 -2
- package/dist/apis/users/UsersApi.mjs.map +1 -1
- package/dist/apis/users/types.d.ts +1 -0
- package/dist/apis/users/types.d.ts.map +1 -1
- package/dist/core/ArchivableCrudApi.cjs +39 -0
- package/dist/core/ArchivableCrudApi.cjs.map +1 -0
- package/dist/core/ArchivableCrudApi.d.ts +31 -0
- package/dist/core/ArchivableCrudApi.d.ts.map +1 -0
- package/dist/core/ArchivableCrudApi.mjs +38 -0
- package/dist/core/ArchivableCrudApi.mjs.map +1 -0
- package/dist/core/BaseApiClient.cjs +12 -9
- package/dist/core/BaseApiClient.cjs.map +1 -1
- package/dist/core/BaseApiClient.d.ts.map +1 -1
- package/dist/core/BaseApiClient.mjs +12 -9
- package/dist/core/BaseApiClient.mjs.map +1 -1
- package/dist/core/BaseCrudApi.cjs +13 -0
- package/dist/core/BaseCrudApi.cjs.map +1 -1
- package/dist/core/BaseCrudApi.d.ts +6 -0
- package/dist/core/BaseCrudApi.d.ts.map +1 -1
- package/dist/core/BaseCrudApi.mjs +13 -0
- package/dist/core/BaseCrudApi.mjs.map +1 -1
- package/dist/core/index.cjs +2 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.mjs +2 -1
- package/dist/index.cjs +2 -0
- package/dist/index.mjs +2 -1
- package/package.json +1 -1
- package/src/apis/contactBlockLists/types.ts +2 -1
- package/src/apis/contacts/ContactsApi.test.ts +24 -0
- package/src/apis/contacts/ContactsApi.ts +18 -0
- package/src/apis/contacts/types.ts +17 -0
- package/src/apis/departments/DepartmentsApi.ts +2 -2
- package/src/apis/departments/types.ts +1 -0
- package/src/apis/kanbanBoards/types.ts +2 -0
- package/src/apis/services/ServicesApi.ts +6 -2
- package/src/apis/services/types.ts +1 -0
- package/src/apis/tags/types.ts +2 -0
- package/src/apis/ticketTopics/TicketTopicsApi.test.ts +58 -0
- package/src/apis/ticketTopics/TicketTopicsApi.ts +27 -0
- package/src/apis/tickets/TicketsApi.test.ts +64 -0
- package/src/apis/tickets/TicketsApi.ts +22 -1
- package/src/apis/tickets/types.ts +12 -0
- package/src/apis/users/UsersApi.ts +2 -2
- package/src/apis/users/types.ts +1 -0
- package/src/core/ArchivableCrudApi.test.ts +84 -0
- package/src/core/ArchivableCrudApi.ts +42 -0
- package/src/core/BaseApiClient.test.ts +41 -0
- package/src/core/BaseApiClient.ts +29 -16
- package/src/core/BaseCrudApi.test.ts +31 -0
- package/src/core/BaseCrudApi.ts +14 -0
- package/src/core/index.ts +1 -0
|
@@ -341,6 +341,30 @@ describe('ContactsApi', () => {
|
|
|
341
341
|
})
|
|
342
342
|
})
|
|
343
343
|
|
|
344
|
+
describe('transferTicket', () => {
|
|
345
|
+
it('calls client.post with /contacts/:id/ticket/transfer and payload', async () => {
|
|
346
|
+
vi.mocked(client.post).mockResolvedValue({ ok: true })
|
|
347
|
+
const result = await api.transferTicket('c1', { departmentId: 'd1' })
|
|
348
|
+
expect(client.post).toHaveBeenCalledWith(
|
|
349
|
+
'/contacts/c1/ticket/transfer',
|
|
350
|
+
{ departmentId: 'd1' },
|
|
351
|
+
undefined,
|
|
352
|
+
)
|
|
353
|
+
expect(result).toEqual({ ok: true })
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('includes optional userId and forwards custom headers', async () => {
|
|
357
|
+
vi.mocked(client.post).mockResolvedValue({ ok: true })
|
|
358
|
+
const headers = { 'X-Custom': 'h' }
|
|
359
|
+
await api.transferTicket('c1', { departmentId: 'd1', userId: 'u1' }, headers)
|
|
360
|
+
expect(client.post).toHaveBeenCalledWith(
|
|
361
|
+
'/contacts/c1/ticket/transfer',
|
|
362
|
+
{ departmentId: 'd1', userId: 'u1' },
|
|
363
|
+
headers,
|
|
364
|
+
)
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
344
368
|
// ── Error propagation ────────────────────────────────────────────
|
|
345
369
|
|
|
346
370
|
describe('error propagation', () => {
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
CreateContactPayload,
|
|
10
10
|
ExistsContactsParams,
|
|
11
11
|
ExportTemplatePayload,
|
|
12
|
+
TransferTicketPayload,
|
|
12
13
|
UpdateContactPayload,
|
|
13
14
|
} from './types'
|
|
14
15
|
|
|
@@ -102,4 +103,21 @@ export class ContactsApi extends BaseCrudApi<Contact, CreateContactPayload, Upda
|
|
|
102
103
|
headers,
|
|
103
104
|
)
|
|
104
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Opens (or transfers) the contact's ticket to a department, and optionally a user.
|
|
109
|
+
* This is the real ticket-open flow used by the UI — there is no `POST /tickets`.
|
|
110
|
+
* @permissions tickets.transfer.all OR tickets.transfer.me
|
|
111
|
+
*/
|
|
112
|
+
transferTicket(
|
|
113
|
+
contactId: string,
|
|
114
|
+
payload: TransferTicketPayload,
|
|
115
|
+
headers?: Record<string, string>,
|
|
116
|
+
): Promise<unknown> {
|
|
117
|
+
return this.client.post<unknown, TransferTicketPayload>(
|
|
118
|
+
`${this.basePath}/${contactId}/ticket/transfer`,
|
|
119
|
+
payload,
|
|
120
|
+
headers,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
105
123
|
}
|
|
@@ -153,6 +153,8 @@ export type CreateContactPayload = {
|
|
|
153
153
|
name?: string
|
|
154
154
|
serviceId: string
|
|
155
155
|
idFromService?: string
|
|
156
|
+
/** Top-level convenience input; the backend copies it into `data.number`. */
|
|
157
|
+
number?: string
|
|
156
158
|
defaultDepartmentId?: string
|
|
157
159
|
defaultUserId?: string
|
|
158
160
|
data?: ContactData
|
|
@@ -170,6 +172,8 @@ export type UpdateContactPayload = {
|
|
|
170
172
|
note?: string | null
|
|
171
173
|
isSilenced?: boolean
|
|
172
174
|
block?: boolean
|
|
175
|
+
/** Tag IDs to associate with the contact. */
|
|
176
|
+
tagIds?: string[]
|
|
173
177
|
}
|
|
174
178
|
|
|
175
179
|
export type ExportTemplatePayload = {
|
|
@@ -187,3 +191,16 @@ export type ExistsContactsParams = {
|
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
export type CountMediaResult = Record<string, number>
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Body for `POST /contacts/:id/ticket/transfer`. Opens or transfers the contact's
|
|
197
|
+
* ticket. The backend only reads these fields; it does not enforce a 24h window or template.
|
|
198
|
+
*/
|
|
199
|
+
export type TransferTicketPayload = {
|
|
200
|
+
departmentId: string
|
|
201
|
+
userId?: string
|
|
202
|
+
comments?: string
|
|
203
|
+
kanbanCardColumnId?: string
|
|
204
|
+
kanbanCardSubject?: string
|
|
205
|
+
initialMessage?: string
|
|
206
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ApiClient } from '../../core/ApiClient'
|
|
2
|
-
import {
|
|
2
|
+
import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
|
|
3
3
|
import type { Department, CreateDepartmentPayload, UpdateDepartmentPayload } from './types'
|
|
4
4
|
|
|
5
|
-
export class DepartmentsApi extends
|
|
5
|
+
export class DepartmentsApi extends ArchivableCrudApi<
|
|
6
6
|
Department,
|
|
7
7
|
CreateDepartmentPayload,
|
|
8
8
|
UpdateDepartmentPayload
|
|
@@ -19,6 +19,8 @@ export type KanbanBoardRelationships = 'departments' | 'columns'
|
|
|
19
19
|
export type CreateKanbanBoardPayload = {
|
|
20
20
|
name: string
|
|
21
21
|
timeoutAlertEnabled?: boolean
|
|
22
|
+
/** Department IDs to link to the board. Required by the backend on creation. */
|
|
23
|
+
departmentIds: string[]
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export type UpdateKanbanBoardPayload = {
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { ApiClient } from '../../core/ApiClient'
|
|
2
|
-
import {
|
|
2
|
+
import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
|
|
3
3
|
import type { Service, CreateServicePayload, UpdateServicePayload } from './types'
|
|
4
4
|
|
|
5
|
-
export class ServicesApi extends
|
|
5
|
+
export class ServicesApi extends ArchivableCrudApi<
|
|
6
|
+
Service,
|
|
7
|
+
CreateServicePayload,
|
|
8
|
+
UpdateServicePayload
|
|
9
|
+
> {
|
|
6
10
|
constructor(client: ApiClient) {
|
|
7
11
|
super(client, '/services')
|
|
8
12
|
}
|
package/src/apis/tags/types.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type TagRelationships = 'contacts' | 'departments'
|
|
|
19
19
|
export type CreateTagPayload = {
|
|
20
20
|
label: string
|
|
21
21
|
backgroundColor?: string
|
|
22
|
+
/** Department IDs to link to the tag on creation. */
|
|
23
|
+
departments?: string[]
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export type UpdateTagPayload = {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { TicketTopicsApi } from './TicketTopicsApi'
|
|
3
|
+
import type { ApiClient } from '../../core/ApiClient'
|
|
4
|
+
import type { TicketTopic } from './types'
|
|
5
|
+
|
|
6
|
+
function createMockClient(): ApiClient {
|
|
7
|
+
return {
|
|
8
|
+
setAccessToken: vi.fn().mockReturnThis(),
|
|
9
|
+
request: vi.fn(),
|
|
10
|
+
get: vi.fn(),
|
|
11
|
+
post: vi.fn(),
|
|
12
|
+
put: vi.fn(),
|
|
13
|
+
patch: vi.fn(),
|
|
14
|
+
delete: vi.fn(),
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('TicketTopicsApi', () => {
|
|
19
|
+
let client: ReturnType<typeof createMockClient>
|
|
20
|
+
let api: TicketTopicsApi
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
client = createMockClient()
|
|
24
|
+
api = new TicketTopicsApi(client)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('archives via POST /ticket-topics/:id/archive with { archive: true }', async () => {
|
|
28
|
+
const topic = { id: 't1' } as TicketTopic
|
|
29
|
+
vi.mocked(client.post).mockResolvedValue(topic)
|
|
30
|
+
|
|
31
|
+
const result = await api.archive('t1')
|
|
32
|
+
expect(client.post).toHaveBeenCalledWith(
|
|
33
|
+
'/ticket-topics/t1/archive',
|
|
34
|
+
{ archive: true },
|
|
35
|
+
undefined,
|
|
36
|
+
)
|
|
37
|
+
expect(result).toEqual(topic)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('unarchives via POST /ticket-topics/:id/archive with { archive: false }', async () => {
|
|
41
|
+
vi.mocked(client.post).mockResolvedValue({ id: 't1' } as TicketTopic)
|
|
42
|
+
|
|
43
|
+
await api.unarchive('t1')
|
|
44
|
+
expect(client.post).toHaveBeenCalledWith(
|
|
45
|
+
'/ticket-topics/t1/archive',
|
|
46
|
+
{ archive: false },
|
|
47
|
+
undefined,
|
|
48
|
+
)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('archive/unarchive stay bound when destructured', async () => {
|
|
52
|
+
vi.mocked(client.post).mockResolvedValue({ id: 't1' } as TicketTopic)
|
|
53
|
+
const { archive, unarchive } = api
|
|
54
|
+
await archive('t1')
|
|
55
|
+
await unarchive('t1')
|
|
56
|
+
expect(client.post).toHaveBeenCalledTimes(2)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -9,5 +9,32 @@ export class TicketTopicsApi extends BaseCrudApi<
|
|
|
9
9
|
> {
|
|
10
10
|
constructor(client: ApiClient) {
|
|
11
11
|
super(client, '/ticket-topics')
|
|
12
|
+
this.archive = this.archive.bind(this)
|
|
13
|
+
this.unarchive = this.unarchive.bind(this)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Archives a ticket topic. Unlike most resources, ticket-topics archive through a
|
|
18
|
+
* dedicated endpoint (POST `${basePath}/${id}/archive`) rather than `updateById`.
|
|
19
|
+
* @permissions ticketTopic.update
|
|
20
|
+
*/
|
|
21
|
+
archive(id: string, headers?: Record<string, string>): Promise<TicketTopic> {
|
|
22
|
+
return this.client.post<TicketTopic>(
|
|
23
|
+
`${this.basePath}/${id}/archive`,
|
|
24
|
+
{ archive: true },
|
|
25
|
+
headers,
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Restores a previously archived ticket topic via the same dedicated endpoint.
|
|
31
|
+
* @permissions ticketTopic.update
|
|
32
|
+
*/
|
|
33
|
+
unarchive(id: string, headers?: Record<string, string>): Promise<TicketTopic> {
|
|
34
|
+
return this.client.post<TicketTopic>(
|
|
35
|
+
`${this.basePath}/${id}/archive`,
|
|
36
|
+
{ archive: false },
|
|
37
|
+
headers,
|
|
38
|
+
)
|
|
12
39
|
}
|
|
13
40
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { TicketsApi } from './TicketsApi'
|
|
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
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe('TicketsApi', () => {
|
|
22
|
+
let client: ReturnType<typeof createMockClient>
|
|
23
|
+
let api: TicketsApi
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
client = createMockClient()
|
|
27
|
+
api = new TicketsApi(client)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe('closeMany', () => {
|
|
31
|
+
it('PUTs /tickets/close-many with explicit ids', async () => {
|
|
32
|
+
vi.mocked(client.put).mockResolvedValue({ ok: true })
|
|
33
|
+
const result = await api.closeMany({ ids: ['t1', 't2'] })
|
|
34
|
+
expect(client.put).toHaveBeenCalledWith(
|
|
35
|
+
'/tickets/close-many',
|
|
36
|
+
{ ids: ['t1', 't2'] },
|
|
37
|
+
undefined,
|
|
38
|
+
)
|
|
39
|
+
expect(result).toEqual({ ok: true })
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('PUTs /tickets/close-many with a JSON-stringified query for the select-all variant', async () => {
|
|
43
|
+
vi.mocked(client.put).mockResolvedValue({ ok: true })
|
|
44
|
+
await api.closeMany({ allContactsSelected: true, query: { query: '{"where":{}}' } })
|
|
45
|
+
expect(client.put).toHaveBeenCalledWith(
|
|
46
|
+
'/tickets/close-many',
|
|
47
|
+
{ allContactsSelected: true, query: { query: '{"where":{}}' } },
|
|
48
|
+
undefined,
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('forwards custom headers', async () => {
|
|
53
|
+
vi.mocked(client.put).mockResolvedValue({ ok: true })
|
|
54
|
+
const headers = { 'X-Custom': 'value' }
|
|
55
|
+
await api.closeMany({ ids: ['t1'] }, headers)
|
|
56
|
+
expect(client.put).toHaveBeenCalledWith('/tickets/close-many', { ids: ['t1'] }, headers)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('propagates errors', async () => {
|
|
60
|
+
vi.mocked(client.put).mockRejectedValue(new Error('Forbidden'))
|
|
61
|
+
await expect(api.closeMany({ ids: ['t1'] })).rejects.toThrow('Forbidden')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
import type { ApiClient } from '../../core/ApiClient'
|
|
2
2
|
import { BaseCrudApi } from '../../core/BaseCrudApi'
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
Ticket,
|
|
5
|
+
CreateTicketPayload,
|
|
6
|
+
UpdateTicketPayload,
|
|
7
|
+
CloseManyTicketsPayload,
|
|
8
|
+
} from './types'
|
|
4
9
|
|
|
5
10
|
export class TicketsApi extends BaseCrudApi<Ticket, CreateTicketPayload, UpdateTicketPayload> {
|
|
6
11
|
constructor(client: ApiClient) {
|
|
7
12
|
super(client, '/tickets')
|
|
8
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Closes multiple tickets in a single request. This is the only ticket-close
|
|
17
|
+
* endpoint exposed by the API — there is no per-ticket close/update route.
|
|
18
|
+
*
|
|
19
|
+
* Pass `{ ids }` to close specific tickets, or `{ allContactsSelected: true, query }`
|
|
20
|
+
* to close every OPEN ticket matching `query` (the backend forces `isOpen: true`).
|
|
21
|
+
* @permissions tickets.close.all OR tickets.close.me
|
|
22
|
+
*/
|
|
23
|
+
closeMany(payload: CloseManyTicketsPayload, headers?: Record<string, string>): Promise<unknown> {
|
|
24
|
+
return this.client.put<unknown, CloseManyTicketsPayload>(
|
|
25
|
+
`${this.basePath}/close-many`,
|
|
26
|
+
payload,
|
|
27
|
+
headers,
|
|
28
|
+
)
|
|
29
|
+
}
|
|
9
30
|
}
|
|
@@ -67,3 +67,15 @@ export type UpdateTicketPayload = {
|
|
|
67
67
|
comments?: string | null
|
|
68
68
|
isOpen?: boolean
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Body for `PUT /tickets/close-many`.
|
|
73
|
+
*
|
|
74
|
+
* Either close an explicit list of tickets by id, or close every open ticket
|
|
75
|
+
* matching a query. For the query variant the outer `query` is an object whose
|
|
76
|
+
* `query` field is a JSON-stringified list query (e.g. `JSON.stringify({ where: {} })`);
|
|
77
|
+
* the backend always forces `where.isOpen = true`.
|
|
78
|
+
*/
|
|
79
|
+
export type CloseManyTicketsPayload =
|
|
80
|
+
| { ids: string[]; ticketTopicIds?: string[] | string; comments?: string }
|
|
81
|
+
| { allContactsSelected: true; query: { query: string } }
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { ApiClient } from '../../core/ApiClient'
|
|
2
|
-
import {
|
|
2
|
+
import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
|
|
3
3
|
import type { User, CreateUserPayload, UpdateUserPayload } from './types'
|
|
4
4
|
|
|
5
|
-
export class UsersApi extends
|
|
5
|
+
export class UsersApi extends ArchivableCrudApi<User, CreateUserPayload, UpdateUserPayload> {
|
|
6
6
|
constructor(client: ApiClient) {
|
|
7
7
|
super(client, '/users')
|
|
8
8
|
}
|
package/src/apis/users/types.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { ArchivableCrudApi } from './ArchivableCrudApi'
|
|
3
|
+
import type { ApiClient } from './ApiClient'
|
|
4
|
+
|
|
5
|
+
// ── Test types ───────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
type Item = { id: string; name: string; archivedAt: string | null }
|
|
8
|
+
type CreateItem = { name: string }
|
|
9
|
+
type UpdateItem = { name?: string; archivedAt?: string | null }
|
|
10
|
+
|
|
11
|
+
// ── Mock client factory ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function createMockClient(): ApiClient {
|
|
14
|
+
return {
|
|
15
|
+
setAccessToken: vi.fn().mockReturnThis(),
|
|
16
|
+
request: vi.fn(),
|
|
17
|
+
get: vi.fn(),
|
|
18
|
+
post: vi.fn(),
|
|
19
|
+
put: vi.fn(),
|
|
20
|
+
patch: vi.fn(),
|
|
21
|
+
delete: vi.fn(),
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Tests ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe('ArchivableCrudApi', () => {
|
|
28
|
+
let client: ReturnType<typeof createMockClient>
|
|
29
|
+
let api: ArchivableCrudApi<Item, CreateItem, UpdateItem>
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
client = createMockClient()
|
|
33
|
+
api = new ArchivableCrudApi<Item, CreateItem, UpdateItem>(client, '/items')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('inherits BaseCrudApi behavior (deleteById)', async () => {
|
|
37
|
+
vi.mocked(client.delete).mockResolvedValue({ id: '1', name: 'x', archivedAt: null })
|
|
38
|
+
await api.deleteById('1')
|
|
39
|
+
expect(client.delete).toHaveBeenCalledWith('/items/1', undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('archive', () => {
|
|
43
|
+
it('PUTs basePath/id with an archivedAt timestamp', async () => {
|
|
44
|
+
const archived: Item = { id: 'abc', name: 'x', archivedAt: '2026-01-01T00:00:00.000Z' }
|
|
45
|
+
vi.mocked(client.put).mockResolvedValue(archived)
|
|
46
|
+
|
|
47
|
+
const result = await api.archive('abc')
|
|
48
|
+
|
|
49
|
+
const [path, body, headers] = vi.mocked(client.put).mock.calls.at(0) ?? []
|
|
50
|
+
expect(path).toBe('/items/abc')
|
|
51
|
+
expect(typeof (body as { archivedAt?: unknown }).archivedAt).toBe('string')
|
|
52
|
+
expect(headers).toBeUndefined()
|
|
53
|
+
expect(result).toEqual(archived)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('forwards custom headers', async () => {
|
|
57
|
+
vi.mocked(client.put).mockResolvedValue({ id: '1', name: 'x', archivedAt: null })
|
|
58
|
+
const headers = { 'X-Reason': 'cleanup' }
|
|
59
|
+
|
|
60
|
+
await api.archive('1', headers)
|
|
61
|
+
expect(vi.mocked(client.put).mock.calls.at(0)?.at(2)).toEqual(headers)
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('unarchive', () => {
|
|
66
|
+
it('PUTs basePath/id with archivedAt null', async () => {
|
|
67
|
+
const restored: Item = { id: 'abc', name: 'x', archivedAt: null }
|
|
68
|
+
vi.mocked(client.put).mockResolvedValue(restored)
|
|
69
|
+
|
|
70
|
+
const result = await api.unarchive('abc')
|
|
71
|
+
|
|
72
|
+
expect(client.put).toHaveBeenCalledWith('/items/abc', { archivedAt: null }, undefined)
|
|
73
|
+
expect(result).toEqual(restored)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('archive/unarchive stay bound when destructured', async () => {
|
|
78
|
+
vi.mocked(client.put).mockResolvedValue({ id: '1', name: 'x', archivedAt: null })
|
|
79
|
+
const { archive, unarchive } = api
|
|
80
|
+
await archive('1')
|
|
81
|
+
await unarchive('1')
|
|
82
|
+
expect(client.put).toHaveBeenCalledTimes(2)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ApiClient } from './ApiClient'
|
|
2
|
+
import { BaseCrudApi } from './BaseCrudApi'
|
|
3
|
+
import type { ListQuery } from './types'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* CRUD base for resources that support soft-delete via an `archivedAt` timestamp
|
|
7
|
+
* (PUT `${basePath}/${id}` with `{ archivedAt }`). Adds `archive`/`unarchive` on
|
|
8
|
+
* top of {@link BaseCrudApi}.
|
|
9
|
+
*
|
|
10
|
+
* Only extend this for resources whose archive write mechanism is `archivedAt`.
|
|
11
|
+
* Resources that archive through a different field (e.g. cards use `isArchived`)
|
|
12
|
+
* or a dedicated endpoint (e.g. ticket-topics POST `/archive`) must NOT use this
|
|
13
|
+
* base and should implement `archive`/`unarchive` themselves.
|
|
14
|
+
*/
|
|
15
|
+
export class ArchivableCrudApi<
|
|
16
|
+
TResponse,
|
|
17
|
+
TCreate,
|
|
18
|
+
TUpdate extends { archivedAt?: string | null } = TCreate & { archivedAt?: string | null },
|
|
19
|
+
TQuery extends ListQuery<TResponse> = ListQuery<TResponse>,
|
|
20
|
+
> extends BaseCrudApi<TResponse, TCreate, TUpdate, TQuery> {
|
|
21
|
+
constructor(client: ApiClient, basePath: string) {
|
|
22
|
+
super(client, basePath)
|
|
23
|
+
this.archive = this.archive.bind(this)
|
|
24
|
+
this.unarchive = this.unarchive.bind(this)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Archives a resource (soft-delete) by setting `archivedAt` to the current time.
|
|
29
|
+
* @permissions $resourceName.update
|
|
30
|
+
*/
|
|
31
|
+
archive(id: string, headers?: Record<string, string>): Promise<TResponse> {
|
|
32
|
+
return this.updateById(id, { archivedAt: new Date().toISOString() } as TUpdate, headers)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Restores a previously archived resource by clearing `archivedAt`.
|
|
37
|
+
* @permissions $resourceName.update
|
|
38
|
+
*/
|
|
39
|
+
unarchive(id: string, headers?: Record<string, string>): Promise<TResponse> {
|
|
40
|
+
return this.updateById(id, { archivedAt: null } as TUpdate, headers)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -127,6 +127,33 @@ describe('BaseApiClient', () => {
|
|
|
127
127
|
expect(result).toBeUndefined()
|
|
128
128
|
})
|
|
129
129
|
|
|
130
|
+
it('returns undefined for empty body even when Content-Type is application/json', async () => {
|
|
131
|
+
// Regression: some endpoints (e.g. POST /me/takeover) reply 200 with the default
|
|
132
|
+
// application/json Content-Type and an empty body. `response.json()` would throw
|
|
133
|
+
// "SyntaxError: Unexpected end of input".
|
|
134
|
+
mockFetch({
|
|
135
|
+
ok: true,
|
|
136
|
+
status: 200,
|
|
137
|
+
statusText: 'OK',
|
|
138
|
+
headers: { 'Content-Type': 'application/json' },
|
|
139
|
+
body: '',
|
|
140
|
+
})
|
|
141
|
+
const result = await client.post('/me/takeover')
|
|
142
|
+
expect(result).toBeUndefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('returns undefined for 204 No Content regardless of Content-Type', async () => {
|
|
146
|
+
mockFetch({
|
|
147
|
+
ok: true,
|
|
148
|
+
status: 204,
|
|
149
|
+
statusText: 'No Content',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
body: '',
|
|
152
|
+
})
|
|
153
|
+
const result = await client.post('/me/takeover')
|
|
154
|
+
expect(result).toBeUndefined()
|
|
155
|
+
})
|
|
156
|
+
|
|
130
157
|
it('returns plain text when response is not JSON', async () => {
|
|
131
158
|
mockFetch({ ok: true, status: 200, statusText: 'OK', body: 'OK' })
|
|
132
159
|
const result = await client.post<string>('/action')
|
|
@@ -220,6 +247,20 @@ describe('BaseApiClient', () => {
|
|
|
220
247
|
})
|
|
221
248
|
})
|
|
222
249
|
|
|
250
|
+
it('does not throw when error response has Content-Type application/json but empty body', async () => {
|
|
251
|
+
mockFetch({
|
|
252
|
+
ok: false,
|
|
253
|
+
status: 502,
|
|
254
|
+
statusText: 'Bad Gateway',
|
|
255
|
+
headers: { 'Content-Type': 'application/json' },
|
|
256
|
+
body: '',
|
|
257
|
+
})
|
|
258
|
+
await expect(client.get('/fail')).rejects.toMatchObject({
|
|
259
|
+
message: 'HTTP Error 502: Bad Gateway',
|
|
260
|
+
status: 502,
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
223
264
|
it('uses error field as message when message is absent', async () => {
|
|
224
265
|
mockFetch({
|
|
225
266
|
ok: false,
|
|
@@ -63,7 +63,7 @@ export class BaseApiClient implements ApiClient {
|
|
|
63
63
|
protected async handleResponse<TResponse>(response: Response): Promise<TResponse> {
|
|
64
64
|
if (!response.ok) {
|
|
65
65
|
const contentType = response.headers.get('Content-Type')
|
|
66
|
-
const isJson = contentType
|
|
66
|
+
const isJson = contentType?.includes('application/json') ?? false
|
|
67
67
|
|
|
68
68
|
let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`
|
|
69
69
|
let errorClass: string | undefined
|
|
@@ -71,10 +71,13 @@ export class BaseApiClient implements ApiClient {
|
|
|
71
71
|
|
|
72
72
|
if (isJson) {
|
|
73
73
|
try {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
74
|
+
const errorText = await response.text()
|
|
75
|
+
if (errorText) {
|
|
76
|
+
const errorData = JSON.parse(errorText)
|
|
77
|
+
errorMessage = errorData.message || errorData.error || errorMessage
|
|
78
|
+
errorClass = errorData.error
|
|
79
|
+
validationErrors = errorData.validationErrors ?? null
|
|
80
|
+
}
|
|
78
81
|
} catch {
|
|
79
82
|
// If JSON parsing fails, use the default error message
|
|
80
83
|
}
|
|
@@ -83,23 +86,33 @@ export class BaseApiClient implements ApiClient {
|
|
|
83
86
|
throw new ApiError(errorMessage, errorClass, response.status, validationErrors)
|
|
84
87
|
}
|
|
85
88
|
|
|
89
|
+
// 204 No Content never has a body; short-circuit before reading the stream.
|
|
90
|
+
if (response.status === 204) {
|
|
91
|
+
return undefined as unknown as TResponse
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Read body as text once and treat an empty body as "no content" regardless of
|
|
95
|
+
// Content-Type. Servers sometimes return 200 with Content-Type: application/json
|
|
96
|
+
// and an empty body (e.g. POST endpoints whose framework keeps the default JSON
|
|
97
|
+
// content-type even when nothing is written). `response.json()` would throw
|
|
98
|
+
// "Unexpected end of input" in that case.
|
|
99
|
+
const text = await response.text()
|
|
100
|
+
if (!text) {
|
|
101
|
+
return undefined as unknown as TResponse
|
|
102
|
+
}
|
|
103
|
+
|
|
86
104
|
const contentType = response.headers.get('Content-Type')
|
|
87
|
-
const isJson = contentType
|
|
105
|
+
const isJson = contentType?.includes('application/json') ?? false
|
|
88
106
|
|
|
89
107
|
if (isJson) {
|
|
90
|
-
return
|
|
108
|
+
return JSON.parse(text) as TResponse
|
|
91
109
|
}
|
|
92
110
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
} catch {
|
|
98
|
-
return text as unknown as TResponse
|
|
99
|
-
}
|
|
111
|
+
try {
|
|
112
|
+
return JSON.parse(text) as TResponse
|
|
113
|
+
} catch {
|
|
114
|
+
return text as unknown as TResponse
|
|
100
115
|
}
|
|
101
|
-
|
|
102
|
-
return undefined as unknown as TResponse
|
|
103
116
|
}
|
|
104
117
|
|
|
105
118
|
get<TResponse = unknown>(endpoint: string, headers?: Record<string, string>): Promise<TResponse> {
|