Skip to content

Commit e341912

Browse files
committed
feat(agiloft): add table discovery and upsert
Two operations the current documentation fully specifies, both of which close real gaps. EWTable answers the question every other operation depends on: what are the logical table and field names? Until now users had to read them out of Setup > Tables by hand. It is knowledge-base scoped, so the request carries $KB but no $table — narrowing to one table uses the plain `table` parameter — and JSON is the only supported output, so the .json decorator is mandatory. Like EWSavedSearch it must run under EWLogin or OAuth, so it uses the token-bearing executor. `includelinkedinfo` exposes the source of linked fields, and `skipColumnsInfo` returns names only, which matters on knowledge bases where the full field listing is large. EWUpsert creates or updates in one call, matched on a caller-chosen field. Every parameter including credentials goes in the form body, so nothing sensitive reaches the URL and record data has no request-line length ceiling. Agiloft distinguishes the two outcomes by status — 201 created, 200 updated — which the tool surfaces as a `created` flag, and answers 409 when the match criteria are ambiguous, which is reported as such rather than as a generic failure. `table` becomes conditionally optional, since List Tables with no table is the operation for users who do not yet know the names. EWLogin omits $table when absent. Route-count baseline moves 1094 -> 1096.
1 parent 3341aec commit e341912

18 files changed

Lines changed: 939 additions & 29 deletions

File tree

apps/docs/content/docs/en/integrations/agiloft.mdx

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,37 @@ Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHER
160160
| --------- | ---- | ----------- |
161161
| `choiceLineId` | number | Internal numeric line ID of the choice value |
162162

163+
### Agiloft List Tables
164+
165+
List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.
166+
167+
#### Input
168+
169+
| Parameter | Type | Required | Description |
170+
| --------- | ---- | -------- | ----------- |
171+
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
172+
| `knowledgeBase` | string | Yes | Knowledge base name |
173+
| `login` | string | Yes | Agiloft username |
174+
| `password` | string | Yes | Agiloft password |
175+
| `table` | string | No | Logical name of a single table to describe \(e.g., "contacts"\). Leave empty to list every table in the knowledge base. |
176+
| `includeLinkedInfo` | boolean | No | Include the source table and column behind each linked field |
177+
| `skipColumnsInfo` | boolean | No | Return table names only, omitting field details, for a much smaller response |
178+
179+
#### Output
180+
181+
| Parameter | Type | Description |
182+
| --------- | ---- | ----------- |
183+
| `tables` | array | Tables in the knowledge base with their fields |
184+
|`label` | string | Display name of the table |
185+
|`logicalName` | string | Logical table name, as other Agiloft operations expect it |
186+
|`fields` | array | Fields on the table |
187+
|`columnName` | string | Logical field name |
188+
|`columnLabel` | string | Display label |
189+
|`columnType` | string | SQL column type |
190+
|`columnTypeDomain` | string | Agiloft field type |
191+
|`isLinked` | boolean | Whether the field is a linked field |
192+
| `totalCount` | number | Number of tables returned |
193+
163194
### Agiloft Lock Record
164195

165196
Lock, unlock, or check the lock status of an Agiloft record.
@@ -378,4 +409,27 @@ Update an existing record in an Agiloft table.
378409
| `id` | string | ID of the updated record |
379410
| `fields` | json | Updated field values of the record |
380411

412+
### Agiloft Upsert Record
413+
414+
Create an Agiloft record, or update it when a record already matches the given fields.
415+
416+
#### Input
417+
418+
| Parameter | Type | Required | Description |
419+
| --------- | ---- | -------- | ----------- |
420+
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
421+
| `knowledgeBase` | string | Yes | Knowledge base name |
422+
| `login` | string | Yes | Agiloft username |
423+
| `password` | string | Yes | Agiloft password |
424+
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
425+
| `match` | string | Yes | Field used to find an existing record \(e.g., "ext_id"\). Pick something that identifies a record uniquely — if more than one record matches, Agiloft writes nothing and returns a conflict. |
426+
| `data` | string | Yes | Field values as a JSON object. On create these populate the new record; on update only the supplied fields change. |
427+
428+
#### Output
429+
430+
| Parameter | Type | Description |
431+
| --------- | ---- | ----------- |
432+
| `id` | string | ID of the created or updated record |
433+
| `created` | boolean | True when a new record was created, false when an existing one was updated |
434+
381435

