@ikatec/digisac-api-sdk 2.2.1 → 3.1.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 (35) hide show
  1. package/dist/apis/acceptanceTerms/types.d.ts +18 -2
  2. package/dist/apis/acceptanceTerms/types.d.ts.map +1 -1
  3. package/dist/apis/accounts/types.d.ts +2 -0
  4. package/dist/apis/accounts/types.d.ts.map +1 -1
  5. package/dist/apis/cards/types.d.ts +89 -7
  6. package/dist/apis/cards/types.d.ts.map +1 -1
  7. package/dist/apis/pipeline/types.d.ts +67 -3
  8. package/dist/apis/pipeline/types.d.ts.map +1 -1
  9. package/dist/apis/terms/TermsApi.cjs +34 -6
  10. package/dist/apis/terms/TermsApi.cjs.map +1 -1
  11. package/dist/apis/terms/TermsApi.d.ts +27 -6
  12. package/dist/apis/terms/TermsApi.d.ts.map +1 -1
  13. package/dist/apis/terms/TermsApi.mjs +34 -6
  14. package/dist/apis/terms/TermsApi.mjs.map +1 -1
  15. package/dist/apis/terms/types.d.ts +0 -14
  16. package/dist/apis/terms/types.d.ts.map +1 -1
  17. package/dist/core/BaseApiClient.cjs +26 -1
  18. package/dist/core/BaseApiClient.cjs.map +1 -1
  19. package/dist/core/BaseApiClient.d.ts.map +1 -1
  20. package/dist/core/BaseApiClient.mjs +26 -1
  21. package/dist/core/BaseApiClient.mjs.map +1 -1
  22. package/dist/incommingWebhooks/index.d.ts +3 -3
  23. package/dist/incommingWebhooks/index.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/apis/acceptanceTerms/AcceptanceTermsApi.test.ts +96 -0
  26. package/src/apis/acceptanceTerms/types.ts +19 -2
  27. package/src/apis/accounts/types.ts +2 -0
  28. package/src/apis/cards/types.ts +104 -7
  29. package/src/apis/pipeline/types.ts +71 -3
  30. package/src/apis/terms/TermsApi.test.ts +56 -102
  31. package/src/apis/terms/TermsApi.ts +41 -12
  32. package/src/apis/terms/types.ts +0 -16
  33. package/src/core/BaseApiClient.test.ts +53 -0
  34. package/src/core/BaseApiClient.ts +37 -1
  35. package/src/incommingWebhooks/index.ts +3 -3
@@ -1,8 +1,7 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { TermsApi } from './TermsApi'
3
3
  import type { ApiClient } from '../../core/ApiClient'
4
- import type { Term, CreateTermPayload } from './types'
5
- import type { Paginated } from '../../core/types'
4
+ import type { Term } from './types'
6
5
 
7
6
  // ── Mock client factory ──────────────────────────────────────────────
8
7
 
@@ -34,17 +33,6 @@ const term: Term = {
34
33
  updatedAt: '2024-01-01T00:00:00Z',
35
34
  }
36
35
 
37
- const paginated: Paginated<Term> = {
38
- data: [term],
39
- total: 1,
40
- limit: 10,
41
- skip: 0,
42
- currentPage: 1,
43
- lastPage: 1,
44
- from: 1,
45
- to: 1,
46
- }
47
-
48
36
  // ── Tests ────────────────────────────────────────────────────────────
49
37
 
