@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.
Files changed (100) hide show
  1. package/dist/apis/contactBlockLists/types.d.ts +2 -1
  2. package/dist/apis/contactBlockLists/types.d.ts.map +1 -1
  3. package/dist/apis/contacts/ContactsApi.cjs +8 -0
  4. package/dist/apis/contacts/ContactsApi.cjs.map +1 -1
  5. package/dist/apis/contacts/ContactsApi.d.ts +7 -1
  6. package/dist/apis/contacts/ContactsApi.d.ts.map +1 -1
  7. package/dist/apis/contacts/ContactsApi.mjs +8 -0
  8. package/dist/apis/contacts/ContactsApi.mjs.map +1 -1
  9. package/dist/apis/contacts/types.d.ts +16 -0
  10. package/dist/apis/contacts/types.d.ts.map +1 -1
  11. package/dist/apis/departments/DepartmentsApi.cjs +2 -2
  12. package/dist/apis/departments/DepartmentsApi.cjs.map +1 -1
  13. package/dist/apis/departments/DepartmentsApi.d.ts +2 -2
  14. package/dist/apis/departments/DepartmentsApi.d.ts.map +1 -1
  15. package/dist/apis/departments/DepartmentsApi.mjs +2 -2
  16. package/dist/apis/departments/DepartmentsApi.mjs.map +1 -1
  17. package/dist/apis/departments/types.d.ts +1 -0
  18. package/dist/apis/departments/types.d.ts.map +1 -1
  19. package/dist/apis/kanbanBoards/types.d.ts +2 -0
  20. package/dist/apis/kanbanBoards/types.d.ts.map +1 -1
  21. package/dist/apis/services/ServicesApi.cjs +2 -2
  22. package/dist/apis/services/ServicesApi.cjs.map +1 -1
  23. package/dist/apis/services/ServicesApi.d.ts +2 -2
  24. package/dist/apis/services/ServicesApi.d.ts.map +1 -1
  25. package/dist/apis/services/ServicesApi.mjs +2 -2
  26. package/dist/apis/services/ServicesApi.mjs.map +1 -1
  27. package/dist/apis/services/types.d.ts +1 -0
  28. package/dist/apis/services/types.d.ts.map +1 -1
  29. package/dist/apis/tags/types.d.ts +2 -0
  30. package/dist/apis/tags/types.d.ts.map +1 -1
  31. package/dist/apis/ticketTopics/TicketTopicsApi.cjs +17 -0
  32. package/dist/apis/ticketTopics/TicketTopicsApi.cjs.map +1 -1
  33. package/dist/apis/ticketTopics/TicketTopicsApi.d.ts +11 -0
  34. package/dist/apis/ticketTopics/TicketTopicsApi.d.ts.map +1 -1
  35. package/dist/apis/ticketTopics/TicketTopicsApi.mjs +17 -0
  36. package/dist/apis/ticketTopics/TicketTopicsApi.mjs.map +1 -1
  37. package/dist/apis/tickets/TicketsApi.cjs +11 -0
  38. package/dist/apis/tickets/TicketsApi.cjs.map +1 -1
  39. package/dist/apis/tickets/TicketsApi.d.ts +10 -1
  40. package/dist/apis/tickets/TicketsApi.d.ts.map +1 -1
  41. package/dist/apis/tickets/TicketsApi.mjs +11 -0
  42. package/dist/apis/tickets/TicketsApi.mjs.map +1 -1
  43. package/dist/apis/tickets/types.d.ts +18 -0
  44. package/dist/apis/tickets/types.d.ts.map +1 -1
  45. package/dist/apis/users/UsersApi.cjs +2 -2
  46. package/dist/apis/users/UsersApi.cjs.map +1 -1
  47. package/dist/apis/users/UsersApi.d.ts +2 -2
  48. package/dist/apis/users/UsersApi.d.ts.map +1 -1
  49. package/dist/apis/users/UsersApi.mjs +2 -2
  50. package/dist/apis/users/UsersApi.mjs.map +1 -1
  51. package/dist/apis/users/types.d.ts +1 -0
  52. package/dist/apis/users/types.d.ts.map +1 -1
  53. package/dist/core/ArchivableCrudApi.cjs +39 -0
  54. package/dist/core/ArchivableCrudApi.cjs.map +1 -0
  55. package/dist/core/ArchivableCrudApi.d.ts +31 -0
  56. package/dist/core/ArchivableCrudApi.d.ts.map +1 -0
  57. package/dist/core/ArchivableCrudApi.mjs +38 -0
  58. package/dist/core/ArchivableCrudApi.mjs.map +1 -0
  59. package/dist/core/BaseApiClient.cjs +12 -9
  60. package/dist/core/BaseApiClient.cjs.map +1 -1
  61. package/dist/core/BaseApiClient.d.ts.map +1 -1
  62. package/dist/core/BaseApiClient.mjs +12 -9
  63. package/dist/core/BaseApiClient.mjs.map +1 -1
  64. package/dist/core/BaseCrudApi.cjs +13 -0
  65. package/dist/core/BaseCrudApi.cjs.map +1 -1
  66. package/dist/core/BaseCrudApi.d.ts +6 -0
  67. package/dist/core/BaseCrudApi.d.ts.map +1 -1
  68. package/dist/core/BaseCrudApi.mjs +13 -0
  69. package/dist/core/BaseCrudApi.mjs.map +1 -1
  70. package/dist/core/index.cjs +2 -0
  71. package/dist/core/index.d.ts +1 -0
  72. package/dist/core/index.d.ts.map +1 -1
  73. package/dist/core/index.mjs +2 -1
  74. package/dist/index.cjs +2 -0
  75. package/dist/index.mjs +2 -1
  76. package/package.json +1 -1
  77. package/src/apis/contactBlockLists/types.ts +2 -1
  78. package/src/apis/contacts/ContactsApi.test.ts +24 -0
  79. package/src/apis/contacts/ContactsApi.ts +18 -0
  80. package/src/apis/contacts/types.ts +17 -0
  81. package/src/apis/departments/DepartmentsApi.ts +2 -2
  82. package/src/apis/departments/types.ts +1 -0
  83. package/src/apis/kanbanBoards/types.ts +2 -0
  84. package/src/apis/services/ServicesApi.ts +6 -2
  85. package/src/apis/services/types.ts +1 -0
  86. package/src/apis/tags/types.ts +2 -0
  87. package/src/apis/ticketTopics/TicketTopicsApi.test.ts +58 -0
  88. package/src/apis/ticketTopics/TicketTopicsApi.ts +27 -0
  89. package/src/apis/tickets/TicketsApi.test.ts +64 -0
  90. package/src/apis/tickets/TicketsApi.ts +22 -1
  91. package/src/apis/tickets/types.ts +12 -0
  92. package/src/apis/users/UsersApi.ts +2 -2
  93. package/src/apis/users/types.ts +1 -0
  94. package/src/core/ArchivableCrudApi.test.ts +84 -0
  95. package/src/core/ArchivableCrudApi.ts +42 -0
  96. package/src/core/BaseApiClient.test.ts +41 -0
  97. package/src/core/BaseApiClient.ts +29 -16
  98. package/src/core/BaseCrudApi.test.ts +31 -0
  99. package/src/core/BaseCrudApi.ts +14 -0
  100. 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 { BaseCrudApi } from '../../core/BaseCrudApi'