apps/sim/app/api/tools/agiloft/create_record/route.test.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,154 @@ describe('documented EWREST response keys', () => {
286286
expect(data.output.choiceLineId).toBe(1)
287287
})
288288
})
289+
290+
describe('EWTable', () => {
291+
it('is KB-scoped: no $table, mandatory .json, and no inline credentials', async () => {
292+
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
293+
arrange(res({ json: { success: true, result: { tables: [] } } }))
294+
295+
await LIST(
296+
createMockRequest('POST', {
297+
instanceUrl: baseBody.instanceUrl,
298+
knowledgeBase: baseBody.knowledgeBase,
299+
login: baseBody.login,
300+
password: baseBody.password,
301+
includeLinkedInfo: true,
302+
})
303+
)
304+
305+
const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
306+
expect(url).toContain('/ewws/EWTable/.json?')
307+
expect(url).toContain('&includelinkedinfo=true')
308+
expect(url).not.toContain('$table=')
309+
expect(url).not.toContain('$password')
310+
expect(init.headers.Authorization).toBe('Bearer tok-123')
311+
})
312+
313+
it('narrows to one table with the plain table parameter, not $table', async () => {
314+
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
315+
arrange(res({ json: { success: true, result: { tables: [] } } }))
316+
317+
await LIST(createMockRequest('POST', { ...baseBody, table: 'contacts' }))
318+
319+
const [url] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
320+
expect(url).toContain('&table=contacts')
321+
expect(url).not.toContain('$table=')
322+
})
323+
324+
it('flattens tables and fields into the documented shape', async () => {
325+
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
326+
arrange(
327+
res({
328+
json: {
329+
success: true,
330+
result: {
331+
tables: [
332+
{
333+
label: 'WMI Sample',
334+
logicalName: 'wmi_sample',
335+
fields: [
336+
{
337+
columnLabel: 'ID',
338+
columnName: 'id',
339+
columnType: 'BIGINT',
340+
columnTypeDomain: 'swautoincrementfield',
341+
},
342+
{
343+
columnLabel: 'Updated By',
344+
columnName: '_1794_full_name',
345+
columnType: 'VARCHAR',
346+
columnTypeDomain: 'swshorttextfield',
347+
isLinked: true,
348+
},
349+
],
350+
},
351+
],
352+
},
353+
},
354+
})
355+
)
356+
357+
const response = await LIST(createMockRequest('POST', baseBody))
358+
const data = (await response.json()) as {
359+
output: { tables: Array<{ logicalName: string; fields: unknown[] }>; totalCount: number }
360+
}
361+
362+
expect(data.output.totalCount).toBe(1)
363+
expect(data.output.tables[0].logicalName).toBe('wmi_sample')
364+
expect(data.output.tables[0].fields).toEqual([
365+
{
366+
columnName: 'id',
367+
columnLabel: 'ID',
368+
columnType: 'BIGINT',
369+
columnTypeDomain: 'swautoincrementfield',
370+
isLinked: false,
371+
},
372+
{
373+
columnName: '_1794_full_name',
374+
columnLabel: 'Updated By',
375+
columnType: 'VARCHAR',
376+
columnTypeDomain: 'swshorttextfield',
377+
isLinked: true,
378+
},
379+
])
380+
})
381+
})
382+
383+
describe('EWUpsert', () => {
384+
const upsertBody = { ...baseBody, match: 'ext_id', data: '{"first_name":"John"}' }
385+
386+
it('sends every parameter in the form body, keeping credentials out of the URL', async () => {
387+
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
388+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
389+
res({ status: 201, text: "EWREST_id='353';" })
390+
)
391+
392+
await UPSERT(createMockRequest('POST', upsertBody))
393+
394+
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
395+
// Inline-credential auth: a single call, no login/logout pair.
396+
expect(calls).toHaveLength(1)
397+
expect(calls[0][0]).toBe('https://example.agiloft.com/ewws/EWUpsert')
398+
expect(calls[0][0]).not.toContain('?')
399+
400+
const sent = new URLSearchParams(calls[0][2].body as string)
401+
expect(sent.get('$match')).toBe('ext_id')
402+
expect(sent.get('$table')).toBe('contract')
403+
expect(sent.get('$password')).toBe(PLACEHOLDER_PASSWORD)
404+
expect(sent.get('first_name')).toBe('John')
405+
})
406+
407+
it('reports 201 as a create and 200 as an update', async () => {
408+
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
409+
410+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
411+
res({ status: 201, text: "EWREST_id='353';" })
412+
)
413+
let data = (await (await UPSERT(createMockRequest('POST', upsertBody))).json()) as {
414+
output: { id: string; created: boolean }
415+
}
416+
expect(data.output).toEqual({ id: '353', created: true })
417+
418+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
419+
res({ status: 200, text: "EWREST_id='353';" })
420+
)
421+
data = (await (await UPSERT(createMockRequest('POST', upsertBody))).json()) as {
422+
output: { id: string; created: boolean }
423+
}
424+
expect(data.output).toEqual({ id: '353', created: false })
425+
})
426+
427+
it('surfaces a 409 as an ambiguous match rather than a generic failure', async () => {
428+
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
429+
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
430+
res({ ok: false, status: 409, text: 'Multiple matching records found' })
431+
)
432+
433+
const response = await UPSERT(createMockRequest('POST', upsertBody))
434+
const data = (await response.json()) as { success: boolean; error?: string }
435+
436+
expect(data.success).toBe(false)
437+
expect(data.error).toContain('more than one record matching "ext_id"')
438+
})
439+
})
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { createLogger } from '@sim/logger'
2+
import { toError } from '@sim/utils/errors'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { agiloftListTablesContract } from '@/lib/api/contracts/tools/agiloft'
5+
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
6+
import { checkInternalAuth } from '@/lib/auth/hybrid'
7+
import { generateRequestId } from '@/lib/core/utils/request'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import type { AgiloftListTablesResponse, AgiloftTableField } from '@/tools/agiloft/types'
10+
import { buildListTablesUrl } from '@/tools/agiloft/utils'
11+
import { executeAgiloftRequest, readAlrestJson } from '@/tools/agiloft/utils.server'
12+
13+
export const dynamic = 'force-dynamic'
14+
15+
const logger = createLogger('AgiloftListTablesAPI')
16+
17+
/** Shape of the `result` object EWTable returns. */
18+
interface EwTableResult {
19+
tables?: Array<{
20+
label?: string
21+
logicalName?: string
22+
fields?: Array<{
23+
columnName?: string
24+
columnLabel?: string
25+
columnType?: string
26+
columnTypeDomain?: string
27+
isLinked?: boolean
28+
}>
29+
}>
30+
}
31+
32+
export const POST = withRouteHandler(async (request: NextRequest) => {
33+
const requestId = generateRequestId()
34+
35+
try {
36+
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
37+
38+
if (!authResult.success || !authResult.userId) {
39+
logger.warn(`[${requestId}] Unauthorized Agiloft list_tables attempt: ${authResult.error}`)
40+
return NextResponse.json(
41+
{ success: false, error: authResult.error || 'Authentication required' },
42+
{ status: 401 }
43+
)
44+
}
45+
46+
const parsed = await parseRequest(
47+
agiloftListTablesContract,
48+
request,
49+
{},
50+
{
51+
validationErrorResponse: (error) => {
52+
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
53+
return NextResponse.json(
54+
{
55+
success: false,
56+
error: getValidationErrorMessage(error, 'Invalid request data'),
57+
details: error.issues,
58+
},
59+
{ status: 400 }
60+
)
61+
},
62+
}
63+
)
64+
if (!parsed.success) return parsed.response
65+
const params = parsed.data.body
66+
67+
/** EWTable must run under EWLogin or OAuth authorization. */
68+
const result = await executeAgiloftRequest<AgiloftListTablesResponse>(
69+
params,
70+
(base) => ({
71+
url: buildListTablesUrl(base, params),
72+
method: 'GET',
73+
headers: { Accept: 'application/json' },
74+
}),
75+
async (response) => {
76+
const payload = await readAlrestJson<EwTableResult>(response)
77+
78+
const tables = (payload?.tables ?? []).map((table) => ({
79+
label: table.label ?? '',
80+
logicalName: table.logicalName ?? '',
81+
fields: (table.fields ?? []).map(
82+
(field): AgiloftTableField => ({
83+
columnName: field.columnName ?? '',
84+
columnLabel: field.columnLabel ?? '',
85+
columnType: field.columnType ?? '',
86+
columnTypeDomain: field.columnTypeDomain ?? '',
87+
isLinked: field.isLinked === true,
88+
})
89+
),
90+
}))
91+
92+
return { success: true, output: { tables, totalCount: tables.length } }
93+
}
94+
)
95+
96+
return NextResponse.json(result)
97+
} catch (error) {
98+
logger.error(`[${requestId}] Error listing Agiloft tables:`, error)
99+
100+
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
101+
}
102+
})

0 commit comments

Comments
 (0)