50
38
  describe('TermsApi', () => {
@@ -56,30 +44,13 @@ describe('TermsApi', () => {
56
44
  api = new TermsApi(client)
57
45
  })
58
46
 
59
- // ── getMany ──────────────────────────────────────────────────────
60
-
61
- describe('getMany', () => {
62
- it('calls client.get with /terms when no query is provided', async () => {
63
- vi.mocked(client.get).mockResolvedValue(paginated)
64
- const result = await api.getMany()
65
- expect(client.get).toHaveBeenCalledWith('/terms', undefined)
66
- expect(result).toEqual(paginated)
67
- })
68
-
69
- it('serializes query parameters', async () => {
70
- vi.mocked(client.get).mockResolvedValue(paginated)
71
- await api.getMany({ page: 2 })
72
- const url = vi.mocked(client.get).mock.calls[0][0] as string
73
- expect(url).toContain('/terms?')
74
- expect(url).toContain('page=2')
75
- })
76
-
77
- it('forwards custom headers', async () => {
78
- vi.mocked(client.get).mockResolvedValue(paginated)
79
- const headers = { 'X-Custom': 'value' }
80
- await api.getMany(undefined, headers)
81
- expect(client.get).toHaveBeenCalledWith('/terms', headers)
82
- })
47
+ it('exposes only the read routes served by the backend', () => {
48
+ expect(api.basePath).toBe('/terms')
49
+ // /terms has no list/create/update/delete routes (404 "Route not found.")
50
+ expect(api).not.toHaveProperty('getMany')
51
+ expect(api).not.toHaveProperty('create')
52
+ expect(api).not.toHaveProperty('updateById')
53
+ expect(api).not.toHaveProperty('deleteById')
83
54
  })
84
55
 
85
56
  // ── getById ──────────────────────────────────────────────────────
@@ -92,100 +63,83 @@ describe('TermsApi', () => {
92
63
  expect(result).toEqual(term)
93
64
  })
94
65
 
66
+ it('serializes the query', async () => {
67
+ vi.mocked(client.get).mockResolvedValue(term)
68
+ await api.getById('term-1', { attributes: ['id', 'version'] })
69
+ const url = vi.mocked(client.get).mock.calls[0][0] as string
70
+ expect(url).toContain('/terms/term-1?')
71
+ expect(url).toContain('attributes')
72
+ })
73
+
95
74
  it('forwards custom headers', async () => {
96
75
  vi.mocked(client.get).mockResolvedValue(term)
97
76
  const headers = { 'X-Tenant': 'abc' }
98
77
  await api.getById('term-1', undefined, headers)
99
78
  expect(client.get).toHaveBeenCalledWith('/terms/term-1', headers)
100
79
  })
101
- })
102
80
 
103
- // ── create ───────────────────────────────────────────────────────
104
-
105
- describe('create', () => {
106
- it('calls client.post with /terms and body', async () => {
107
- vi.mocked(client.post).mockResolvedValue(term)
108
- const body: CreateTermPayload = {
109
- title: { en: 'ToS' },
110
- text: { en: 'Text' },
111
- expirationDate: '2025-01-01',
112
- version: '1.0',
113
- agnusId: 'agnus-1',
114
- }
115
- const result = await api.create(body)
116
- expect(client.post).toHaveBeenCalledWith('/terms', body, undefined)
117
- expect(result).toEqual(term)
81
+ it('keeps working when detached from the instance', async () => {
82
+ vi.mocked(client.get).mockResolvedValue(term)
83
+ const { getById } = api
84
+ await getById('term-1')
85
+ expect(client.get).toHaveBeenCalledWith('/terms/term-1', undefined)
118
86
  })
119
87
  })
120
88
 
121
- // ── updateById ───────────────────────────────────────────────────
89
+ // ── getNotAgreed ─────────────────────────────────────────────────
122
90
 
123
- describe('updateById', () => {
124
- it('calls client.put with /terms/:id and body', async () => {
125
- vi.mocked(client.put).mockResolvedValue(term)
126
- const result = await api.updateById('term-1', { version: '1.1' })
127
- expect(client.put).toHaveBeenCalledWith('/terms/term-1', { version: '1.1' }, undefined)
128
- expect(result).toEqual(term)
91
+ describe('getNotAgreed', () => {
92
+ it('calls client.get with /terms/terms-not-agreed and returns the plain array', async () => {
93
+ vi.mocked(client.get).mockResolvedValue([term])
94
+ const result = await api.getNotAgreed()
95
+ expect(client.get).toHaveBeenCalledWith('/terms/terms-not-agreed', undefined)
96
+ expect(result).toEqual([term])
129
97
  })
130
98
 
131
99
  it('forwards custom headers', async () => {
132
- vi.mocked(client.put).mockResolvedValue(term)
133
- const headers = { 'If-Match': 'etag' }
134
- await api.updateById('term-1', { version: '2.0' }, headers)
135
- expect(client.put).toHaveBeenCalledWith('/terms/term-1', { version: '2.0' }, headers)
100
+ vi.mocked(client.get).mockResolvedValue([])
101
+ const headers = { 'X-Custom': 'h' }
102
+ await api.getNotAgreed(headers)
103
+ expect(client.get).toHaveBeenCalledWith('/terms/terms-not-agreed', headers)
136
104
  })
137
105
  })
138
106
 
139
- // ── deleteById ───────────────────────────────────────────────────
140
-
141
- describe('deleteById', () => {
142
- it('calls client.delete with /terms/:id', async () => {
143
- vi.mocked(client.delete).mockResolvedValue(term)
144
- const result = await api.deleteById('term-1')
145
- expect(client.delete).toHaveBeenCalledWith('/terms/term-1', undefined)
146
- expect(result).toEqual(term)
107
+ // ── agree ────────────────────────────────────────────────────────
108
+
109
+ describe('agree', () => {
110
+ it('calls client.patch with /terms/agree and the term ids', async () => {
111
+ const agnusResponse = { agreed: true }
112
+ vi.mocked(client.patch).mockResolvedValue(agnusResponse)
113
+ const result = await api.agree(['term-1', 'term-2'])
114
+ expect(client.patch).toHaveBeenCalledWith(
115
+ '/terms/agree',
116
+ { terms: ['term-1', 'term-2'] },
117
+ undefined,
118
+ )
119
+ expect(result).toEqual(agnusResponse)
147
120
  })
148
121
 
149
122
  it('forwards custom headers', async () => {
150
- vi.mocked(client.delete).mockResolvedValue(term)
151
- const headers = { 'X-Reason': 'cleanup' }
152
- await api.deleteById('term-1', headers)
153
- expect(client.delete).toHaveBeenCalledWith('/terms/term-1', headers)
154
- })
155
- })
156
-
157
- // ── getNotAgreed ─────────────────────────────────────────────────
158
-
159
- describe('getNotAgreed', () => {
160
- it('calls client.get with /terms/terms-not-agreed when no query is provided', async () => {
161
- vi.mocked(client.get).mockResolvedValue(paginated)
162
- const result = await api.getNotAgreed()
163
- expect(client.get).toHaveBeenCalledWith('/terms/terms-not-agreed', undefined)
164
- expect(result).toEqual(paginated)
165
- })
166
-
167
- it('serializes query parameters', async () => {
168
- vi.mocked(client.get).mockResolvedValue(paginated)
169
- await api.getNotAgreed({ page: 1, perPage: 5 })
170
- const url = vi.mocked(client.get).mock.calls[0][0] as string
171
- expect(url).toContain('/terms/terms-not-agreed?')
172
- expect(url).toContain('page=1')
123
+ vi.mocked(client.patch).mockResolvedValue({})
124
+ const headers = { 'X-Custom': 'h' }
125
+ await api.agree(['term-1'], headers)
126
+ expect(client.patch).toHaveBeenCalledWith('/terms/agree', { terms: ['term-1'] }, headers)
173
127
  })
174
128
 
175
- it('forwards custom headers', async () => {
176
- vi.mocked(client.get).mockResolvedValue(paginated)
177
- const headers = { 'X-Custom': 'h' }
178
- await api.getNotAgreed(undefined, headers)
179
- expect(client.get).toHaveBeenCalledWith('/terms/terms-not-agreed', headers)
129
+ it('keeps working when detached from the instance', async () => {
130
+ vi.mocked(client.patch).mockResolvedValue({})
131
+ const { agree } = api
132
+ await agree(['term-1'])
133
+ expect(client.patch).toHaveBeenCalledWith('/terms/agree', { terms: ['term-1'] }, undefined)
180
134
  })
181
135
  })
182
136
 
183
137
  // ── Error propagation ────────────────────────────────────────────
184
138
 
185
139
  describe('error propagation', () => {
186
- it('propagates errors from getMany', async () => {
140
+ it('propagates errors from getById', async () => {
187
141
  vi.mocked(client.get).mockRejectedValue(new Error('Network error'))
188
- await expect(api.getMany()).rejects.toThrow('Network error')
142
+ await expect(api.getById('term-1')).rejects.toThrow('Network error')
189
143
  })
190
144
 
191
145
  it('propagates errors from getNotAgreed', async () => {
@@ -1,22 +1,51 @@
1
1
  import qs from 'qs'
2
2
  import type { ApiClient } from '../../core/ApiClient'
3
- import { BaseCrudApi } from '../../core/BaseCrudApi'
4
- import type { ListQuery, Paginated } from '../../core/types'
5
- import type { Term, CreateTermPayload, UpdateTermPayload } from './types'
3
+ import type { GetByIdQuery, HasGetById } from '../../core/types'
4
+ import type { Term } from './types'
5
+
6
+ /**
7
+ * Platform terms of use. Terms are published by Agnus (billing) and accepted by the account users,
8
+ * so the `/terms` route has no create/list/update/delete — only the routes below.
9
+ *
10
+ * `GET /terms/:id/export-pdf` is not exposed: it answers a binary PDF, and `ApiClient` only
11
+ * handles JSON/text bodies.
12
+ */
13
+ export class TermsApi implements HasGetById<Term> {
14
+ protected readonly client: ApiClient
15
+ public readonly basePath = '/terms'
6
16
 
7
- export class TermsApi extends BaseCrudApi<Term, CreateTermPayload, UpdateTermPayload> {
8
17
  constructor(client: ApiClient) {
9
- super(client, '/terms')
18
+ this.client = client
19
+ this.getById = this.getById.bind(this)
20
+ this.getNotAgreed = this.getNotAgreed.bind(this)
21
+ this.agree = this.agree.bind(this)
10
22
  }
11
23
 
12
- /** Returns all terms where agreementDate is null for the current user's account (i.e. terms not yet agreed to). */
13
- getNotAgreed(
14
- query?: ListQuery<Term>,
15
- headers?: Record<string, string>,
16
- ): Promise<Paginated<Term>> {
24
+ /** Returns a single term by its ID, scoped to the current account (`null` when not found). */
25
+ getById(id: string, query?: GetByIdQuery<Term>, headers?: Record<string, string>): Promise<Term> {
17
26
  const queryString = query ? `?${qs.stringify(query)}` : ''
18
- return this.client.get<Paginated<Term>>(
19
- `${this.basePath}/terms-not-agreed${queryString}`,
27
+ return this.client.get<Term>(`${this.basePath}/${id}${queryString}`, headers)
28
+ }
29
+
30
+ /**
31
+ * Returns the terms of the current account that were not agreed to yet (`agreementDate` null).
32
+ * The backend answers a plain array (not paginated) and ignores query parameters.
33
+ */
34
+ getNotAgreed(headers?: Record<string, string>): Promise<Term[]> {
35
+ return this.client.get<Term[]>(`${this.basePath}/terms-not-agreed`, headers)
36
+ }
37
+
38
+ /**
39
+ * Agrees to the given terms on behalf of the authenticated user. IRREVERSIBLE: the acceptance is
40
+ * registered on Agnus, the terms get `agreementDate`/`userId`, and the signed PDFs are e-mailed
41
+ * to the user. Only terms of the current account are affected.
42
+ *
43
+ * The response is passed through from Agnus as-is, so it is not typed.
44
+ */
45
+ agree(termIds: string[], headers?: Record<string, string>): Promise<unknown> {
46
+ return this.client.patch<unknown, { terms: string[] }>(
47
+ `${this.basePath}/agree`,
48
+ { terms: termIds },
20
49
  headers,
21
50
  )
22
51
  }
@@ -11,19 +11,3 @@ export type Term = {
11
11
  createdAt: string
12
12
  updatedAt: string
13
13
  }
14
-
15
- export type CreateTermPayload = {
16
- title: Record<string, string>
17
- text: Record<string, string>
18
- expirationDate: string
19
- version: string
20
- agnusId: string
21
- accountId?: string
22
- }
23
-
24
- export type UpdateTermPayload = {
25
- title?: Record<string, string>
26
- text?: Record<string, string>
27
- expirationDate?: string
28
- version?: string
29
- }
@@ -230,6 +230,59 @@ describe('BaseApiClient', () => {
230
230
  })
231
231
  })
232
232
 
233
+ it('maps the Digisac field errors (errors.<location>.<field>) into validationErrors', async () => {
234
+ mockFetch({
235
+ ok: false,
236
+ status: 400,
237
+ statusText: 'Bad Request',
238
+ headers: { 'Content-Type': 'application/json' },
239
+ body: JSON.stringify({
240
+ error: 'ValidationError',
241
+ message: 'The given data was invalid.',
242
+ status: 400,
243
+ errors: {
244
+ body: {
245
+ name: { messages: ['Required.'], types: ['required'] },
246
+ title: {
247
+ messages: ['Required.', 'Should contain lesser than or equals 255 characters.'],
248
+ types: ['required', 'hasLengthLesserThanOrEqual'],
249
+ },
250
+ },
251
+ params: { contactId: { messages: ['Should be an UUID v4 string.'], types: ['uuid4'] } },
252
+ },
253
+ }),
254
+ })
255
+ await expect(client.post('/fail', {})).rejects.toMatchObject({
256
+ errorClass: 'ValidationError',
257
+ status: 400,
258
+ validationErrors: [
259
+ { path: 'body.name', message: 'Required.', code: 'required' },
260
+ { path: 'body.title', message: 'Required.', code: 'required' },
261
+ {
262
+ path: 'body.title',
263
+ message: 'Should contain lesser than or equals 255 characters.',
264
+ code: 'hasLengthLesserThanOrEqual',
265
+ },
266
+ { path: 'params.contactId', message: 'Should be an UUID v4 string.', code: 'uuid4' },
267
+ ],
268
+ })
269
+ })
270
+
271
+ it('keeps validationErrors null when the error body has no field errors', async () => {
272
+ mockFetch({
273
+ ok: false,
274
+ status: 400,
275
+ statusText: 'Bad Request',
276
+ headers: { 'Content-Type': 'application/json' },
277
+ body: JSON.stringify({
278
+ error: 'BadRequestHttpError',
279
+ message: 'Já existe uma funil com esse nome',
280
+ status: 400,
281
+ }),
282
+ })
283
+ await expect(client.get('/fail')).rejects.toMatchObject({ validationErrors: null })
284
+ })
285
+
233
286
  it('falls back to default message when JSON parsing fails', async () => {
234
287
  mockFetch({
235
288
  ok: false,
@@ -6,6 +6,42 @@ export interface ValidationError {
6
6
  code: string
7
7
  }
8
8
 
9
+ /**
10
+ * Digisac field errors: `{ errors: { <location>: { <field>: { messages: string[], types: string[] } } } }`,
11
+ * where location is `body`, `params`, `query`, `headers` or `cookies` (backend `validate` middleware).
12
+ */
13
+ type DigisacFieldErrors = Record<string, Record<string, { messages?: unknown; types?: unknown }>>
14
+
15
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
16
+ typeof value === 'object' && value !== null && !Array.isArray(value)
17
+
18
+ /**
19
+ * Flattens the Digisac `errors` object into `ValidationError[]` — one entry per message, with
20
+ * `path` = `<location>.<field>` (e.g. `body.name`) and `code` = the rule type (e.g. `required`).
21
+ * Returns `null` when the body carries no field errors.
22
+ */
23
+ function parseValidationErrors(errors: unknown): ValidationError[] | null {
24
+ if (!isRecord(errors)) {
25
+ return null
26
+ }
27
+
28
+ const parsed: ValidationError[] = []
29
+ for (const [location, fields] of Object.entries(errors as DigisacFieldErrors)) {
30
+ if (!isRecord(fields)) {
31
+ continue
32
+ }
33
+ for (const [field, detail] of Object.entries(fields)) {
34
+ const messages = Array.isArray(detail?.messages) ? detail.messages.map(String) : []
35
+ const types = Array.isArray(detail?.types) ? detail.types.map(String) : []
36
+ messages.forEach((message, index) => {
37
+ parsed.push({ path: `${location}.${field}`, message, code: types[index] ?? types[0] ?? '' })
38
+ })
39
+ }
40
+ }
41
+
42
+ return parsed.length ? parsed : null
43
+ }
44
+
9
45
  export class ApiError extends Error {
10
46
  public readonly errorClass?: string | undefined
11
47
  public readonly status?: number | undefined
@@ -76,7 +112,7 @@ export class BaseApiClient implements ApiClient {
76
112
  const errorData = JSON.parse(errorText)
77
113
  errorMessage = errorData.message || errorData.error || errorMessage
78
114
  errorClass = errorData.error
79
- validationErrors = errorData.validationErrors ?? null
115
+ validationErrors = errorData.validationErrors ?? parseValidationErrors(errorData.errors)
80
116
  }
81
117
  } catch {
82
118
  // If JSON parsing fails, use the default error message
@@ -5,7 +5,7 @@ import type { Department, DepartmentRelationships } from '../apis/departments'
5
5
  import type { Message, MessageRelationships } from '../apis/messages'
6
6
  import type { Organization, OrganizationRelationships } from '../apis/organizations'
7
7
  import type { Person, PersonRelationships } from '../apis/people'
8
- import type { Pipeline, PipelineRelationships } from '../apis/pipeline'
8
+ import type { PipelineRecord } from '../apis/pipeline'
9
9
  import type { QuickReply, QuickReplyRelationships } from '../apis/quickReplies'
10
10
  import type { Role, RoleRelationships } from '../apis/roles'
11
11
  import type { Service, ServiceRelationships } from '../apis/services'
@@ -119,8 +119,8 @@ export interface WebhookEventPayloadMap {
119
119
  'campaign.created': Omit<Campaign, CampaignRelationships>
120
120
  'campaign.updated': Omit<Campaign, CampaignRelationships>
121
121
  'campaign.destroyed': Omit<Campaign, CampaignRelationships>
122
- 'pipeline.created': Omit<Pipeline, PipelineRelationships>
123
- 'pipeline.updated': Omit<Pipeline, PipelineRelationships>
122
+ 'pipeline.created': PipelineRecord
123
+ 'pipeline.updated': PipelineRecord
124
124
  }
125
125
 
126
126
  // ─── Webhook Envelope ────────────────────────────────────────────────────────