@ikatec/digisac-api-sdk 3.0.0 → 4.0.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 (37) hide show
  1. package/README.md +42 -7
  2. package/dist/apis/cards/types.d.ts +89 -7
  3. package/dist/apis/cards/types.d.ts.map +1 -1
  4. package/dist/apis/pipeline/types.d.ts +67 -3
  5. package/dist/apis/pipeline/types.d.ts.map +1 -1
  6. package/dist/core/BaseApiClient.cjs +25 -31
  7. package/dist/core/BaseApiClient.cjs.map +1 -1
  8. package/dist/core/BaseApiClient.d.ts +2 -11
  9. package/dist/core/BaseApiClient.d.ts.map +1 -1
  10. package/dist/core/BaseApiClient.mjs +27 -31
  11. package/dist/core/BaseApiClient.mjs.map +1 -1
  12. package/dist/core/errors.cjs +192 -0
  13. package/dist/core/errors.cjs.map +1 -0
  14. package/dist/core/errors.d.ts +117 -0
  15. package/dist/core/errors.d.ts.map +1 -0
  16. package/dist/core/errors.mjs +177 -0
  17. package/dist/core/errors.mjs.map +1 -0
  18. package/dist/core/index.cjs +16 -2
  19. package/dist/core/index.d.ts +3 -2
  20. package/dist/core/index.d.ts.map +1 -1
  21. package/dist/core/index.mjs +3 -2
  22. package/dist/incommingWebhooks/index.d.ts +3 -3
  23. package/dist/incommingWebhooks/index.d.ts.map +1 -1
  24. package/dist/index.cjs +16 -2
  25. package/dist/index.mjs +3 -2
  26. package/package.json +1 -1
  27. package/src/apis/cards/types.ts +104 -7
  28. package/src/apis/pipeline/types.ts +71 -3
  29. package/src/core/BaseApiClient.test.ts +221 -7
  30. package/src/core/BaseApiClient.ts +37 -50
  31. package/src/core/BaseCrudApi.test-d.ts +48 -0
  32. package/src/core/BaseCrudApi.test.ts +150 -0
  33. package/src/core/errors.test-d.ts +14 -0
  34. package/src/core/errors.test.ts +389 -0
  35. package/src/core/errors.ts +284 -0
  36. package/src/core/index.ts +18 -2
  37. package/src/incommingWebhooks/index.ts +3 -3
