Skip to content

Commit 0d6045a

Browse files
committed
fix(agiloft): align the integration with the documented ewws REST interface
The CRUD tools targeted /ewws/REST/{kb}/{table}/{id} with JSON bodies and guessed at the response by probing `data.result ?? data` and `id ?? ID`. Agiloft documents that path as a URL convention only -- no method table, no example call, and no response shape -- and no known client uses it. The EW* operation family is specified end to end, including exact response bodies, so every operation now goes through it and parses the documented `EWREST_key='value';` assignment format. - EWCreate/EWRead/EWUpdate/EWDelete/EWSearch/EWSelect/EWGetChoiceLineId are form-encoded and parsed via a shared EWREST parser; the /.json suffix is kept only on EWAttachInfo, the one operation with a published JSON sample - EWDelete now sends the deleteRule the docs require, defaulting to ERROR_IF_DEPENDANTS so a delete fails rather than cascading - EWRemoveAttachment uses GET; it does not accept DELETE - EWSearch accepts the documented `search` saved-search label, so saved searches are reachable for the first time - Search query help taught AND/OR; Agiloft uses && and || - Add run_action_button (POST /ewws/async/EWActionButton) for approvals and send-for-signature steps - Drop saved_search: EWSavedSearch has no doc page, so neither its URL nor its response could be verified and it could only ever return an empty list - Add force on unlock, filter read fields locally since $fields is undocumented, correct lock status to LOCKED/NO_LOCK, and stop reporting a fabricated page size of 25
1 parent 061ecd3 commit 0d6045a

30 files changed

Lines changed: 1141 additions & 375 deletions

File tree

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

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ In Sim, the Agiloft integration enables your agents to manage contracts and reco
3434

3535
## Usage Instructions
3636

37-
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, and record locking across any table in your knowledge base.
37+
Integrate with Agiloft contract lifecycle management to create, read, update, delete, and search records. Supports file attachments, SQL-based selection, saved searches, record locking, and running action buttons across any table in your knowledge base.
3838

3939

4040

@@ -129,6 +129,7 @@ Delete a record from an Agiloft table.
129129
| `password` | string | Yes | Agiloft password |
130130
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
131131
| `recordId` | string | Yes | ID of the record to delete |
132+
| `deleteRule` | string | No | How to treat records that depend on this one: ERROR_IF_DEPENDANTS \(default — fails rather than cascading\), APPLY_DELETE_WHERE_POSSIBLE, DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK, or UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE |
132133

133134
#### Output
134135

@@ -174,13 +175,14 @@ Lock, unlock, or check the lock status of an Agiloft record.
174175
| `table` | string | Yes | Table name \(e.g., "contracts"\) |
175176
| `recordId` | string | Yes | ID of the record to lock, unlock, or check |
176177
| `lockAction` | string | Yes | Action to perform: "lock", "unlock", or "check" |
178+
| `force` | boolean | No | Unlock only: release a lock held by another user. Requires membership in the admin group. |
177179

178180
#### Output
179181

180182
| Parameter | Type | Description |
181183
| --------- | ---- | ----------- |
182184
| `id` | string | Record ID |
183-
| `lockStatus` | string | Lock status \(e.g., "LOCKED", "UNLOCKED"\) |
185+
| `lockStatus` | string | Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free |
184186
| `lockedBy` | string | Username of the user who locked the record |
185187
| `lockExpiresInMinutes` | number | Minutes until the lock expires |
186188

@@ -255,9 +257,9 @@ Download an attached file from an Agiloft record field.
255257
| --------- | ---- | ----------- |
256258
| `file` | file | Downloaded attachment file |
257259

258-
### Agiloft Saved Search
260+
### Agiloft Run Action Button
259261

260-
List saved searches defined for an Agiloft table.
262+
Run an action button on an Agiloft record, such as an approval or send-for-signature step.
261263

262264
#### Input
263265

@@ -267,17 +269,16 @@ List saved searches defined for an Agiloft table.
267269
| `knowledgeBase` | string | Yes | Knowledge base name |
268270
| `login` | string | Yes | Agiloft username |
269271
| `password` | string | Yes | Agiloft password |
270-
| `table` | string | Yes | Table name to list saved searches for \(e.g., "contracts"\) |
272+
| `table` | string | Yes | Table name \(e.g., "contracts", "case"\) |
273+
| `recordId` | string | Yes | ID of the record to run the action button on |
274+
| `actionButtonField` | string | Yes | Logical name of the field holding the action button \(e.g., "ab_field"\) |
271275

272276
#### Output
273277

274278
| Parameter | Type | Description |
275279
| --------- | ---- | ----------- |
276-
| `searches` | array | List of saved searches for the table |
277-
|`name` | string | Saved search name |
278-
|`label` | string | Saved search display label |
279-
|`id` | number | Saved search database identifier |
280-
|`description` | string | Saved search description |
280+
| `recordId` | string | ID of the record the action button was run on |
281+
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |
281282

282283
### Agiloft Search Records
283284

