mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-31 01:11:53 +08:00
fix(agiloft): repoint the block at the alrest surface and fix EWLogin (#6562)
* fix(agiloft): make the block work, and align it with the REST documentation
The native Agiloft block could not authenticate against any instance. A
customer reported it; production traces for their workspace confirm every
failure mode verbatim. Fixing that exposed a second, larger problem, and a
per-endpoint audit against the full published documentation found the rest.
Authentication
- EWLogin sent only $KB/$login/$password as query parameters. A live instance
answers `400 EWWrongDataException ... One has to specify $table, $KB, $lang
parameters`. $table is required even though only $KB/$login/$password/$lang
are documented. Parameters now travel in a form-encoded body, which the docs
permit and which keeps the password out of URLs and access logs.
- The authentication scheme is read from the login response and trimmed;
Agiloft returns it as "Bearer " with a trailing space.
- EWLogout was missing $lang.
Surfaces
- Record create, read, update, search and saved-search now use the endpoints
that accept the token EWLogin issues; the legacy operations authenticate from
inline credentials, which is what that surface expects. Nothing sends both
forms at once — the documented 400 for doing so is what the original report
had run into.
- EWSelect passes credentials in a POST body, one of the five operations
documented to support it.
- Attachment retrieval uses the documented EWRetrieve endpoint, with
filePosition rather than position, and no longer needs a login/logout pair.
Defects found in the audit
- remove_attachment reported zero on every call: its body is the EWREST
assignment form but the route ran JSON.parse then Number(), yielding NaN.
- The EWREST parser could not read EWActionButton's documented response, which
puts both assignments on one line.
- EWLock treated any 200 as success, including the documented
{error, error_description} envelope, and invented an 'UNKNOWN' status.
- EWTable discarded the linked-field details, required flag and text field type
it had asked for, making includeLinkedInfo inert.
- select_records had no result ceiling at all; both it and search now cap and
report a truncated flag rather than reporting a capped length as a total.
- Optional string inputs rejected null, so a blank Page field failed validation
before any request was made.
- Upsert treated the documented 202 async acknowledgement as a missing-ID
failure, and returned no callback ID for the caller to poll.
- Every response contract required an output that the 401 and 500 paths never
return.
Coverage added
- Table and field discovery (EWTable), upsert (EWUpsert), async status
(EWAsyncStatus), natural language search (EWNLPSearch), action buttons
(EWActionButton), the REPLACE_WITH_ANOTHER delete rule with its substitute
records, $async on upsert, and <fieldName>$overwrite on attach.
- Reads with a named field list go through the search projection; an unfiltered
contract record runs to roughly 184KB and swamps downstream agent context.
- Errors are readable: Agiloft wraps failures in HTML around a typed exception
and an internal task id, and the JSON endpoints now request real status codes
rather than a 200 the caller has to interpret.
Not implemented: $searchSQL and $operationHints=NOLOCK are EWRead/EWUpdate
parameters and those operations do not run on that surface here; EWQuestion,
EWHotlinks, EWOData, EWBroadcast and webhook registration have no documentation
beyond their names.
Verified against the published documentation, not against a live instance.
* fix(agiloft): give natural language search a sentence that paints
check:canvas-sentences failed: the nlp_search card resolved to nothing on an
untouched canvas, so it painted empty. Its only basic-mode field was the
long-input query, and the field list is advanced, so every segment dropped.
The sentence now leads with the knowledge base, matching the shape List Tables
already uses — both operations are knowledge-base scoped rather than
table-scoped, so it also reads more accurately.
* fix(agiloft): stop retrying refusals, and expose the outputs the new operations return
Five findings from review that had gone unanswered.
An Agiloft refusal was surfacing as HTTP 500. readAlrestJson throws when the
envelope reports success:false, the route catch mapped that to 500, and the
tool runner retries 500s — so a create the server had already rejected could be
retried and duplicate the record. Refusals now return a settled failure with the
message intact; genuine faults still 500.
list_tables could not run in its primary mode. EWTable is knowledge-base scoped,
but some instances reject EWLogin without a $table, so whole-knowledge-base
discovery failed at login with nothing to fall back to. It now says what the
caller can do about it rather than surfacing the raw login error.
Upsert corrupted structured values. Every field went through String(), so a
multi-value field collapsed into one joined string instead of the documented
repeated key/value pairs, and an object silently wrote "[object Object]" into
the record. Arrays now encode as repeated pairs and objects are refused, since
Agiloft documents no encoding for them.
Two outputs were invisible in the editor. `records` was conditioned on
search_records alone, so natural language search results could not be chained,
and `callbackId` on run_action_button alone, so a queued upsert's callback could
not be wired into Async Status even though both values exist at runtime.
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
# Agiloft REST API — authoritative spec (transcribed from live help.agiloft.com)
|
||||
|
||||
## GLOBAL / API Security
|
||||
- Every REST call should contain credentials as `login={login}&password={password}`.
|
||||
- POST-body credentials supported ONLY for: /ewws/EWRead, /ewws/EWSelect, /ewws/EWCreate,
|
||||
/ewws/EWUpdate, /ewws/EWDelete. ("avoid passing the login or password ... by using POST
|
||||
instead of GET to pass the parameters in the request body")
|
||||
- JWT: EWLogin returns a token; "The token can then be used in an Authorization request header,
|
||||
prefixed by the authentication scheme, instead of including the login and password parameters
|
||||
in following requests." Default scheme Bearer, expiry 15 min (token_expires_in, max 60).
|
||||
- Statefulness: pattern is "login, do multiple calls, logout". EWLogout terminates the session
|
||||
associated with the token passed in the Authorization header.
|
||||
- DELAYS: every REST call has a delay after completion, default 1 second, global var WSDelay.
|
||||
- Group must be REST-enabled (Setup > System > Manage Web Services > Groups allowed for REST),
|
||||
else 403.
|
||||
|
||||
### General error codes (selected)
|
||||
- 400 "There is no permissions to access this resource"
|
||||
- 400 "One has to specify $login and $password parameters or authentication token." <-- BOTH auth methods provided
|
||||
- 400 "Token is expired"
|
||||
- 400 "One has to specify $login, $password parameters or use $genhotlink/$genproject pair ..." <-- no auth
|
||||
- 401 "Wrong Authorization data" <-- invalid authorization scheme
|
||||
- 401 "Token is blocked" / "User is blocked"
|
||||
- 403 invalid login attempt / "Authentication failed." / "Invalid login/password combination ..."
|
||||
- 500 "No active session found for current token"
|
||||
- 400 "Unable to identify KB with name" / "Cannot find specified knowledgebase: <KBName>"
|
||||
- 400 "One has to specify $table, $KB, $lang parameters or use $genhotlink/$genproject pair ..."
|
||||
- 400 "Wrong combination of access token and KB name. No access to data in KB"
|
||||
- 400 "One has to specify id value."
|
||||
- 400 "Project <projectId> has not been found" / "Table <tableId> has not been found"
|
||||
- 400 "No value for 'field' parameter specified."
|
||||
- 403 "not allowed, please check logs" (IP blacklist)
|
||||
- 405 "HTTP method <methodName> is not supported by this URL"
|
||||
|
||||
## URL CONVENTIONS
|
||||
- KB names and table names are CASE SENSITIVE (use Logical Table Name).
|
||||
- REST style: `/ewws/REST/{kbName}/{table}[/{id}]?$login={login}&password={password}&lang={lang}&...`
|
||||
(omit /{id} for Create)
|
||||
- GET/POST style: `/ewws/{operation}?$KB={kbName}&$table={table}&$login={login}&password={password}&lang={lang}&...`
|
||||
"The parameters of the POST request can be inserted into the body of the request to conceal the user credentials."
|
||||
- Return values: JavaScript eval() form, all names prefixed `EWREST_`. Empty fields returned as nulls.
|
||||
- JSON decorator: append `/.json` -> `{"success":true,"message":"","result":{...}}`
|
||||
optional `err_code_resp=1` for real status codes instead of always 200.
|
||||
- Async decorator: `/ewws/async/EWCreate?...` or `/ewws/EWCreate/.async?...` (EWCreate, EWUpdate, EWDelete)
|
||||
- Redirect decorator: `/ewws/redirect/...` with $exiturl and $errorurl
|
||||
- Decorators chain left to right.
|
||||
|
||||
## OPERATIONS TABLE (endpoint / methods / returns)
|
||||
| Create | GET/POST | /ewws/EWCreate | ID of new record |
|
||||
| Read | GET/POST | /ewws/EWRead | encoded record info |
|
||||
| Update | GET/POST | /ewws/EWUpdate | encoded record info after update |
|
||||
| Delete | GET/POST/DELETE | /ewws/EWDelete | nothing |
|
||||
| Select | GET/POST | /ewws/EWSelect | list of record ids + length |
|
||||
| Login | GET/POST | /ewws/EWLogin | session token, expiration, auth scheme |
|
||||
| Logout | GET/POST | /ewws/EWLogout | nothing |
|
||||
| Search | GET/POST | /ewws/EWSearch | saved search + ad hoc |
|
||||
| Attach | PUT | /ewws/EWAttach | total files attached |
|
||||
| RemoveAttached | GET/POST | /ewws/EWRemoveAttachment | nothing |
|
||||
| RetrieveAttached | GET/POST | /ewws/EWRetrieve | attachment |
|
||||
| Lock | GET/PUT/DELETE | /ewws/EWLock | lock status |
|
||||
| AttachInfo | GET/POST | /ewws/EWAttachInfo | attachment info |
|
||||
| Hotlink | POST | /ewws/EWHotlinks | hotlink |
|
||||
| Table | GET/POST | /ewws/EWTable | all tables and fields |
|
||||
| Async Status | GET/POST | /ewws/EWAsyncStatus | execution status |
|
||||
| GetChoiceLineId | GET | /ewws/GetChoiceLineID | internal id for a choice value |
|
||||
| Action Button | POST | /ewws/EWActionButton | runs an action button |
|
||||
| Saved Search | GET/POST | /ewws/EWSavedSearch | saved search details |
|
||||
|
||||
NOTE: the operations table lists `/ewws/GetChoiceLineID` and `/ewws/EWActionButton`, but the
|
||||
detail pages use `/ewws/EWGetChoiceLineId` and `/ewws/async/EWActionButton`. Detail pages carry
|
||||
working curl examples; the table does not.
|
||||
|
||||
## EWLogin
|
||||
POST /ewws/EWLogin, Content-Type: plain/text.
|
||||
Params (CAN BE FILLED TO REQUEST BODY): $KB, $login, $password, $lang (default en).
|
||||
Response JSON: access_token, refresh_token, expiration_time_unit, expires_in, authentication_scheme
|
||||
(default "Bearer " — NOTE TRAILING SPACE in examples).
|
||||
Example: POST https://server/ewws/EWLogin?$login=user&$password=passwd&$KB=Demo&$lang=en
|
||||
Refresh: POST /ewws/EWLogin with Authorization header + body refresh_token=...
|
||||
Logout: POST or GET /ewws/EWLogout with Authorization header; params $KB, $lang.
|
||||
Errors: 400 no refresh_token / wrong refresh_token; 401 Refresh Token is expired;
|
||||
403 "User <userName> lacks permission log in"
|
||||
|
||||
## EWCreate
|
||||
GET/POST /ewws/EWCreate. Content-Type application/x-www-form-urlencoded.
|
||||
Params in URL/body: $KB, $table, $login, $password, $lang + field values.
|
||||
Returns: EWREST_id='353';
|
||||
Async-compatible.
|
||||
Errors: 400 "Wrong format/value pointed to <columnName>"; linked-field errors.
|
||||
|
||||
## EWRead
|
||||
GET/POST /ewws/EWRead. Params: $KB,$table,$login,$password,$lang,id
|
||||
Alternative to id: $searchSQL=ext_id='a0B2c345' (must match exactly one record).
|
||||
Returns EWREST_<field>='<value>'; lines including EWREST_id.
|
||||
Errors: 400 "no data found for id range(s)"
|
||||
|
||||
## EWUpdate
|
||||
GET/POST /ewws/EWUpdate. Params: ... id=358 + field values.
|
||||
Alternative to id: $searchSQL.
|
||||
$operationHints=NOLOCK forces update on a locked record (URL or POST body).
|
||||
Returns full updated record as EWREST_ lines.
|
||||
Errors: 400 "One has to specify id or searchSQL value."; constraint violations.
|
||||
|
||||
## EWDelete
|
||||
GET/POST/DELETE /ewws/EWDelete. Params: ... id=358 & deleteRule=...
|
||||
deleteRule values: ERROR_IF_DEPENDANTS, APPLY_DELETE_WHERE_POSSIBLE,
|
||||
DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK, APPLY_UNLINK,
|
||||
UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, REPLACE_WITH_ANOTHER (needs `subs`).
|
||||
Returns nothing on success; error message on failure.
|
||||
Errors: 400 "One has to specify deleteRule, id and substitute values.";
|
||||
409 "Operation cannot be done. Record has <n> dependants" etc.
|
||||
|
||||
## EWSelect
|
||||
GET/POST /ewws/EWSelect. Params: ... where=<sql where clause>
|
||||
Queries in the URL must use %N equivalent operators.
|
||||
SQL uses dbname column names. Choice values via GetChoiceLineId.
|
||||
Limit via DB syntax e.g. "limit 0,200". No sort control (use EWSearch).
|
||||
Returns: EWREST_id_length = '3'; EWREST_id_0 = '150'; ...
|
||||
Empty: EWREST_id_length = '0';
|
||||
Errors: 400 "Error <error> parsing the query <query>"; 500 "Error executing query, please consult logs"
|
||||
|
||||
## EWSearch
|
||||
GET/POST /ewws/EWSearch. Content-Type x-www-form-urlencoded.
|
||||
Params: $KB,$table,$login,$password,$lang, search=<saved search label>, query=<ad hoc>,
|
||||
field=<repeated>, page, limit
|
||||
Operators: = %3D | != %21%3D | ~= %7E%3D (contains) | && %26%26 | || %7C%7C | < <= > >=
|
||||
Surround each search value in single quotes; if a field label contains spaces, quote the label too.
|
||||
Empty fields designated with null.
|
||||
Returns: EWREST_length = '4'; then EWREST_<field>_<i>='value';
|
||||
Empty: EWREST_id_length = '0';
|
||||
Pagination: page starts 0. limit 0 = ALL records on page 0.
|
||||
"The REST interface creates a new session and performs an explicit logout for each call.
|
||||
As such, though pagination is available, the query will always be rebuilt and rerun."
|
||||
ALREST EQUIVALENT (documented in this page):
|
||||
curl --location 'http://localhost:8080/ewws/alrest/CLM Template/case/search?lang=en' \
|
||||
--data '{ "field": ["id","summary"], "query": "summary=test" }'
|
||||
Errors: 400 "No search <savedSearch> for table <tableName>"; 400 "No column <columnName> in table <tableName>"
|
||||
|
||||
## EWAttach
|
||||
PUT /ewws/EWAttach. Content-Type multipart/form-data (file in body).
|
||||
Params: $KB,$table,$login,$password,id,field,fileName
|
||||
Returns: EWREST_someField.length='1'; (key is `<fieldName>.length`)
|
||||
Errors: 400 "No value for 'fileName' parameter specified."; forbidden extension etc.
|
||||
|
||||
## EWAttachInfo
|
||||
GET/POST /ewws/EWAttachInfo. Example uses /.json
|
||||
URL: /ewws/EWAttachInfo/.json?$KB=..&$table=..&$lang=en&field=attached_file&$login=..&$password=..&id=1
|
||||
Returns: {"success":true,"message":"","result":[{"fileName":"..","size":22126,"filePosition":0}]}
|
||||
|
||||
## EWRemoveAttachment
|
||||
GET/POST /ewws/EWRemoveAttachment. Params: $KB,$table,$login,$password,id,field,filePosition
|
||||
Returns: the number of attached files remaining in the field.
|
||||
|
||||
## EWRetrieve
|
||||
GET/POST /ewws/EWRetrieve. Params: $KB,$table,$login,$password,id,field,filePosition
|
||||
Returns: file content in body. Content-Type = the type used when attaching.
|
||||
|
||||
## EWLock
|
||||
GET (status) / PUT (lock) / DELETE (unlock) /ewws/EWLock
|
||||
Params: $KB,$table,$lang,id (+ $login/$password OR OAuth/JWT). `force` (any value) on DELETE only.
|
||||
Success JSON: {"id":18,"table_id":2788,"locked_by":"admin","lock_status":"LOCKED","lock_expires_in_minutes":25}
|
||||
Unlock: {"id":18,"table_id":2788,"lock_status":"NO_LOCK"}
|
||||
lock_status values: NO_LOCK | LOCKED
|
||||
Failure JSON: {error, error_description}; codes BAD_REQUEST/UNAUTHORIZED/FORBIDDEN/CONFLICT/SERVER_ERROR
|
||||
|
||||
## EWGetChoiceLineId
|
||||
GET. Detail page URL: /ewws/EWGetChoiceLineId?$KB=..&$login=..&$password=..&$table=case&$lang=en&field=priority&value=High
|
||||
Returns: EWREST_choiceLineId = '1';
|
||||
No match -> HTTP 400.
|
||||
Errors: 400 "No choice line found for value <fieldName>"; 500 unexpected
|
||||
|
||||
## EWActionButton
|
||||
POST ONLY. URL: /ewws/async/EWActionButton?$KB=..&$login=..&$password=..&$lang=en&$table=case&name=ab_field&id=82
|
||||
Returns: EWREST_id='82'; EWREST_EWCALLBACK_ID='10100_1';
|
||||
Compatible with EWAsyncStatus.
|
||||
Errors: 400 "Wrong value for 'sequence' parameter"; 400 "No information for requested column <fieldName>"
|
||||
|
||||
## EWAsyncStatus
|
||||
GET or POST /ewws/EWAsyncStatus. Params: $KB,$login,$password,$lang,$table,callback_id
|
||||
Returns response CODE only (empty body): 200 completed, 201 queued, 202 in progress,
|
||||
501 failed, 523 no info for callback id.
|
||||
|
||||
## EWSavedSearch
|
||||
GET/POST /ewws/EWSavedSearch/.json (JSON is the ONLY output; /.json is MANDATORY)
|
||||
URL must include the logical table name. For POST the table param may be in the body.
|
||||
MUST be used with EWLogin or OAuth 2.0 authorization. Never asynchronous.
|
||||
Example: https://localhost:8080/ewws/EWSavedSearch/.json?$table=contract
|
||||
Returns: {"success":true,"message":"","result":[{"label":"...","name":"...","id":265185,"description":""}]}
|
||||
|
||||
## EWTable
|
||||
GET/POST /ewws/EWTable/.json. Requests x-www-form-urlencoded; returns application/json.
|
||||
MUST be used with EWLogin or OAuth 2.0 authorization. Never asynchronous.
|
||||
Params: $KB; optional `table` (plain, NOT $table) = logical name e.g. table=contacts;
|
||||
includelinkedinfo=true; skipColumnsInfo=true
|
||||
Example: https://localhost:8080/ewws/EWTable/.json?$KB=Demo&includelinkedinfo=true
|
||||
Returns: {"success":true,"message":"","result":{"tables":[{"label":"WMI Sample","logicalName":"wmi_sample",
|
||||
"fields":[{"columnLabel":"ID","columnName":"id","columnType":"BIGINT","columnTypeDomain":"swautoincrementfield"},
|
||||
{"columnLabel":"Updated By","columnName":"_1794_full_name","columnType":"VARCHAR",
|
||||
"columnTypeDomain":"swshorttextfield","isLinked":true,
|
||||
"linkedInfo":[{"linkedTable":"contacts","linkedColumn":"full_name","linkedDao":"_dao3_link0"}],
|
||||
"textFieldType":"text/plain"}]}]}}
|
||||
Returns for fields: name, label, type, and required flag. Linked fields return only source table info.
|
||||
Action buttons, related tables, embedded search results, embedded communications NOT supported.
|
||||
|
||||
## EWUpsert
|
||||
POST /ewws/EWUpsert. Content-Type application/x-www-form-urlencoded.
|
||||
Authentication: Required ($login and $password).
|
||||
Async support: Yes (EWAsyncStatus) via $async.
|
||||
System params: $KB*, $table*, $login*, $password*, $match*, $lang, $async
|
||||
Remaining params are record fields.
|
||||
Matching: no match -> create; one match -> update; multiple -> error.
|
||||
Example body: $KB=Demo / $table=contacts.employees / $login=admin / $password=qwerty /
|
||||
$lang=en / $match=_login / _login=jdoe / first_name=John / ...
|
||||
Returns: EWREST_id='353';
|
||||
Status: 200 updated, 201 created, 202 accepted(async), 400, 401, 403, 404,
|
||||
409 Conflict (multiple matching records), 500
|
||||
|
||||
## EWNLPSearch
|
||||
Content-Types: application/json, application/x-www-form-urlencoded
|
||||
Params: $KB*, $login*, $password*, $lang, field[]* , nlp_query*, page, limit
|
||||
Returns same format as REST-Search.
|
||||
Example return: {"success":true,"message":"","result":[{"company_name":"...","id":31,...}]}
|
||||
|
||||
## DATA ENCODING
|
||||
- Choice fields: text value as in GUI (`&country=USA`). For EWSelect ad hoc queries use
|
||||
GetChoiceLineId IDs instead.
|
||||
- Multi-choice: repeated key/value pairs.
|
||||
- Elapsed time: "days:hours:minutes:seconds" e.g. "0:1:35:15"
|
||||
- Linked fields: Query By Example with ':' qualifier (`&company_name=:Agiloft` or
|
||||
`&company_name=Company:Agiloft`). ':' and '?' in values escaped with backslash.
|
||||
SQL sub-select form uses '?' qualifier.
|
||||
- File/image fields: POST with enctype multipart/form-data; form field name = file field name;
|
||||
`fieldName$overwrite` to replace rather than add.
|
||||
@@ -40,6 +40,30 @@ Integrate with Agiloft contract lifecycle management to create, read, update, de
|
||||
|
||||
## Actions
|
||||
|
||||
### Agiloft Async Status
|
||||
|
||||
Check whether an asynchronous Agiloft call, such as a run action button, has completed.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
|
||||
| `knowledgeBase` | string | Yes | Knowledge base name |
|
||||
| `login` | string | Yes | Agiloft username |
|
||||
| `password` | string | Yes | Agiloft password |
|
||||
| `table` | string | Yes | Table the asynchronous call was made against |
|
||||
| `callbackId` | string | Yes | Callback ID returned by the asynchronous call, e.g. from Run Action Button |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `callbackId` | string | Callback ID that was checked |
|
||||
| `statusCode` | number | Raw status code Agiloft returned |
|
||||
| `status` | string | completed, queued, in_progress, failed, or unknown_callback |
|
||||
| `complete` | boolean | True when the operation has finished, whether it succeeded or failed |
|
||||
|
||||
### Agiloft Attach File
|
||||
|
||||
Attach a file to a field in an Agiloft record.
|
||||
@@ -55,8 +79,9 @@ Attach a file to a field in an Agiloft record.
|
||||
| `table` | string | Yes | Table name \(e.g., "contracts"\) |
|
||||
| `recordId` | string | Yes | ID of the record to attach the file to |
|
||||
| `fieldName` | string | Yes | Name of the attachment field |
|
||||
| `file` | file | No | File to attach |
|
||||
| `file` | file | Yes | File to attach |
|
||||
| `fileName` | string | No | Name to assign to the file \(defaults to original file name\) |
|
||||
| `overwrite` | boolean | No | Replace the contents of the field instead of adding another file to it |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -129,7 +154,8 @@ Delete a record from an Agiloft table.
|
||||
| `password` | string | Yes | Agiloft password |
|
||||
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
|
||||
| `recordId` | string | Yes | ID of the record to delete |
|
||||
| `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 |
|
||||
| `substituteIds` | string | No | Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER. |
|
||||
| `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, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER |
|
||||
|
||||
#### Output
|
||||
|
||||
@@ -160,6 +186,42 @@ Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHER
|
||||
| --------- | ---- | ----------- |
|
||||
| `choiceLineId` | number | Internal numeric line ID of the choice value |
|
||||
|
||||
### Agiloft List Tables
|
||||
|
||||
List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
|
||||
| `knowledgeBase` | string | Yes | Knowledge base name |
|
||||
| `login` | string | Yes | Agiloft username |
|
||||
| `password` | string | Yes | Agiloft password |
|
||||
| `table` | string | No | Logical name of a single table to describe \(e.g., "contacts"\). Leave empty to list every table in the knowledge base. |
|
||||
| `includeLinkedInfo` | boolean | No | Include the source table and column behind each linked field |
|
||||
| `skipColumnsInfo` | boolean | No | Return table names only, omitting field details, for a much smaller response |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `tables` | array | Tables in the knowledge base with their fields |
|
||||
| ↳ `label` | string | Display name of the table |
|
||||
| ↳ `logicalName` | string | Logical table name, as other Agiloft operations expect it |
|
||||
| ↳ `fields` | array | Fields on the table |
|
||||
| ↳ `columnName` | string | Logical field name |
|
||||
| ↳ `columnLabel` | string | Display label |
|
||||
| ↳ `columnType` | string | SQL column type |
|
||||
| ↳ `columnTypeDomain` | string | Agiloft field type |
|
||||
| ↳ `required` | boolean | Whether the field is mandatory |
|
||||
| ↳ `isLinked` | boolean | Whether the field is a linked field |
|
||||
| ↳ `linkedInfo` | array | Source table and column, when linked-field details were requested |
|
||||
| ↳ `linkedTable` | string | Source table |
|
||||
| ↳ `linkedColumn` | string | Source column |
|
||||
| ↳ `textFieldType` | string | Content type for text fields, e.g. text/plain |
|
||||
| `totalCount` | number | Number of tables returned |
|
||||
|
||||
### Agiloft Lock Record
|
||||
|
||||
Lock, unlock, or check the lock status of an Agiloft record.
|
||||
@@ -175,17 +237,43 @@ Lock, unlock, or check the lock status of an Agiloft record.
|
||||
| `table` | string | Yes | Table name \(e.g., "contracts"\) |
|
||||
| `recordId` | string | Yes | ID of the record to lock, unlock, or check |
|
||||
| `lockAction` | string | Yes | Action to perform: "lock", "unlock", or "check" |
|
||||
| `force` | boolean | No | Unlock only: release a lock held by another user. Requires membership in the admin group. |
|
||||
| `force` | boolean | No | Unlock only: release a lock held by another user. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | Record ID |
|
||||
| `tableId` | number | Numeric system identifier of the table holding the record |
|
||||
| `lockStatus` | string | Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free |
|
||||
| `lockedBy` | string | Username of the user who locked the record |
|
||||
| `lockExpiresInMinutes` | number | Minutes until the lock expires |
|
||||
|
||||
### Agiloft Natural Language Search
|
||||
|
||||
Search Agiloft records by describing what you want in plain language, such as "active NDAs submitted last month".
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
|
||||
| `knowledgeBase` | string | Yes | Knowledge base name |
|
||||
| `login` | string | Yes | Agiloft username |
|
||||
| `password` | string | Yes | Agiloft password |
|
||||
| `nlpQuery` | string | Yes | The request in plain language, e.g. "Show me open, high-priority contracts". Structured field filters are not accepted — use Search Records for those. |
|
||||
| `fields` | string | Yes | Comma-separated field names to return, e.g. "id, contract_title1, company_name" |
|
||||
| `page` | string | No | Page number, starting from 0 |
|
||||
| `limit` | string | No | Records per page |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `records` | json | Matching records with the requested field values |
|
||||
| `totalCount` | number | Number of records in this response |
|
||||
| `truncated` | boolean | True when more records were returned upstream than this call reports |
|
||||
|
||||
### Agiloft Read Record
|
||||
|
||||
Read a record by ID from an Agiloft table.
|
||||
@@ -280,29 +368,30 @@ Run an action button on an Agiloft record, such as an approval or send-for-signa
|
||||
| `recordId` | string | ID of the record the action button was run on |
|
||||
| `callbackId` | string | Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID |
|
||||
|
||||
### saved_search
|
||||
### Agiloft Saved Search
|
||||
|
||||
|
||||
### Agiloft Saved Search (retired)
|
||||
|
||||
Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.
|
||||
List the saved searches defined for an Agiloft table.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | No | Agiloft instance URL |
|
||||
| `knowledgeBase` | string | No | Knowledge base name |
|
||||
| `login` | string | No | Agiloft username |
|
||||
| `password` | string | No | Agiloft password |
|
||||
| `table` | string | No | Table name |
|
||||
| `output` | string | No | No description |
|
||||
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
|
||||
| `knowledgeBase` | string | Yes | Knowledge base name |
|
||||
| `login` | string | Yes | Agiloft username |
|
||||
| `password` | string | Yes | Agiloft password |
|
||||
| `table` | string | Yes | Logical table name to list saved searches for \(e.g., "contract"\) |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `searches` | array | Always empty; this operation is retired |
|
||||
| `searches` | array | Saved searches defined on the table |
|
||||
| ↳ `name` | string | Internal saved search name |
|
||||
| ↳ `label` | string | Display label, as used by Search Records |
|
||||
| ↳ `id` | number | Saved search identifier in the Agiloft database |
|
||||
| ↳ `description` | string | Saved search description |
|
||||
| `totalCount` | number | Number of saved searches returned |
|
||||
|
||||
### Agiloft Search Records
|
||||
|
||||
@@ -327,8 +416,9 @@ Search for records in an Agiloft table using a query.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `truncated` | boolean | True when more records were returned upstream than this call reports |
|
||||
| `records` | json | Array of matching records with their field values |
|
||||
| `totalCount` | number | Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set. |
|
||||
| `totalCount` | number | Number of records in this response. Not a total match count — compare with `truncated`. |
|
||||
| `page` | number | Page number that was requested \(0-based\) |
|
||||
| `limit` | number | Page size that was requested; 0 when no limit was sent and Agiloft chose one |
|
||||
|
||||
@@ -351,8 +441,9 @@ Select record IDs matching a SQL WHERE clause from an Agiloft table.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `truncated` | boolean | True when more IDs matched than this call reports |
|
||||
| `recordIds` | array | Array of record IDs matching the query |
|
||||
| `totalCount` | number | Total number of matching records |
|
||||
| `totalCount` | number | Number of IDs in this response — compare with `truncated` |
|
||||
|
||||
### Agiloft Update Record
|
||||
|
||||
@@ -377,4 +468,29 @@ Update an existing record in an Agiloft table.
|
||||
| `id` | string | ID of the updated record |
|
||||
| `fields` | json | Updated field values of the record |
|
||||
|
||||
### Agiloft Upsert Record
|
||||
|
||||
Create an Agiloft record, or update it when a record already matches the given fields.
|
||||
|
||||
#### Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `instanceUrl` | string | Yes | Agiloft instance URL \(e.g., https://mycompany.agiloft.com\) |
|
||||
| `knowledgeBase` | string | Yes | Knowledge base name |
|
||||
| `login` | string | Yes | Agiloft username |
|
||||
| `password` | string | Yes | Agiloft password |
|
||||
| `table` | string | Yes | Table name \(e.g., "contracts", "contacts.employees"\) |
|
||||
| `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. |
|
||||
| `async` | boolean | No | Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result. |
|
||||
| `data` | string | Yes | Field values as a JSON object. On create these populate the new record; on update only the supplied fields change. |
|
||||
|
||||
#### Output
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| --------- | ---- | ----------- |
|
||||
| `id` | string | ID of the created or updated record |
|
||||
| `created` | boolean | True when a new record was created, false when an existing one was updated |
|
||||
| `callbackId` | string | Returned for a queued upsert; pass it to Async Status to poll the result |
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftAsyncStatusContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftAsyncStatusResponse } from '@/tools/agiloft/types'
|
||||
import { AGILOFT_ASYNC_STATUS, buildAsyncStatusUrl } from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftAsyncStatusAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft async_status attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftAsyncStatusContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeEwRequest<AgiloftAsyncStatusResponse>(
|
||||
params,
|
||||
(base) => ({ url: buildAsyncStatusUrl(base, params), method: 'GET' }),
|
||||
async (response) => {
|
||||
/**
|
||||
* EWAsyncStatus communicates entirely through the status code and
|
||||
* returns an empty body, so the code is the result rather than an
|
||||
* error signal — 501 means the async operation failed, not that the
|
||||
* status check did.
|
||||
*/
|
||||
const known = AGILOFT_ASYNC_STATUS[response.status]
|
||||
|
||||
if (!known) {
|
||||
const body = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
callbackId: params.callbackId.trim(),
|
||||
statusCode: response.status,
|
||||
status: 'unrecognized',
|
||||
complete: false,
|
||||
},
|
||||
error: `Agiloft returned an unrecognized async status ${response.status}: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
callbackId: params.callbackId.trim(),
|
||||
statusCode: response.status,
|
||||
status: known.status,
|
||||
complete: known.complete,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error checking Agiloft async status:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
} from '@sim/testing'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** Obvious non-secret so credential scanners do not flag these fixtures. */
|
||||
const PLACEHOLDER_PASSWORD = 'not-a-real-password'
|
||||
|
||||
const { mockProcessFilesToUserFiles, mockDownloadFileFromStorage, mockAssertToolFileAccess } =
|
||||
vi.hoisted(() => ({
|
||||
mockProcessFilesToUserFiles: vi.fn(),
|
||||
@@ -35,7 +38,7 @@ const baseBody = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
password: PLACEHOLDER_PASSWORD,
|
||||
table: 'contracts',
|
||||
recordId: '42',
|
||||
fieldName: 'attachments',
|
||||
@@ -55,7 +58,7 @@ function mockSecureFetchResponse(body: {
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
text: async () => body.text ?? JSON.stringify(body.json ?? {}),
|
||||
json: async () => body.json ?? {},
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}
|
||||
@@ -109,11 +112,11 @@ describe('POST /api/tools/agiloft/attach', () => {
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins the resolved IP for login, attach, and logout (TOCTOU fix)', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-att' } }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ text: '1' }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
it('attaches with inline credentials on the pinned IP, with no login round trip', async () => {
|
||||
/** Documented response: EWREST_<fieldName>.length='1'; */
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({ text: "EWREST_attachments.length='1';" })
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
@@ -125,21 +128,17 @@ describe('POST /api/tools/agiloft/attach', () => {
|
||||
expect(data.output.fileName).toBe('file.txt')
|
||||
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
expect(calls).toHaveLength(3)
|
||||
for (const call of calls) {
|
||||
expect(call[1]).toBe(PINNED_IP)
|
||||
}
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0][1]).toBe(PINNED_IP)
|
||||
|
||||
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin')
|
||||
expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWAttach')
|
||||
expect(calls[1][2]).toMatchObject({
|
||||
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWAttach')
|
||||
expect(calls[0][0]).toContain('&$login=admin')
|
||||
expect(calls[0][2]).toMatchObject({
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: 'Bearer tok-att',
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
})
|
||||
expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout')
|
||||
// A bearer token on this surface is rejected; it must not be sent.
|
||||
expect(calls[0][2].headers.Authorization).toBeUndefined()
|
||||
|
||||
// DNS only resolved once.
|
||||
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -12,12 +12,9 @@ import { processFilesToUserFiles } from '@/lib/uploads/utils/file-utils'
|
||||
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
|
||||
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
|
||||
import { assertToolFileAccess } from '@/app/api/files/authorization'
|
||||
import { buildAttachFileUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
agiloftLoginPinned,
|
||||
agiloftLogoutPinned,
|
||||
resolveAgiloftInstance,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
import { parseEwRest } from '@/tools/agiloft/ewrest'
|
||||
import { buildAttachFileUrl, describeAgiloftError } from '@/tools/agiloft/utils'
|
||||
import { resolveAgiloftInstance } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -99,10 +96,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = await agiloftLoginPinned(data, resolvedIP)
|
||||
const base = data.instanceUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
{
|
||||
/**
|
||||
* EWAttach lives on the legacy surface, which authenticates from the
|
||||
* inline credentials in the URL and rejects a bearer token — so there is
|
||||
* no login/logout pair here.
|
||||
*/
|
||||
const url = buildAttachFileUrl(base, data, resolvedFileName)
|
||||
|
||||
logger.info(`[${requestId}] Uploading file to Agiloft: ${resolvedFileName}`)
|
||||
@@ -111,7 +112,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: new Uint8Array(fileBuffer),
|
||||
})
|
||||
@@ -122,19 +122,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
`[${requestId}] Agiloft attach error: ${agiloftResponse.status} - ${errorText}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` },
|
||||
{
|
||||
success: false,
|
||||
error: `Agiloft error ${agiloftResponse.status}: ${describeAgiloftError(errorText)}`,
|
||||
},
|
||||
{ status: agiloftResponse.status }
|
||||
)
|
||||
}
|
||||
|
||||
let totalAttachments = 0
|
||||
/**
|
||||
* EWAttach reports the new file count against the field it wrote to, as
|
||||
* EWREST_<fieldName>.length='1'; — the key is field-specific, so the
|
||||
* single returned assignment is read rather than a fixed key.
|
||||
*/
|
||||
const responseText = await agiloftResponse.text()
|
||||
try {
|
||||
const responseData = JSON.parse(responseText)
|
||||
const result = responseData.result ?? responseData
|
||||
totalAttachments = typeof result === 'number' ? result : (result.count ?? result.total ?? 1)
|
||||
} catch {
|
||||
totalAttachments = Number(responseText) || 1
|
||||
const assignments = parseEwRest(responseText)
|
||||
const countRaw =
|
||||
assignments.get(`${data.fieldName.trim()}.length`) ?? [...assignments.values()][0]
|
||||
const totalAttachments = Number(countRaw)
|
||||
|
||||
/**
|
||||
* A 200 with no parsable count is not a confirmed write, so it fails
|
||||
* closed rather than reporting success with zero attachments.
|
||||
*/
|
||||
if (!Number.isFinite(totalAttachments)) {
|
||||
logger.error(`[${requestId}] Agiloft attach returned an unrecognised body`, {
|
||||
body: responseText.slice(0, 200),
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Agiloft did not confirm the attachment: ${describeAgiloftError(responseText) || '(empty response)'}`,
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -150,8 +171,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
totalAttachments,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error attaching file to Agiloft:`, error)
|
||||
|
||||
@@ -7,8 +7,8 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftAttachmentInfoResponse } from '@/tools/agiloft/types'
|
||||
import { buildAttachmentInfoUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { buildAttachmentInfoUrl, describeAgiloftError } from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -51,7 +51,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftAttachmentInfoResponse>(
|
||||
const result = await executeEwRequest<AgiloftAttachmentInfoResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildAttachmentInfoUrl(base, params),
|
||||
@@ -63,7 +63,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return {
|
||||
success: false,
|
||||
output: { attachments: [], totalCount: 0 },
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(errorText)}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,33 +12,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
|
||||
|
||||
import { POST } from '@/app/api/tools/agiloft/create_record/route'
|
||||
import { POST as READ } from '@/app/api/tools/agiloft/read_record/route'
|
||||
import { POST as SEARCH } from '@/app/api/tools/agiloft/search_records/route'
|
||||
import { POST as SELECT } from '@/app/api/tools/agiloft/select_records/route'
|
||||
|
||||
/** Obvious non-secret so credential scanners do not flag these fixtures. */
|
||||
const PLACEHOLDER_PASSWORD = 'not-a-real-password'
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'Demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
table: 'contacts.employees',
|
||||
data: JSON.stringify({ first_name: 'John', last_name: 'Doe' }),
|
||||
knowledgeBase: 'Russell Investments',
|
||||
login: 'svc.user',
|
||||
password: PLACEHOLDER_PASSWORD,
|
||||
table: 'contract',
|
||||
}
|
||||
|
||||
function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: string }) {
|
||||
function res(body: { ok?: boolean; status?: number; json?: unknown; text?: string }) {
|
||||
const text = body.text ?? JSON.stringify(body.json ?? {})
|
||||
return {
|
||||
ok: body.ok ?? true,
|
||||
status: body.ok === false ? 400 : 200,
|
||||
status: body.status ?? 200,
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
json: async () => body.json ?? {},
|
||||
text: async () => text,
|
||||
json: async () => JSON.parse(text),
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}
|
||||
}
|
||||
|
||||
/** Login envelope Agiloft returns — note the trailing space on the scheme. */
|
||||
const LOGIN_OK = res({
|
||||
json: { access_token: 'tok-123', authentication_scheme: 'Bearer ' },
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hybridAuthMockFns.mockCheckInternalAuth.mockResolvedValue({
|
||||
@@ -53,124 +61,571 @@ beforeEach(() => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/tools/agiloft/create_record', () => {
|
||||
it("reads the record ID out of EWCreate's EWREST_id assignment", async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ text: "EWREST_id='353';" }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
function arrange(operationResponse: ReturnType<typeof res>) {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(LOGIN_OK)
|
||||
.mockResolvedValueOnce(operationResponse)
|
||||
.mockResolvedValueOnce(res({}))
|
||||
}
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
const data = (await response.json()) as {
|
||||
success: boolean
|
||||
output: { id: string | null }
|
||||
}
|
||||
describe('EWLogin', () => {
|
||||
it('sends $table and $lang alongside $KB in a form body, which the live server requires', async () => {
|
||||
arrange(res({ json: { success: true, result: { id: 6342 } } }))
|
||||
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.id).toBe('353')
|
||||
await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
|
||||
|
||||
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(operationCall[0]).toContain('/ewws/EWCreate?')
|
||||
expect(operationCall[0]).toContain('&first_name=John')
|
||||
expect(operationCall[2]).toMatchObject({ method: 'POST' })
|
||||
const [url, ip, init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0]
|
||||
expect(url).toBe('https://example.agiloft.com/ewws/EWLogin')
|
||||
expect(ip).toBe(PINNED_IP)
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.headers['Content-Type']).toBe('application/x-www-form-urlencoded')
|
||||
|
||||
const sent = new URLSearchParams(init.body as string)
|
||||
expect(sent.get('$KB')).toBe('Russell Investments')
|
||||
expect(sent.get('$table')).toBe('contract')
|
||||
expect(sent.get('$lang')).toBe('en')
|
||||
expect(sent.get('$login')).toBe('svc.user')
|
||||
expect(sent.get('$password')).toBe(PLACEHOLDER_PASSWORD)
|
||||
// Credentials must not leak into the URL.
|
||||
expect(url).not.toContain(PLACEHOLDER_PASSWORD)
|
||||
})
|
||||
|
||||
it('fails loudly when Agiloft answers 200 with something that is not an EWREST body', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-c' } }))
|
||||
.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({ text: 'Error executing query, please consult logs' })
|
||||
)
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
it('trims the trailing space Agiloft puts on authentication_scheme', async () => {
|
||||
arrange(res({ json: { success: true, result: { id: 6342 } } }))
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('did not return a record ID')
|
||||
const [, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(init.headers.Authorization).toBe('Bearer tok-123')
|
||||
})
|
||||
|
||||
it('rejects a data payload that is not a JSON object', async () => {
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, data: '["not", "an", "object"]' })
|
||||
it('surfaces the live "One has to specify" refusal instead of a generic failure', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: '<html><body>EWWrongDataException has occurred: One has to specify $table, $KB, $lang parameters</body></html>',
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('must be a JSON object')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
expect(data.error).toContain('One has to specify $table, $KB, $lang')
|
||||
})
|
||||
})
|
||||
|
||||
describe('empty EWREST bodies on search and select', () => {
|
||||
const listBase = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'Demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
table: 'helpdesk_case',
|
||||
}
|
||||
describe('alrest envelope handling', () => {
|
||||
it('targets /ewws/alrest/{KB} for record creation', async () => {
|
||||
arrange(res({ json: { success: true, result: { id: 6342, contract_title1: 'X' } } }))
|
||||
|
||||
function arrange(text: string) {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok' } }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ text }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
}
|
||||
|
||||
it('treats a plain-text refusal from EWSearch as a failure, not an empty result', async () => {
|
||||
arrange('Error executing query, please consult logs')
|
||||
|
||||
const response = await SEARCH(
|
||||
createMockRequest('POST', { ...listBase, query: "priority='High'" })
|
||||
const response = await POST(
|
||||
createMockRequest('POST', { ...baseBody, data: '{"contract_title1":"X"}' })
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; output: { id: string | null } }
|
||||
|
||||
const [url] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(url).toBe(
|
||||
'https://example.agiloft.com/ewws/alrest/Russell%20Investments/contract?lang=en'
|
||||
)
|
||||
expect(data.output.id).toBe('6342')
|
||||
})
|
||||
|
||||
it('treats HTTP 200 with success:false as a failure, not a successful create', async () => {
|
||||
arrange(
|
||||
res({
|
||||
json: { success: false, errors: [{ message: 'Field contract_title1 is required' }] },
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('did not return search results')
|
||||
expect(data.error).toContain('Field contract_title1 is required')
|
||||
})
|
||||
})
|
||||
|
||||
describe('field projection', () => {
|
||||
it('reads through search when fields are named, so a 184KB record is not pulled whole', async () => {
|
||||
arrange(res({ json: { success: true, result: [{ id: 6342, contract_title1: 'X' }] } }))
|
||||
|
||||
await READ(
|
||||
createMockRequest('POST', {
|
||||
...baseBody,
|
||||
recordId: '6342',
|
||||
fields: 'contract_title1, company_name',
|
||||
})
|
||||
)
|
||||
|
||||
const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(url).toContain('/contract/search?lang=en')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
field: ['id', 'contract_title1', 'company_name'],
|
||||
query: 'id=6342',
|
||||
})
|
||||
})
|
||||
|
||||
it('still reports a genuinely empty EWSearch result as a success', async () => {
|
||||
arrange("EWREST_id_length = '0';")
|
||||
it('fetches the record directly when no projection was asked for', async () => {
|
||||
arrange(res({ json: { success: true, result: { id: 6342 } } }))
|
||||
|
||||
await READ(createMockRequest('POST', { ...baseBody, recordId: '6342' }))
|
||||
|
||||
const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(url).toContain('/contract/6342?lang=en')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
})
|
||||
|
||||
describe('optional inputs arriving as null', () => {
|
||||
it('accepts a null page instead of rejecting the call before it is made', async () => {
|
||||
arrange(res({ json: { success: true, result: [] } }))
|
||||
|
||||
const response = await SEARCH(
|
||||
createMockRequest('POST', { ...listBase, query: "priority='High'" })
|
||||
createMockRequest('POST', {
|
||||
...baseBody,
|
||||
query: "status='Active'",
|
||||
page: null,
|
||||
limit: null,
|
||||
fields: null,
|
||||
search: null,
|
||||
})
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('omits unset optional fields from the search body rather than sending null', async () => {
|
||||
arrange(res({ json: { success: true, result: [] } }))
|
||||
|
||||
await SEARCH(createMockRequest('POST', { ...baseBody, query: "status='Active'", page: null }))
|
||||
|
||||
const [, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(JSON.parse(init.body as string)).toEqual({ query: "status='Active'" })
|
||||
})
|
||||
})
|
||||
|
||||
describe('search result ceiling', () => {
|
||||
it('caps the returned records, since alrest honouring limit is unverified', async () => {
|
||||
arrange(
|
||||
res({
|
||||
json: {
|
||||
success: true,
|
||||
result: Array.from({ length: 250 }, (_, i) => ({ id: i })),
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const response = await SEARCH(
|
||||
createMockRequest('POST', { ...baseBody, query: "status='Active'" })
|
||||
)
|
||||
const data = (await response.json()) as {
|
||||
success: boolean
|
||||
output: { records: unknown[]; totalCount: number }
|
||||
}
|
||||
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.records).toEqual([])
|
||||
expect(data.output.totalCount).toBe(0)
|
||||
expect(data.output.records).toHaveLength(200)
|
||||
expect(data.output.totalCount).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
describe('review round 1 fixes', () => {
|
||||
it('fails a create that comes back without a record ID', async () => {
|
||||
arrange(res({ json: { success: true, result: {} } }))
|
||||
|
||||
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('did not return an ID')
|
||||
})
|
||||
|
||||
it('treats a plain-text refusal from EWSelect as a failure, not an empty result', async () => {
|
||||
arrange('Error executing query, please consult logs')
|
||||
|
||||
const response = await SELECT(
|
||||
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
|
||||
it('refuses a non-numeric record ID on a projected read rather than interpolating it', async () => {
|
||||
const response = await READ(
|
||||
createMockRequest('POST', {
|
||||
...baseBody,
|
||||
recordId: "1' || priority='High",
|
||||
fields: 'contract_title1',
|
||||
})
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('did not return a result set')
|
||||
expect(data.error).toContain('must be numeric')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('still reports a genuinely empty EWSelect result as a success', async () => {
|
||||
arrange("EWREST_id_length = '0';")
|
||||
it('does not return an unrelated record when the search matches something else', async () => {
|
||||
arrange(res({ json: { success: true, result: [{ id: 99, contract_title1: 'Other' }] } }))
|
||||
|
||||
const response = await SELECT(
|
||||
createMockRequest('POST', { ...listBase, where: "summary like '%new%'" })
|
||||
const response = await READ(
|
||||
createMockRequest('POST', { ...baseBody, recordId: '6342', fields: 'contract_title1' })
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('no record for ID 6342')
|
||||
})
|
||||
})
|
||||
|
||||
describe('documented EWREST response keys', () => {
|
||||
it('reads the choice line ID from EWREST_choiceLineId', async () => {
|
||||
const { POST: CHOICE } = await import('@/app/api/tools/agiloft/get_choice_line_id/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ text: "EWREST_choiceLineId = '1';" })
|
||||
)
|
||||
|
||||
const response = await CHOICE(
|
||||
createMockRequest('POST', { ...baseBody, fieldName: 'priority', value: 'High' })
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; output: { choiceLineId: number } }
|
||||
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.choiceLineId).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EWTable', () => {
|
||||
it('is KB-scoped: no $table, mandatory .json, and no inline credentials', async () => {
|
||||
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
|
||||
arrange(res({ json: { success: true, result: { tables: [] } } }))
|
||||
|
||||
await LIST(
|
||||
createMockRequest('POST', {
|
||||
instanceUrl: baseBody.instanceUrl,
|
||||
knowledgeBase: baseBody.knowledgeBase,
|
||||
login: baseBody.login,
|
||||
password: baseBody.password,
|
||||
includeLinkedInfo: true,
|
||||
})
|
||||
)
|
||||
|
||||
const [url, , init] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(url).toContain('/ewws/EWTable/.json?')
|
||||
expect(url).toContain('&includelinkedinfo=true')
|
||||
expect(url).not.toContain('$table=')
|
||||
expect(url).not.toContain('$password')
|
||||
expect(init.headers.Authorization).toBe('Bearer tok-123')
|
||||
})
|
||||
|
||||
it('narrows to one table with the plain table parameter, not $table', async () => {
|
||||
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
|
||||
arrange(res({ json: { success: true, result: { tables: [] } } }))
|
||||
|
||||
await LIST(createMockRequest('POST', { ...baseBody, table: 'contacts' }))
|
||||
|
||||
const [url] = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(url).toContain('&table=contacts')
|
||||
expect(url).not.toContain('$table=')
|
||||
})
|
||||
|
||||
it('flattens tables and fields into the documented shape', async () => {
|
||||
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
|
||||
arrange(
|
||||
res({
|
||||
json: {
|
||||
success: true,
|
||||
result: {
|
||||
tables: [
|
||||
{
|
||||
label: 'WMI Sample',
|
||||
logicalName: 'wmi_sample',
|
||||
fields: [
|
||||
{
|
||||
columnLabel: 'ID',
|
||||
columnName: 'id',
|
||||
columnType: 'BIGINT',
|
||||
columnTypeDomain: 'swautoincrementfield',
|
||||
},
|
||||
{
|
||||
columnLabel: 'Updated By',
|
||||
columnName: '_1794_full_name',
|
||||
columnType: 'VARCHAR',
|
||||
columnTypeDomain: 'swshorttextfield',
|
||||
isLinked: true,
|
||||
linkedInfo: [
|
||||
{
|
||||
linkedTable: 'contacts',
|
||||
linkedColumn: 'full_name',
|
||||
linkedDao: '_dao3_link0',
|
||||
},
|
||||
],
|
||||
textFieldType: 'text/plain',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const response = await LIST(createMockRequest('POST', baseBody))
|
||||
const data = (await response.json()) as {
|
||||
output: { tables: Array<{ logicalName: string; fields: unknown[] }>; totalCount: number }
|
||||
}
|
||||
|
||||
expect(data.output.totalCount).toBe(1)
|
||||
expect(data.output.tables[0].logicalName).toBe('wmi_sample')
|
||||
expect(data.output.tables[0].fields).toEqual([
|
||||
{
|
||||
columnName: 'id',
|
||||
columnLabel: 'ID',
|
||||
columnType: 'BIGINT',
|
||||
columnTypeDomain: 'swautoincrementfield',
|
||||
required: false,
|
||||
isLinked: false,
|
||||
linkedInfo: [],
|
||||
textFieldType: null,
|
||||
},
|
||||
{
|
||||
columnName: '_1794_full_name',
|
||||
columnLabel: 'Updated By',
|
||||
columnType: 'VARCHAR',
|
||||
columnTypeDomain: 'swshorttextfield',
|
||||
required: false,
|
||||
isLinked: true,
|
||||
// includeLinkedInfo must actually surface the source table/column.
|
||||
linkedInfo: [{ linkedTable: 'contacts', linkedColumn: 'full_name' }],
|
||||
textFieldType: 'text/plain',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('EWUpsert', () => {
|
||||
const upsertBody = { ...baseBody, match: 'ext_id', data: '{"first_name":"John"}' }
|
||||
|
||||
it('sends every parameter in the form body, keeping credentials out of the URL', async () => {
|
||||
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ status: 201, text: "EWREST_id='353';" })
|
||||
)
|
||||
|
||||
await UPSERT(createMockRequest('POST', upsertBody))
|
||||
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
// Inline-credential auth: a single call, no login/logout pair.
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0][0]).toBe('https://example.agiloft.com/ewws/EWUpsert')
|
||||
expect(calls[0][0]).not.toContain('?')
|
||||
|
||||
const sent = new URLSearchParams(calls[0][2].body as string)
|
||||
expect(sent.get('$match')).toBe('ext_id')
|
||||
expect(sent.get('$table')).toBe('contract')
|
||||
expect(sent.get('$password')).toBe(PLACEHOLDER_PASSWORD)
|
||||
expect(sent.get('first_name')).toBe('John')
|
||||
})
|
||||
|
||||
it('reports 201 as a create and 200 as an update', async () => {
|
||||
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
|
||||
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ status: 201, text: "EWREST_id='353';" })
|
||||
)
|
||||
let data = (await (await UPSERT(createMockRequest('POST', upsertBody))).json()) as {
|
||||
output: { id: string; created: boolean }
|
||||
}
|
||||
expect(data.output).toEqual({ id: '353', created: true, callbackId: null })
|
||||
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ status: 200, text: "EWREST_id='353';" })
|
||||
)
|
||||
data = (await (await UPSERT(createMockRequest('POST', upsertBody))).json()) as {
|
||||
output: { id: string; created: boolean }
|
||||
}
|
||||
expect(data.output).toEqual({ id: '353', created: false, callbackId: null })
|
||||
})
|
||||
|
||||
it('surfaces a 409 as an ambiguous match rather than a generic failure', async () => {
|
||||
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ ok: false, status: 409, text: 'Multiple matching records found' })
|
||||
)
|
||||
|
||||
const response = await UPSERT(createMockRequest('POST', upsertBody))
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('more than one record matching "ext_id"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EWAsyncStatus', () => {
|
||||
const statusBody = { ...baseBody, callbackId: '10100_1' }
|
||||
|
||||
it('maps each documented status code to its meaning', async () => {
|
||||
const { POST: STATUS } = await import('@/app/api/tools/agiloft/async_status/route')
|
||||
|
||||
const cases: Array<[number, string, boolean]> = [
|
||||
[200, 'completed', true],
|
||||
[201, 'queued', false],
|
||||
[202, 'in_progress', false],
|
||||
[501, 'failed', true],
|
||||
[523, 'unknown_callback', true],
|
||||
]
|
||||
|
||||
for (const [status, expected, complete] of cases) {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ ok: status < 400, status, text: '' })
|
||||
)
|
||||
|
||||
const response = await STATUS(createMockRequest('POST', statusBody))
|
||||
const data = (await response.json()) as {
|
||||
success: boolean
|
||||
output: { status: string; complete: boolean; statusCode: number }
|
||||
}
|
||||
|
||||
// 501 is a failed *operation*, not a failed status check.
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output).toMatchObject({ status: expected, complete, statusCode: status })
|
||||
}
|
||||
})
|
||||
|
||||
it('authenticates inline and sends the documented callback_id param', async () => {
|
||||
const { POST: STATUS } = await import('@/app/api/tools/agiloft/async_status/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ status: 200, text: '' })
|
||||
)
|
||||
|
||||
await STATUS(createMockRequest('POST', statusBody))
|
||||
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0][0]).toContain('/ewws/EWAsyncStatus?')
|
||||
expect(calls[0][0]).toContain('&callback_id=10100_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('EWActionButton single-line response', () => {
|
||||
it('parses both assignments when Agiloft returns them on one line', async () => {
|
||||
const { POST: ACTION } = await import('@/app/api/tools/agiloft/run_action_button/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ text: "EWREST_id='82'; EWREST_EWCALLBACK_ID='10100_1';" })
|
||||
)
|
||||
|
||||
const response = await ACTION(
|
||||
createMockRequest('POST', { ...baseBody, recordId: '82', actionButtonField: 'ab_field' })
|
||||
)
|
||||
const data = (await response.json()) as {
|
||||
success: boolean
|
||||
output: { recordIds: string[]; totalCount: number }
|
||||
output: { recordId: string; callbackId: string | null }
|
||||
}
|
||||
|
||||
expect(data.output.recordId).toBe('82')
|
||||
expect(data.output.callbackId).toBe('10100_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('review round 2 fixes', () => {
|
||||
const upsertBody = { ...baseBody, match: 'ext_id', data: '{"a":"b"}' }
|
||||
|
||||
it('returns a callback ID for a queued upsert so Async Status can poll it', async () => {
|
||||
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ status: 202, text: "EWREST_EWCALLBACK_ID='10100_7';" })
|
||||
)
|
||||
|
||||
const response = await UPSERT(createMockRequest('POST', upsertBody))
|
||||
const data = (await response.json()) as {
|
||||
success: boolean
|
||||
output: { id: string | null; callbackId: string | null }
|
||||
}
|
||||
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.recordIds).toEqual([])
|
||||
expect(data.output.totalCount).toBe(0)
|
||||
expect(data.output.callbackId).toBe('10100_7')
|
||||
})
|
||||
|
||||
it('matches a projected read on the canonical numeric ID, not the string', async () => {
|
||||
arrange(res({ json: { success: true, result: [{ id: 123, contract_title1: 'X' }] } }))
|
||||
|
||||
// Agiloft echoes 123 for a request made as 00123.
|
||||
const response = await READ(
|
||||
createMockRequest('POST', { ...baseBody, recordId: '00123', fields: 'contract_title1' })
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; output: { id: string | null } }
|
||||
|
||||
expect(data.success).toBe(true)
|
||||
expect(data.output.id).toBe('123')
|
||||
})
|
||||
})
|
||||
|
||||
describe('review round 3 fixes', () => {
|
||||
it('reports an Agiloft refusal as a non-retryable failure, not a 500', async () => {
|
||||
arrange(
|
||||
res({ json: { success: false, errors: [{ message: 'Field contract_title1 is required' }] } })
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', { ...baseBody, data: '{"a":"b"}' }))
|
||||
|
||||
/**
|
||||
* The tool runner retries 500s, and retrying a refused create can duplicate
|
||||
* a record — a refusal must come back as a settled failure.
|
||||
*/
|
||||
expect(response.status).toBe(200)
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('Field contract_title1 is required')
|
||||
})
|
||||
|
||||
it('tells the user what to do when EWTable login needs a table', async () => {
|
||||
const { POST: LIST } = await import('@/app/api/tools/agiloft/list_tables/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({
|
||||
ok: false,
|
||||
status: 400,
|
||||
text: 'EWWrongDataException has occurred: One has to specify $table, $KB, $lang parameters',
|
||||
})
|
||||
)
|
||||
|
||||
const response = await LIST(
|
||||
createMockRequest('POST', {
|
||||
instanceUrl: baseBody.instanceUrl,
|
||||
knowledgeBase: baseBody.knowledgeBase,
|
||||
login: baseBody.login,
|
||||
password: baseBody.password,
|
||||
})
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('requires a table name to authenticate')
|
||||
})
|
||||
|
||||
it('encodes a multi-value upsert field as repeated pairs, not a joined string', async () => {
|
||||
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
res({ status: 200, text: "EWREST_id='353';" })
|
||||
)
|
||||
|
||||
await UPSERT(
|
||||
createMockRequest('POST', {
|
||||
...baseBody,
|
||||
match: 'ext_id',
|
||||
data: '{"contactMethod":["phone","email"]}',
|
||||
})
|
||||
)
|
||||
|
||||
const body = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[0][2].body as string
|
||||
expect(new URLSearchParams(body).getAll('contactMethod')).toEqual(['phone', 'email'])
|
||||
})
|
||||
|
||||
it('refuses an object field value rather than writing [object Object]', async () => {
|
||||
const { POST: UPSERT } = await import('@/app/api/tools/agiloft/upsert_record/route')
|
||||
|
||||
const response = await UPSERT(
|
||||
createMockRequest('POST', {
|
||||
...baseBody,
|
||||
match: 'ext_id',
|
||||
data: '{"nested":{"a":1}}',
|
||||
})
|
||||
)
|
||||
const data = (await response.json()) as { success: boolean; error?: string }
|
||||
|
||||
expect(data.success).toBe(false)
|
||||
expect(data.error).toContain('has no encoding for')
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,10 +6,13 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
|
||||
import { buildCreateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { alrestRecordCollectionUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
executeAlrestRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -65,52 +68,53 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
}
|
||||
|
||||
const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
|
||||
buildCreateRecordUrl(base, params, fieldValues)
|
||||
)
|
||||
if (oversized) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: oversized,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
|
||||
const result = await executeAlrestRequest<AgiloftRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildCreateRecordUrl(base, params, fieldValues),
|
||||
url: alrestRecordCollectionUrl(base, params.table),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(fieldValues),
|
||||
}),
|
||||
async (response) => {
|
||||
const body = await response.text()
|
||||
const record = await readAlrestJson<Record<string, unknown>>(response)
|
||||
const id = record?.id
|
||||
|
||||
if (!response.ok) {
|
||||
/**
|
||||
* A create that reports no ID did not create anything usable — callers
|
||||
* chain on this ID, so surface it as a failure rather than handing back
|
||||
* a successful-looking null.
|
||||
*/
|
||||
if (id == null) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
output: { id: null, fields: record ?? {} },
|
||||
error: 'Agiloft did not return an ID for the created record',
|
||||
}
|
||||
}
|
||||
|
||||
/** EWCreate answers with a single assignment: EWREST_id='353'; */
|
||||
const { id, fields } = toRecord(parseEwRest(body))
|
||||
|
||||
if (id === null) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields },
|
||||
error: `Agiloft did not return a record ID: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { id: String(id), fields: record ?? {} },
|
||||
}
|
||||
|
||||
return { success: true, output: { id, fields } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error creating Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
|
||||
@@ -6,10 +6,13 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { isEwRestBody } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftDeleteResponse } from '@/tools/agiloft/types'
|
||||
import { buildDeleteRecordUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { alrestDeleteRecordUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
executeAlrestRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -50,45 +53,43 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftDeleteResponse>(
|
||||
const result = await executeAlrestRequest<AgiloftDeleteResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildDeleteRecordUrl(base, params),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
url: alrestDeleteRecordUrl(
|
||||
base,
|
||||
params.table,
|
||||
params.recordId,
|
||||
params.deleteRule,
|
||||
params.substituteIds
|
||||
),
|
||||
method: 'DELETE',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
async (response) => {
|
||||
const body = (await response.text()).trim()
|
||||
const recordId = params.recordId.trim()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: recordId, deleted: false },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
}
|
||||
await readAlrestJson<unknown>(response)
|
||||
return {
|
||||
success: true,
|
||||
output: { id: params.recordId.trim(), deleted: true },
|
||||
}
|
||||
|
||||
/**
|
||||
* EWDelete returns nothing on success and an error message on failure,
|
||||
* so a non-empty body that is not an EWREST assignment is a refusal the
|
||||
* HTTP status did not surface — most often the delete rule rejecting
|
||||
* dependent records.
|
||||
*/
|
||||
if (body && !isEwRestBody(body)) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: recordId, deleted: false },
|
||||
error: `Agiloft refused the delete: ${body}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, output: { id: recordId, deleted: true } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: '', deleted: false },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error deleting Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
|
||||
@@ -8,8 +8,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftGetChoiceLineIdResponse } from '@/tools/agiloft/types'
|
||||
import { buildGetChoiceLineIdUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { buildGetChoiceLineIdUrl, describeAgiloftError } from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -52,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftGetChoiceLineIdResponse>(
|
||||
const result = await executeEwRequest<AgiloftGetChoiceLineIdResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildGetChoiceLineIdUrl(base, params),
|
||||
@@ -65,21 +65,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return {
|
||||
success: false,
|
||||
output: { choiceLineId: null },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(body)}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The docs state only that EWGetChoiceLineId "returns the ID of the
|
||||
* choice list element" without naming the assignment key, so the first
|
||||
* numeric EWREST_ value is taken rather than guessing a key name.
|
||||
*/
|
||||
let choiceLineId: number | null = null
|
||||
for (const value of parseEwRest(body).values()) {
|
||||
const parsedValue = Number(value)
|
||||
if (value.trim() !== '' && Number.isFinite(parsedValue)) {
|
||||
choiceLineId = parsedValue
|
||||
break
|
||||
/** Documented response: EWREST_choiceLineId = '1'; */
|
||||
const raw = parseEwRest(body).get('choiceLineId')
|
||||
const parsedId = Number(raw)
|
||||
const choiceLineId =
|
||||
raw !== undefined && raw.trim() !== '' && Number.isFinite(parsedId) ? parsedId : null
|
||||
|
||||
if (raw === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
output: { choiceLineId: null },
|
||||
error: `Agiloft did not return a choice line ID for "${params.value}" in field "${params.fieldName}": ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return {
|
||||
success: false,
|
||||
output: { choiceLineId: null },
|
||||
error: `No choice line ID found for value "${params.value}" in field "${params.fieldName}": ${body.trim() || '(empty response)'}`,
|
||||
error: `Agiloft returned a non-numeric choice line ID for "${params.value}" in field "${params.fieldName}": "${raw}"`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftListTablesContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type {
|
||||
AgiloftListTablesParams,
|
||||
AgiloftListTablesResponse,
|
||||
AgiloftTableField,
|
||||
} from '@/tools/agiloft/types'
|
||||
import { buildListTablesUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
executeAgiloftRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftListTablesAPI')
|
||||
|
||||
/** Shape of the `result` object EWTable returns. */
|
||||
interface EwTableResult {
|
||||
tables?: Array<{
|
||||
label?: string
|
||||
logicalName?: string
|
||||
fields?: Array<{
|
||||
columnName?: string
|
||||
columnLabel?: string
|
||||
columnType?: string
|
||||
columnTypeDomain?: string
|
||||
required?: boolean
|
||||
isLinked?: boolean
|
||||
linkedInfo?: Array<{ linkedTable?: string; linkedColumn?: string }>
|
||||
textFieldType?: string
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
let params: AgiloftListTablesParams | undefined
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft list_tables attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftListTablesContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const listParams = parsed.data.body
|
||||
params = listParams
|
||||
|
||||
/** EWTable must run under EWLogin or OAuth authorization. */
|
||||
const result = await executeAgiloftRequest<AgiloftListTablesResponse>(
|
||||
listParams,
|
||||
(base) => ({
|
||||
url: buildListTablesUrl(base, listParams),
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
async (response) => {
|
||||
const payload = await readAlrestJson<EwTableResult>(response)
|
||||
|
||||
const tables = (payload?.tables ?? []).map((table) => ({
|
||||
label: table.label ?? '',
|
||||
logicalName: table.logicalName ?? '',
|
||||
fields: (table.fields ?? []).map(
|
||||
(field): AgiloftTableField => ({
|
||||
columnName: field.columnName ?? '',
|
||||
columnLabel: field.columnLabel ?? '',
|
||||
columnType: field.columnType ?? '',
|
||||
columnTypeDomain: field.columnTypeDomain ?? '',
|
||||
required: field.required === true,
|
||||
isLinked: field.isLinked === true,
|
||||
/**
|
||||
* Only present when includeLinkedInfo was requested; dropping it
|
||||
* would make that option have no observable effect.
|
||||
*/
|
||||
linkedInfo: (field.linkedInfo ?? []).map((link) => ({
|
||||
linkedTable: link.linkedTable ?? '',
|
||||
linkedColumn: link.linkedColumn ?? '',
|
||||
})),
|
||||
textFieldType: field.textFieldType ?? null,
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
return { success: true, output: { tables, totalCount: tables.length } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
/**
|
||||
* EWTable is knowledge-base scoped, but some instances reject EWLogin
|
||||
* without a $table. When that happens there is nothing to fall back to, so
|
||||
* say what the caller can actually do about it.
|
||||
*/
|
||||
if (!params?.table && /\$table/.test(toError(error).message)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { tables: [], totalCount: 0 },
|
||||
error:
|
||||
'This Agiloft instance requires a table name to authenticate. Put any known table in the Table field — it also narrows the result to that table.',
|
||||
})
|
||||
}
|
||||
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { tables: [], totalCount: 0 },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error listing Agiloft tables:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -7,13 +7,24 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftLockResponse } from '@/tools/agiloft/types'
|
||||
import { buildLockRecordUrl, getLockHttpMethod } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { buildLockRecordUrl, describeAgiloftError, getLockHttpMethod } from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftLockRecordAPI')
|
||||
|
||||
/** Lock output for a call that never returned a lock state. */
|
||||
function emptyLock(recordId: string) {
|
||||
return {
|
||||
id: recordId.trim(),
|
||||
tableId: null,
|
||||
lockStatus: '',
|
||||
lockedBy: null,
|
||||
lockExpiresInMinutes: null,
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
@@ -49,7 +60,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftLockResponse>(
|
||||
const result = await executeEwRequest<AgiloftLockResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildLockRecordUrl(base, params),
|
||||
@@ -60,31 +71,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const errorText = await response.text()
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
id: params.recordId?.trim() ?? '',
|
||||
lockStatus: 'UNKNOWN',
|
||||
lockedBy: null,
|
||||
lockExpiresInMinutes: null,
|
||||
},
|
||||
error: `Agiloft error: ${response.status} - ${errorText}`,
|
||||
output: emptyLock(params.recordId),
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(errorText)}`,
|
||||
}
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>
|
||||
const result = (data.result ?? data) as Record<string, unknown>
|
||||
|
||||
/**
|
||||
* EWLock answers failures with `{error, error_description}` rather than
|
||||
* a lock body, and can do so on a 200 — so the absence of a
|
||||
* `lock_status` is the reliable signal, not the status code.
|
||||
*/
|
||||
if (typeof data.lock_status !== 'string') {
|
||||
const code = typeof data.error === 'string' ? data.error : 'UNKNOWN'
|
||||
const detail =
|
||||
typeof data.error_description === 'string'
|
||||
? data.error_description
|
||||
: JSON.stringify(data)
|
||||
return {
|
||||
success: false,
|
||||
output: emptyLock(params.recordId),
|
||||
error: `Agiloft lock error (${code}): ${detail}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: data.success !== false,
|
||||
success: true,
|
||||
output: {
|
||||
id: String(result.id ?? params.recordId?.trim() ?? ''),
|
||||
lockStatus:
|
||||
(result.lock_status as string) ?? (result.lockStatus as string) ?? 'UNKNOWN',
|
||||
lockedBy:
|
||||
(result.locked_by as string | null) ?? (result.lockedBy as string | null) ?? null,
|
||||
id: String(data.id ?? params.recordId.trim()),
|
||||
tableId: typeof data.table_id === 'number' ? data.table_id : null,
|
||||
lockStatus: data.lock_status,
|
||||
lockedBy: typeof data.locked_by === 'string' ? data.locked_by : null,
|
||||
lockExpiresInMinutes:
|
||||
(result.lock_expires_in_minutes as number | null) ??
|
||||
(result.lockExpiresInMinutes as number | null) ??
|
||||
null,
|
||||
typeof data.lock_expires_in_minutes === 'number'
|
||||
? data.lock_expires_in_minutes
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftNlpSearchContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftNlpSearchResponse } from '@/tools/agiloft/types'
|
||||
import {
|
||||
AGILOFT_LANG,
|
||||
AGILOFT_MAX_SEARCH_RECORDS,
|
||||
buildNlpSearchUrl,
|
||||
parseFieldList,
|
||||
} from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest, readAlrestJson } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftNlpSearchAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft nlp_search attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftNlpSearchContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeEwRequest<AgiloftNlpSearchResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildNlpSearchUrl(base),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
/**
|
||||
* EWNLPSearch accepts application/json, so credentials travel in the
|
||||
* body rather than the query string.
|
||||
*/
|
||||
body: JSON.stringify(
|
||||
filterUndefined({
|
||||
$KB: params.knowledgeBase,
|
||||
$login: params.login,
|
||||
$password: params.password,
|
||||
$lang: AGILOFT_LANG,
|
||||
field: parseFieldList(params.fields),
|
||||
nlp_query: params.nlpQuery.trim(),
|
||||
page: params.page ? Number(params.page) : undefined,
|
||||
limit: params.limit ? Number(params.limit) : undefined,
|
||||
})
|
||||
),
|
||||
}),
|
||||
async (response) => {
|
||||
const returned = (await readAlrestJson<Record<string, unknown>[]>(response)) ?? []
|
||||
const records = returned.slice(0, AGILOFT_MAX_SEARCH_RECORDS)
|
||||
|
||||
if (returned.length > records.length) {
|
||||
logger.warn(
|
||||
`[${requestId}] Agiloft NLP search returned ${returned.length} records; truncated to ${AGILOFT_MAX_SEARCH_RECORDS}`
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
records,
|
||||
totalCount: records.length,
|
||||
truncated: returned.length > records.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error running Agiloft NLP search:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -6,10 +6,14 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
|
||||
import { buildReadRecordUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { alrestRecordUrl, alrestSearchUrl, parseFieldList } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
type AgiloftRequestConfig,
|
||||
executeAlrestRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -51,57 +55,103 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const params = parsed.data.body
|
||||
|
||||
/**
|
||||
* EWRead has no documented field-selection parameter — it returns the whole
|
||||
* record the caller is permitted to see — so the requested subset is
|
||||
* applied here rather than sent upstream.
|
||||
* A full record is large — a contract runs to roughly 184 KB — so when the
|
||||
* caller names the fields they want, the read goes through the search
|
||||
* endpoint, which is the only route that accepts a `field` projection.
|
||||
* Without a field list there is nothing to project and the plain record
|
||||
* fetch is cheaper.
|
||||
*/
|
||||
const requestedFields = params.fields
|
||||
?.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
const requestedFields = parseFieldList(params.fields)
|
||||
const recordId = params.recordId.trim()
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
|
||||
/**
|
||||
* The projected read puts the ID inside a search predicate, so anything
|
||||
* other than a plain number could change which records the query selects.
|
||||
* Agiloft record IDs are integers, so reject everything else rather than
|
||||
* trying to escape it.
|
||||
*/
|
||||
if (requestedFields && !/^\d+$/.test(recordId)) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Record ID must be numeric to read specific fields, got "${recordId}"`,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeAlrestRequest<AgiloftRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildReadRecordUrl(base, params),
|
||||
method: 'GET',
|
||||
}),
|
||||
(base): AgiloftRequestConfig => {
|
||||
if (!requestedFields) {
|
||||
return {
|
||||
url: alrestRecordUrl(base, params.table, params.recordId),
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: alrestSearchUrl(base, params.table),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({
|
||||
field: requestedFields.includes('id') ? requestedFields : ['id', ...requestedFields],
|
||||
query: `id=${recordId}`,
|
||||
}),
|
||||
}
|
||||
},
|
||||
async (response) => {
|
||||
const body = await response.text()
|
||||
const payload = await readAlrestJson<Record<string, unknown> | Record<string, unknown>[]>(
|
||||
response
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
/**
|
||||
* Match on ID rather than taking the first row: a search answers with a
|
||||
* result set, and returning an unrelated record as a successful read
|
||||
* would be worse than failing.
|
||||
*/
|
||||
/**
|
||||
* Compare numerically: Agiloft echoes the canonical ID, so a request
|
||||
* for `00123` comes back as `123` and a string compare would discard
|
||||
* the very record that was asked for.
|
||||
*/
|
||||
const record = Array.isArray(payload)
|
||||
? payload.find((row) => {
|
||||
const rowId = String(row?.id ?? '')
|
||||
return /^\d+$/.test(rowId) && BigInt(rowId) === BigInt(recordId)
|
||||
})
|
||||
: payload
|
||||
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
error: `Agiloft returned no record for ID ${recordId}`,
|
||||
}
|
||||
}
|
||||
|
||||
const values = parseEwRest(body)
|
||||
if (values.size === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft returned no record data: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
const id = record.id
|
||||
return {
|
||||
success: true,
|
||||
output: { id: id == null ? null : String(id), fields: record },
|
||||
}
|
||||
|
||||
const { id, fields } = toRecord(values)
|
||||
|
||||
if (!requestedFields?.length) {
|
||||
return { success: true, output: { id, fields } }
|
||||
}
|
||||
|
||||
const selected: Record<string, string> = {}
|
||||
for (const field of requestedFields) {
|
||||
if (field in fields) selected[field] = fields[field]
|
||||
}
|
||||
return { success: true, output: { id, fields: selected } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error reading Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
|
||||
@@ -13,13 +13,16 @@ vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock
|
||||
|
||||
import { POST } from '@/app/api/tools/agiloft/remove_attachment/route'
|
||||
|
||||
/** Obvious non-secret so credential scanners do not flag these fixtures. */
|
||||
const PLACEHOLDER_PASSWORD = 'not-a-real-password'
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
password: PLACEHOLDER_PASSWORD,
|
||||
table: 'contracts',
|
||||
recordId: '42',
|
||||
fieldName: 'attachments',
|
||||
@@ -33,7 +36,7 @@ function mockSecureFetchResponse(body: { ok?: boolean; json?: unknown; text?: st
|
||||
statusText: '',
|
||||
headers: new Headers(),
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
text: async () => body.text ?? JSON.stringify(body.json ?? {}),
|
||||
json: async () => body.json ?? {},
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}
|
||||
@@ -55,10 +58,9 @@ beforeEach(() => {
|
||||
|
||||
describe('POST /api/tools/agiloft/remove_attachment', () => {
|
||||
it('calls EWRemoveAttachment with GET, the only verb it accepts besides POST', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-rm' } }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ text: '2' }))
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({ text: "EWREST_attachments.length='2';" })
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
@@ -69,8 +71,14 @@ describe('POST /api/tools/agiloft/remove_attachment', () => {
|
||||
}
|
||||
expect(data.output.remainingAttachments).toBe(2)
|
||||
|
||||
const operationCall = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[1]
|
||||
expect(operationCall[0]).toContain('/ewws/EWRemoveAttachment')
|
||||
expect(operationCall[2]).toMatchObject({ method: 'GET' })
|
||||
/**
|
||||
* One call, not three: the EW* surface rejects the bearer token, so it
|
||||
* authenticates from inline credentials and needs no login/logout pair.
|
||||
*/
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0][0]).toContain('/ewws/EWRemoveAttachment')
|
||||
expect(calls[0][0]).toContain('&$login=admin')
|
||||
expect(calls[0][2]).toMatchObject({ method: 'GET' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftRemoveAttachmentResponse } from '@/tools/agiloft/types'
|
||||
import { buildRemoveAttachmentUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { buildRemoveAttachmentUrl, describeAgiloftError } from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -51,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRemoveAttachmentResponse>(
|
||||
const result = await executeEwRequest<AgiloftRemoveAttachmentResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildRemoveAttachmentUrl(base, params),
|
||||
@@ -69,25 +70,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
fieldName: params.fieldName?.trim() ?? '',
|
||||
remainingAttachments: 0,
|
||||
},
|
||||
error: `Agiloft error: ${response.status} - ${text}`,
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(text)}`,
|
||||
}
|
||||
}
|
||||
|
||||
let remainingAttachments = 0
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
const result = data.result ?? data
|
||||
remainingAttachments =
|
||||
typeof result === 'number' ? result : (result.count ?? result.remaining ?? 0)
|
||||
} catch {
|
||||
remainingAttachments = Number(text) || 0
|
||||
/**
|
||||
* The URL carries no `.json` decorator, so the body is the EWREST
|
||||
* assignment form — `EWREST_<fieldName>.length='0';` — exactly as
|
||||
* EWAttach returns. Parsing it as JSON silently produced 0 on every
|
||||
* call regardless of what Agiloft reported.
|
||||
*/
|
||||
const fieldName = params.fieldName.trim()
|
||||
const assignments = parseEwRest(text)
|
||||
const countRaw = assignments.get(`${fieldName}.length`) ?? [...assignments.values()][0]
|
||||
const remainingAttachments = Number(countRaw)
|
||||
|
||||
if (!Number.isFinite(remainingAttachments)) {
|
||||
return {
|
||||
success: false,
|
||||
output: { recordId: params.recordId.trim(), fieldName, remainingAttachments: 0 },
|
||||
error: `Agiloft did not report the remaining attachment count: ${text.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
recordId: params.recordId?.trim() ?? '',
|
||||
fieldName: params.fieldName?.trim() ?? '',
|
||||
recordId: params.recordId.trim(),
|
||||
fieldName,
|
||||
remainingAttachments,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,13 +13,16 @@ vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock
|
||||
|
||||
import { POST } from '@/app/api/tools/agiloft/retrieve/route'
|
||||
|
||||
/** Obvious non-secret so credential scanners do not flag these fixtures. */
|
||||
const PLACEHOLDER_PASSWORD = 'not-a-real-password'
|
||||
|
||||
const PINNED_IP = '93.184.216.34'
|
||||
|
||||
const baseBody = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
password: PLACEHOLDER_PASSWORD,
|
||||
table: 'contracts',
|
||||
recordId: '42',
|
||||
fieldName: 'attachments',
|
||||
@@ -40,7 +43,7 @@ function mockSecureFetchResponse(body: {
|
||||
statusText: '',
|
||||
headers: body.headers ?? new Headers(),
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
text: async () => body.text ?? JSON.stringify(body.json ?? {}),
|
||||
json: async () => body.json ?? {},
|
||||
arrayBuffer: async () => body.arrayBuffer ?? new ArrayBuffer(0),
|
||||
}
|
||||
@@ -89,24 +92,21 @@ describe('POST /api/tools/agiloft/retrieve', () => {
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pins the resolved IP for login, retrieve, and logout (TOCTOU fix)', async () => {
|
||||
it('retrieves on the pinned IP in a single call (TOCTOU fix)', async () => {
|
||||
const fileBytes = Buffer.from('hello-attachment', 'utf-8')
|
||||
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-xyz' } }))
|
||||
.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({
|
||||
arrayBuffer: fileBytes.buffer.slice(
|
||||
fileBytes.byteOffset,
|
||||
fileBytes.byteOffset + fileBytes.byteLength
|
||||
) as ArrayBuffer,
|
||||
headers: new Headers({
|
||||
'content-type': 'text/plain',
|
||||
'content-disposition': 'attachment; filename="report.txt"',
|
||||
}),
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({
|
||||
arrayBuffer: fileBytes.buffer.slice(
|
||||
fileBytes.byteOffset,
|
||||
fileBytes.byteOffset + fileBytes.byteLength
|
||||
) as ArrayBuffer,
|
||||
headers: new Headers({
|
||||
'content-type': 'text/plain',
|
||||
'content-disposition': 'attachment; filename="report.txt"',
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(200)
|
||||
@@ -121,43 +121,53 @@ describe('POST /api/tools/agiloft/retrieve', () => {
|
||||
expect(Buffer.from(data.output.file.data, 'base64').toString('utf-8')).toBe('hello-attachment')
|
||||
|
||||
const calls = inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls
|
||||
expect(calls).toHaveLength(3)
|
||||
|
||||
// All three outbound calls must use the pre-resolved IP.
|
||||
for (const call of calls) {
|
||||
expect(call[1]).toBe(PINNED_IP)
|
||||
}
|
||||
/**
|
||||
* EWRetrieve authenticates from inline credentials, so there is no
|
||||
* login/logout pair — one call, on the pre-resolved IP.
|
||||
*/
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0][1]).toBe(PINNED_IP)
|
||||
|
||||
// Original hostname is preserved in the URL (so TLS SNI works).
|
||||
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWLogin')
|
||||
expect(calls[1][0]).toContain('https://example.agiloft.com/ewws/EWRetrieve')
|
||||
expect(calls[1][2]).toMatchObject({
|
||||
method: 'GET',
|
||||
headers: { Authorization: 'Bearer tok-xyz' },
|
||||
})
|
||||
expect(calls[2][0]).toContain('https://example.agiloft.com/ewws/EWLogout')
|
||||
expect(calls[0][0]).toContain('https://example.agiloft.com/ewws/EWRetrieve')
|
||||
expect(calls[0][0]).toContain('&filePosition=')
|
||||
expect(calls[0][2]).toMatchObject({ method: 'GET' })
|
||||
expect(calls[0][2].headers?.Authorization).toBeUndefined()
|
||||
// Attachment downloads are byte-capped rather than inheriting the global default.
|
||||
expect(calls[0][2].maxResponseBytes).toBe(25 * 1024 * 1024)
|
||||
|
||||
// DNS only resolved once — no second lookup that could rebind.
|
||||
expect(inputValidationMockFns.mockValidateUrlWithDNS).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('propagates upstream errors and still calls logout', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({ json: { access_token: 'tok-err' } }))
|
||||
.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({ ok: false, status: 404, text: 'Record not found' })
|
||||
)
|
||||
.mockResolvedValueOnce(mockSecureFetchResponse({}))
|
||||
it('propagates upstream errors', async () => {
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({ ok: false, status: 404, text: 'Record not found' })
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(404)
|
||||
const data = (await response.json()) as { success: false; error: string }
|
||||
expect(data.error).toContain('Record not found')
|
||||
})
|
||||
|
||||
// Logout still runs.
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP).toHaveBeenCalledTimes(3)
|
||||
expect(inputValidationMockFns.mockSecureFetchWithPinnedIP.mock.calls[2][0]).toContain(
|
||||
'/ewws/EWLogout'
|
||||
it('rejects an EWREST error document returned in place of file bytes', async () => {
|
||||
const errorBody = Buffer.from(
|
||||
"EWREST_error='Search in table contracts returns no records for key 1';"
|
||||
)
|
||||
inputValidationMockFns.mockSecureFetchWithPinnedIP.mockResolvedValueOnce(
|
||||
mockSecureFetchResponse({
|
||||
arrayBuffer: errorBody.buffer.slice(
|
||||
errorBody.byteOffset,
|
||||
errorBody.byteOffset + errorBody.byteLength
|
||||
) as ArrayBuffer,
|
||||
headers: new Headers({ 'content-type': 'text/html' }),
|
||||
})
|
||||
)
|
||||
|
||||
const response = await POST(createMockRequest('POST', baseBody))
|
||||
expect(response.status).toBe(502)
|
||||
const data = (await response.json()) as { success: false; error: string }
|
||||
expect(data.error).toContain('no records for key')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,12 +7,13 @@ import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { secureFetchWithPinnedIP } from '@/lib/core/security/input-validation.server'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { buildRetrieveAttachmentUrl } from '@/tools/agiloft/utils'
|
||||
import { isEwRestBody } from '@/tools/agiloft/ewrest'
|
||||
import {
|
||||
agiloftLoginPinned,
|
||||
agiloftLogoutPinned,
|
||||
resolveAgiloftInstance,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
AGILOFT_MAX_ATTACHMENT_BYTES,
|
||||
buildRetrieveAttachmentUrl,
|
||||
describeAgiloftError,
|
||||
} from '@/tools/agiloft/utils'
|
||||
import { resolveAgiloftInstance } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -63,10 +64,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 400 })
|
||||
}
|
||||
|
||||
const token = await agiloftLoginPinned(data, resolvedIP)
|
||||
const base = data.instanceUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
{
|
||||
/**
|
||||
* EWRetrieve is the documented endpoint for this: GET on the legacy
|
||||
* surface, authenticating from inline credentials, answering with the
|
||||
* raw file bytes. It needs no login/logout pair, which also avoids two
|
||||
* extra round trips against Agiloft's one-second per-call delay.
|
||||
*/
|
||||
const url = buildRetrieveAttachmentUrl(base, data)
|
||||
|
||||
logger.info(`[${requestId}] Downloading attachment from Agiloft`, {
|
||||
@@ -77,9 +83,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
|
||||
const agiloftResponse = await secureFetchWithPinnedIP(url, resolvedIP, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
maxResponseBytes: AGILOFT_MAX_ATTACHMENT_BYTES,
|
||||
})
|
||||
|
||||
if (!agiloftResponse.ok) {
|
||||
@@ -88,7 +92,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
`[${requestId}] Agiloft retrieve error: ${agiloftResponse.status} - ${errorText}`
|
||||
)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Agiloft error: ${agiloftResponse.status} - ${errorText}` },
|
||||
{
|
||||
success: false,
|
||||
error: `Agiloft error ${agiloftResponse.status}: ${describeAgiloftError(errorText)}`,
|
||||
},
|
||||
{ status: agiloftResponse.status }
|
||||
)
|
||||
}
|
||||
@@ -107,6 +114,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const arrayBuffer = await agiloftResponse.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
/**
|
||||
* A refusal comes back as an EWREST assignment or a plain-text error
|
||||
* rather than file bytes, so a body that parses as one is an error
|
||||
* document, not an attachment.
|
||||
*/
|
||||
if (isEwRestBody(fileBuffer.subarray(0, 512).toString('utf8'))) {
|
||||
const envelope = fileBuffer.toString('utf8').slice(0, 300)
|
||||
logger.error(`[${requestId}] Agiloft refused the attachment retrieve`, { envelope })
|
||||
return NextResponse.json(
|
||||
{ success: false, error: `Agiloft error: ${envelope}` },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
logger.info(`[${requestId}] Attachment downloaded successfully`, {
|
||||
name: fileName,
|
||||
size: fileBuffer.length,
|
||||
@@ -126,8 +147,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
},
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
await agiloftLogoutPinned(data.instanceUrl, data.knowledgeBase, token, resolvedIP)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error retrieving Agiloft attachment:`, error)
|
||||
|
||||
@@ -8,8 +8,8 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftRunActionButtonResponse } from '@/tools/agiloft/types'
|
||||
import { buildRunActionButtonUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { buildRunActionButtonUrl, describeAgiloftError } from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -52,7 +52,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRunActionButtonResponse>(
|
||||
const result = await executeEwRequest<AgiloftRunActionButtonResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildRunActionButtonUrl(base, params),
|
||||
@@ -68,7 +68,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
return {
|
||||
success: false,
|
||||
output: { recordId, callbackId: null },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(body)}`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftSavedSearchContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import type { AgiloftSavedSearchResponse } from '@/tools/agiloft/types'
|
||||
import { buildSavedSearchUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
executeAgiloftRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftSavedSearchAPI')
|
||||
|
||||
interface SavedSearchRow {
|
||||
name?: string
|
||||
label?: string
|
||||
id?: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft saved_search attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftSavedSearchContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
/**
|
||||
* EWSavedSearch must run under EWLogin or OAuth authorization, so this goes
|
||||
* through the token-bearing executor rather than the inline-credential one
|
||||
* the other legacy operations use.
|
||||
*/
|
||||
const result = await executeAgiloftRequest<AgiloftSavedSearchResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildSavedSearchUrl(base, params),
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
}),
|
||||
async (response) => {
|
||||
const rows = (await readAlrestJson<SavedSearchRow[]>(response)) ?? []
|
||||
|
||||
const searches = rows.map((row) => ({
|
||||
name: row.name ?? '',
|
||||
label: row.label ?? row.name ?? '',
|
||||
id: row.id ?? null,
|
||||
description: row.description ?? null,
|
||||
}))
|
||||
|
||||
return { success: true, output: { searches, totalCount: searches.length } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { searches: [], totalCount: 0 },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error listing Agiloft saved searches:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -1,15 +1,19 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftSearchRecordsContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest, toSearchRecords } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftSearchResponse } from '@/tools/agiloft/types'
|
||||
import { buildSearchRecordsUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { AGILOFT_MAX_SEARCH_RECORDS, alrestSearchUrl, parseFieldList } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
executeAlrestRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -50,52 +54,64 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftSearchResponse>(
|
||||
const page = params.page ? Number(params.page) : 0
|
||||
const limit = params.limit ? Number(params.limit) : 0
|
||||
|
||||
const result = await executeAlrestRequest<AgiloftSearchResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildSearchRecordsUrl(base, params),
|
||||
method: 'GET',
|
||||
url: alrestSearchUrl(base, params.table),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(
|
||||
filterUndefined({
|
||||
search: params.search?.trim() || undefined,
|
||||
query: params.query?.trim() || undefined,
|
||||
field: parseFieldList(params.fields),
|
||||
page: params.page ? page : undefined,
|
||||
limit: params.limit ? limit : undefined,
|
||||
})
|
||||
),
|
||||
}),
|
||||
async (response) => {
|
||||
const body = await response.text()
|
||||
const page = params.page ? Number(params.page) : 0
|
||||
const limit = params.limit ? Number(params.limit) : 0
|
||||
const returned = (await readAlrestJson<Record<string, unknown>[]>(response)) ?? []
|
||||
const records = returned.slice(0, AGILOFT_MAX_SEARCH_RECORDS)
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { records: [], totalCount: 0, page, limit },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
}
|
||||
if (returned.length > records.length) {
|
||||
logger.warn(
|
||||
`[${requestId}] Agiloft search returned ${returned.length} records; truncated to ${AGILOFT_MAX_SEARCH_RECORDS}`,
|
||||
{ table: params.table }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* EWSearch answers with EWREST_length plus one EWREST_<field>_<index>
|
||||
* assignment per field per row, and reports an empty result set as
|
||||
* EWREST_id_length = '0'. A body with no assignments at all is therefore
|
||||
* never a legitimate empty search — it is a refusal Agiloft returned
|
||||
* with HTTP 200, such as an invalid query or an unknown saved search.
|
||||
*/
|
||||
const values = parseEwRest(body)
|
||||
if (values.size === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { records: [], totalCount: 0, page, limit },
|
||||
error: `Agiloft did not return search results: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
const { records, count } = toSearchRecords(values)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: { records, totalCount: count, page, limit },
|
||||
output: {
|
||||
records,
|
||||
totalCount: records.length,
|
||||
page,
|
||||
limit,
|
||||
truncated: returned.length > records.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { records: [], totalCount: 0, page: 0, limit: 0, truncated: false },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error searching Agiloft records:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
|
||||
@@ -8,8 +8,13 @@ import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest, toRecordIds } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftSelectResponse } from '@/tools/agiloft/types'
|
||||
import { buildSelectRecordsUrl } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import {
|
||||
AGILOFT_MAX_SELECT_IDS,
|
||||
buildSelectRecordsUrl,
|
||||
describeAgiloftError,
|
||||
ewCredentialBody,
|
||||
} from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -50,11 +55,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftSelectResponse>(
|
||||
const result = await executeEwRequest<AgiloftSelectResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildSelectRecordsUrl(base, params),
|
||||
method: 'GET',
|
||||
/**
|
||||
* POST with the credentials in the body: EWSelect is one of the five
|
||||
* operations that support it, and it keeps the password out of the URL
|
||||
* and out of the server's access logs.
|
||||
*/
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: ewCredentialBody(params),
|
||||
}),
|
||||
async (response) => {
|
||||
const body = await response.text()
|
||||
@@ -62,8 +74,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { recordIds: [], totalCount: 0 },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
output: { recordIds: [], totalCount: 0, truncated: false },
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(body)}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,14 +90,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
if (values.size === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { recordIds: [], totalCount: 0 },
|
||||
output: { recordIds: [], totalCount: 0, truncated: false },
|
||||
error: `Agiloft did not return a result set: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
const { recordIds, count } = toRecordIds(values)
|
||||
const { recordIds } = toRecordIds(values)
|
||||
const capped = recordIds.slice(0, AGILOFT_MAX_SELECT_IDS)
|
||||
|
||||
return { success: true, output: { recordIds, totalCount: count } }
|
||||
if (recordIds.length > capped.length) {
|
||||
logger.warn(
|
||||
`[${requestId}] Agiloft select returned ${recordIds.length} IDs; truncated to ${AGILOFT_MAX_SELECT_IDS}`,
|
||||
{ table: params.table }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
recordIds: capped,
|
||||
totalCount: capped.length,
|
||||
truncated: recordIds.length > capped.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -6,10 +6,13 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest, toRecord } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftRecordResponse } from '@/tools/agiloft/types'
|
||||
import { buildUpdateRecordUrl, recordUrlLengthError } from '@/tools/agiloft/utils'
|
||||
import { executeAgiloftRequest } from '@/tools/agiloft/utils.server'
|
||||
import { alrestRecordUrl } from '@/tools/agiloft/utils'
|
||||
import {
|
||||
executeAlrestRequest,
|
||||
isAgiloftRefusal,
|
||||
readAlrestJson,
|
||||
} from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -65,52 +68,40 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
})
|
||||
}
|
||||
|
||||
const oversized = recordUrlLengthError(params.instanceUrl, (base) =>
|
||||
buildUpdateRecordUrl(base, params, fieldValues)
|
||||
)
|
||||
if (oversized) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: oversized,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeAgiloftRequest<AgiloftRecordResponse>(
|
||||
const result = await executeAlrestRequest<AgiloftRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildUpdateRecordUrl(base, params, fieldValues),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
url: alrestRecordUrl(base, params.table, params.recordId),
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(fieldValues),
|
||||
}),
|
||||
async (response) => {
|
||||
const body = await response.text()
|
||||
const record = await readAlrestJson<Record<string, unknown>>(response)
|
||||
const id = record?.id ?? params.recordId.trim()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft error: ${response.status} - ${body}`,
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
output: { id: String(id), fields: record ?? {} },
|
||||
}
|
||||
|
||||
/** EWUpdate echoes the whole record back as EWREST_ assignments. */
|
||||
const values = parseEwRest(body)
|
||||
if (values.size === 0) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: `Agiloft returned no record data: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
const { id, fields } = toRecord(values)
|
||||
return { success: true, output: { id, fields } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
/**
|
||||
* A refusal Agiloft already decided on is a final answer, not a transient
|
||||
* fault — returning 500 would make the tool runner retry it.
|
||||
*/
|
||||
if (isAgiloftRefusal(error)) {
|
||||
logger.warn(`[${requestId}] Agiloft refused the request`, { error: error.message })
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, fields: {} },
|
||||
error: error.message,
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(`[${requestId}] Error updating Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { agiloftUpsertRecordContract } from '@/lib/api/contracts/tools/agiloft'
|
||||
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { generateRequestId } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { parseEwRest } from '@/tools/agiloft/ewrest'
|
||||
import type { AgiloftUpsertRecordResponse } from '@/tools/agiloft/types'
|
||||
import {
|
||||
buildUpsertRecordBody,
|
||||
buildUpsertRecordUrl,
|
||||
describeAgiloftError,
|
||||
} from '@/tools/agiloft/utils'
|
||||
import { executeEwRequest } from '@/tools/agiloft/utils.server'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const logger = createLogger('AgiloftUpsertRecordAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
try {
|
||||
const authResult = await checkInternalAuth(request, { requireWorkflowId: false })
|
||||
|
||||
if (!authResult.success || !authResult.userId) {
|
||||
logger.warn(`[${requestId}] Unauthorized Agiloft upsert_record attempt: ${authResult.error}`)
|
||||
return NextResponse.json(
|
||||
{ success: false, error: authResult.error || 'Authentication required' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(
|
||||
agiloftUpsertRecordContract,
|
||||
request,
|
||||
{},
|
||||
{
|
||||
validationErrorResponse: (error) => {
|
||||
logger.warn(`[${requestId}] Invalid request data`, { errors: error.issues })
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: getValidationErrorMessage(error, 'Invalid request data'),
|
||||
details: error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
},
|
||||
}
|
||||
)
|
||||
if (!parsed.success) return parsed.response
|
||||
const params = parsed.data.body
|
||||
|
||||
let fieldValues: Record<string, unknown>
|
||||
try {
|
||||
const parsedData = JSON.parse(params.data)
|
||||
if (typeof parsedData !== 'object' || parsedData === null || Array.isArray(parsedData)) {
|
||||
throw new Error('not an object')
|
||||
}
|
||||
fieldValues = parsedData as Record<string, unknown>
|
||||
} catch {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, created: false, callbackId: null },
|
||||
error: 'The data parameter must be a JSON object of field names to values',
|
||||
})
|
||||
}
|
||||
|
||||
let body: string
|
||||
try {
|
||||
body = buildUpsertRecordBody(params, fieldValues)
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
success: false,
|
||||
output: { id: null, created: false, callbackId: null },
|
||||
error: toError(error).message,
|
||||
})
|
||||
}
|
||||
|
||||
const result = await executeEwRequest<AgiloftUpsertRecordResponse>(
|
||||
params,
|
||||
(base) => ({
|
||||
url: buildUpsertRecordUrl(base),
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
}),
|
||||
async (response) => {
|
||||
const body = await response.text()
|
||||
|
||||
/** 409 means the match criteria selected more than one record. */
|
||||
if (response.status === 409) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, created: false, callbackId: null },
|
||||
error: `Agiloft found more than one record matching "${params.match}", so it did not write: ${body.trim()}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, created: false, callbackId: null },
|
||||
error: `Agiloft error ${response.status}: ${describeAgiloftError(body)}`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 202 is the documented acknowledgement for an asynchronous upsert. It
|
||||
* carries no record ID, so it must not be read as a failed write.
|
||||
*/
|
||||
if (response.status === 202) {
|
||||
const callbackId = parseEwRest(body).get('EWCALLBACK_ID') ?? null
|
||||
|
||||
if (!callbackId) {
|
||||
logger.warn(
|
||||
`[${requestId}] Agiloft queued the upsert without a callback ID; the result cannot be polled`,
|
||||
{ body: body.slice(0, 200) }
|
||||
)
|
||||
}
|
||||
|
||||
return { success: true, output: { id: null, created: false, callbackId } }
|
||||
}
|
||||
|
||||
/** Documented response: EWREST_id='353'; with 201 on create, 200 on update. */
|
||||
const id = parseEwRest(body).get('id')
|
||||
|
||||
if (id === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
output: { id: null, created: false, callbackId: null },
|
||||
error: `Agiloft did not return a record ID: ${body.trim() || '(empty response)'}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, output: { id, created: response.status === 201, callbackId: null } }
|
||||
}
|
||||
)
|
||||
|
||||
return NextResponse.json(result)
|
||||
} catch (error) {
|
||||
logger.error(`[${requestId}] Error upserting Agiloft record:`, error)
|
||||
|
||||
return NextResponse.json({ success: false, error: toError(error).message }, { status: 500 })
|
||||
}
|
||||
})
|
||||
@@ -25,6 +25,10 @@ export const AgiloftBlock: BlockConfig = {
|
||||
defaultTitle: 'Agiloft',
|
||||
sentences: {
|
||||
byOperation: {
|
||||
list_tables: [
|
||||
{ text: 'List tables and fields in', field: 'knowledgeBase', core: true },
|
||||
{ text: ', for', field: 'table' },
|
||||
],
|
||||
create_record: [
|
||||
{ text: 'Create a record in', field: 'table', core: true },
|
||||
{ text: ', with', field: 'data' },
|
||||
@@ -39,6 +43,11 @@ export const AgiloftBlock: BlockConfig = {
|
||||
{ text: 'in', field: 'table' },
|
||||
{ text: ', setting', field: 'data' },
|
||||
],
|
||||
upsert_record: [
|
||||
{ text: 'Upsert a record in', field: 'table', core: true },
|
||||
{ text: ', matching on', field: 'match' },
|
||||
{ text: ', with', field: 'data' },
|
||||
],
|
||||
delete_record: [
|
||||
{ text: 'Delete record', field: 'recordId', core: true },
|
||||
{ text: 'from', field: 'table' },
|
||||
@@ -50,6 +59,11 @@ export const AgiloftBlock: BlockConfig = {
|
||||
{ text: ', using saved search', field: 'search' },
|
||||
{ text: ', up to', field: 'limit', after: 'records' },
|
||||
],
|
||||
nlp_search: [
|
||||
{ text: 'Search', field: 'knowledgeBase', core: true },
|
||||
{ text: 'for', field: 'nlpQuery' },
|
||||
{ text: ', returning', field: 'fields' },
|
||||
],
|
||||
select_records: [
|
||||
{ text: 'Select record IDs from', field: 'table', core: true },
|
||||
{ text: ', where', field: 'where' },
|
||||
@@ -91,6 +105,11 @@ export const AgiloftBlock: BlockConfig = {
|
||||
{ text: 'Run action button', field: 'actionButtonField', core: true },
|
||||
{ text: 'on record', field: 'recordId', core: true },
|
||||
],
|
||||
saved_search: [{ text: 'List saved searches on', field: 'table', core: true }],
|
||||
async_status: [
|
||||
{ text: 'Check async call', field: 'callbackId', core: true },
|
||||
{ text: 'on', field: 'table' },
|
||||
],
|
||||
get_choice_line_id: [
|
||||
{ text: 'Resolve the internal ID of choice', field: 'value', core: true },
|
||||
{ text: 'on field', field: 'fieldName' },
|
||||
@@ -106,11 +125,14 @@ export const AgiloftBlock: BlockConfig = {
|
||||
title: 'Operation',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'List Tables & Fields', id: 'list_tables' },
|
||||
{ label: 'Create Record', id: 'create_record' },
|
||||
{ label: 'Read Record', id: 'read_record' },
|
||||
{ label: 'Update Record', id: 'update_record' },
|
||||
{ label: 'Upsert Record', id: 'upsert_record' },
|
||||
{ label: 'Delete Record', id: 'delete_record' },
|
||||
{ label: 'Search Records', id: 'search_records' },
|
||||
{ label: 'Natural Language Search', id: 'nlp_search' },
|
||||
{ label: 'Select Records', id: 'select_records' },
|
||||
{ label: 'Attach File', id: 'attach_file' },
|
||||
{ label: 'Retrieve Attachment', id: 'retrieve_attachment' },
|
||||
@@ -118,6 +140,8 @@ export const AgiloftBlock: BlockConfig = {
|
||||
{ label: 'Attachment Info', id: 'attachment_info' },
|
||||
{ label: 'Lock Record', id: 'lock_record' },
|
||||
{ label: 'Run Action Button', id: 'run_action_button' },
|
||||
{ label: 'Async Status', id: 'async_status' },
|
||||
{ label: 'Saved Search', id: 'saved_search' },
|
||||
{ label: 'Get Choice Line ID', id: 'get_choice_line_id' },
|
||||
],
|
||||
value: () => 'search_records',
|
||||
@@ -156,7 +180,12 @@ export const AgiloftBlock: BlockConfig = {
|
||||
title: 'Table',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., contracts, contacts.employees',
|
||||
required: true,
|
||||
/**
|
||||
* Optional only for List Tables, where an empty value means "describe
|
||||
* every table" — that operation exists precisely for users who do not yet
|
||||
* know the logical names.
|
||||
*/
|
||||
required: { field: 'operation', value: ['list_tables', 'nlp_search'], not: true },
|
||||
},
|
||||
{
|
||||
id: 'recordId',
|
||||
@@ -197,8 +226,8 @@ export const AgiloftBlock: BlockConfig = {
|
||||
title: 'Record Data',
|
||||
type: 'long-input',
|
||||
placeholder: '{"field_name": "value", "another_field": "value"}',
|
||||
condition: { field: 'operation', value: ['create_record', 'update_record'] },
|
||||
required: { field: 'operation', value: ['create_record', 'update_record'] },
|
||||
condition: { field: 'operation', value: ['create_record', 'update_record', 'upsert_record'] },
|
||||
required: { field: 'operation', value: ['create_record', 'update_record', 'upsert_record'] },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
@@ -206,6 +235,38 @@ export const AgiloftBlock: BlockConfig = {
|
||||
generationType: 'json-object',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'match',
|
||||
title: 'Match Field',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., ext_id',
|
||||
condition: { field: 'operation', value: 'upsert_record' },
|
||||
required: { field: 'operation', value: 'upsert_record' },
|
||||
},
|
||||
{
|
||||
id: 'includeLinkedInfo',
|
||||
title: 'Include Linked Field Details',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list_tables' },
|
||||
},
|
||||
{
|
||||
id: 'skipColumnsInfo',
|
||||
title: 'Table Names Only',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'list_tables' },
|
||||
},
|
||||
{
|
||||
id: 'query',
|
||||
title: 'Search Query',
|
||||
@@ -215,9 +276,64 @@ export const AgiloftBlock: BlockConfig = {
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
"Generate an Agiloft EWSearch query. Use field_name='value' for exact match, field_name~='value' for contains, != for not equals, and <, <=, >, >= for comparisons. Combine conditions with && for and, || for or. Quote every value in single quotes, and quote field labels that contain spaces. Return ONLY the query string - no explanations, no extra text.",
|
||||
"Generate an Agiloft EWSearch query. Use field_name='value' for exact match, field_name~='value' for contains, != for not equals, and <, <=, >, >= for comparisons. Combine conditions with && for and, || for or. Quote every value in single quotes, quote field labels that contain spaces, and use null to match an empty field. Return ONLY the query string - no explanations, no extra text.",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'nlpQuery',
|
||||
title: 'Natural Language Query',
|
||||
type: 'long-input',
|
||||
placeholder: 'Active NDAs submitted last month',
|
||||
condition: { field: 'operation', value: 'nlp_search' },
|
||||
required: { field: 'operation', value: 'nlp_search' },
|
||||
mode: 'advanced',
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Rewrite the request as a plain-language Agiloft search, e.g. "Show me open, high-priority contracts". Do not use field names or operators. Return ONLY the sentence - no explanations, no extra text.',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'substituteIds',
|
||||
title: 'Substitute Record IDs',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated IDs that adopt the dependants',
|
||||
mode: 'advanced',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: 'delete_record',
|
||||
and: { field: 'deleteRule', value: 'REPLACE_WITH_ANOTHER' },
|
||||
},
|
||||
required: {
|
||||
field: 'operation',
|
||||
value: 'delete_record',
|
||||
and: { field: 'deleteRule', value: 'REPLACE_WITH_ANOTHER' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'overwrite',
|
||||
title: 'Replace Existing Files',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'attach_file' },
|
||||
},
|
||||
{
|
||||
id: 'async',
|
||||
title: 'Queue Instead of Waiting',
|
||||
type: 'dropdown',
|
||||
options: [
|
||||
{ label: 'No', id: 'false' },
|
||||
{ label: 'Yes', id: 'true' },
|
||||
],
|
||||
value: () => 'false',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'upsert_record' },
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
title: 'Saved Search',
|
||||
@@ -272,6 +388,14 @@ export const AgiloftBlock: BlockConfig = {
|
||||
condition: { field: 'operation', value: 'run_action_button' },
|
||||
required: { field: 'operation', value: 'run_action_button' },
|
||||
},
|
||||
{
|
||||
id: 'callbackId',
|
||||
title: 'Callback ID',
|
||||
type: 'short-input',
|
||||
placeholder: 'e.g., 10100_1',
|
||||
condition: { field: 'operation', value: 'async_status' },
|
||||
required: { field: 'operation', value: 'async_status' },
|
||||
},
|
||||
{
|
||||
id: 'value',
|
||||
title: 'Choice Value',
|
||||
@@ -346,6 +470,7 @@ export const AgiloftBlock: BlockConfig = {
|
||||
{ label: 'Delete, otherwise unlink', id: 'DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK' },
|
||||
{ label: 'Unlink dependents', id: 'APPLY_UNLINK' },
|
||||
{ label: 'Unlink, otherwise delete', id: 'UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE' },
|
||||
{ label: 'Reassign dependents to another record', id: 'REPLACE_WITH_ANOTHER' },
|
||||
],
|
||||
value: () => 'ERROR_IF_DEPENDANTS',
|
||||
condition: { field: 'operation', value: 'delete_record' },
|
||||
@@ -370,13 +495,19 @@ export const AgiloftBlock: BlockConfig = {
|
||||
id: 'fields',
|
||||
title: 'Fields',
|
||||
type: 'short-input',
|
||||
placeholder: 'Comma-separated field names to return',
|
||||
placeholder: 'e.g., id, contract_title1, company_name',
|
||||
/**
|
||||
* Advanced for the operations where it narrows an existing result, but
|
||||
* Natural Language Search cannot run without it — the endpoint requires
|
||||
* the field list.
|
||||
*/
|
||||
condition: { field: 'operation', value: ['read_record', 'search_records', 'nlp_search'] },
|
||||
required: { field: 'operation', value: 'nlp_search' },
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: ['read_record', 'search_records'] },
|
||||
wandConfig: {
|
||||
enabled: true,
|
||||
prompt:
|
||||
'Generate a comma-separated list of Agiloft table field names to include in the response. Return ONLY the comma-separated list - no explanations, no extra text.',
|
||||
'Generate a comma-separated list of Agiloft table field names to return. Keeping this short matters — an unfiltered contract record is roughly 184KB. Return ONLY the comma-separated list - no explanations, no extra text.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -384,6 +515,7 @@ export const AgiloftBlock: BlockConfig = {
|
||||
title: 'Page',
|
||||
type: 'short-input',
|
||||
placeholder: '0',
|
||||
description: 'Zero-based page number',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'search_records' },
|
||||
},
|
||||
@@ -392,6 +524,7 @@ export const AgiloftBlock: BlockConfig = {
|
||||
title: 'Limit',
|
||||
type: 'short-input',
|
||||
placeholder: '25',
|
||||
description: 'Records per page. 0 means every record, returned on page 0.',
|
||||
mode: 'advanced',
|
||||
condition: { field: 'operation', value: 'search_records' },
|
||||
},
|
||||
@@ -399,21 +532,24 @@ export const AgiloftBlock: BlockConfig = {
|
||||
|
||||
tools: {
|
||||
access: [
|
||||
'agiloft_async_status',
|
||||
'agiloft_attach_file',
|
||||
'agiloft_attachment_info',
|
||||
'agiloft_create_record',
|
||||
'agiloft_delete_record',
|
||||
'agiloft_get_choice_line_id',
|
||||
'agiloft_list_tables',
|
||||
'agiloft_lock_record',
|
||||
'agiloft_nlp_search',
|
||||
'agiloft_read_record',
|
||||
'agiloft_remove_attachment',
|
||||
'agiloft_retrieve_attachment',
|
||||
'agiloft_run_action_button',
|
||||
// Retired, but retained so blocks saved with operation='saved_search' still resolve.
|
||||
'agiloft_saved_search',
|
||||
'agiloft_search_records',
|
||||
'agiloft_select_records',
|
||||
'agiloft_update_record',
|
||||
'agiloft_upsert_record',
|
||||
],
|
||||
config: {
|
||||
tool: (params) => `agiloft_${params.operation}`,
|
||||
@@ -424,8 +560,16 @@ export const AgiloftBlock: BlockConfig = {
|
||||
if (normalizedFile) {
|
||||
params.file = normalizedFile
|
||||
}
|
||||
if (params.force !== undefined) {
|
||||
params.force = params.force === true || params.force === 'true'
|
||||
for (const flag of [
|
||||
'force',
|
||||
'includeLinkedInfo',
|
||||
'skipColumnsInfo',
|
||||
'overwrite',
|
||||
'async',
|
||||
] as const) {
|
||||
if (params[flag] !== undefined) {
|
||||
params[flag] = params[flag] === true || params[flag] === 'true'
|
||||
}
|
||||
}
|
||||
return params
|
||||
},
|
||||
@@ -441,11 +585,28 @@ export const AgiloftBlock: BlockConfig = {
|
||||
table: { type: 'string', description: 'Table name' },
|
||||
recordId: { type: 'string', description: 'Record ID' },
|
||||
data: { type: 'string', description: 'Record data as JSON' },
|
||||
match: { type: 'string', description: 'Field used to match an existing record on upsert' },
|
||||
includeLinkedInfo: {
|
||||
type: 'boolean',
|
||||
description: 'Include the source table and column behind each linked field',
|
||||
},
|
||||
skipColumnsInfo: { type: 'boolean', description: 'Return table names without field details' },
|
||||
query: { type: 'string', description: 'Ad hoc EWSearch query' },
|
||||
search: { type: 'string', description: 'Label of a saved search defined on the table' },
|
||||
nlpQuery: { type: 'string', description: 'Plain-language description of the records to find' },
|
||||
substituteIds: {
|
||||
type: 'string',
|
||||
description: 'Records that adopt dependants when the delete rule reassigns them',
|
||||
},
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
description: 'Replace the attachment field instead of appending',
|
||||
},
|
||||
async: { type: 'boolean', description: 'Queue the upsert rather than waiting for it' },
|
||||
where: { type: 'string', description: 'SQL WHERE clause for select' },
|
||||
fieldName: { type: 'string', description: 'Attachment field name or choice field name' },
|
||||
value: { type: 'string', description: 'Choice value to resolve to its line ID' },
|
||||
callbackId: { type: 'string', description: 'Callback ID of an asynchronous Agiloft call' },
|
||||
actionButtonField: {
|
||||
type: 'string',
|
||||
description: 'Logical name of the field holding the action button to run',
|
||||
@@ -454,12 +615,15 @@ export const AgiloftBlock: BlockConfig = {
|
||||
fileName: { type: 'string', description: 'Name for the attached file' },
|
||||
position: { type: 'string', description: 'Attachment position index' },
|
||||
lockAction: { type: 'string', description: 'Lock action (lock, unlock, check)' },
|
||||
force: { type: 'boolean', description: 'Force an unlock held by another user (admins only)' },
|
||||
force: { type: 'boolean', description: 'Force an unlock held by another user' },
|
||||
deleteRule: {
|
||||
type: 'string',
|
||||
description: 'How EWDelete treats records that depend on the one being deleted',
|
||||
},
|
||||
fields: { type: 'string', description: 'Fields to return' },
|
||||
fields: {
|
||||
type: 'string',
|
||||
description: 'Comma-separated fields to return; strongly recommended on large records',
|
||||
},
|
||||
page: { type: 'string', description: 'Page number' },
|
||||
limit: { type: 'string', description: 'Results per page' },
|
||||
},
|
||||
@@ -470,7 +634,14 @@ export const AgiloftBlock: BlockConfig = {
|
||||
description: 'Record ID',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['create_record', 'read_record', 'update_record', 'delete_record', 'lock_record'],
|
||||
value: [
|
||||
'create_record',
|
||||
'read_record',
|
||||
'update_record',
|
||||
'upsert_record',
|
||||
'delete_record',
|
||||
'lock_record',
|
||||
],
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
@@ -489,15 +660,22 @@ export const AgiloftBlock: BlockConfig = {
|
||||
records: {
|
||||
type: 'json',
|
||||
description: 'Array of matching records',
|
||||
condition: { field: 'operation', value: 'search_records' },
|
||||
condition: { field: 'operation', value: ['search_records', 'nlp_search'] },
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Number of matching results. For a paginated search this counts the current page only',
|
||||
'Number of items returned by this call, which may be capped — not a total match count',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['search_records', 'select_records', 'attachment_info'],
|
||||
value: [
|
||||
'search_records',
|
||||
'select_records',
|
||||
'attachment_info',
|
||||
'saved_search',
|
||||
'list_tables',
|
||||
'nlp_search',
|
||||
],
|
||||
},
|
||||
},
|
||||
page: {
|
||||
@@ -508,7 +686,12 @@ export const AgiloftBlock: BlockConfig = {
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Page size that was requested; 0 when Agiloft chose the page size',
|
||||
condition: { field: 'operation', value: 'search_records' },
|
||||
condition: { field: 'operation', value: ['search_records', 'nlp_search'] },
|
||||
},
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description: 'True when the result was capped and more records exist upstream',
|
||||
condition: { field: 'operation', value: ['search_records', 'select_records', 'nlp_search'] },
|
||||
},
|
||||
recordIds: {
|
||||
type: 'json',
|
||||
@@ -553,6 +736,11 @@ export const AgiloftBlock: BlockConfig = {
|
||||
description: 'Number of attachments remaining after removal',
|
||||
condition: { field: 'operation', value: 'remove_attachment' },
|
||||
},
|
||||
tableId: {
|
||||
type: 'number',
|
||||
description: 'Numeric system identifier of the table holding the locked record',
|
||||
condition: { field: 'operation', value: 'lock_record' },
|
||||
},
|
||||
lockStatus: {
|
||||
type: 'string',
|
||||
description: 'Lock status: LOCKED when the record is held, NO_LOCK when it is free',
|
||||
@@ -568,10 +756,43 @@ export const AgiloftBlock: BlockConfig = {
|
||||
description: 'Minutes until the lock expires',
|
||||
condition: { field: 'operation', value: 'lock_record' },
|
||||
},
|
||||
tables: {
|
||||
type: 'json',
|
||||
description: 'Tables and their fields (label, logicalName, fields[])',
|
||||
condition: { field: 'operation', value: 'list_tables' },
|
||||
},
|
||||
created: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the upsert created a new record rather than updating one',
|
||||
condition: { field: 'operation', value: 'upsert_record' },
|
||||
},
|
||||
searches: {
|
||||
type: 'json',
|
||||
description: 'Saved searches on the table (name, label, id, description)',
|
||||
condition: { field: 'operation', value: 'saved_search' },
|
||||
},
|
||||
callbackId: {
|
||||
type: 'string',
|
||||
description: 'Callback identifier Agiloft returns for the asynchronous action-button run',
|
||||
condition: { field: 'operation', value: 'run_action_button' },
|
||||
description: 'Callback identifier for an asynchronous call, to pass to Async Status',
|
||||
condition: {
|
||||
field: 'operation',
|
||||
value: ['run_action_button', 'async_status', 'upsert_record'],
|
||||
},
|
||||
},
|
||||
statusCode: {
|
||||
type: 'number',
|
||||
description: 'Raw status code Agiloft returned for the asynchronous call',
|
||||
condition: { field: 'operation', value: 'async_status' },
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'completed, queued, in_progress, failed, or unknown_callback',
|
||||
condition: { field: 'operation', value: 'async_status' },
|
||||
},
|
||||
complete: {
|
||||
type: 'boolean',
|
||||
description: 'True when the asynchronous operation has finished',
|
||||
condition: { field: 'operation', value: 'async_status' },
|
||||
},
|
||||
choiceLineId: {
|
||||
type: 'number',
|
||||
|
||||
@@ -7,6 +7,17 @@ import type {
|
||||
import { defineRouteContract } from '@/lib/api/contracts/types'
|
||||
import { FileInputSchema } from '@/lib/uploads/utils/file-schemas'
|
||||
|
||||
/**
|
||||
* Optional string inputs arrive as `null` when a block leaves the field blank,
|
||||
* which a bare `z.string().optional()` rejects with "expected string, received
|
||||
* null" before any request is made. Normalizing null to undefined keeps those
|
||||
* fields genuinely optional.
|
||||
*/
|
||||
const optionalText = z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined)
|
||||
|
||||
const agiloftFileOutputSchema = z.object({
|
||||
name: z.string(),
|
||||
mimeType: z.string(),
|
||||
@@ -51,7 +62,14 @@ export const agiloftAttachBodySchema = z.object({
|
||||
recordId: z.string().min(1, 'Record ID is required'),
|
||||
fieldName: z.string().min(1, 'Field name is required'),
|
||||
file: FileInputSchema.optional(),
|
||||
fileName: z.string().optional(),
|
||||
fileName: z
|
||||
.string()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
overwrite: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
})
|
||||
|
||||
export const agiloftRetrieveContract = defineRouteContract({
|
||||
@@ -90,10 +108,12 @@ export const agiloftCreateRecordBodySchema = z.object({
|
||||
|
||||
export const agiloftCreateRecordResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
id: z.string().nullable(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -111,15 +131,17 @@ export type AgiloftCreateRecordResponse = ContractJsonResponse<typeof agiloftCre
|
||||
export const agiloftReadRecordBodySchema = z.object({
|
||||
...agiloftBaseFields,
|
||||
recordId: z.string().min(1, 'Record ID is required'),
|
||||
fields: z.string().optional(),
|
||||
fields: optionalText,
|
||||
})
|
||||
|
||||
export const agiloftReadRecordResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
id: z.string().nullable(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -142,10 +164,12 @@ export const agiloftUpdateRecordBodySchema = z.object({
|
||||
|
||||
export const agiloftUpdateRecordResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
id: z.string().nullable(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
fields: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -160,11 +184,7 @@ export type AgiloftUpdateRecordBody = ContractBody<typeof agiloftUpdateRecordCon
|
||||
export type AgiloftUpdateRecordBodyInput = ContractBodyInput<typeof agiloftUpdateRecordContract>
|
||||
export type AgiloftUpdateRecordResponse = ContractJsonResponse<typeof agiloftUpdateRecordContract>
|
||||
|
||||
/**
|
||||
* EWDelete requires a delete rule naming how dependent records are handled.
|
||||
* `REPLACE_WITH_ANOTHER` is deliberately excluded — it additionally needs a
|
||||
* `subs` list of substitute record IDs, which this tool does not model.
|
||||
*/
|
||||
/** EWDelete requires a delete rule naming how dependent records are handled. */
|
||||
export const agiloftDeleteRecordBodySchema = z.object({
|
||||
...agiloftBaseFields,
|
||||
recordId: z.string().min(1, 'Record ID is required'),
|
||||
@@ -175,16 +195,21 @@ export const agiloftDeleteRecordBodySchema = z.object({
|
||||
'DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK',
|
||||
'APPLY_UNLINK',
|
||||
'UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE',
|
||||
'REPLACE_WITH_ANOTHER',
|
||||
])
|
||||
.default('ERROR_IF_DEPENDANTS'),
|
||||
.nullish()
|
||||
.transform((value) => value ?? 'ERROR_IF_DEPENDANTS'),
|
||||
substituteIds: optionalText,
|
||||
})
|
||||
|
||||
export const agiloftDeleteRecordResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
id: z.string(),
|
||||
deleted: z.boolean(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
deleted: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -205,17 +230,24 @@ export const agiloftLockRecordBodySchema = z.object({
|
||||
lockAction: z.enum(['lock', 'unlock', 'check'], {
|
||||
message: 'Lock action must be "lock", "unlock", or "check"',
|
||||
}),
|
||||
force: z.boolean().optional(),
|
||||
force: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
})
|
||||
|
||||
export const agiloftLockRecordResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
id: z.string(),
|
||||
lockStatus: z.string(),
|
||||
lockedBy: z.string().nullable(),
|
||||
lockExpiresInMinutes: z.number().nullable(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
tableId: z.number().nullable(),
|
||||
/** Documented values are LOCKED and NO_LOCK; empty on a failed call. */
|
||||
lockStatus: z.string(),
|
||||
lockedBy: z.string().nullable(),
|
||||
lockExpiresInMinutes: z.number().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -231,18 +263,18 @@ export type AgiloftLockRecordBodyInput = ContractBodyInput<typeof agiloftLockRec
|
||||
export type AgiloftLockRecordResponse = ContractJsonResponse<typeof agiloftLockRecordContract>
|
||||
|
||||
/**
|
||||
* EWSearch accepts a saved-search label (`search`), an ad hoc `query`, or both.
|
||||
* Search accepts a saved-search label (`search`), an ad hoc `query`, or both.
|
||||
* At least one must be present, otherwise the call degenerates into an
|
||||
* unbounded scan of the whole table.
|
||||
*/
|
||||
export const agiloftSearchRecordsBodySchema = z
|
||||
.object({
|
||||
...agiloftBaseFields,
|
||||
query: z.string().optional(),
|
||||
search: z.string().optional(),
|
||||
fields: z.string().optional(),
|
||||
page: z.string().optional(),
|
||||
limit: z.string().optional(),
|
||||
query: optionalText,
|
||||
search: optionalText,
|
||||
fields: optionalText,
|
||||
page: optionalText,
|
||||
limit: optionalText,
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (!value.query?.trim() && !value.search?.trim()) {
|
||||
@@ -256,12 +288,15 @@ export const agiloftSearchRecordsBodySchema = z
|
||||
|
||||
export const agiloftSearchRecordsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
records: z.array(z.record(z.string(), z.unknown())),
|
||||
totalCount: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
records: z.array(z.record(z.string(), z.unknown())),
|
||||
totalCount: z.number(),
|
||||
page: z.number(),
|
||||
limit: z.number(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -283,10 +318,13 @@ export const agiloftSelectRecordsBodySchema = z.object({
|
||||
|
||||
export const agiloftSelectRecordsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
recordIds: z.array(z.string()),
|
||||
totalCount: z.number(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
recordIds: z.array(z.string()),
|
||||
totalCount: z.number(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -309,16 +347,18 @@ export const agiloftAttachmentInfoBodySchema = z.object({
|
||||
|
||||
export const agiloftAttachmentInfoResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
position: z.number(),
|
||||
name: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
),
|
||||
totalCount: z.number(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
attachments: z.array(
|
||||
z.object({
|
||||
position: z.number(),
|
||||
name: z.string(),
|
||||
size: z.number(),
|
||||
})
|
||||
),
|
||||
totalCount: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -344,11 +384,13 @@ export const agiloftRemoveAttachmentBodySchema = z.object({
|
||||
|
||||
export const agiloftRemoveAttachmentResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
recordId: z.string(),
|
||||
fieldName: z.string(),
|
||||
remainingAttachments: z.number(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
recordId: z.string(),
|
||||
fieldName: z.string(),
|
||||
remainingAttachments: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -375,9 +417,11 @@ export const agiloftGetChoiceLineIdBodySchema = z.object({
|
||||
|
||||
export const agiloftGetChoiceLineIdResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
choiceLineId: z.number().nullable(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
choiceLineId: z.number().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -404,10 +448,12 @@ export const agiloftRunActionButtonBodySchema = z.object({
|
||||
|
||||
export const agiloftRunActionButtonResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z.object({
|
||||
recordId: z.string(),
|
||||
callbackId: z.string().nullable(),
|
||||
}),
|
||||
output: z
|
||||
.object({
|
||||
recordId: z.string(),
|
||||
callbackId: z.string().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -425,3 +471,186 @@ export type AgiloftRunActionButtonBodyInput = ContractBodyInput<
|
||||
export type AgiloftRunActionButtonResponse = ContractJsonResponse<
|
||||
typeof agiloftRunActionButtonContract
|
||||
>
|
||||
|
||||
export const agiloftSavedSearchBodySchema = z.object({ ...agiloftBaseFields })
|
||||
|
||||
export const agiloftSavedSearchResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z
|
||||
.object({
|
||||
searches: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
label: z.string(),
|
||||
id: z.number().nullable(),
|
||||
description: z.string().nullable(),
|
||||
})
|
||||
),
|
||||
totalCount: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const agiloftSavedSearchContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/tools/agiloft/saved_search',
|
||||
body: agiloftSavedSearchBodySchema,
|
||||
response: { mode: 'json', schema: agiloftSavedSearchResponseSchema },
|
||||
})
|
||||
|
||||
export type AgiloftSavedSearchBody = ContractBody<typeof agiloftSavedSearchContract>
|
||||
export type AgiloftSavedSearchBodyInput = ContractBodyInput<typeof agiloftSavedSearchContract>
|
||||
export type AgiloftSavedSearchResponse = ContractJsonResponse<typeof agiloftSavedSearchContract>
|
||||
|
||||
const agiloftCredentialFields = {
|
||||
instanceUrl: z.string().min(1, 'Instance URL is required'),
|
||||
knowledgeBase: z.string().min(1, 'Knowledge base is required'),
|
||||
login: z.string().min(1, 'Login is required'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
} as const
|
||||
|
||||
/** EWTable is KB-scoped; `table` narrows the result rather than selecting it. */
|
||||
export const agiloftListTablesBodySchema = z.object({
|
||||
...agiloftCredentialFields,
|
||||
table: optionalText,
|
||||
includeLinkedInfo: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
skipColumnsInfo: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
})
|
||||
|
||||
export const agiloftListTablesResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z
|
||||
.object({
|
||||
tables: z.array(
|
||||
z.object({
|
||||
label: z.string(),
|
||||
logicalName: z.string(),
|
||||
fields: z.array(
|
||||
z.object({
|
||||
columnName: z.string(),
|
||||
columnLabel: z.string(),
|
||||
columnType: z.string(),
|
||||
columnTypeDomain: z.string(),
|
||||
isLinked: z.boolean(),
|
||||
})
|
||||
),
|
||||
})
|
||||
),
|
||||
totalCount: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const agiloftListTablesContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/tools/agiloft/list_tables',
|
||||
body: agiloftListTablesBodySchema,
|
||||
response: { mode: 'json', schema: agiloftListTablesResponseSchema },
|
||||
})
|
||||
|
||||
export type AgiloftListTablesBody = ContractBody<typeof agiloftListTablesContract>
|
||||
export type AgiloftListTablesBodyInput = ContractBodyInput<typeof agiloftListTablesContract>
|
||||
export type AgiloftListTablesResponse = ContractJsonResponse<typeof agiloftListTablesContract>
|
||||
|
||||
export const agiloftUpsertRecordBodySchema = z.object({
|
||||
...agiloftBaseFields,
|
||||
match: z.string().min(1, 'A match field is required'),
|
||||
data: z.string().min(1, 'Data is required'),
|
||||
async: z
|
||||
.boolean()
|
||||
.nullish()
|
||||
.transform((value) => value ?? undefined),
|
||||
})
|
||||
|
||||
export const agiloftUpsertRecordResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z
|
||||
.object({
|
||||
id: z.string().nullable(),
|
||||
created: z.boolean(),
|
||||
/** Present only for a queued (202) upsert, to poll with Async Status. */
|
||||
callbackId: z.string().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const agiloftUpsertRecordContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/tools/agiloft/upsert_record',
|
||||
body: agiloftUpsertRecordBodySchema,
|
||||
response: { mode: 'json', schema: agiloftUpsertRecordResponseSchema },
|
||||
})
|
||||
|
||||
export type AgiloftUpsertRecordBody = ContractBody<typeof agiloftUpsertRecordContract>
|
||||
export type AgiloftUpsertRecordBodyInput = ContractBodyInput<typeof agiloftUpsertRecordContract>
|
||||
export type AgiloftUpsertRecordResponse = ContractJsonResponse<typeof agiloftUpsertRecordContract>
|
||||
|
||||
export const agiloftAsyncStatusBodySchema = z.object({
|
||||
...agiloftBaseFields,
|
||||
callbackId: z.string().min(1, 'Callback ID is required'),
|
||||
})
|
||||
|
||||
export const agiloftAsyncStatusResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z
|
||||
.object({
|
||||
callbackId: z.string(),
|
||||
statusCode: z.number(),
|
||||
status: z.string(),
|
||||
complete: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const agiloftAsyncStatusContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/tools/agiloft/async_status',
|
||||
body: agiloftAsyncStatusBodySchema,
|
||||
response: { mode: 'json', schema: agiloftAsyncStatusResponseSchema },
|
||||
})
|
||||
|
||||
export type AgiloftAsyncStatusBody = ContractBody<typeof agiloftAsyncStatusContract>
|
||||
export type AgiloftAsyncStatusBodyInput = ContractBodyInput<typeof agiloftAsyncStatusContract>
|
||||
export type AgiloftAsyncStatusResponse = ContractJsonResponse<typeof agiloftAsyncStatusContract>
|
||||
|
||||
/** EWNLPSearch is knowledge-base scoped and takes no table. */
|
||||
export const agiloftNlpSearchBodySchema = z.object({
|
||||
...agiloftCredentialFields,
|
||||
nlpQuery: z.string().min(1, 'A natural language query is required'),
|
||||
fields: z.string().min(1, 'At least one field to return is required'),
|
||||
page: optionalText,
|
||||
limit: optionalText,
|
||||
})
|
||||
|
||||
export const agiloftNlpSearchResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
output: z
|
||||
.object({
|
||||
records: z.array(z.record(z.string(), z.unknown())),
|
||||
totalCount: z.number(),
|
||||
truncated: z.boolean(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
|
||||
export const agiloftNlpSearchContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/tools/agiloft/nlp_search',
|
||||
body: agiloftNlpSearchBodySchema,
|
||||
response: { mode: 'json', schema: agiloftNlpSearchResponseSchema },
|
||||
})
|
||||
|
||||
export type AgiloftNlpSearchBody = ContractBody<typeof agiloftNlpSearchContract>
|
||||
export type AgiloftNlpSearchBodyInput = ContractBodyInput<typeof agiloftNlpSearchContract>
|
||||
export type AgiloftNlpSearchResponse = ContractJsonResponse<typeof agiloftNlpSearchContract>
|
||||
|
||||
@@ -280,6 +280,10 @@
|
||||
"iconName": "AgiloftIcon",
|
||||
"docsUrl": "https://docs.sim.ai/integrations/agiloft",
|
||||
"operations": [
|
||||
{
|
||||
"name": "List Tables & Fields",
|
||||
"description": "List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need."
|
||||
},
|
||||
{
|
||||
"name": "Create Record",
|
||||
"description": "Create a new record in an Agiloft table."
|
||||
@@ -292,6 +296,10 @@
|
||||
"name": "Update Record",
|
||||
"description": "Update an existing record in an Agiloft table."
|
||||
},
|
||||
{
|
||||
"name": "Upsert Record",
|
||||
"description": "Create an Agiloft record, or update it when a record already matches the given fields."
|
||||
},
|
||||
{
|
||||
"name": "Delete Record",
|
||||
"description": "Delete a record from an Agiloft table."
|
||||
@@ -300,6 +308,10 @@
|
||||
"name": "Search Records",
|
||||
"description": "Search for records in an Agiloft table using a query."
|
||||
},
|
||||
{
|
||||
"name": "Natural Language Search",
|
||||
"description": "Search Agiloft records by describing what you want in plain language, such as \"active NDAs submitted last month\"."
|
||||
},
|
||||
{
|
||||
"name": "Select Records",
|
||||
"description": "Select record IDs matching a SQL WHERE clause from an Agiloft table."
|
||||
@@ -328,12 +340,20 @@
|
||||
"name": "Run Action Button",
|
||||
"description": "Run an action button on an Agiloft record, such as an approval or send-for-signature step."
|
||||
},
|
||||
{
|
||||
"name": "Async Status",
|
||||
"description": "Check whether an asynchronous Agiloft call, such as a run action button, has completed."
|
||||
},
|
||||
{
|
||||
"name": "Saved Search",
|
||||
"description": "List the saved searches defined for an Agiloft table."
|
||||
},
|
||||
{
|
||||
"name": "Get Choice Line ID",
|
||||
"description": "Resolve the internal numeric ID of a choice-list value, for use in EWSelect WHERE clauses against choice fields."
|
||||
}
|
||||
],
|
||||
"operationCount": 13,
|
||||
"operationCount": 18,
|
||||
"triggers": [],
|
||||
"triggerCount": 0,
|
||||
"authType": "api-key",
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { AgiloftAsyncStatusParams, AgiloftAsyncStatusResponse } from '@/tools/agiloft/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const agiloftAsyncStatusTool: ToolConfig<
|
||||
AgiloftAsyncStatusParams,
|
||||
AgiloftAsyncStatusResponse
|
||||
> = {
|
||||
id: 'agiloft_async_status',
|
||||
name: 'Agiloft Async Status',
|
||||
description:
|
||||
'Check whether an asynchronous Agiloft call, such as a run action button, has completed.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
instanceUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
|
||||
},
|
||||
knowledgeBase: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Knowledge base name',
|
||||
},
|
||||
login: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft password',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Table the asynchronous call was made against',
|
||||
},
|
||||
callbackId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Callback ID returned by the asynchronous call, e.g. from Run Action Button',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => '/api/tools/agiloft/async_status',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
instanceUrl: params.instanceUrl,
|
||||
knowledgeBase: params.knowledgeBase,
|
||||
login: params.login,
|
||||
password: params.password,
|
||||
table: params.table,
|
||||
callbackId: params.callbackId,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: data.success ?? true,
|
||||
output: data.output,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
callbackId: { type: 'string', description: 'Callback ID that was checked' },
|
||||
statusCode: { type: 'number', description: 'Raw status code Agiloft returned' },
|
||||
status: {
|
||||
type: 'string',
|
||||
description: 'completed, queued, in_progress, failed, or unknown_callback',
|
||||
},
|
||||
complete: {
|
||||
type: 'boolean',
|
||||
description: 'True when the operation has finished, whether it succeeded or failed',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export const agiloftAttachFileTool: ToolConfig<AgiloftAttachFileParams, AgiloftA
|
||||
},
|
||||
file: {
|
||||
type: 'file',
|
||||
required: false,
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'File to attach',
|
||||
},
|
||||
@@ -63,6 +63,12 @@ export const agiloftAttachFileTool: ToolConfig<AgiloftAttachFileParams, AgiloftA
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Name to assign to the file (defaults to original file name)',
|
||||
},
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Replace the contents of the field instead of adding another file to it',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
@@ -81,6 +87,7 @@ export const agiloftAttachFileTool: ToolConfig<AgiloftAttachFileParams, AgiloftA
|
||||
fieldName: params.fieldName,
|
||||
file: params.file,
|
||||
fileName: params.fileName,
|
||||
overwrite: params.overwrite,
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@@ -45,12 +45,19 @@ export const agiloftDeleteRecordTool: ToolConfig<AgiloftDeleteRecordParams, Agil
|
||||
visibility: 'user-or-llm',
|
||||
description: 'ID of the record to delete',
|
||||
},
|
||||
substituteIds: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated IDs of records that adopt the dependants of the deleted record. Read only when the delete rule is REPLACE_WITH_ANOTHER.',
|
||||
},
|
||||
deleteRule: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'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',
|
||||
'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, UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE, or REPLACE_WITH_ANOTHER',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -66,6 +73,7 @@ export const agiloftDeleteRecordTool: ToolConfig<AgiloftDeleteRecordParams, Agil
|
||||
table: params.table,
|
||||
recordId: params.recordId,
|
||||
deleteRule: params.deleteRule,
|
||||
substituteIds: params.substituteIds,
|
||||
}),
|
||||
},
|
||||
|
||||
|
||||
@@ -6,13 +6,7 @@
|
||||
* assumed.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isEwRestBody,
|
||||
parseEwRest,
|
||||
toRecord,
|
||||
toRecordIds,
|
||||
toSearchRecords,
|
||||
} from '@/tools/agiloft/ewrest'
|
||||
import { parseEwRest, toRecordIds } from '@/tools/agiloft/ewrest'
|
||||
|
||||
/** REST - Create: "A result similar to the following will be returned". */
|
||||
const CREATE_BODY = "EWREST_id='353';"
|
||||
@@ -67,25 +61,6 @@ describe('parseEwRest', () => {
|
||||
expect(values.size).toBe(1)
|
||||
expect(values.get('id')).toBe('353')
|
||||
})
|
||||
|
||||
it('reports a plain-text error body as not being an EWREST response', () => {
|
||||
expect(isEwRestBody('Error executing query, please consult logs')).toBe(false)
|
||||
expect(isEwRestBody(CREATE_BODY)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRecord', () => {
|
||||
it('surfaces the id while keeping it among the fields', () => {
|
||||
const { id, fields } = toRecord(parseEwRest(READ_BODY))
|
||||
|
||||
expect(id).toBe('358')
|
||||
expect(fields.first_name).toBe('John')
|
||||
expect(fields.id).toBe('358')
|
||||
})
|
||||
|
||||
it('reports a missing id as null rather than inventing one', () => {
|
||||
expect(toRecord(parseEwRest("EWREST_summary='no id here';")).id).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('toRecordIds', () => {
|
||||
@@ -100,46 +75,3 @@ describe('toRecordIds', () => {
|
||||
expect(toRecordIds(parseEwRest(SELECT_EMPTY_BODY))).toEqual({ recordIds: [], count: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('toSearchRecords', () => {
|
||||
it('regroups the flat field_index assignments into one object per row', () => {
|
||||
const { records, count } = toSearchRecords(parseEwRest(SEARCH_BODY))
|
||||
|
||||
expect(count).toBe(4)
|
||||
expect(records).toHaveLength(4)
|
||||
expect(records[0]).toEqual({
|
||||
summary: 'Here is a new service request with some tasks',
|
||||
priority: 'High',
|
||||
})
|
||||
expect(records[3].summary).toBe('Need New Wireless Card for Laptop')
|
||||
})
|
||||
|
||||
it('keeps rows in index order regardless of assignment order', () => {
|
||||
const shuffled = `EWREST_length = '2';
|
||||
EWREST_name_1='second';
|
||||
EWREST_name_0='first';`
|
||||
|
||||
expect(toSearchRecords(parseEwRest(shuffled)).records.map((r) => r.name)).toEqual([
|
||||
'first',
|
||||
'second',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not mistake the length line for a record field', () => {
|
||||
const { records } = toSearchRecords(parseEwRest(SEARCH_BODY))
|
||||
|
||||
for (const record of records) {
|
||||
expect(record).not.toHaveProperty('length')
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves fields whose own names end in a number', () => {
|
||||
/** EWRead's documented sample includes _1576_company_name0. */
|
||||
const body = `EWREST_length = '1';
|
||||
EWREST__1576_company_name0_0='IBM';`
|
||||
|
||||
expect(toSearchRecords(parseEwRest(body)).records[0]).toEqual({
|
||||
_1576_company_name0: 'IBM',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Parser for Agiloft's `EWREST_` response format.
|
||||
*
|
||||
* Every `/ewws/EW*` operation answers with a body of JavaScript assignments
|
||||
* The legacy `/ewws/EW*` operations answer with a body of JavaScript assignments
|
||||
* rather than JSON — the interface was designed to be `eval`-ed by a browser
|
||||
* client. The documented shapes are:
|
||||
*
|
||||
@@ -18,7 +18,13 @@
|
||||
* accepted here.
|
||||
*/
|
||||
|
||||
const ASSIGNMENT = /^EWREST_(?<key>[^=\s]+)\s*=\s*'(?<value>[\s\S]*)';?$/
|
||||
/**
|
||||
* Matches one `EWREST_key='value';` assignment anywhere in the body. Most
|
||||
* responses put one per line, but EWActionButton documents both of its
|
||||
* assignments on a single line, so the scan is not line-anchored. The value is
|
||||
* non-greedy up to the closing `';` for the same reason.
|
||||
*/
|
||||
const ASSIGNMENT = /EWREST_(?<key>[^=\s']+)\s*=\s*'(?<value>[\s\S]*?)'\s*;/g
|
||||
|
||||
/**
|
||||
* Parses a body into its raw `EWREST_` key/value pairs, preserving document
|
||||
@@ -28,14 +34,9 @@ const ASSIGNMENT = /^EWREST_(?<key>[^=\s]+)\s*=\s*'(?<value>[\s\S]*)';?$/
|
||||
export function parseEwRest(body: string): Map<string, string> {
|
||||
const values = new Map<string, string>()
|
||||
|
||||
for (const rawLine of body.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
|
||||
const match = ASSIGNMENT.exec(line)
|
||||
const key = match?.groups?.key
|
||||
for (const match of body.matchAll(ASSIGNMENT)) {
|
||||
const key = match.groups?.key
|
||||
if (!key) continue
|
||||
|
||||
values.set(key, match.groups?.value ?? '')
|
||||
}
|
||||
|
||||
@@ -43,74 +44,19 @@ export function parseEwRest(body: string): Map<string, string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the body carries at least one `EWREST_` assignment. Agiloft answers
|
||||
* some failures with HTTP 200 and a plain-text error, so callers use this to
|
||||
* tell "no data" apart from "not an EWREST response at all".
|
||||
* Reads the `EWREST_id_length` / `EWREST_id_<n>` pairs EWSelect returns. A
|
||||
* result of zero records is reported as `EWREST_id_length = '0';` with no
|
||||
* indexed entries.
|
||||
*/
|
||||
/**
|
||||
* True when the body carries at least one `EWREST_` assignment. Used where a
|
||||
* binary payload is expected, since a refusal arrives as an assignment or
|
||||
* plain text instead of file bytes.
|
||||
*/
|
||||
export function isEwRestBody(body: string): boolean {
|
||||
return parseEwRest(body).size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a parsed record into its ID and the remaining field values. EWRead and
|
||||
* EWUpdate both return the whole record with `id` among the fields.
|
||||
*/
|
||||
export function toRecord(values: Map<string, string>): {
|
||||
id: string | null
|
||||
fields: Record<string, string>
|
||||
} {
|
||||
const fields: Record<string, string> = {}
|
||||
for (const [key, value] of values) {
|
||||
fields[key] = value
|
||||
}
|
||||
return { id: fields.id ?? null, fields }
|
||||
}
|
||||
|
||||
/**
|
||||
* Regroups the flat `EWREST_<field>_<index>` assignments EWSearch returns into
|
||||
* one object per record. `EWREST_length` gives the row count for the current
|
||||
* page; when it is absent the highest observed index is used instead so a
|
||||
* partial body still yields the rows it did contain.
|
||||
*/
|
||||
export function toSearchRecords(values: Map<string, string>): {
|
||||
records: Record<string, string>[]
|
||||
count: number
|
||||
} {
|
||||
const INDEXED = /^(?<field>.+)_(?<index>\d+)$/
|
||||
const byIndex = new Map<number, Record<string, string>>()
|
||||
|
||||
for (const [key, value] of values) {
|
||||
if (key === 'length' || key === 'id_length') continue
|
||||
|
||||
const match = INDEXED.exec(key)
|
||||
const field = match?.groups?.field
|
||||
if (!field) continue
|
||||
|
||||
const index = Number(match.groups?.index)
|
||||
let record = byIndex.get(index)
|
||||
if (!record) {
|
||||
record = {}
|
||||
byIndex.set(index, record)
|
||||
}
|
||||
record[field] = value
|
||||
}
|
||||
|
||||
const declared = Number(values.get('length') ?? values.get('id_length'))
|
||||
const count = Number.isFinite(declared) ? declared : byIndex.size
|
||||
|
||||
const records: Record<string, string>[] = []
|
||||
for (const index of [...byIndex.keys()].sort((a, b) => a - b)) {
|
||||
records.push(byIndex.get(index) as Record<string, string>)
|
||||
}
|
||||
|
||||
return { records, count }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `EWREST_id_length` / `EWREST_id_<n>` pairs EWSelect returns. A
|
||||
* result of zero records is reported as `EWREST_id_length = '0';` with no
|
||||
* indexed entries.
|
||||
*/
|
||||
export function toRecordIds(values: Map<string, string>): {
|
||||
recordIds: string[]
|
||||
count: number
|
||||
@@ -122,9 +68,11 @@ export function toRecordIds(values: Map<string, string>): {
|
||||
recordIds.push(id)
|
||||
}
|
||||
|
||||
const declared = Number(values.get('id_length'))
|
||||
return {
|
||||
recordIds,
|
||||
count: Number.isFinite(declared) ? declared : recordIds.length,
|
||||
}
|
||||
/**
|
||||
* Report what was actually parsed rather than the declared length. A
|
||||
* declared count that disagrees with the rows present means the body was
|
||||
* truncated, and returning the larger number would hide that from callers
|
||||
* who compare `totalCount` against `recordIds.length`.
|
||||
*/
|
||||
return { recordIds, count: recordIds.length }
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
export { agiloftAsyncStatusTool } from '@/tools/agiloft/async_status'
|
||||
export { agiloftAttachFileTool } from '@/tools/agiloft/attach_file'
|
||||
export { agiloftAttachmentInfoTool } from '@/tools/agiloft/attachment_info'
|
||||
export { agiloftCreateRecordTool } from '@/tools/agiloft/create_record'
|
||||
export { agiloftDeleteRecordTool } from '@/tools/agiloft/delete_record'
|
||||
export { agiloftGetChoiceLineIdTool } from '@/tools/agiloft/get_choice_line_id'
|
||||
export { agiloftListTablesTool } from '@/tools/agiloft/list_tables'
|
||||
export { agiloftLockRecordTool } from '@/tools/agiloft/lock_record'
|
||||
export { agiloftNlpSearchTool } from '@/tools/agiloft/nlp_search'
|
||||
export { agiloftReadRecordTool } from '@/tools/agiloft/read_record'
|
||||
export { agiloftRemoveAttachmentTool } from '@/tools/agiloft/remove_attachment'
|
||||
export { agiloftRetrieveAttachmentTool } from '@/tools/agiloft/retrieve_attachment'
|
||||
@@ -13,3 +16,4 @@ export { agiloftSearchRecordsTool } from '@/tools/agiloft/search_records'
|
||||
export { agiloftSelectRecordsTool } from '@/tools/agiloft/select_records'
|
||||
export * from '@/tools/agiloft/types'
|
||||
export { agiloftUpdateRecordTool } from '@/tools/agiloft/update_record'
|
||||
export { agiloftUpsertRecordTool } from '@/tools/agiloft/upsert_record'
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { AgiloftListTablesParams, AgiloftListTablesResponse } from '@/tools/agiloft/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const agiloftListTablesTool: ToolConfig<AgiloftListTablesParams, AgiloftListTablesResponse> =
|
||||
{
|
||||
id: 'agiloft_list_tables',
|
||||
name: 'Agiloft List Tables',
|
||||
description:
|
||||
'List the tables and fields in an Agiloft knowledge base, to discover the logical names other operations need.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
instanceUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
|
||||
},
|
||||
knowledgeBase: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Knowledge base name',
|
||||
},
|
||||
login: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft password',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Logical name of a single table to describe (e.g., "contacts"). Leave empty to list every table in the knowledge base.',
|
||||
},
|
||||
includeLinkedInfo: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Include the source table and column behind each linked field',
|
||||
},
|
||||
skipColumnsInfo: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Return table names only, omitting field details, for a much smaller response',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => '/api/tools/agiloft/list_tables',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
instanceUrl: params.instanceUrl,
|
||||
knowledgeBase: params.knowledgeBase,
|
||||
login: params.login,
|
||||
password: params.password,
|
||||
table: params.table,
|
||||
includeLinkedInfo: params.includeLinkedInfo,
|
||||
skipColumnsInfo: params.skipColumnsInfo,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: data.success ?? true,
|
||||
output: data.output,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
tables: {
|
||||
type: 'array',
|
||||
description: 'Tables in the knowledge base with their fields',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', description: 'Display name of the table' },
|
||||
logicalName: {
|
||||
type: 'string',
|
||||
description: 'Logical table name, as other Agiloft operations expect it',
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
description: 'Fields on the table',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
columnName: { type: 'string', description: 'Logical field name' },
|
||||
columnLabel: { type: 'string', description: 'Display label' },
|
||||
columnType: { type: 'string', description: 'SQL column type' },
|
||||
columnTypeDomain: { type: 'string', description: 'Agiloft field type' },
|
||||
required: { type: 'boolean', description: 'Whether the field is mandatory' },
|
||||
isLinked: { type: 'boolean', description: 'Whether the field is a linked field' },
|
||||
linkedInfo: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Source table and column, when linked-field details were requested',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
linkedTable: { type: 'string', description: 'Source table' },
|
||||
linkedColumn: { type: 'string', description: 'Source column' },
|
||||
},
|
||||
},
|
||||
},
|
||||
textFieldType: {
|
||||
type: 'string',
|
||||
description: 'Content type for text fields, e.g. text/plain',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
totalCount: { type: 'number', description: 'Number of tables returned' },
|
||||
},
|
||||
}
|
||||
@@ -54,8 +54,7 @@ export const agiloftLockRecordTool: ToolConfig<AgiloftLockRecordParams, AgiloftL
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Unlock only: release a lock held by another user. Requires membership in the admin group.',
|
||||
description: 'Unlock only: release a lock held by another user.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -89,6 +88,11 @@ export const agiloftLockRecordTool: ToolConfig<AgiloftLockRecordParams, AgiloftL
|
||||
type: 'string',
|
||||
description: 'Record ID',
|
||||
},
|
||||
tableId: {
|
||||
type: 'number',
|
||||
description: 'Numeric system identifier of the table holding the record',
|
||||
optional: true,
|
||||
},
|
||||
lockStatus: {
|
||||
type: 'string',
|
||||
description: 'Lock status: "LOCKED" when the record is held, "NO_LOCK" when it is free',
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { AgiloftNlpSearchParams, AgiloftNlpSearchResponse } from '@/tools/agiloft/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const agiloftNlpSearchTool: ToolConfig<AgiloftNlpSearchParams, AgiloftNlpSearchResponse> = {
|
||||
id: 'agiloft_nlp_search',
|
||||
name: 'Agiloft Natural Language Search',
|
||||
description:
|
||||
'Search Agiloft records by describing what you want in plain language, such as "active NDAs submitted last month".',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
instanceUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
|
||||
},
|
||||
knowledgeBase: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Knowledge base name',
|
||||
},
|
||||
login: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft password',
|
||||
},
|
||||
nlpQuery: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The request in plain language, e.g. "Show me open, high-priority contracts". Structured field filters are not accepted — use Search Records for those.',
|
||||
},
|
||||
fields: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated field names to return, e.g. "id, contract_title1, company_name"',
|
||||
},
|
||||
page: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Page number, starting from 0',
|
||||
},
|
||||
limit: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Records per page',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => '/api/tools/agiloft/nlp_search',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
instanceUrl: params.instanceUrl,
|
||||
knowledgeBase: params.knowledgeBase,
|
||||
login: params.login,
|
||||
password: params.password,
|
||||
nlpQuery: params.nlpQuery,
|
||||
fields: params.fields,
|
||||
page: params.page,
|
||||
limit: params.limit,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: data.success ?? true,
|
||||
output: data.output,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
records: { type: 'json', description: 'Matching records with the requested field values' },
|
||||
totalCount: { type: 'number', description: 'Number of records in this response' },
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description: 'True when more records were returned upstream than this call reports',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export const agiloftRunActionButtonTool: ToolConfig<
|
||||
},
|
||||
callbackId: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
description:
|
||||
'Callback identifier for the asynchronous run, which Agiloft returns as EWCALLBACK_ID',
|
||||
},
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agiloftSavedSearchTool } from '@/tools/agiloft/saved_search'
|
||||
import toolIds from '@/tools/generated/tool-ids'
|
||||
|
||||
describe('retired agiloft_saved_search', () => {
|
||||
it('keeps its id registered so workflows saved with that operation still resolve a tool', () => {
|
||||
expect(toolIds).toContain('agiloft_saved_search')
|
||||
expect(agiloftSavedSearchTool.id).toBe('agiloft_saved_search')
|
||||
})
|
||||
|
||||
it('fails with a migration hint instead of calling an undocumented endpoint', async () => {
|
||||
const result = await agiloftSavedSearchTool.directExecution?.({})
|
||||
|
||||
expect(result?.success).toBe(false)
|
||||
expect(result?.error).toContain('Search Records')
|
||||
})
|
||||
})
|
||||
@@ -1,88 +1,84 @@
|
||||
import type { AgiloftSavedSearchParams, AgiloftSavedSearchResponse } from '@/tools/agiloft/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Retired operation, kept registered so workflows saved while it was offered
|
||||
* still resolve a tool instead of failing with "Tool not found".
|
||||
*
|
||||
* `EWSavedSearch` appears in Agiloft's Scope Parameter operation list, so the
|
||||
* endpoint exists, but it has no documentation page — neither its URL
|
||||
* parameters nor its response shape can be verified. The previous
|
||||
* implementation guessed both and could only ever report an empty list, which
|
||||
* reads as "this table has no saved searches". Running a saved search is now
|
||||
* supported for real through the Search Records operation's Saved Search
|
||||
* field, so this fails fast and points there rather than issuing a request
|
||||
* whose behavior nobody can predict.
|
||||
*/
|
||||
export const agiloftSavedSearchTool: ToolConfig<
|
||||
AgiloftSavedSearchParams,
|
||||
AgiloftSavedSearchResponse
|
||||
> = {
|
||||
id: 'agiloft_saved_search',
|
||||
name: 'Agiloft Saved Search (retired)',
|
||||
description:
|
||||
'Retired. Agiloft does not document an endpoint for listing saved searches — use the Search Records operation and set its Saved Search field instead.',
|
||||
name: 'Agiloft Saved Search',
|
||||
description: 'List the saved searches defined for an Agiloft table.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
instanceUrl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft instance URL',
|
||||
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
|
||||
},
|
||||
knowledgeBase: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Knowledge base name',
|
||||
},
|
||||
login: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft password',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Table name',
|
||||
description: 'Logical table name to list saved searches for (e.g., "contract")',
|
||||
},
|
||||
},
|
||||
|
||||
/** Fails without a network call — there is no endpoint we can correctly call. */
|
||||
directExecution: async () => ({
|
||||
success: false,
|
||||
output: { searches: [] },
|
||||
error:
|
||||
'The Agiloft "Saved Search" operation has been retired because Agiloft does not document an endpoint for listing saved searches. Switch this block to the "Search Records" operation and enter the saved search name in its Saved Search field.',
|
||||
}),
|
||||
|
||||
request: {
|
||||
url: () => '/api/tools/agiloft/search_records',
|
||||
url: () => '/api/tools/agiloft/saved_search',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: () => ({}),
|
||||
body: (params) => ({
|
||||
instanceUrl: params.instanceUrl,
|
||||
knowledgeBase: params.knowledgeBase,
|
||||
login: params.login,
|
||||
password: params.password,
|
||||
table: params.table,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async () => ({
|
||||
success: false,
|
||||
output: { searches: [] },
|
||||
error: 'The Agiloft "Saved Search" operation has been retired.',
|
||||
}),
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: data.success ?? true,
|
||||
output: data.output,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
searches: {
|
||||
type: 'array',
|
||||
description: 'Always empty; this operation is retired',
|
||||
items: { type: 'object' },
|
||||
description: 'Saved searches defined on the table',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', description: 'Internal saved search name' },
|
||||
label: { type: 'string', description: 'Display label, as used by Search Records' },
|
||||
id: { type: 'number', description: 'Saved search identifier in the Agiloft database' },
|
||||
description: { type: 'string', description: 'Saved search description' },
|
||||
},
|
||||
},
|
||||
},
|
||||
totalCount: { type: 'number', description: 'Number of saved searches returned' },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -104,6 +104,10 @@ export const agiloftSearchRecordsTool: ToolConfig<
|
||||
},
|
||||
|
||||
outputs: {
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description: 'True when more records were returned upstream than this call reports',
|
||||
},
|
||||
records: {
|
||||
type: 'json',
|
||||
description: 'Array of matching records with their field values',
|
||||
@@ -111,7 +115,7 @@ export const agiloftSearchRecordsTool: ToolConfig<
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description:
|
||||
'Number of records reported by EWSearch. When paginating this is the count for the current page, not the whole result set.',
|
||||
'Number of records in this response. Not a total match count — compare with `truncated`.',
|
||||
},
|
||||
page: {
|
||||
type: 'number',
|
||||
|
||||
@@ -74,6 +74,10 @@ export const agiloftSelectRecordsTool: ToolConfig<
|
||||
},
|
||||
|
||||
outputs: {
|
||||
truncated: {
|
||||
type: 'boolean',
|
||||
description: 'True when more IDs matched than this call reports',
|
||||
},
|
||||
recordIds: {
|
||||
type: 'array',
|
||||
description: 'Array of record IDs matching the query',
|
||||
@@ -83,7 +87,7 @@ export const agiloftSelectRecordsTool: ToolConfig<
|
||||
},
|
||||
totalCount: {
|
||||
type: 'number',
|
||||
description: 'Total number of matching records',
|
||||
description: 'Number of IDs in this response — compare with `truncated`',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Base parameters shared by all Agiloft tools.
|
||||
* Agiloft authenticates via instance URL, KB name, and user credentials.
|
||||
* Connection and credentials. `table` is optional here because EWLogin is
|
||||
* KB-scoped and EWTable operates across the whole knowledge base; every
|
||||
* table-scoped operation uses `AgiloftBaseParams` below instead.
|
||||
*/
|
||||
export interface AgiloftBaseParams {
|
||||
export interface AgiloftCredentials {
|
||||
instanceUrl: string
|
||||
knowledgeBase: string
|
||||
login: string
|
||||
password: string
|
||||
table?: string
|
||||
}
|
||||
|
||||
/** Credentials plus the table a table-scoped operation acts on. */
|
||||
export interface AgiloftBaseParams extends AgiloftCredentials {
|
||||
table: string
|
||||
}
|
||||
|
||||
@@ -33,10 +39,13 @@ export type AgiloftDeleteRule =
|
||||
| 'DELETE_WHERE_POSSIBLE_OTHERWISE_UNLINK'
|
||||
| 'APPLY_UNLINK'
|
||||
| 'UNLINK_WHERE_POSSIBLE_OTHERWISE_DELETE'
|
||||
| 'REPLACE_WITH_ANOTHER'
|
||||
|
||||
export interface AgiloftDeleteRecordParams extends AgiloftBaseParams {
|
||||
recordId: string
|
||||
deleteRule?: AgiloftDeleteRule
|
||||
/** Comma-separated substitute record IDs; read only under REPLACE_WITH_ANOTHER. */
|
||||
substituteIds?: string
|
||||
}
|
||||
|
||||
export interface AgiloftSearchRecordsParams extends AgiloftBaseParams {
|
||||
@@ -82,6 +91,7 @@ export interface AgiloftSearchResponse extends ToolResponse {
|
||||
totalCount: number
|
||||
page: number
|
||||
limit: number
|
||||
truncated: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +99,7 @@ export interface AgiloftSelectResponse extends ToolResponse {
|
||||
output: {
|
||||
recordIds: string[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +117,7 @@ export interface AgiloftAttachmentInfoResponse extends ToolResponse {
|
||||
export interface AgiloftLockResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string
|
||||
tableId: number | null
|
||||
lockStatus: string
|
||||
lockedBy: string | null
|
||||
lockExpiresInMinutes: number | null
|
||||
@@ -117,6 +129,7 @@ export interface AgiloftAttachFileParams extends AgiloftBaseParams {
|
||||
fieldName: string
|
||||
file?: unknown
|
||||
fileName?: string
|
||||
overwrite?: boolean
|
||||
}
|
||||
|
||||
export interface AgiloftAttachFileResponse extends ToolResponse {
|
||||
@@ -182,10 +195,92 @@ export interface AgiloftRunActionButtonResponse extends ToolResponse {
|
||||
}
|
||||
}
|
||||
|
||||
export type AgiloftSavedSearchParams = Partial<AgiloftBaseParams>
|
||||
export type AgiloftSavedSearchParams = AgiloftBaseParams
|
||||
|
||||
export interface AgiloftSavedSearchResponse extends ToolResponse {
|
||||
output: {
|
||||
searches: unknown[]
|
||||
searches: Array<{
|
||||
name: string
|
||||
label: string
|
||||
id: number | null
|
||||
description: string | null
|
||||
}>
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* EWTable is knowledge-base scoped: `table` narrows the result to one logical
|
||||
* table rather than selecting the target, and is passed as `table` — not
|
||||
* `$table` — per the documented example.
|
||||
*/
|
||||
export interface AgiloftListTablesParams extends AgiloftCredentials {
|
||||
includeLinkedInfo?: boolean
|
||||
skipColumnsInfo?: boolean
|
||||
}
|
||||
|
||||
export interface AgiloftTableField {
|
||||
columnName: string
|
||||
columnLabel: string
|
||||
columnType: string
|
||||
columnTypeDomain: string
|
||||
required: boolean
|
||||
isLinked: boolean
|
||||
/** Populated only when includeLinkedInfo was requested and the field is linked. */
|
||||
linkedInfo: Array<{ linkedTable: string; linkedColumn: string }>
|
||||
textFieldType: string | null
|
||||
}
|
||||
|
||||
export interface AgiloftListTablesResponse extends ToolResponse {
|
||||
output: {
|
||||
tables: Array<{
|
||||
label: string
|
||||
logicalName: string
|
||||
fields: AgiloftTableField[]
|
||||
}>
|
||||
totalCount: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface AgiloftUpsertRecordParams extends AgiloftBaseParams {
|
||||
match: string
|
||||
data: string
|
||||
async?: boolean
|
||||
}
|
||||
|
||||
export interface AgiloftUpsertRecordResponse extends ToolResponse {
|
||||
output: {
|
||||
id: string | null
|
||||
created: boolean
|
||||
callbackId: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface AgiloftAsyncStatusParams extends AgiloftBaseParams {
|
||||
callbackId: string
|
||||
}
|
||||
|
||||
export interface AgiloftAsyncStatusResponse extends ToolResponse {
|
||||
output: {
|
||||
callbackId: string
|
||||
statusCode: number
|
||||
status: string
|
||||
complete: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/** EWNLPSearch is KB-scoped; the table comes from the KB's chat-search configuration. */
|
||||
export interface AgiloftNlpSearchParams extends AgiloftCredentials {
|
||||
nlpQuery: string
|
||||
fields: string
|
||||
page?: string
|
||||
limit?: string
|
||||
}
|
||||
|
||||
export interface AgiloftNlpSearchResponse extends ToolResponse {
|
||||
output: {
|
||||
records: Record<string, unknown>[]
|
||||
totalCount: number
|
||||
truncated: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { AgiloftUpsertRecordParams, AgiloftUpsertRecordResponse } from '@/tools/agiloft/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const agiloftUpsertRecordTool: ToolConfig<
|
||||
AgiloftUpsertRecordParams,
|
||||
AgiloftUpsertRecordResponse
|
||||
> = {
|
||||
id: 'agiloft_upsert_record',
|
||||
name: 'Agiloft Upsert Record',
|
||||
description:
|
||||
'Create an Agiloft record, or update it when a record already matches the given fields.',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
instanceUrl: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft instance URL (e.g., https://mycompany.agiloft.com)',
|
||||
},
|
||||
knowledgeBase: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Knowledge base name',
|
||||
},
|
||||
login: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft username',
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Agiloft password',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Table name (e.g., "contracts", "contacts.employees")',
|
||||
},
|
||||
match: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'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.',
|
||||
},
|
||||
async: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Queue the write instead of waiting for it. Returns a callback ID instead of a record ID; pass that to Async Status to poll the result.',
|
||||
},
|
||||
data: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Field values as a JSON object. On create these populate the new record; on update only the supplied fields change.',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: () => '/api/tools/agiloft/upsert_record',
|
||||
method: 'POST',
|
||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
||||
body: (params) => ({
|
||||
instanceUrl: params.instanceUrl,
|
||||
knowledgeBase: params.knowledgeBase,
|
||||
login: params.login,
|
||||
password: params.password,
|
||||
table: params.table,
|
||||
match: params.match,
|
||||
data: params.data,
|
||||
async: params.async,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
return {
|
||||
success: data.success ?? true,
|
||||
output: data.output,
|
||||
...(data.error ? { error: data.error } : {}),
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
id: { type: 'string', description: 'ID of the created or updated record' },
|
||||
created: {
|
||||
type: 'boolean',
|
||||
description: 'True when a new record was created, false when an existing one was updated',
|
||||
},
|
||||
callbackId: {
|
||||
type: 'string',
|
||||
description: 'Returned for a queued upsert; pass it to Async Status to poll the result',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
/** Obvious non-secret so credential scanners do not flag these fixtures. */
|
||||
const PLACEHOLDER_PASSWORD = 'not-a-real-password'
|
||||
|
||||
const { mockValidateUrlWithDNS, mockSecureFetch } = vi.hoisted(() => ({
|
||||
mockValidateUrlWithDNS: vi.fn(),
|
||||
mockSecureFetch: vi.fn(),
|
||||
@@ -19,7 +22,7 @@ const baseParams = {
|
||||
instanceUrl: 'https://example.agiloft.com',
|
||||
knowledgeBase: 'demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
password: PLACEHOLDER_PASSWORD,
|
||||
table: 'contracts',
|
||||
}
|
||||
|
||||
@@ -30,7 +33,7 @@ function mockResponse(body: { ok?: boolean; status?: number; json?: unknown; tex
|
||||
statusText: '',
|
||||
headers: { get: () => null, getSetCookie: () => [], toRecord: () => ({}) },
|
||||
body: null,
|
||||
text: async () => body.text ?? '',
|
||||
text: async () => body.text ?? JSON.stringify(body.json ?? {}),
|
||||
json: async () => body.json ?? {},
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
}
|
||||
@@ -74,11 +77,18 @@ describe('executeAgiloftRequest', () => {
|
||||
|
||||
const calls = mockSecureFetch.mock.calls
|
||||
expect(calls).toHaveLength(3)
|
||||
expect(calls[0][0]).toBe(
|
||||
'https://example.agiloft.com/ewws/EWLogin?$KB=demo&$login=admin&$password=secret'
|
||||
)
|
||||
// Credentials go in a form body, never the URL.
|
||||
expect(calls[0][0]).toBe('https://example.agiloft.com/ewws/EWLogin')
|
||||
expect(calls[0][2]).toMatchObject({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
const sent = new URLSearchParams(calls[0][2].body as string)
|
||||
expect(sent.get('$KB')).toBe('demo')
|
||||
expect(sent.get('$table')).toBe('contracts')
|
||||
expect(sent.get('$lang')).toBe('en')
|
||||
expect(calls[1][0]).toBe('https://example.agiloft.com/ewws/REST/demo/contracts/42')
|
||||
expect(calls[2][0]).toBe('https://example.agiloft.com/ewws/EWLogout?$KB=demo')
|
||||
expect(calls[2][0]).toBe('https://example.agiloft.com/ewws/EWLogout?$KB=demo&$lang=en')
|
||||
|
||||
for (const call of calls) {
|
||||
expect(call[1]).toBe('203.0.113.10')
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { filterUndefined } from '@sim/utils/object'
|
||||
import { truncate } from '@sim/utils/string'
|
||||
import {
|
||||
type SecureFetchResponse,
|
||||
secureFetchWithPinnedIP,
|
||||
validateUrlWithDNS,
|
||||
} from '@/lib/core/security/input-validation.server'
|
||||
import type { AgiloftBaseParams } from '@/tools/agiloft/types'
|
||||
import type { AgiloftBaseParams, AgiloftCredentials } from '@/tools/agiloft/types'
|
||||
import { AGILOFT_LANG, agiloftAlrestBase, describeAgiloftError } from '@/tools/agiloft/utils'
|
||||
import type { HttpMethod, ToolResponse } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('AgiloftAuthServer')
|
||||
|
||||
interface AgiloftRequestConfig {
|
||||
export interface AgiloftRequestConfig {
|
||||
url: string
|
||||
method: HttpMethod
|
||||
headers?: Record<string, string>
|
||||
@@ -31,35 +34,79 @@ export async function resolveAgiloftInstance(instanceUrl: string): Promise<strin
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS-pinned variant of agiloftLogin. Requires a pre-resolved IP so the
|
||||
* connection cannot be steered to a different host between validation and
|
||||
* the actual TCP connection.
|
||||
* Serializes credentials the way EWLogin expects them.
|
||||
*
|
||||
* The parameters go in a form-encoded request body rather than the query
|
||||
* string: Agiloft's own documentation notes they "can be filled to request
|
||||
* body", and it keeps the password out of URLs, access logs, and proxy traces.
|
||||
*/
|
||||
function formEncode(fields: Record<string, string>): string {
|
||||
return Object.entries(fields)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
}
|
||||
|
||||
export interface AgiloftSession {
|
||||
/** Ready-to-send Authorization header value, e.g. `Bearer eyJ...`. */
|
||||
authorization: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* DNS-pinned login. Requires a pre-resolved IP so the connection cannot be
|
||||
* steered to a different host between validation and the actual TCP connect.
|
||||
*
|
||||
* `$table` and `$lang` are as mandatory as `$KB` here even though only `$KB`,
|
||||
* `$login`, `$password`, and `$lang` are documented — a live instance rejects
|
||||
* the call with:
|
||||
*
|
||||
* EWWrongDataException ... One has to specify $table, $KB, $lang parameters
|
||||
*
|
||||
* The scheme is read back from `authentication_scheme` rather than hardcoded,
|
||||
* because Agiloft returns it with a trailing space ("Bearer ") and naively
|
||||
* concatenating it yields a malformed `Bearer <token>` header.
|
||||
*/
|
||||
export async function agiloftLoginPinned(
|
||||
params: AgiloftBaseParams,
|
||||
params: AgiloftCredentials,
|
||||
resolvedIP: string
|
||||
): Promise<string> {
|
||||
): Promise<AgiloftSession> {
|
||||
const base = params.instanceUrl.replace(/\/$/, '')
|
||||
const kb = encodeURIComponent(params.knowledgeBase)
|
||||
const login = encodeURIComponent(params.login)
|
||||
const password = encodeURIComponent(params.password)
|
||||
|
||||
const url = `${base}/ewws/EWLogin?$KB=${kb}&$login=${login}&$password=${password}`
|
||||
const response = await secureFetchWithPinnedIP(url, resolvedIP, { method: 'POST' })
|
||||
const response = await secureFetchWithPinnedIP(`${base}/ewws/EWLogin`, resolvedIP, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: formEncode(
|
||||
filterUndefined({
|
||||
$KB: params.knowledgeBase,
|
||||
$login: params.login,
|
||||
$password: params.password,
|
||||
// Undocumented on EWLogin, but a live instance rejects the call without
|
||||
// it. Omitted for KB-scoped operations that have no table.
|
||||
$table: params.table || undefined,
|
||||
$lang: AGILOFT_LANG,
|
||||
})
|
||||
),
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Agiloft login failed: ${response.status} - ${errorText}`)
|
||||
throw new Error(`Agiloft login failed (${response.status}): ${describeAgiloftError(text)}`)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { access_token?: string }
|
||||
const token = data.access_token
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Agiloft login did not return an access token')
|
||||
let data: { access_token?: string; authentication_scheme?: string }
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`Agiloft login returned a non-JSON response: ${truncate(text, 200)}`)
|
||||
}
|
||||
|
||||
return token
|
||||
if (!data.access_token) {
|
||||
throw new Error(`Agiloft login did not return an access token: ${truncate(text, 200)}`)
|
||||
}
|
||||
|
||||
const scheme = (data.authentication_scheme || 'Bearer').trim() || 'Bearer'
|
||||
return { authorization: `${scheme} ${data.access_token}`, token: data.access_token }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,16 +116,20 @@ export async function agiloftLoginPinned(
|
||||
export async function agiloftLogoutPinned(
|
||||
instanceUrl: string,
|
||||
knowledgeBase: string,
|
||||
token: string,
|
||||
authorization: string,
|
||||
resolvedIP: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const base = instanceUrl.replace(/\/$/, '')
|
||||
const kb = encodeURIComponent(knowledgeBase)
|
||||
await secureFetchWithPinnedIP(`${base}/ewws/EWLogout?$KB=${kb}`, resolvedIP, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
await secureFetchWithPinnedIP(
|
||||
`${base}/ewws/EWLogout?$KB=${kb}&$lang=${AGILOFT_LANG}`,
|
||||
resolvedIP,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { Authorization: authorization },
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
logger.warn('Agiloft logout failed (best-effort)', { error })
|
||||
}
|
||||
@@ -105,12 +156,12 @@ export async function agiloftLogoutPinned(
|
||||
* Server-only — uses node:dns/promises and node:http(s) via the pinned fetch.
|
||||
*/
|
||||
export async function executeAgiloftRequest<R extends ToolResponse>(
|
||||
params: AgiloftBaseParams,
|
||||
params: AgiloftCredentials,
|
||||
buildRequest: (base: string) => AgiloftRequestConfig,
|
||||
transformResponse: (response: SecureFetchResponse) => Promise<R>
|
||||
): Promise<R> {
|
||||
const resolvedIP = await resolveAgiloftInstance(params.instanceUrl)
|
||||
const token = await agiloftLoginPinned(params, resolvedIP)
|
||||
const session = await agiloftLoginPinned(params, resolvedIP)
|
||||
const base = params.instanceUrl.replace(/\/$/, '')
|
||||
|
||||
try {
|
||||
@@ -119,14 +170,127 @@ export async function executeAgiloftRequest<R extends ToolResponse>(
|
||||
method: req.method,
|
||||
headers: {
|
||||
...req.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
Authorization: session.authorization,
|
||||
},
|
||||
body: req.body,
|
||||
})
|
||||
return await transformResponse(response)
|
||||
} finally {
|
||||
await agiloftLogoutPinned(params.instanceUrl, params.knowledgeBase, token, resolvedIP)
|
||||
await agiloftLogoutPinned(
|
||||
params.instanceUrl,
|
||||
params.knowledgeBase,
|
||||
session.authorization,
|
||||
resolvedIP
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export type { SecureFetchResponse }
|
||||
|
||||
/**
|
||||
* Shape every `/ewws/alrest` endpoint answers with.
|
||||
*
|
||||
* The surface reports failures as HTTP 200 with `success: false`, so checking
|
||||
* `response.ok` alone silently turns an upstream refusal into a successful
|
||||
* empty result. `readAlrestJson` is the only sanctioned way to read one.
|
||||
*/
|
||||
export interface AlrestEnvelope<T> {
|
||||
success?: boolean
|
||||
message?: string
|
||||
errors?: Array<{ message?: string }>
|
||||
result?: T
|
||||
}
|
||||
|
||||
export class AgiloftAlrestError extends Error {}
|
||||
|
||||
/**
|
||||
* True for an upstream refusal Agiloft already decided on — a validation error,
|
||||
* a permission denial, a conflicting match.
|
||||
*
|
||||
* These must not surface as HTTP 500: the tool runner treats 500 as retryable,
|
||||
* and retrying a refused create can duplicate a record rather than converge.
|
||||
*/
|
||||
export function isAgiloftRefusal(error: unknown): error is AgiloftAlrestError {
|
||||
return error instanceof AgiloftAlrestError
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an alrest envelope, throwing `AgiloftAlrestError` when the call failed
|
||||
* — whether it failed by status code, by `success: false`, or by returning
|
||||
* something that is not JSON at all.
|
||||
*/
|
||||
export async function readAlrestJson<T>(response: SecureFetchResponse): Promise<T | undefined> {
|
||||
const text = await response.text()
|
||||
|
||||
let envelope: AlrestEnvelope<T>
|
||||
try {
|
||||
envelope = JSON.parse(text)
|
||||
} catch {
|
||||
throw new AgiloftAlrestError(
|
||||
`Agiloft returned a non-JSON response (${response.status}): ${truncate(text, 300)}`
|
||||
)
|
||||
}
|
||||
|
||||
if (!response.ok || envelope.success === false) {
|
||||
const detail =
|
||||
envelope.errors
|
||||
?.map((entry) => entry?.message)
|
||||
.filter(Boolean)
|
||||
.join('; ') ||
|
||||
envelope.message ||
|
||||
describeAgiloftError(truncate(text, 300))
|
||||
throw new AgiloftAlrestError(`Agiloft error: ${detail}`)
|
||||
}
|
||||
|
||||
return envelope.result
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single authenticated `/ewws/alrest/{KB}` call. `buildRequest` receives
|
||||
* the KB-scoped base URL, so callers compose `${base}/{table}/...` paths.
|
||||
*/
|
||||
export async function executeAlrestRequest<R extends ToolResponse>(
|
||||
params: AgiloftBaseParams,
|
||||
buildRequest: (base: string) => AgiloftRequestConfig,
|
||||
transformResponse: (response: SecureFetchResponse) => Promise<R>
|
||||
): Promise<R> {
|
||||
const resolvedIP = await resolveAgiloftInstance(params.instanceUrl)
|
||||
const session = await agiloftLoginPinned(params, resolvedIP)
|
||||
|
||||
try {
|
||||
const req = buildRequest(agiloftAlrestBase(params.instanceUrl, params.knowledgeBase))
|
||||
const response = await secureFetchWithPinnedIP(req.url, resolvedIP, {
|
||||
method: req.method,
|
||||
headers: { ...req.headers, Authorization: session.authorization },
|
||||
body: req.body,
|
||||
})
|
||||
return await transformResponse(response)
|
||||
} finally {
|
||||
await agiloftLogoutPinned(
|
||||
params.instanceUrl,
|
||||
params.knowledgeBase,
|
||||
session.authorization,
|
||||
resolvedIP
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single `/ewws/EW*` call. No login round-trip: that surface rejects the
|
||||
* bearer token and authenticates from the inline `$login`/`$password` already
|
||||
* present in the URL built by the caller.
|
||||
*/
|
||||
export async function executeEwRequest<R extends ToolResponse>(
|
||||
params: AgiloftCredentials,
|
||||
buildRequest: (base: string) => AgiloftRequestConfig,
|
||||
transformResponse: (response: SecureFetchResponse) => Promise<R>
|
||||
): Promise<R> {
|
||||
const resolvedIP = await resolveAgiloftInstance(params.instanceUrl)
|
||||
const req = buildRequest(params.instanceUrl.replace(/\/$/, ''))
|
||||
const response = await secureFetchWithPinnedIP(req.url, resolvedIP, {
|
||||
method: req.method,
|
||||
headers: req.headers,
|
||||
body: req.body,
|
||||
})
|
||||
return await transformResponse(response)
|
||||
}
|
||||
|
||||
@@ -3,174 +3,207 @@
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildCreateRecordUrl,
|
||||
buildDeleteRecordUrl,
|
||||
agiloftAlrestBase,
|
||||
alrestDeleteRecordUrl,
|
||||
alrestRecordCollectionUrl,
|
||||
alrestRecordUrl,
|
||||
alrestSearchUrl,
|
||||
buildAttachFileUrl,
|
||||
buildLockRecordUrl,
|
||||
buildReadRecordUrl,
|
||||
buildRunActionButtonUrl,
|
||||
buildSearchRecordsUrl,
|
||||
buildUpdateRecordUrl,
|
||||
recordUrlLengthError,
|
||||
buildRetrieveAttachmentUrl,
|
||||
buildSavedSearchUrl,
|
||||
buildSelectRecordsUrl,
|
||||
describeAgiloftError,
|
||||
ewCredentialBody,
|
||||
parseFieldList,
|
||||
} from '@/tools/agiloft/utils'
|
||||
|
||||
const BASE = 'https://example.agiloft.com'
|
||||
/** Obvious non-secret so credential scanners do not flag these fixtures. */
|
||||
const PLACEHOLDER_PASSWORD = 'not-a-real-password'
|
||||
|
||||
const INSTANCE = 'https://example.agiloft.com'
|
||||
|
||||
const baseParams = {
|
||||
instanceUrl: BASE,
|
||||
knowledgeBase: 'Demo',
|
||||
login: 'admin',
|
||||
password: 'secret',
|
||||
table: 'helpdesk_case',
|
||||
instanceUrl: INSTANCE,
|
||||
knowledgeBase: 'Russell Investments',
|
||||
login: 'svc.user',
|
||||
password: PLACEHOLDER_PASSWORD,
|
||||
table: 'contract',
|
||||
}
|
||||
|
||||
describe('CRUD endpoints', () => {
|
||||
it('targets the documented EW* operations rather than the /ewws/REST path', () => {
|
||||
expect(buildCreateRecordUrl(BASE, baseParams, {})).toContain('/ewws/EWCreate?')
|
||||
expect(buildReadRecordUrl(BASE, { ...baseParams, recordId: '358' })).toContain('/ewws/EWRead?')
|
||||
expect(buildUpdateRecordUrl(BASE, { ...baseParams, recordId: '358' }, {})).toContain(
|
||||
'/ewws/EWUpdate?'
|
||||
const BASE = agiloftAlrestBase(INSTANCE, baseParams.knowledgeBase)
|
||||
|
||||
describe('agiloftAlrestBase', () => {
|
||||
it('targets the alrest surface that accepts the EWLogin token', () => {
|
||||
expect(BASE).toBe('https://example.agiloft.com/ewws/alrest/Russell%20Investments')
|
||||
})
|
||||
|
||||
it('encodes the KB name and tolerates a trailing slash on the instance URL', () => {
|
||||
expect(agiloftAlrestBase('https://example.agiloft.com/', 'A B&C')).toBe(
|
||||
'https://example.agiloft.com/ewws/alrest/A%20B%26C'
|
||||
)
|
||||
expect(buildDeleteRecordUrl(BASE, { ...baseParams, recordId: '358' })).toContain(
|
||||
'/ewws/EWDelete?'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes record data as the &field=value pairs EWCreate reads off the query string', () => {
|
||||
const url = buildCreateRecordUrl(BASE, baseParams, {
|
||||
first_name: 'John',
|
||||
last_name: 'Doe',
|
||||
})
|
||||
|
||||
expect(url).toContain('&first_name=John')
|
||||
expect(url).toContain('&last_name=Doe')
|
||||
})
|
||||
|
||||
it('percent-encodes field names and values so separators cannot be injected', () => {
|
||||
const url = buildCreateRecordUrl(BASE, baseParams, {
|
||||
summary: "Acme & Co: 100% 'done'",
|
||||
})
|
||||
|
||||
expect(url).toContain("&summary=Acme%20%26%20Co%3A%20100%25%20'done'")
|
||||
expect(url).not.toContain('Acme & Co')
|
||||
})
|
||||
|
||||
it('skips null and undefined field values instead of sending the literal text', () => {
|
||||
const url = buildCreateRecordUrl(BASE, baseParams, {
|
||||
keep: 'yes',
|
||||
skipNull: null,
|
||||
skipUndefined: undefined,
|
||||
})
|
||||
|
||||
expect(url).toContain('&keep=yes')
|
||||
expect(url).not.toContain('skipNull')
|
||||
expect(url).not.toContain('skipUndefined')
|
||||
})
|
||||
|
||||
it('always sends a delete rule, defaulting to the non-cascading one', () => {
|
||||
expect(buildDeleteRecordUrl(BASE, { ...baseParams, recordId: '358' })).toContain(
|
||||
'&deleteRule=ERROR_IF_DEPENDANTS'
|
||||
)
|
||||
expect(
|
||||
buildDeleteRecordUrl(BASE, {
|
||||
...baseParams,
|
||||
recordId: '358',
|
||||
deleteRule: 'APPLY_UNLINK',
|
||||
})
|
||||
).toContain('&deleteRule=APPLY_UNLINK')
|
||||
})
|
||||
|
||||
it('does not ask for the .json variant on operations whose JSON shape is undocumented', () => {
|
||||
expect(buildSearchRecordsUrl(BASE, { ...baseParams, query: "a='b'" })).not.toContain('.json')
|
||||
expect(
|
||||
buildLockRecordUrl(BASE, { ...baseParams, recordId: '18', lockAction: 'check' })
|
||||
).not.toContain('.json')
|
||||
})
|
||||
})
|
||||
|
||||
describe('recordUrlLengthError', () => {
|
||||
it('accepts an ordinary record payload', () => {
|
||||
const error = recordUrlLengthError(BASE, (base) =>
|
||||
buildCreateRecordUrl(base, baseParams, { summary: 'A normal contract title' })
|
||||
describe('alrest record routes', () => {
|
||||
it('builds collection, item, and search paths under the KB base', () => {
|
||||
expect(alrestRecordCollectionUrl(BASE, 'contract')).toBe(`${BASE}/contract?lang=en`)
|
||||
expect(alrestRecordUrl(BASE, 'contract', ' 6342 ')).toBe(`${BASE}/contract/6342?lang=en`)
|
||||
expect(alrestSearchUrl(BASE, 'contract')).toBe(`${BASE}/contract/search?lang=en`)
|
||||
})
|
||||
|
||||
it('retrieves attachments through the documented EWRetrieve endpoint', () => {
|
||||
const url = buildRetrieveAttachmentUrl(INSTANCE, {
|
||||
...baseParams,
|
||||
recordId: '1234',
|
||||
fieldName: 'someField',
|
||||
position: '1',
|
||||
})
|
||||
|
||||
expect(url).toContain('/ewws/EWRetrieve?')
|
||||
expect(url).toContain('&id=1234')
|
||||
expect(url).toContain('&field=someField')
|
||||
// Documented parameter name is filePosition, not position.
|
||||
expect(url).toContain('&filePosition=1')
|
||||
})
|
||||
|
||||
it('always carries a delete rule so linked-record behavior is explicit', () => {
|
||||
expect(alrestDeleteRecordUrl(BASE, 'contract', '6342', 'ERROR_IF_DEPENDANTS')).toBe(
|
||||
`${BASE}/contract/6342?lang=en&deleteRule=ERROR_IF_DEPENDANTS`
|
||||
)
|
||||
|
||||
expect(error).toBeNull()
|
||||
})
|
||||
|
||||
it('explains the URL ceiling rather than letting Agiloft answer 414', () => {
|
||||
const error = recordUrlLengthError(BASE, (base) =>
|
||||
buildCreateRecordUrl(base, baseParams, { description: 'x'.repeat(7000) })
|
||||
)
|
||||
|
||||
expect(error).toContain('too large')
|
||||
expect(error).toContain('carry field values in the URL')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRunActionButtonUrl', () => {
|
||||
it('uses the /ewws/async prefix EWActionButton is documented under', () => {
|
||||
const url = buildRunActionButtonUrl(BASE, {
|
||||
...baseParams,
|
||||
recordId: '82',
|
||||
actionButtonField: 'ab_field',
|
||||
})
|
||||
describe('parseFieldList', () => {
|
||||
it('splits and trims a comma-separated projection', () => {
|
||||
expect(parseFieldList(' id , contract_title1 ,, ')).toEqual(['id', 'contract_title1'])
|
||||
})
|
||||
|
||||
expect(url).toContain('/ewws/async/EWActionButton?')
|
||||
expect(url).toContain('&name=ab_field')
|
||||
expect(url).toContain('&id=82')
|
||||
it('returns undefined when nothing usable was given, so no projection is sent', () => {
|
||||
expect(parseFieldList(undefined)).toBeUndefined()
|
||||
expect(parseFieldList(' ')).toBeUndefined()
|
||||
expect(parseFieldList(',,')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSearchRecordsUrl', () => {
|
||||
it('sends the saved search label as the documented "search" parameter', () => {
|
||||
const url = buildSearchRecordsUrl(BASE, {
|
||||
...baseParams,
|
||||
search: 'C: Status is Closed',
|
||||
})
|
||||
describe('legacy EW* endpoints', () => {
|
||||
it('keeps credentials out of the EWSelect URL, which supports a POST body', () => {
|
||||
const url = buildSelectRecordsUrl(INSTANCE, { ...baseParams, where: "id='1'" })
|
||||
|
||||
expect(url).toContain('&search=C%3A%20Status%20is%20Closed')
|
||||
expect(url).not.toContain('query=')
|
||||
expect(url).toContain('/ewws/EWSelect?')
|
||||
expect(url).toContain('&$lang=en')
|
||||
expect(url).not.toContain('$login')
|
||||
expect(url).not.toContain('$password')
|
||||
})
|
||||
|
||||
it('combines a saved search with an ad hoc query, as EWSearch allows', () => {
|
||||
const url = buildSearchRecordsUrl(BASE, {
|
||||
...baseParams,
|
||||
search: 'C: Status is Closed',
|
||||
query: "priority='High'",
|
||||
})
|
||||
it('form-encodes credentials for the operations that accept a body', () => {
|
||||
const body = ewCredentialBody(baseParams)
|
||||
|
||||
expect(url).toContain('&search=C%3A%20Status%20is%20Closed')
|
||||
expect(url).toContain("&query=priority%3D'High'")
|
||||
const sent = new URLSearchParams(body)
|
||||
expect(sent.get('$login')).toBe('svc.user')
|
||||
expect(sent.get('$password')).toBe(PLACEHOLDER_PASSWORD)
|
||||
})
|
||||
|
||||
it('omits the query parameter entirely when no query is supplied', () => {
|
||||
const url = buildSearchRecordsUrl(BASE, { ...baseParams, search: 'All Open' })
|
||||
|
||||
expect(url).not.toMatch(/[?&]query=/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildLockRecordUrl', () => {
|
||||
it('adds force only on unlock, where EWLock accepts it', () => {
|
||||
const unlock = buildLockRecordUrl(BASE, {
|
||||
it('percent-encodes credentials in the URL for operations with no body option', () => {
|
||||
const url = buildLockRecordUrl(INSTANCE, {
|
||||
...baseParams,
|
||||
login: 'a&b=c',
|
||||
password: 'placeholder&pass=word',
|
||||
recordId: '18',
|
||||
lockAction: 'unlock',
|
||||
force: true,
|
||||
lockAction: 'check',
|
||||
})
|
||||
|
||||
expect(unlock).toContain('&force=true')
|
||||
expect(url).toContain('&$login=a%26b%3Dc')
|
||||
expect(url).toContain('&$password=placeholder%26pass%3Dword')
|
||||
})
|
||||
|
||||
it('never sends force on lock or status checks', () => {
|
||||
for (const lockAction of ['lock', 'check'] as const) {
|
||||
const url = buildLockRecordUrl(BASE, {
|
||||
it('adds force only when unlocking', () => {
|
||||
expect(
|
||||
buildLockRecordUrl(INSTANCE, {
|
||||
...baseParams,
|
||||
recordId: '18',
|
||||
lockAction,
|
||||
lockAction: 'unlock',
|
||||
force: true,
|
||||
})
|
||||
).toContain('&force=true')
|
||||
|
||||
expect(url).not.toContain('force')
|
||||
}
|
||||
expect(
|
||||
buildLockRecordUrl(INSTANCE, {
|
||||
...baseParams,
|
||||
recordId: '18',
|
||||
lockAction: 'lock',
|
||||
force: true,
|
||||
})
|
||||
).not.toContain('force=true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('documented response keys', () => {
|
||||
it('builds the EWSavedSearch URL with the mandatory .json decorator and no credentials', () => {
|
||||
const url = buildSavedSearchUrl(INSTANCE, baseParams)
|
||||
|
||||
expect(url).toContain('/ewws/EWSavedSearch/.json?')
|
||||
expect(url).toContain('$table=contract')
|
||||
// Must run under EWLogin/OAuth, so inline credentials are not appended.
|
||||
expect(url).not.toContain('$login')
|
||||
expect(url).not.toContain('$password')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeAgiloftError', () => {
|
||||
it('reduces the HTML-wrapped exception to its message', () => {
|
||||
const body =
|
||||
'<html><head><title>Error</title></head><body>EWWrongDataException has occurred: ' +
|
||||
'[default task-70331][1786479740423] One has to specify $table, $KB, $lang parameters' +
|
||||
'</body></html>'
|
||||
|
||||
expect(describeAgiloftError(body)).toBe(
|
||||
'EWWrongDataException: One has to specify $table, $KB, $lang parameters'
|
||||
)
|
||||
})
|
||||
|
||||
it('passes through a body that is not a typed exception', () => {
|
||||
expect(describeAgiloftError('Error executing query, please consult logs')).toBe(
|
||||
'Error executing query, please consult logs'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('documented optional parameters', () => {
|
||||
it('adds subs only for the delete rule that reads it', () => {
|
||||
const withReplace = alrestDeleteRecordUrl(
|
||||
BASE,
|
||||
'contract',
|
||||
'6342',
|
||||
'REPLACE_WITH_ANOTHER',
|
||||
'7,8'
|
||||
)
|
||||
expect(withReplace).toContain('&subs=7')
|
||||
expect(withReplace).toContain('&subs=8')
|
||||
|
||||
const withoutReplace = alrestDeleteRecordUrl(BASE, 'contract', '6342', 'APPLY_UNLINK', '7,8')
|
||||
expect(withoutReplace).not.toContain('subs')
|
||||
})
|
||||
|
||||
it('asks the JSON decorator for real status codes', () => {
|
||||
expect(buildSavedSearchUrl(INSTANCE, baseParams)).toContain('err_code_resp=1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attach overwrite', () => {
|
||||
it('sends the documented fieldName$overwrite marker only when requested', () => {
|
||||
const on = buildAttachFileUrl(
|
||||
INSTANCE,
|
||||
{ ...baseParams, recordId: '1', fieldName: 'docs', overwrite: true },
|
||||
'a.pdf'
|
||||
)
|
||||
expect(on).toContain('docs%24overwrite=true')
|
||||
|
||||
const off = buildAttachFileUrl(
|
||||
INSTANCE,
|
||||
{ ...baseParams, recordId: '1', fieldName: 'docs' },
|
||||
'a.pdf'
|
||||
)
|
||||
expect(off).not.toContain('overwrite')
|
||||
})
|
||||
})
|
||||
|
||||
+277
-98
@@ -1,19 +1,144 @@
|
||||
import type {
|
||||
AgiloftAsyncStatusParams,
|
||||
AgiloftAttachmentInfoParams,
|
||||
AgiloftBaseParams,
|
||||
AgiloftDeleteRecordParams,
|
||||
AgiloftCredentials,
|
||||
AgiloftGetChoiceLineIdParams,
|
||||
AgiloftListTablesParams,
|
||||
AgiloftLockRecordParams,
|
||||
AgiloftReadRecordParams,
|
||||
AgiloftRemoveAttachmentParams,
|
||||
AgiloftRetrieveAttachmentParams,
|
||||
AgiloftRunActionButtonParams,
|
||||
AgiloftSearchRecordsParams,
|
||||
AgiloftSelectRecordsParams,
|
||||
AgiloftUpsertRecordParams,
|
||||
} from '@/tools/agiloft/types'
|
||||
import type { HttpMethod } from '@/tools/types'
|
||||
|
||||
/** URL builders (credential-free -- auth is via Bearer token header) */
|
||||
/**
|
||||
* Asks the JSON decorator for real HTTP status codes. Without it Agiloft
|
||||
* answers 200 even on failure, which is what forces callers to infer errors
|
||||
* from the body shape.
|
||||
*/
|
||||
export const AGILOFT_JSON_ERROR_CODES = 'err_code_resp=1'
|
||||
|
||||
/**
|
||||
* Reduces an Agiloft error body to its message.
|
||||
*
|
||||
* Failures arrive as an HTML document wrapping a typed exception and an
|
||||
* internal task id — `<html>…EWWrongDataException has occurred:
|
||||
* [default task-70331][1786479740423] One has to specify $table…</html>` —
|
||||
* none of which helps the person reading the workflow log.
|
||||
*/
|
||||
export function describeAgiloftError(body: string): string {
|
||||
const text = body
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
const typed = /(EW[A-Za-z]*Exception)\s*(?:has occurred)?\s*:?\s*(.*)/.exec(text)
|
||||
if (!typed) return text
|
||||
|
||||
const detail = typed[2].replace(/^(\[[^\]]*\]\s*)+/, '').trim()
|
||||
return detail ? `${typed[1]}: ${detail}` : typed[1]
|
||||
}
|
||||
|
||||
/** Language sent on every Agiloft call; EWLogin rejects the request without it. */
|
||||
export const AGILOFT_LANG = 'en'
|
||||
|
||||
/**
|
||||
* Base URL for the `/ewws/alrest/{KB}` REST surface, which is the one that
|
||||
* accepts the token EWLogin issues. The legacy `/ewws/EW*` endpoints expect
|
||||
* inline `$login`/`$password` credentials instead and reject a bearer token.
|
||||
*/
|
||||
export function agiloftAlrestBase(instanceUrl: string, knowledgeBase: string): string {
|
||||
return `${instanceUrl.replace(/\/$/, '')}/ewws/alrest/${encodeURIComponent(knowledgeBase)}`
|
||||
}
|
||||
|
||||
/** Table segment of an alrest path. */
|
||||
function tableSegment(table: string): string {
|
||||
return encodeURIComponent(table.trim())
|
||||
}
|
||||
|
||||
export function alrestRecordCollectionUrl(base: string, table: string): string {
|
||||
return `${base}/${tableSegment(table)}?lang=${AGILOFT_LANG}`
|
||||
}
|
||||
|
||||
export function alrestRecordUrl(base: string, table: string, recordId: string): string {
|
||||
return `${base}/${tableSegment(table)}/${encodeURIComponent(recordId.trim())}?lang=${AGILOFT_LANG}`
|
||||
}
|
||||
|
||||
/**
|
||||
* EWDelete's dependent-record strategy carries over to alrest as a query
|
||||
* parameter; omitting it leaves the behavior for linked records unspecified.
|
||||
*/
|
||||
export function alrestDeleteRecordUrl(
|
||||
base: string,
|
||||
table: string,
|
||||
recordId: string,
|
||||
deleteRule: string,
|
||||
substituteIds?: string
|
||||
): string {
|
||||
let url = `${alrestRecordUrl(base, table, recordId)}&deleteRule=${encodeURIComponent(deleteRule)}`
|
||||
|
||||
/**
|
||||
* `subs` is read only under REPLACE_WITH_ANOTHER, and names records from the
|
||||
* same table that adopt the dependants of the one being deleted.
|
||||
*/
|
||||
if (deleteRule === 'REPLACE_WITH_ANOTHER' && substituteIds) {
|
||||
for (const id of substituteIds
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)) {
|
||||
url += `&subs=${encodeURIComponent(id)}`
|
||||
}
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export function alrestSearchUrl(base: string, table: string): string {
|
||||
return `${base}/${tableSegment(table)}/search?lang=${AGILOFT_LANG}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard ceiling on records returned from a search.
|
||||
*
|
||||
* Whether alrest honours `page`/`limit` in the request body is unverified — the
|
||||
* names carry over from the legacy EWSearch query string. If it ignores them a
|
||||
* broad query returns the whole table, and an unfiltered contract record runs
|
||||
* to roughly 184 KB, so the result is capped here rather than trusting the
|
||||
* server to bound it.
|
||||
*/
|
||||
export const AGILOFT_MAX_SEARCH_RECORDS = 200
|
||||
|
||||
/**
|
||||
* Byte ceiling for a single attachment download. The route base64-encodes the
|
||||
* body into a JSON response, so peak memory is several times the file size;
|
||||
* without a cap it inherits the shared 100 MiB default.
|
||||
*/
|
||||
export const AGILOFT_MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024
|
||||
|
||||
/**
|
||||
* Ceiling on record IDs returned by EWSelect. The operation has no page size
|
||||
* of its own — the documented way to bound it is a database `limit` inside the
|
||||
* WHERE clause — so an unqualified clause returns every matching ID.
|
||||
*/
|
||||
export const AGILOFT_MAX_SELECT_IDS = 1000
|
||||
|
||||
/**
|
||||
* Splits a comma-separated field list into the `field` array alrest search
|
||||
* accepts. Field selection is the only way to keep a response small — a single
|
||||
* contract record runs to roughly 184 KB unfiltered.
|
||||
*/
|
||||
export function parseFieldList(fields?: string): string[] | undefined {
|
||||
const list = fields
|
||||
?.split(',')
|
||||
.map((field) => field.trim())
|
||||
.filter(Boolean)
|
||||
return list?.length ? list : undefined
|
||||
}
|
||||
|
||||
/** URL builders for the legacy `/ewws/EW*` surface (inline-credential auth) */
|
||||
|
||||
function encodeTable(params: AgiloftBaseParams) {
|
||||
return {
|
||||
@@ -22,109 +147,125 @@ function encodeTable(params: AgiloftBaseParams) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Non-secret query prefix shared by the legacy `/ewws/EW*` endpoints. */
|
||||
function buildEwBaseQuery(params: AgiloftBaseParams): string {
|
||||
const { kb, table } = encodeTable(params)
|
||||
return `$KB=${kb}&$table=${table}&$lang=en`
|
||||
return `$KB=${kb}&$table=${table}&$lang=${AGILOFT_LANG}`
|
||||
}
|
||||
|
||||
/**
|
||||
* EWCreate and EWUpdate carry record data in the query string, so an oversized
|
||||
* payload hits the server's request-line limit rather than a body limit. Tomcat
|
||||
* allows 8 KB for the whole request line by default; this leaves headroom for
|
||||
* the method, protocol, and surrounding headers.
|
||||
* Credentials appended to an EW* URL.
|
||||
*
|
||||
* "Every REST call should contain the user's credentials in the form
|
||||
* login={login}&password={password}" — the legacy surface authenticates this
|
||||
* way rather than from the bearer token EWLogin issues. Used only for the
|
||||
* operations that cannot carry them in a body instead; see
|
||||
* `ewCredentialBody`.
|
||||
*/
|
||||
export const AGILOFT_MAX_RECORD_URL_LENGTH = 6000
|
||||
|
||||
/**
|
||||
* Serializes a record's field values as the `&field=value` pairs EWCreate and
|
||||
* EWUpdate expect. Agiloft reads record data straight off the query string;
|
||||
* there is no documented JSON body form of these operations.
|
||||
*/
|
||||
function encodeRecordData(data: Record<string, unknown>): string {
|
||||
let encoded = ''
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
if (value === undefined || value === null) continue
|
||||
encoded += `&${encodeURIComponent(field)}=${encodeURIComponent(String(value))}`
|
||||
}
|
||||
return encoded
|
||||
function ewCredentialQuery(params: AgiloftCredentials): string {
|
||||
const login = encodeURIComponent(params.login)
|
||||
const password = encodeURIComponent(params.password)
|
||||
return `&$login=${login}&$password=${password}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an explanatory message when a record's field data would push the
|
||||
* request line past what Agiloft accepts, so the caller reports the real cause
|
||||
* instead of surfacing an opaque 414 from the server.
|
||||
* Credentials as a form-encoded POST body, which is how Agiloft recommends
|
||||
* production systems pass them: "you can avoid passing the login or password
|
||||
* in REST calls by using POST instead of GET to pass the parameters in the
|
||||
* request body."
|
||||
*
|
||||
* Only EWRead, EWSelect, EWCreate, EWUpdate, and EWDelete accept credentials
|
||||
* this way. Every other EW* operation has to keep them in the query string.
|
||||
*/
|
||||
export function recordUrlLengthError(
|
||||
instanceUrl: string,
|
||||
build: (base: string) => string
|
||||
): string | null {
|
||||
const url = build(instanceUrl.replace(/\/$/, ''))
|
||||
if (url.length <= AGILOFT_MAX_RECORD_URL_LENGTH) return null
|
||||
return `Record data is too large: Agiloft's create and update operations carry field values in the URL, and this request is ${url.length} characters against a ${AGILOFT_MAX_RECORD_URL_LENGTH} limit. Split it into smaller updates or move long text into an attachment.`
|
||||
}
|
||||
|
||||
export function buildCreateRecordUrl(
|
||||
base: string,
|
||||
params: AgiloftBaseParams,
|
||||
data: Record<string, unknown>
|
||||
): string {
|
||||
return `${base}/ewws/EWCreate?${buildEwBaseQuery(params)}${encodeRecordData(data)}`
|
||||
}
|
||||
|
||||
export function buildReadRecordUrl(base: string, params: AgiloftReadRecordParams): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
return `${base}/ewws/EWRead?${buildEwBaseQuery(params)}&id=${id}`
|
||||
}
|
||||
|
||||
export function buildUpdateRecordUrl(
|
||||
base: string,
|
||||
params: AgiloftBaseParams & { recordId: string },
|
||||
data: Record<string, unknown>
|
||||
): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
return `${base}/ewws/EWUpdate?${buildEwBaseQuery(params)}&id=${id}${encodeRecordData(data)}`
|
||||
export function ewCredentialBody(params: AgiloftCredentials): string {
|
||||
return `$login=${encodeURIComponent(params.login)}&$password=${encodeURIComponent(params.password)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* EWDelete requires a delete rule naming the strategy for dependent records;
|
||||
* omitting it is a malformed request.
|
||||
* EWSelect is one of the five operations that accept credentials in a POST
|
||||
* body, so the URL deliberately carries no `$login`/`$password`.
|
||||
*/
|
||||
export function buildDeleteRecordUrl(base: string, params: AgiloftDeleteRecordParams): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
const deleteRule = encodeURIComponent(params.deleteRule ?? 'ERROR_IF_DEPENDANTS')
|
||||
return `${base}/ewws/EWDelete?${buildEwBaseQuery(params)}&id=${id}&deleteRule=${deleteRule}`
|
||||
}
|
||||
/**
|
||||
* EWSavedSearch answers JSON only, so the `.json` decorator is mandatory. It
|
||||
* also has to run under EWLogin or OAuth authorization rather than inline
|
||||
* credentials, so no `$login`/`$password` are appended here.
|
||||
*/
|
||||
/**
|
||||
* EWTable is knowledge-base scoped, so the query carries `$KB` and `$lang` but
|
||||
* no `$table`. Narrowing to one table uses the plain `table` parameter, and
|
||||
* JSON is the only supported output, hence the mandatory `.json` decorator.
|
||||
* Runs under EWLogin or OAuth authorization, so no inline credentials.
|
||||
*/
|
||||
export function buildListTablesUrl(base: string, params: AgiloftListTablesParams): string {
|
||||
const kb = encodeURIComponent(params.knowledgeBase)
|
||||
let url = `${base}/ewws/EWTable/.json?${AGILOFT_JSON_ERROR_CODES}&$KB=${kb}&$lang=${AGILOFT_LANG}`
|
||||
|
||||
export function buildSearchRecordsUrl(base: string, params: AgiloftSearchRecordsParams): string {
|
||||
let url = `${base}/ewws/EWSearch?${buildEwBaseQuery(params)}`
|
||||
|
||||
if (params.search) {
|
||||
url += `&search=${encodeURIComponent(params.search.trim())}`
|
||||
}
|
||||
if (params.query) {
|
||||
url += `&query=${encodeURIComponent(params.query)}`
|
||||
}
|
||||
|
||||
if (params.fields) {
|
||||
const fieldList = params.fields
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean)
|
||||
for (const field of fieldList) {
|
||||
url += `&field=${encodeURIComponent(field)}`
|
||||
}
|
||||
}
|
||||
|
||||
if (params.page) {
|
||||
url += `&page=${encodeURIComponent(params.page)}`
|
||||
}
|
||||
if (params.limit) {
|
||||
url += `&limit=${encodeURIComponent(params.limit)}`
|
||||
}
|
||||
const table = params.table?.trim()
|
||||
if (table) url += `&table=${encodeURIComponent(table)}`
|
||||
if (params.includeLinkedInfo) url += '&includelinkedinfo=true'
|
||||
if (params.skipColumnsInfo) url += '&skipColumnsInfo=true'
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* EWUpsert takes every parameter — credentials included — in a form-encoded
|
||||
* body, so nothing sensitive reaches the URL and there is no request-line
|
||||
* length ceiling on the record data.
|
||||
*/
|
||||
export function buildUpsertRecordUrl(base: string): string {
|
||||
return `${base}/ewws/EWUpsert`
|
||||
}
|
||||
|
||||
export function buildUpsertRecordBody(
|
||||
params: AgiloftUpsertRecordParams,
|
||||
data: Record<string, unknown>
|
||||
): string {
|
||||
const fields: Array<[string, string]> = [
|
||||
['$KB', params.knowledgeBase],
|
||||
['$table', params.table],
|
||||
['$login', params.login],
|
||||
['$password', params.password],
|
||||
['$lang', AGILOFT_LANG],
|
||||
['$match', params.match.trim()],
|
||||
]
|
||||
|
||||
if (params.async) fields.push(['$async', 'true'])
|
||||
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
if (value === undefined || value === null) continue
|
||||
|
||||
/**
|
||||
* Multi-value fields are encoded as repeated key/value pairs, not as a
|
||||
* joined string. Objects have no documented encoding at all, and
|
||||
* String()-ing one silently writes "[object Object]" into the record.
|
||||
*/
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) {
|
||||
if (entry === undefined || entry === null) continue
|
||||
fields.push([field, String(entry)])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
throw new TypeError(
|
||||
`Field "${field}" is an object, which Agiloft has no encoding for. Use a string, a number, or an array of values.`
|
||||
)
|
||||
}
|
||||
|
||||
fields.push([field, String(value)])
|
||||
}
|
||||
|
||||
return fields
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
}
|
||||
|
||||
export function buildSavedSearchUrl(base: string, params: AgiloftBaseParams): string {
|
||||
return `${base}/ewws/EWSavedSearch/.json?${AGILOFT_JSON_ERROR_CODES}&${buildEwBaseQuery(params)}`
|
||||
}
|
||||
|
||||
export function buildSelectRecordsUrl(base: string, params: AgiloftSelectRecordsParams): string {
|
||||
const where = encodeURIComponent(params.where)
|
||||
return `${base}/ewws/EWSelect?${buildEwBaseQuery(params)}&where=${where}`
|
||||
@@ -136,8 +277,8 @@ export function buildRetrieveAttachmentUrl(
|
||||
): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
const field = encodeURIComponent(params.fieldName.trim())
|
||||
const position = encodeURIComponent(params.position)
|
||||
return `${base}/ewws/EWRetrieve?${buildEwBaseQuery(params)}&id=${id}&field=${field}&filePosition=${position}`
|
||||
const position = encodeURIComponent(params.position.trim())
|
||||
return `${base}/ewws/EWRetrieve?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&id=${id}&field=${field}&filePosition=${position}`
|
||||
}
|
||||
|
||||
export function buildRemoveAttachmentUrl(
|
||||
@@ -147,13 +288,13 @@ export function buildRemoveAttachmentUrl(
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
const field = encodeURIComponent(params.fieldName.trim())
|
||||
const position = encodeURIComponent(params.position)
|
||||
return `${base}/ewws/EWRemoveAttachment?${buildEwBaseQuery(params)}&id=${id}&field=${field}&filePosition=${position}`
|
||||
return `${base}/ewws/EWRemoveAttachment?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&id=${id}&field=${field}&filePosition=${position}`
|
||||
}
|
||||
|
||||
export function buildAttachmentInfoUrl(base: string, params: AgiloftAttachmentInfoParams): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
const fieldName = encodeURIComponent(params.fieldName.trim())
|
||||
return `${base}/ewws/EWAttachInfo/.json?${buildEwBaseQuery(params)}&id=${id}&field=${fieldName}`
|
||||
return `${base}/ewws/EWAttachInfo/.json?${AGILOFT_JSON_ERROR_CODES}&${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&id=${id}&field=${fieldName}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,23 +303,34 @@ export function buildAttachmentInfoUrl(base: string, params: AgiloftAttachmentIn
|
||||
*/
|
||||
export function buildLockRecordUrl(base: string, params: AgiloftLockRecordParams): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
let url = `${base}/ewws/EWLock?${buildEwBaseQuery(params)}&id=${id}`
|
||||
let url = `${base}/ewws/EWLock?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&id=${id}`
|
||||
if (params.lockAction === 'unlock' && params.force) {
|
||||
url += '&force=true'
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* EWAttach carries the file as the raw request body, so its credentials have to
|
||||
* travel in the query string like the rest of the EW* surface — there is no
|
||||
* room for a form-encoded credential body here.
|
||||
*/
|
||||
export function buildAttachFileUrl(
|
||||
base: string,
|
||||
params: AgiloftBaseParams & { recordId: string; fieldName: string },
|
||||
params: AgiloftBaseParams & { recordId: string; fieldName: string; overwrite?: boolean },
|
||||
fileName: string
|
||||
): string {
|
||||
const { kb, table } = encodeTable(params)
|
||||
const recordId = encodeURIComponent(params.recordId.trim())
|
||||
const fieldName = encodeURIComponent(params.fieldName.trim())
|
||||
const encodedFileName = encodeURIComponent(fileName)
|
||||
return `${base}/ewws/EWAttach?$KB=${kb}&$table=${table}&$lang=en&id=${recordId}&field=${fieldName}&fileName=${encodedFileName}`
|
||||
let url = `${base}/ewws/EWAttach?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&id=${recordId}&field=${fieldName}&fileName=${encodedFileName}`
|
||||
|
||||
/** `<fieldName>$overwrite` replaces the field's contents instead of appending. */
|
||||
if (params.overwrite) {
|
||||
url += `&${fieldName}%24overwrite=true`
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
export function buildGetChoiceLineIdUrl(
|
||||
@@ -187,7 +339,34 @@ export function buildGetChoiceLineIdUrl(
|
||||
): string {
|
||||
const field = encodeURIComponent(params.fieldName.trim())
|
||||
const value = encodeURIComponent(params.value.trim())
|
||||
return `${base}/ewws/EWGetChoiceLineId?${buildEwBaseQuery(params)}&field=${field}&value=${value}`
|
||||
return `${base}/ewws/EWGetChoiceLineId?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&field=${field}&value=${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* EWAsyncStatus reports the outcome of a call made through the `/ewws/async`
|
||||
* prefix — including EWActionButton, whose callback ID is otherwise unusable.
|
||||
*/
|
||||
export function buildAsyncStatusUrl(base: string, params: AgiloftAsyncStatusParams): string {
|
||||
const callbackId = encodeURIComponent(params.callbackId.trim())
|
||||
return `${base}/ewws/EWAsyncStatus?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&callback_id=${callbackId}`
|
||||
}
|
||||
|
||||
/** Documented EWAsyncStatus response codes. */
|
||||
export const AGILOFT_ASYNC_STATUS: Record<number, { status: string; complete: boolean }> = {
|
||||
200: { status: 'completed', complete: true },
|
||||
201: { status: 'queued', complete: false },
|
||||
202: { status: 'in_progress', complete: false },
|
||||
501: { status: 'failed', complete: true },
|
||||
523: { status: 'unknown_callback', complete: true },
|
||||
}
|
||||
|
||||
/**
|
||||
* EWNLPSearch answers semantic queries and returns records in the same shape as
|
||||
* search. It is KB-scoped — the table is chosen by the KB's chat-search
|
||||
* configuration rather than by the caller.
|
||||
*/
|
||||
export function buildNlpSearchUrl(base: string): string {
|
||||
return `${base}/ewws/EWNLPSearch`
|
||||
}
|
||||
|
||||
export function getLockHttpMethod(lockAction: string): HttpMethod {
|
||||
@@ -211,5 +390,5 @@ export function buildRunActionButtonUrl(
|
||||
): string {
|
||||
const id = encodeURIComponent(params.recordId.trim())
|
||||
const name = encodeURIComponent(params.actionButtonField.trim())
|
||||
return `${base}/ewws/async/EWActionButton?${buildEwBaseQuery(params)}&name=${name}&id=${id}`
|
||||
return `${base}/ewws/async/EWActionButton?${buildEwBaseQuery(params)}${ewCredentialQuery(params)}&name=${name}&id=${id}`
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -52,12 +52,15 @@ import {
|
||||
agentphoneUpdateConversationTool,
|
||||
} from '@/tools/agentphone'
|
||||
import {
|
||||
agiloftAsyncStatusTool,
|
||||
agiloftAttachFileTool,
|
||||
agiloftAttachmentInfoTool,
|
||||
agiloftCreateRecordTool,
|
||||
agiloftDeleteRecordTool,
|
||||
agiloftGetChoiceLineIdTool,
|
||||
agiloftListTablesTool,
|
||||
agiloftLockRecordTool,
|
||||
agiloftNlpSearchTool,
|
||||
agiloftReadRecordTool,
|
||||
agiloftRemoveAttachmentTool,
|
||||
agiloftRetrieveAttachmentTool,
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
agiloftSearchRecordsTool,
|
||||
agiloftSelectRecordsTool,
|
||||
agiloftUpdateRecordTool,
|
||||
agiloftUpsertRecordTool,
|
||||
} from '@/tools/agiloft'
|
||||
import {
|
||||
ahrefsAnchorsTool,
|
||||
@@ -4984,12 +4988,15 @@ export const tools: Record<string, ToolConfig> = {
|
||||
agentphone_send_message: agentphoneSendMessageTool,
|
||||
agentphone_update_contact: agentphoneUpdateContactTool,
|
||||
agentphone_update_conversation: agentphoneUpdateConversationTool,
|
||||
agiloft_async_status: agiloftAsyncStatusTool,
|
||||
agiloft_attach_file: agiloftAttachFileTool,
|
||||
agiloft_attachment_info: agiloftAttachmentInfoTool,
|
||||
agiloft_create_record: agiloftCreateRecordTool,
|
||||
agiloft_delete_record: agiloftDeleteRecordTool,
|
||||
agiloft_get_choice_line_id: agiloftGetChoiceLineIdTool,
|
||||
agiloft_list_tables: agiloftListTablesTool,
|
||||
agiloft_lock_record: agiloftLockRecordTool,
|
||||
agiloft_nlp_search: agiloftNlpSearchTool,
|
||||
agiloft_read_record: agiloftReadRecordTool,
|
||||
agiloft_remove_attachment: agiloftRemoveAttachmentTool,
|
||||
agiloft_retrieve_attachment: agiloftRetrieveAttachmentTool,
|
||||
@@ -4998,6 +5005,7 @@ export const tools: Record<string, ToolConfig> = {
|
||||
agiloft_search_records: agiloftSearchRecordsTool,
|
||||
agiloft_select_records: agiloftSelectRecordsTool,
|
||||
agiloft_update_record: agiloftUpdateRecordTool,
|
||||
agiloft_upsert_record: agiloftUpsertRecordTool,
|
||||
airweave_search: airweaveSearchTool,
|
||||
amplitude_send_event: amplitudeSendEventTool,
|
||||
amplitude_identify_user: amplitudeIdentifyUserTool,
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 1093,
|
||||
zodRoutes: 1093,
|
||||
totalRoutes: 1098,
|
||||
zodRoutes: 1098,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user