Skip to content

Commit fbfb7f0

Browse files
committed
fix(tools): render Ashby object-shaped API errors readably
Ashby documents two error shapes and uses both. The `errors` array form carries `{ message, parameter }` objects, which stringified to '[object Object]' and hid the real cause - including the 403 a key gets when it lacks a module permission. Also extracts the custom field value shape into a shared constant and mapper so the read and write paths agree on one definition.
1 parent 9436a93 commit fbfb7f0

3 files changed

Lines changed: 113 additions & 20 deletions

File tree

apps/sim/tools/ashby/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface AshbyCustomField {
3131
id: string | null
3232
title: string
3333
isPrivate: boolean
34-
valueLabel: string | null
34+
valueLabel: string | string[] | null
3535
value: unknown
3636
}
3737

@@ -122,6 +122,7 @@ export interface AshbySearchCandidatesParams extends AshbyBaseParams {
122122
export interface AshbyListJobsParams extends AshbyBaseParams {
123123
cursor?: string
124124
perPage?: number
125+
syncToken?: string
125126
status?: string
126127
createdAfter?: string
127128
openedAfter?: string
@@ -266,6 +267,7 @@ export interface AshbyListJobsResponse extends ToolResponse {
266267
jobs: AshbyJob[]
267268
moreDataAvailable: boolean
268269
nextCursor: string | null
270+
syncToken: string | null
269271
}
270272
}
271273

apps/sim/tools/ashby/utils.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { ashbyErrorMessage } from '@/tools/ashby/utils'
6+
7+
describe('ashbyErrorMessage', () => {
8+
it('reads the message out of the documented { message, parameter } entries', () => {
9+
// This is the shape Ashby's OpenAPI definition declares, and the one a 403
10+
// for a missing module permission arrives in. Stringifying the entry
11+
// directly yields '[object Object]' and hides the real cause.
12+
expect(
13+
ashbyErrorMessage(
14+
{ success: false, errors: [{ message: 'missing_endpoint_permission' }] },
15+
'fallback'
16+
)
17+
).toBe('missing_endpoint_permission')
18+
})
19+
20+
it('names the offending parameter when Ashby supplies one', () => {
21+
expect(
22+
ashbyErrorMessage(
23+
{ success: false, errors: [{ message: 'Invalid value', parameter: 'fieldValue' }] },
24+
'fallback'
25+
)
26+
).toBe('Invalid value (fieldValue)')
27+
})
28+
29+
it('joins multiple errors', () => {
30+
expect(
31+
ashbyErrorMessage(
32+
{ success: false, errors: [{ message: 'a' }, { message: 'b' }] },
33+
'fallback'
34+
)
35+
).toBe('a; b')
36+
})
37+
38+
it('still handles the plain string array form', () => {
39+
expect(ashbyErrorMessage({ success: false, errors: ['boom'] }, 'fallback')).toBe('boom')
40+
})
41+
42+
it('prefers errorInfo.message, the other documented shape', () => {
43+
expect(
44+
ashbyErrorMessage({ success: false, errorInfo: { message: 'rate limited' } }, 'fallback')
45+
).toBe('rate limited')
46+
})
47+
48+
it('falls back when the entries carry no usable message', () => {
49+
expect(ashbyErrorMessage({ success: false, errors: [{ parameter: 'x' }] }, 'fallback')).toBe(
50+
'fallback'
51+
)
52+
expect(ashbyErrorMessage({ success: false, errors: [] }, 'fallback')).toBe('fallback')
53+
expect(ashbyErrorMessage(null, 'fallback')).toBe('fallback')
54+
})
55+
})

apps/sim/tools/ashby/utils.ts

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,32 @@ export function ashbyAuthHeaders(apiKey: string): Record<string, string> {
3131

3232
/**
3333
* Extract a human-readable error message from an Ashby error response. Ashby
34-
* returns errors as either `errorInfo.message` or an `errors` string array.
34+
* documents two shapes and uses both: `errorInfo.message`, and an `errors`
35+
* array whose entries are either plain strings or `{ message, parameter }`
36+
* objects. An object entry stringifies to `[object Object]` unless its message
37+
* is read explicitly, which is the form a 403 for a missing module permission
38+
* arrives in.
3539
*/
3640
export function ashbyErrorMessage(data: unknown, fallback: string): string {
3741
if (!data || typeof data !== 'object') return fallback
3842
const d = data as Unknown
3943
const info = d.errorInfo as Unknown | undefined
4044
if (info && typeof info.message === 'string' && info.message) return info.message
4145
if (Array.isArray(d.errors) && d.errors.length > 0) {
42-
return d.errors.map((e) => String(e)).join('; ')
46+
const messages = d.errors
47+
.map((e) => {
48+
if (typeof e === 'string') return e
49+
if (e && typeof e === 'object') {
50+
const entry = e as Unknown
51+
const message = typeof entry.message === 'string' ? entry.message : ''
52+
const parameter = typeof entry.parameter === 'string' ? entry.parameter : ''
53+
if (message && parameter) return `${message} (${parameter})`
54+
if (message) return message
55+
}
56+
return ''
57+
})
58+
.filter(Boolean)
59+
if (messages.length > 0) return messages.join('; ')
4360
}
4461
return fallback
4562
}
@@ -59,18 +76,25 @@ function mapContactArray(raw: unknown): AshbyContactInfo[] {
5976
return raw.map((c) => mapContact(c)).filter((c): c is AshbyContactInfo => c !== null)
6077
}
6178

79+
/**
80+
* Map a single custom field value as returned on an object. Ashby returns
81+
* `valueLabel` as a string for ValueSelect fields and an array of strings for
82+
* MultiValueSelect, and omits it entirely for every other field type.
83+
*/
84+
export function mapCustomFieldOnObject(raw: unknown): AshbyCustomField {
85+
const cf = (raw ?? {}) as Unknown
86+
return {
87+
id: (cf.id as string) ?? null,
88+
title: (cf.title as string) ?? '',
89+
isPrivate: (cf.isPrivate as boolean) ?? false,
90+
valueLabel: (cf.valueLabel as string | string[]) ?? null,
91+
value: cf.value ?? null,
92+
}
93+
}
94+
6295
function mapCustomFields(raw: unknown): AshbyCustomField[] {
6396
if (!Array.isArray(raw)) return []
64-
return raw.map((f) => {
65-
const cf = f as Unknown
66-
return {
67-
id: (cf.id as string) ?? null,
68-
title: (cf.title as string) ?? '',
69-
isPrivate: (cf.isPrivate as boolean) ?? false,
70-
valueLabel: (cf.valueLabel as string) ?? null,
71-
value: cf.value ?? null,
72-
}
73-
})
97+
return raw.map(mapCustomFieldOnObject)
7498
}
7599

76100
function mapFileHandle(raw: unknown): AshbyFileHandle | null {
@@ -373,18 +397,30 @@ export const CONTACT_INFO_OUTPUT = {
373397
},
374398
} as const satisfies OutputProperty
375399

400+
/**
401+
* Shape of a custom field as it exists on an Application, Candidate, Job, or
402+
* Opening - the value, not the field definition. Shared by every tool that
403+
* reads or writes custom field values.
404+
*/
405+
export const CUSTOM_FIELD_ON_OBJECT_OUTPUT = {
406+
id: { type: 'string', description: 'Custom field UUID' },
407+
title: { type: 'string', description: 'Field title' },
408+
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
409+
valueLabel: {
410+
type: 'string',
411+
description:
412+
'Human-readable value label, present only for ValueSelect and MultiValueSelect fields. MultiValueSelect returns an array of labels.',
413+
optional: true,
414+
},
415+
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
416+
} as const satisfies Record<string, OutputProperty>
417+
376418
export const CUSTOM_FIELDS_OUTPUT = {
377419
type: 'array',
378420
description: 'Custom field values',
379421
items: {
380422
type: 'object',
381-
properties: {
382-
id: { type: 'string', description: 'Custom field UUID' },
383-
title: { type: 'string', description: 'Field title' },
384-
isPrivate: { type: 'boolean', description: 'Whether the field is private' },
385-
valueLabel: { type: 'string', description: 'Human-readable value label', optional: true },
386-
value: { type: 'string', description: 'Raw field value (type depends on fieldType)' },
387-
},
423+
properties: CUSTOM_FIELD_ON_OBJECT_OUTPUT,
388424
},
389425
} as const satisfies OutputProperty
390426

0 commit comments

Comments
 (0)