@@ -292,19 +293,20 @@ Search for records in an Agiloft table using a query.
292293
| `login` | string | Yes | Agiloft username |
293294
| `password` | string | Yes | Agiloft password |
294295
| `table` | string | Yes | Table name to search in \(e.g., "contracts", "contacts.employees"\) |
295-
| `query` | string | Yes | Search query using Agiloft query syntax \(e.g., "status=\'Active\'" or "company_name~=\'Acme\'"\) |
296+
| `query` | string | No | Ad hoc EWSearch query. Combine conditions with && \(and\) or \|\| \(or\) and quote every value — e.g. \"summary~='test'&&priority='High'\". Required unless a saved search is given. |
297+
| `search` | string | No | Label of a saved search defined on the table \(e.g., "C: Status is Closed"\). Can be combined with a query to narrow it further. |
296298
| `fields` | string | No | Comma-separated list of field names to include in the results |
297299
| `page` | string | No | Page number for paginated results \(starting from 0\) |
298-
| `limit` | string | No | Maximum number of records to return per page |
300+
| `limit` | string | No | Maximum number of records to return per page. Agiloft treats 0 as "all records", so leave it unset or use a positive value to keep result sizes bounded. |
299301

300302
#### Output
301303

302304
| Parameter | Type | Description |
303305
| --------- | ---- | ----------- |
304306
| `records` | json | Array of matching records with their field values |
305-
| `totalCount` | number | Total number of matching records |
306-
| `page` | number | Current page number |
307-
| `limit` | number | Records per page |
307+
| `totalCount` | number | Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set. |
308+
| `page` | number | Page number that was requested \(0-based\) |
309+
| `limit` | number | Page size that was requested; 0 when no limit was sent and Agiloft chose one |
308310

309311
### Agiloft Select Records
310312

@@ -319,7 +321,7 @@ Select record IDs matching a SQL WHERE clause from an Agiloft table.
319321
| `login` | string | Yes | Agiloft username |
320322
| `password` | string | Yes | Agiloft password |
321323
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
322-
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\) |
324+
| `where` | string | Yes | SQL WHERE clause using database column names \(e.g., "summary like \'%new%\'" or "assigned_person=\'John Doe\'"\). EWSelect has no page size and returns every matching ID, so append a database limit such as "limit 0,200" to bound the result. |
323325

324326
#### Output
325327

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import {
5+
createMockRequest,
6+
hybridAuthMockFns,
7+
inputValidationMock,
8+
inputValidationMockFns,
9+
} from '@sim/testing'
10+
import { beforeEach, describe, expect, it, vi } from 'vitest'
11+
12+
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
13+
14+
import { POST } from '@/app/api/tools/agiloft/create_record/route'
15+
16+
const PINNED_IP = '93.184.216.34'
17+
18+
const baseBody = {
19+
instanceUrl: 'https://example.agiloft.com',
20+
knowledgeBase: 'Demo',
21+
login: 'admin',
22+
password: 'secret',
23+
table: 'contacts.employees',
24+
data: JSON.stringify({ first_name: 'John', last_name: 'Doe' }),
25+
}
26+
27+
function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
28+
return {
29+
ok: body.ok ?? true,
30+
status: body.ok === false ? 400 : 200,
31+
statusText: '',
32+
headers: new Headers(),
33+
body: null,
34+
text: async () => body.text ?? '',
35+
json: async () => body.json ?? {},
36+
arrayBuffer: async () => new ArrayBuffer(0),
37+
}
38+
}
39+
40+
beforeEach(() => {
41+
vi.clearAllMocks()
42+
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
43+
success: true,
44+
userId: 'user-1',
45+
authType: 'internal_jwt',
46+
})
47+
inputValidationMockFns.mockValidateUrlWithDNS.mockResolvedValue({
48+
isValid: true,
49+
resolvedIP: PINNED_IP,
50+
originalHostname: 'example.agiloft.com',
51+
})
52+
})
53+
54+
describe('POST /api/tools/agiloft/create_record', () => {
55+
it("reads the record ID out of EWCreate's EWREST_id assignment", async () => {
56+
inputValidationMockFns.mockSecureFetchWithPinnedIP
57+
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
58+
.mockResolvedValueOnce(mockSecureFetchResponse({ text: "EWREST_id='353';" }))
59+
.mockResolvedValueOnce(mockSecureFetchResponse({}))
60+
61+
const response = await POST(createMockRequest('POST', baseBody))
62+
const data = (await response.json()) as {
63+
success: boolean
64+
output: { id: string | null }
65+
}
66+
67+
expect(data.success).toBe(true)
68+
expect(data.output.id).toBe('353')
69+
70+
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
71+
expect(operationCall[0]).toContain('/ewws/EWCreate?')
72+
expect(operationCall[0]).toContain('&first_name=John')
73+
expect(operationCall[2]).toMatchObject({ method: 'POST' })
74+
})
75+
76+
it('fails loudly when Agiloft answers 200 with something that is not an EWREST body', async () => {
77+
inputValidationMockFns.mockSecureFetchWithPinnedIP
78+
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
79+
.mockResolvedValueOnce(
80+
mockSecureFetchResponse({ text: 'Error executing query, please consult logs' })
81+
)
82+
.mockResolvedValueOnce(mockSecureFetchResponse({}))
83+
84+
const response = await POST(createMockRequest('POST', baseBody))
85+
const data = (await response.json()) as { success: boolean; error?: string }
86+
87+
expect(data.success).toBe(false)
88+
expect(data.error).toContain('did not return a record ID')
89+
})
90+
91+
it('rejects a data payload that is not a JSON object', async () => {
92+
const response = await POST(
93+
createMockRequest('POST', { ...baseBody, data: '["not", "an", "object"]' })
94+
)
95+
const data = (await response.json()) as { success: boolean; error?: string }
96+
97+
expect(data.success).toBe(false)
98+
expect(data.error).toContain('must be a JSON object')
99+
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
100+
})
101+
})

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

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
910
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
1011
import { buildCreateRecordUrl } from '@/tools/agiloft/utils'
1112
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -49,46 +50,51 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
4950
if (!parsed.success) return parsed.response
5051
const params = parsed.data.body
5152