@@ -0,0 +1,284 @@
1
+ /**
2
+ * A field-level error from the backend `validate` middleware — one entry per failed rule.
3
+ */
4
+ export interface FieldError {
5
+ /** Where the field was read from: `body`, `params`, `query`, `headers` or `cookies`. */
6
+ location: string
7
+ /** The field name, e.g. `name`. */
8
+ field: string
9
+ /** `<location>.<field>`, e.g. `body.name`. */
10
+ path: string
11
+ message: string
12
+ /** The failed rule, e.g. `required` or `hasLengthLesserThanOrEqual`. */
13
+ code: string
14
+ }
15
+
16
+ /**
17
+ * The backend error name sent as `error` in the body (`ApiError.errorClass`). Known names are listed
18
+ * for autocomplete; any other string is still accepted. `HttpError` is the generic one: most 400s
19
+ * and every unexpected 500.
20
+ */
21
+ export type ErrorClassName =
22
+ | 'HttpError'
23
+ | 'ValidationError'
24
+ | 'BadRequestHttpError'
25
+ | 'UnauthorizedHttpError'
26
+ | 'PaymentRequired'
27
+ | 'ForbiddenHttpError'
28
+ | 'NotFoundHttpError'
29
+ | 'TooManyRequestsHttpError'
30
+ | (string & {})
31
+
32
+ export interface ApiErrorOptions {
33
+ /** Response headers. */
34
+ headers?: Headers
35
+ /** The parsed JSON error body, or the raw text when it is not JSON. */
36
+ body?: unknown
37
+ }
38
+
39
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
40
+ typeof value === 'object' && value !== null && !Array.isArray(value)
41
+
42
+ /**
43
+ * Flattens the backend `errors` object — `{ <location>: { <field>: { messages: string[], types: string[] } } }`
44
+ * — into one {@link FieldError} per message. Returns `null` when there are no field errors.
45
+ */
46
+ export function parseFieldErrors(errors: unknown): FieldError[] | null {
47
+ if (!isRecord(errors)) {
48
+ return null
49
+ }
50
+
51
+ const parsed: FieldError[] = []
52
+ for (const [location, fields] of Object.entries(errors)) {
53
+ if (!isRecord(fields)) {
54
+ continue
55
+ }
56
+ for (const [field, detail] of Object.entries(fields)) {
57
+ if (!isRecord(detail)) {
58
+ continue
59
+ }
60
+ const messages = Array.isArray(detail.messages) ? detail.messages.map(String) : []
61
+ const types = Array.isArray(detail.types) ? detail.types.map(String) : []
62
+ messages.forEach((message, index) => {
63
+ parsed.push({
64
+ location,
65
+ field,
66
+ path: `${location}.${field}`,
67
+ message,
68
+ code: types[index] ?? types[0] ?? '',
69
+ })
70
+ })
71
+ }
72
+ }
73
+
74
+ return parsed.length ? parsed : null
75
+ }
76
+
77
+ /** Base class of every error thrown by the SDK. */
78
+ export class DigisacError extends Error {
79
+ constructor(message: string, options?: { cause?: unknown }) {
80
+ super(message, options)
81
+ this.name = 'DigisacError'
82
+ }
83
+ }
84
+
85
+ /** The request never got an HTTP response (network failure, DNS, connection refused, ...). */
86
+ export class ApiConnectionError extends DigisacError {
87
+ constructor(message = 'Connection error.', options?: { cause?: unknown }) {
88
+ super(message, options)
89
+ this.name = 'ApiConnectionError'
90
+ }
91
+ }
92
+
93
+ /**
94
+ * The API answered with a non-2xx status. Thrown as the subclass matching the status
95
+ * (`NotFoundError`, `BadRequestError`, ...) — or `ValidationError` for a 400 field validation
96
+ * failure — so callers can narrow with `instanceof`.
97
+ */
98
+ export class ApiError extends DigisacError {
99
+ /** The backend error name (`error` in the body), e.g. `ValidationError` or `PaymentRequired`. */
100
+ public readonly errorClass?: ErrorClassName | undefined
101
+ public readonly status?: number | undefined
102
+ /** Field errors of a 400 `ValidationError`; `null` when the response carries none. */
103
+ public readonly validationErrors: FieldError[] | null
104
+ public readonly headers?: Headers | undefined
105
+ /** The parsed JSON error body (`{ error, message, status, errors?, extra? }`), or the raw text. */
106
+ public readonly body?: unknown
107
+
108
+ constructor(
109
+ message: string,
110
+ errorClass?: ErrorClassName,
111
+ status?: number,
112
+ validationErrors: FieldError[] | null = null,
113
+ options: ApiErrorOptions = {},
114
+ ) {
115
+ super(message)
116
+ this.name = 'ApiError'
117
+ this.errorClass = errorClass
118
+ this.status = status
119
+ this.validationErrors = validationErrors
120
+ this.headers = options.headers
121
+ this.body = options.body
122
+ }
123
+
124
+ /**
125
+ * Builds the `ApiError` subclass for a response and its (already read) body: `ValidationError`
126
+ * for a 400 `ValidationError`, else the class of the status.
127
+ */
128
+ static fromResponse(
129
+ status: number,
130
+ statusText: string,
131
+ headers: Headers,
132
+ body: unknown,
133
+ ): ApiError {
134
+ let message = `HTTP Error ${status}: ${statusText}`
135
+ let errorClass: string | undefined
136
+ let validationErrors: FieldError[] | null = null
137
+
138
+ if (isRecord(body)) {
139
+ const apiMessage = typeof body.message === 'string' ? body.message : undefined
140
+ errorClass = typeof body.error === 'string' ? body.error : undefined
141
+ message = apiMessage || errorClass || message
142
+ validationErrors = parseFieldErrors(body.errors)
143
+ }
144
+
145
+ const ErrorClass = errorClassFor(status, errorClass)
146
+ return new ErrorClass(message, errorClass, status, validationErrors, { headers, body })
147
+ }
148
+ }
149
+
150
+ /** 400 (see {@link ValidationError} for field validation failures). */
151
+ export class BadRequestError extends ApiError {
152
+ override name = 'BadRequestError'
153
+ }
154
+
155
+ /**
156
+ * 400 `ValidationError` — the request failed field validation. `validationErrors` is always an
157
+ * array here (empty when the backend sent no field details).
158
+ */
159
+ export class ValidationError extends BadRequestError {
160
+ override name = 'ValidationError'
161
+ declare public readonly validationErrors: FieldError[]
162
+
163
+ constructor(
164
+ message: string,
165
+ errorClass?: ErrorClassName,
166
+ status?: number,
167
+ validationErrors: FieldError[] | null = null,
168
+ options: ApiErrorOptions = {},
169
+ ) {
170
+ super(message, errorClass, status, validationErrors ?? [], options)
171
+ }
172
+ }
173
+
174
+ /** 401 — missing or invalid access token. */
175
+ export class AuthenticationError extends ApiError {
176
+ override name = 'AuthenticationError'
177
+ }
178
+
179
+ /** 402 — the account is out of credits or over a plan limit. */
180
+ export class PaymentRequiredError extends ApiError {
181
+ override name = 'PaymentRequiredError'
182
+ }
183
+
184
+ /** 403 — the user lacks the permission for this action. */
185
+ export class PermissionDeniedError extends ApiError {
186
+ override name = 'PermissionDeniedError'
187
+ }
188
+
189
+ /** 404 */
190
+ export class NotFoundError extends ApiError {
191
+ override name = 'NotFoundError'
192
+ }
193
+
194
+ /** 409 */
195
+ export class ConflictError extends ApiError {
196
+ override name = 'ConflictError'
197
+ }
198
+
199
+ /** 422 */
200
+ export class UnprocessableEntityError extends ApiError {
201
+ override name = 'UnprocessableEntityError'
202
+ }
203
+
204
+ /** 429 */
205
+ export class RateLimitError extends ApiError {
206
+ override name = 'RateLimitError'
207
+
208
+ /**
209
+ * Seconds to wait before retrying, or `null` when the response does not say.
210
+ *
211
+ * Reads the standard `Retry-After` header (delay seconds or HTTP date) first, then the
212
+ * `X-RateLimit-Reset` header the Digisac backend sends (Unix time in seconds).
213
+ */
214
+ get retryAfter(): number | null {
215
+ return (
216
+ parseRetryAfter(this.headers?.get('Retry-After')) ??
217
+ parseRateLimitReset(this.headers?.get('X-RateLimit-Reset'))
218
+ )
219
+ }
220
+ }
221
+
222
+ const secondsUntil = (epochMs: number): number =>
223
+ Math.max(0, Math.ceil((epochMs - Date.now()) / 1000))
224
+
225
+ /** `Retry-After`: delay seconds or an HTTP date. */
226
+ function parseRetryAfter(value: string | null | undefined): number | null {
227
+ if (!value?.trim()) {
228
+ return null
229
+ }
230
+ const seconds = Number(value)
231
+ if (Number.isFinite(seconds)) {
232
+ return Math.max(0, seconds)
233
+ }
234
+ const date = Date.parse(value)
235
+ return Number.isNaN(date) ? null : secondsUntil(date)
236
+ }
237
+
238
+ /**
239
+ * `X-RateLimit-Reset`: Unix time in seconds (what the Digisac backend sends). Small values are
240
+ * read as delay seconds instead, the other convention in use for this header.
241
+ */
242
+ function parseRateLimitReset(value: string | null | undefined): number | null {
243
+ if (!value?.trim()) {
244
+ return null
245
+ }
246
+ const reset = Number(value)
247
+ if (!Number.isFinite(reset)) {
248
+ return null
249
+ }
250
+ // Anything before 2001-09-09 (1e9 s) can't be a reset time, so it is a delay.
251
+ return reset < 1e9 ? Math.max(0, reset) : secondsUntil(reset * 1000)
252
+ }
253
+
254
+ /** 5xx */
255
+ export class InternalServerError extends ApiError {
256
+ override name = 'InternalServerError'
257
+ }
258
+
259
+ function errorClassFor(status: number, errorClass: string | undefined): typeof ApiError {
260
+ if (status === 400 && errorClass === 'ValidationError') {
261
+ return ValidationError
262
+ }
263
+
264
+ switch (status) {
265
+ case 400:
266
+ return BadRequestError
267
+ case 401:
268
+ return AuthenticationError
269
+ case 402:
270
+ return PaymentRequiredError
271
+ case 403:
272
+ return PermissionDeniedError
273
+ case 404:
274
+ return NotFoundError
275
+ case 409:
276
+ return ConflictError
277
+ case 422:
278
+ return UnprocessableEntityError
279
+ case 429:
280
+ return RateLimitError
281
+ default:
282
+ return status >= 500 ? InternalServerError : ApiError
283
+ }
284
+ }
package/src/core/index.ts CHANGED
@@ -1,6 +1,22 @@
1
1
  export type { ApiClient, HttpMethod } from './ApiClient'
2
- export { BaseApiClient, ApiError } from './BaseApiClient'
3
- export type { ValidationError } from './BaseApiClient'
2
+ export { BaseApiClient } from './BaseApiClient'
3
+ export {
4
+ DigisacError,
5
+ ApiConnectionError,
6
+ ApiError,
7
+ BadRequestError,
8
+ ValidationError,
9
+ AuthenticationError,
10
+ PaymentRequiredError,
11
+ PermissionDeniedError,
12
+ NotFoundError,
13
+ ConflictError,
14
+ UnprocessableEntityError,
15
+ RateLimitError,
16
+ InternalServerError,
17
+ parseFieldErrors,
18
+ } from './errors'
19
+ export type { FieldError, ErrorClassName, ApiErrorOptions } from './errors'
4
20
  export { BaseCrudApi } from './BaseCrudApi'
5
21
  export { ArchivableCrudApi } from './ArchivableCrudApi'
6
22
  export type { WhereClause, IncludeItem, ListQuery, GetByIdQuery, Paginated } from './types'
@@ -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 ────────────────────────────────────────────────────────