2
+ import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
3
3
  import type { Department, CreateDepartmentPayload, UpdateDepartmentPayload } from './types'
4
4
 
5
- export class DepartmentsApi extends BaseCrudApi<
5
+ export class DepartmentsApi extends ArchivableCrudApi<
6
6
  Department,
7
7
  CreateDepartmentPayload,
8
8
  UpdateDepartmentPayload
@@ -24,4 +24,5 @@ export type CreateDepartmentPayload = {
24
24
  export type UpdateDepartmentPayload = {
25
25
  name?: string
26
26
  distributionId?: string | null
27
+ archivedAt?: string | null
27
28
  }
@@ -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 { BaseCrudApi } from '../../core/BaseCrudApi'
2
+ import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
3
3
  import type { Service, CreateServicePayload, UpdateServicePayload } from './types'
4
4
 
5
- export class ServicesApi extends BaseCrudApi<Service, CreateServicePayload, UpdateServicePayload> {
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
  }
@@ -219,4 +219,5 @@ export type UpdateServicePayload = {
219
219
  settings?: ServiceSettings
220
220
  defaultDepartmentId?: string | null
221
221
  botId?: string | null
222
+ archivedAt?: string | null
222
223
  }
@@ -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 { Ticket, CreateTicketPayload, UpdateTicketPayload } from './types'
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 { BaseCrudApi } from '../../core/BaseCrudApi'
2
+ import { ArchivableCrudApi } from '../../core/ArchivableCrudApi'
3
3
  import type { User, CreateUserPayload, UpdateUserPayload } from './types'
4
4
 
5
- export class UsersApi extends BaseCrudApi<User, CreateUserPayload, UpdateUserPayload> {
5
+ export class UsersApi extends ArchivableCrudApi<User, CreateUserPayload, UpdateUserPayload> {
6
6
  constructor(client: ApiClient) {
7
7
  super(client, '/users')
8
8
  }
@@ -67,4 +67,5 @@ export type UpdateUserPayload = {
67
67
  timetableId?: string | null
68
68
  preferences?: UserPreferences
69
69
  language?: string
70
+ archivedAt?: string | null
70
71
  }
@@ -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 && contentType.includes('application/json')
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 errorData = await response.json()
75
- errorMessage = errorData.message || errorData.error || errorMessage
76
- errorClass = errorData.error
77
- validationErrors = errorData.validationErrors ?? null
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 && contentType.includes('application/json')
105
+ const isJson = contentType?.includes('application/json') ?? false
88
106
 
89
107
  if (isJson) {
90
- return (await response.json()) as TResponse
108
+ return JSON.parse(text) as TResponse
91
109
  }
92
110
 
93
- const text = await response.text()
94
- if (text) {
95
- try {
96
- return JSON.parse(text) as TResponse
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> {