52-
let body: string
53+
let fieldValues: Record<string, unknown>
5354
try {
54-
body = JSON.stringify(JSON.parse(params.data))
55+
const parsedData = JSON.parse(params.data)
56+
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) {
57+
throw new Error('not an object')
58+
}
59+
fieldValues = parsedData as Record<string, unknown>
5560
} catch {
5661
return NextResponse.json({
5762
success: false,
5863
output: { id: null, fields: {} },
59-
error: 'Invalid JSON in data parameter',
64+
error: 'The data parameter must be a JSON object of field names to values',
6065
})
6166
}
6267

6368
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
6469
params,
6570
(base) => ({
66-
url: buildCreateRecordUrl(base, params),
71+
url: buildCreateRecordUrl(base, params, fieldValues),
6772
method: 'POST',
68-
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
69-
body,
73+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
7074
}),
7175
async (response) => {
76+
const body = await response.text()
77+
7278
if (!response.ok) {
73-
const errorText = await response.text()
7479
return {
7580
success: false,
7681
output: { id: null, fields: {} },
77-
error: `Agiloft error: ${response.status} - ${errorText}`,
82+
error: `Agiloft error: ${response.status} - ${body}`,
7883
}
7984
}
8085

81-
const data = (await response.json()) as Record<string, unknown>
82-
const result = (data.result ?? data) as Record<string, unknown>
83-
const id = result.id ?? result.ID ?? data.id ?? data.ID ?? null
86+
/** EWCreate answers with a single assignment: EWREST_id='353'; */
87+
const { id, fields } = toRecord(parseEwRest(body))
8488

85-
return {
86-
success: data.success !== false,
87-
output: {
88-
id: id != null ? String(id) : null,
89-
fields: result ?? {},
90-
},
89+
if (id === null) {
90+
return {
91+
success: false,
92+
output: { id: null, fields },
93+
error: `Agiloft did not return a record ID: ${body.trim() || '(empty response)'}`,
94+
}
9195
}
96+
97+
return { success: true, output: { id, fields } }
9298
}
9399
)
94100

apps/sim/app/api/tools/agiloft/delete_record/route.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
66
import { checkInternalAuth } from '@/lib/auth/hybrid'
77
import { generateRequestId } from '@/lib/core/utils/request'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
import { isEwRestBody } from '@/tools/agiloft/ewrest'
910
import type { AgiloftDeleteResponse } from '@/tools/agiloft/types'
1011
import { buildDeleteRecordUrl } from '@/tools/agiloft/utils'
1112
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
@@ -53,26 +54,36 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
5354
params,
5455
(base) => ({
5556
url: buildDeleteRecordUrl(base, params),
56-
method: 'DELETE',
57-
headers: { Accept: 'application/json' },
57+
method: 'POST',
58+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
5859
}),
5960
async (response) => {
61+
const body = (await response.text()).trim()
62+
const recordId = params.recordId.trim()
63+
6064
if (!response.ok) {
61-
const errorText = await response.text()
6265
return {
6366
success: false,
64-
output: { id: params.recordId?.trim() ?? '', deleted: false },
65-
error: `Agiloft error: ${response.status} - ${errorText}`,
67+
output: { id: recordId, deleted: false },
68+
error: `Agiloft error: ${response.status} - ${body}`,
6669
}
6770
}
6871

69-
return {
70-
success: true,
71-
output: {
72-
id: params.recordId?.trim() ?? '',
73-
deleted: true,
74-
},
72+
/**
73+
* EWDelete returns nothing on success and an error message on failure,
74+
* so a non-empty body that is not an EWREST assignment is a refusal the
75+
* HTTP status did not surface — most often the delete rule rejecting
76+
* dependent records.
77+
*/
78+
if (body && !isEwRestBody(body)) {
79+
return {
80+
success: false,
81+
output: { id: recordId, deleted: false },
82+
error: `Agiloft refused the delete: ${body}`,
83+
}
7584
}
85+
86+
return { success: true, output: { id: recordId, deleted: true } }
7687
}
7788
)
7889

0 commit comments

Comments
 (0)