@ikatec/digisac-api-sdk 2.1.3 → 2.2.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.
- 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/permissions/types.d.ts +1 -1
- package/dist/apis/permissions/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/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/permissions/types.ts +2 -1
- 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/BaseCrudApi.test.ts +31 -0
- package/src/core/BaseCrudApi.ts +14 -0
- package/src/core/index.ts +1 -0
|
@@ -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 = {
|
|
@@ -121,7 +121,8 @@ export type PermissionName =
|
|
|
121
121
|
| 'myAccount.absence'
|
|
122
122
|
| 'settingsApi.update'
|
|
123
123
|
| 'authHistory.view'
|
|
124
|
-
| 'overview.now.view'
|
|
124
|
+
| 'overview.now.view.all'
|
|
125
|
+
| 'overview.now.view.departments'
|
|
125
126
|
| 'overview.history.view'
|
|
126
127
|
| 'overview.hsm.statistic.view'
|
|
127
128
|
| 'overview.evaluation.view'
|
|
@@ -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
|
+
}
|
|
@@ -131,6 +131,37 @@ describe('BaseCrudApi', () => {
|
|
|
131
131
|
})
|
|
132
132
|
})
|
|
133
133
|
|
|
134
|
+
// ── getOne ───────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
describe('getOne', () => {
|
|
137
|
+
it('requests limit 1 (non-paginated) and returns the first item', async () => {
|
|
138
|
+
const item: Item = { id: '1', name: 'Test' }
|
|
139
|
+
vi.mocked(client.get).mockResolvedValue([item])
|
|
140
|
+
|
|
141
|
+
const result = await api.getOne()
|
|
142
|
+
const calledUrl = vi.mocked(client.get).mock.calls.at(0)?.at(0) as string
|
|
143
|
+
expect(calledUrl).toContain('/items?')
|
|
144
|
+
expect(calledUrl).toContain('limit=1')
|
|
145
|
+
expect(calledUrl).toContain('paginate=false')
|
|
146
|
+
expect(result).toEqual(item)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns null when no records match', async () => {
|
|
150
|
+
vi.mocked(client.get).mockResolvedValue([])
|
|
151
|
+
|
|
152
|
+
const result = await api.getOne({ where: { name: 'missing' } })
|
|
153
|
+
expect(result).toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('forwards custom headers', async () => {
|
|
157
|
+
vi.mocked(client.get).mockResolvedValue([])
|
|
158
|
+
const headers = { 'X-Custom': 'value' }
|
|
159
|
+
|
|
160
|
+
await api.getOne(undefined, headers)
|
|
161
|
+
expect(vi.mocked(client.get).mock.calls.at(0)?.at(1)).toEqual(headers)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
134
165
|
// ── getById ──────────────────────────────────────────────────────
|
|
135
166
|
|
|
136
167
|
describe('getById', () => {
|
package/src/core/BaseCrudApi.ts
CHANGED
|
@@ -28,6 +28,7 @@ export class BaseCrudApi<
|
|
|
28
28
|
this.client = client
|
|
29
29
|
this.basePath = basePath.replace(/\/$/, '')
|
|
30
30
|
this.getMany = this.getMany.bind(this)
|
|
31
|
+
this.getOne = this.getOne.bind(this)
|
|
31
32
|
this.getById = this.getById.bind(this)
|
|
32
33
|
this.create = this.create.bind(this)
|
|
33
34
|
this.updateById = this.updateById.bind(this)
|
|
@@ -53,6 +54,19 @@ export class BaseCrudApi<
|
|
|
53
54
|
)
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Returns the first resource matching the query, or `null` when none match.
|
|
59
|
+
* Convenience over `getMany` with `limit: 1`.
|
|
60
|
+
* @permissions $resourceName.view
|
|
61
|
+
*/
|
|
62
|
+
async getOne(query?: TQuery, headers?: Record<string, string>): Promise<TResponse | null> {
|
|
63
|
+
const results = await this.getMany(
|
|
64
|
+
{ ...query, limit: 1, paginate: false } as TQuery & { paginate: false },
|
|
65
|
+
headers,
|
|
66
|
+
)
|
|
67
|
+
return results[0] ?? null
|
|
68
|
+
}
|
|
69
|
+
|
|
56
70
|
/**
|
|
57
71
|
* Returns a single resource by ID.
|
|
58
72
|
* @permissions $resourceName.view
|
package/src/core/index.ts
CHANGED
|
@@ -2,4 +2,5 @@ export type { ApiClient, HttpMethod } from './ApiClient'
|
|
|
2
2
|
export { BaseApiClient, ApiError } from './BaseApiClient'
|
|
3
3
|
export type { ValidationError } from './BaseApiClient'
|
|
4
4
|
export { BaseCrudApi } from './BaseCrudApi'
|
|
5
|
+
export { ArchivableCrudApi } from './ArchivableCrudApi'
|
|
5
6
|
export type { WhereClause, IncludeItem, ListQuery, GetByIdQuery, Paginated } from './types'
|