diff --git a/docs.json b/docs.json index 5354715..3773662 100644 --- a/docs.json +++ b/docs.json @@ -81,6 +81,7 @@ "get-started/connect/data-sources/graphql-hasura", "get-started/connect/data-sources/cosmosdb", "get-started/connect/data-sources/snowflake", + "get-started/connect/data-sources/pylon", "get-started/connect/data-sources/zendesk", { "group": "Custom datasources", @@ -179,6 +180,7 @@ "pages": [ "product/process/advanced-concepts/plugins/overview", "product/process/advanced-concepts/plugins/active-storage", + "product/process/advanced-concepts/plugins/pylon", "product/process/advanced-concepts/plugins/zendesk" ] }, diff --git a/get-started/connect/data-sources/overview.mdx b/get-started/connect/data-sources/overview.mdx index 2bceeb8..1301de9 100644 --- a/get-started/connect/data-sources/overview.mdx +++ b/get-started/connect/data-sources/overview.mdx @@ -126,6 +126,7 @@ This allows you to navigate between related data even when it lives in different - **[RPC](/get-started/connect/data-sources/rpc)** - Connect remote data sources via RPC - **[GraphQL / Hasura](/get-started/connect/data-sources/graphql-hasura)** - Connect GraphQL APIs or Hasura instances - **[CosmosDB](/get-started/connect/data-sources/cosmosdb)** - Connect Azure CosmosDB +- **[Pylon](/get-started/connect/data-sources/pylon)** - Connect a Pylon workspace (issues, accounts, contacts, users, teams) ### Custom datasources diff --git a/get-started/connect/data-sources/pylon.mdx b/get-started/connect/data-sources/pylon.mdx new file mode 100644 index 0000000..ece7d23 --- /dev/null +++ b/get-started/connect/data-sources/pylon.mdx @@ -0,0 +1,335 @@ +--- +title: "Pylon" +description: "Surface a Pylon workspace (issues, accounts, contacts, users, teams) as Forest collections" +--- + +The Pylon datasource surfaces a [Pylon](https://usepylon.com) workspace as Forest collections. It exposes issues, accounts, contacts, users and teams on top of the [Pylon API](https://docs.usepylon.com/pylon-docs/developer/api/api-reference), with filters, free-text search, relations, the conversation thread of an issue, Pylon custom fields, CRUD writes and two action plugins. + + +The Pylon datasource is only available for Ruby (gem `forest_admin_datasource_pylon`). There is no Node.js equivalent yet. + + +## Installation + +Install the gem `forest_admin_datasource_pylon`. + +```ruby +# Gemfile +gem 'forest_admin_datasource_pylon' +``` + +```ruby +# app/lib/forest_admin_rails/create_agent.rb +module ForestAdminRails + class CreateAgent + def self.setup! + datasource = ForestAdminDatasourcePylon::Datasource.new(api_key: ENV['PYLON_API_KEY']) + + @create_agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(datasource, {}) + customize + @create_agent.build + end + end +end +``` + +## Configuration + +A single Bearer token is the whole configuration — an [API key](https://docs.usepylon.com/pylon-docs/developer/api/api-reference) created from your Pylon workspace settings. Nothing validates it at boot: the custom-field introspection does call Pylon, but it is best-effort, so a `401` there costs the custom columns and a log line rather than the boot. A wrong key therefore starts the agent and fails on the first list. `client.me` is the check to run yourself — `GET /me` returns the organization owning the token, which is enough to prove the credentials are usable. + +`api_key` is mandatory; the datasource fails fast with a `ForestAdminDatasourcePylon::ConfigurationError` when it is missing or blank. Everything else is optional and only exists to trade throughput, timeouts and rate-limit behaviour. + +| Option | Default | Description | +| ------------------- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api_key` | — | **Required.** Pylon API key, sent as a Bearer token. | +| `base_url` | `https://api.usepylon.com` | Alternative base URL — an egress proxy, or a mock server. A base URL mounted under a subpath is supported (the subpath is stripped before the rate-limit table is consulted). | +| `open_timeout` | `5` | Connection timeout, in seconds, for the requests a running agent makes. | +| `timeout` | `30` | Read timeout, in seconds, for the requests a running agent makes. | +| `retry_policy` | `RetryPolicy.new` | How a failed request is retried. See [Rate limits and retries](#rate-limits-and-retries). | +| `rate_limiter` | `RateLimiter.new` | Proactive per-endpoint throttling. Pass `nil` to remove it and leave the 429 retry as the only rate-limit handling. | +| `boot_open_timeout` | `3` | Connection timeout for the custom-field introspection that runs while the datasource is being built. | +| `boot_timeout` | `10` | Read timeout for that same introspection. | +| `boot_retry_policy` | `RetryPolicy.boot` | Retry policy for that same introspection: one retry, and a much tighter wait, since it sits in front of a Rails boot. | + + +The API key never reaches an `inspect`: `Configuration`, `Client` and `Datasource` all mask it, and the base URL is printed with its user-info redacted (an egress proxy spelled `https://user:pass@proxy.internal` would otherwise leak a password onto a Rails error page). + + +The `forest_admin_datasource_pylon` gem also ships two action plugins (`CloseIssue` and `CreateIssueWithNotification`) that you can attach to any host collection. See [the Pylon plugins page](/product/process/advanced-concepts/plugins/pylon) for details. + +## Provided collections + +Once the datasource is registered, five collections are added to your Forest project: + +| Collection | Pylon resource | Read | Write | +| --------------- | -------------- | ------------------------------------------- | ------------------------ | +| `PylonIssue` | `/issues` | `POST /issues/search`, cursor-paginated | create / update / delete | +| `PylonAccount` | `/accounts` | search + list, cursor-paginated | create / update / delete | +| `PylonContact` | `/contacts` | search + list, cursor-paginated | create / update / delete | +| `PylonUser` | `/users` | whole dataset, unpaginated | update | +| `PylonTeam` | `/teams` | whole dataset, unpaginated | create / update | + +`PylonUser` has no create and no delete (a Pylon agent is invited and deactivated from Pylon itself), and `PylonTeam` has no delete — Pylon exposes no such endpoints, and the datasource refuses those verbs rather than pretending to perform them. + +`PylonIssue`, `PylonAccount` and `PylonContact` also carry their Pylon [custom fields](#custom-fields), introspected at boot. + +### Relationships + +The following relationships are exposed automatically: + +- `PylonIssue.account` → `PylonAccount` (foreign key `account_id`) +- `PylonIssue.requester` → `PylonContact` (foreign key `requester_id`) +- `PylonIssue.assignee` → `PylonUser` (foreign key `assignee_id`) +- `PylonIssue.team` → `PylonTeam` (foreign key `team_id`) +- `PylonAccount.issues` → `PylonIssue` (origin key `account_id`) +- `PylonAccount.contacts` → `PylonContact` (origin key `account_id`) +- `PylonContact.account` → `PylonAccount` (foreign key `account_id`) +- `PylonContact.requested_issues` → `PylonIssue` (origin key `requester_id`) +- `PylonUser.assigned_issues` → `PylonIssue` (origin key `assignee_id`) +- `PylonTeam.issues` → `PylonIssue` (origin key `team_id`) + +Every one of those keys is filtered server-side by `/issues/search` or `/contacts/search`, so a related list costs one request and no in-memory pass. + +Two relations are not exposed, for two different reasons: + +- **Team membership**, which Pylon's shape does not allow. Pylon nests its members inside a team and exposes no team id on a user, so the membership is a many-to-many with no key column to build it on. `PylonTeam.user_ids` carries the member ids as a `Json` column instead. +- **Account owner**, which it would allow. `PylonAccount.owner_id` points at a `PylonUser` and would be resolved like any other key, but is left as a plain column until something in the panel asks for the owner of an account. + +### Conversation thread + +Pylon has no way to read the threads of several issues at once, so messages are not exposed as their own collection. Instead, each issue carries a structured `messages` array column, fetched from `GET /issues/{id}/messages` only when the projection asks for it (i.e. when `messages` is rendered on the detail view or referenced in a custom action). + +Each entry has the following shape: + +| Field | Type | Source | +| ------------------- | --------- | ---------------------------------------------------------- | +| `id` | `String` | Pylon message id | +| `body_html` | `String` | HTML body (Pylon's `message_html`) | +| `is_private` | `Boolean` | `true` for an internal note | +| `source` | `String` | Channel the message came through | +| `thread_id` | `String` | Thread the message belongs to | +| `file_urls` | `Json` | Attachments | +| `created_at` | `Date` | Message timestamp (Pylon's `timestamp`) | +| `author_name` | `String` | Author, flattened from Pylon's nested author object | +| `author_email` | `String` | Same | +| `author_avatar_url` | `String` | Same | +| `author_contact_id` | `String` | Set when the author is a contact | +| `author_user_id` | `String` | Set when the author is an agent | + +The column is read-only, and neither filterable nor sortable: `POST /issues/search` covers no message field, and the search payload does not even carry the thread. + +One thread is one request, and a thread is the whole conversation rather than a page of it, so a list view asking for more of them than `MAX_MESSAGE_EMBEDS` (**10**) reads the first ones and logs a warning. A row past that cap — and a row whose thread could not be read — is left at `nil` ("unknown"), never at an empty list, which would read as "this issue has no message". + +### Custom fields + +Custom fields defined in your Pylon workspace are introspected at boot, one `GET /custom-fields` call per object type (`issue`, `account`, `contact` — Pylon carries none on users and teams), and added to the matching collection's schema under **the Pylon slug verbatim**: the slug is both what a read payload indexes the values by and what a search filter sends. + +The Forest column type is derived from the Pylon field type: + +| Pylon field type | Forest column type | +| ---------------- | ------------------ | +| `text`, `url` | `String` | +| `user` | `String` | +| `number` | `Number` | +| `decimal` | `Number` | +| `boolean` | `Boolean` | +| `date` | `Dateonly` | +| `datetime` | `Date` | +| `select` | `Enum` | +| `multiselect` | `Json` | + +- An unrecognized type is **skipped and logged** rather than guessed at. +- A column name colliding with a native column is skipped. +- A `user` field holds a Pylon user id and stays a `String` rather than becoming a relation. +- A `select` advertises the **slugs** of its options (Pylon reads and filters a select by slug, never by label). A select whose options were all removed falls back to a read-only `String`, so the column still shows what it holds. +- Nothing is sortable (no Pylon endpoint takes a sort parameter) and nothing is groupable. +- A custom field is writable only when Pylon flags it `is_read_only: false`. A definition carrying no flag at all is left read-only and reported once, because nothing can tell it apart from a field synced from an app — whose every save Pylon would reject. + + +The introspection runs while the datasource is being constructed, in front of your Rails boot, which is why it uses its own short timeouts (`boot_open_timeout`, `boot_timeout`, `boot_retry_policy`). + +If it fails, it costs the custom columns and not the datasource: the agent boots on the native schema and says so in the log. The first failure also stands for the object types after it, rather than paying the same timeout three times over. + + +## Capabilities + +Pylon is a ticketing API, not a database, and several things Forest asks for have no equivalent. Where that happens the datasource **refuses with a message naming the reason** rather than answering something that looks right and is not. All of these reach the operator as a `400` carrying that text. + +### Filters + +The condition tree is translated into the filter payload of the matching Pylon search endpoint. Each collection derives its columns' filter operators from the allow-list of its endpoint, so the UI never offers a filter of the collection's own that Pylon would refuse. + + + + +| Column | Filter sent as | Supported operators | +| --------------------- | ---------------------------- | --------------------------------------------------------------------------------------- | +| `id` | _(short-circuited)_ | `EQUAL`, `IN` — see [Primary-key lookups](#primary-key-lookups) | +| `state` | `state` | `EQUAL`, `IN`, `NOT_IN` | +| `type` | `issue_type` | `EQUAL`, `IN`, `NOT_IN`, `PRESENT`, `BLANK`, `MISSING` | +| `account_id` | `account_id` | `EQUAL`, `IN`, `NOT_IN`, `PRESENT`, `BLANK`, `MISSING` | +| `requester_id` | `requester_id` | `EQUAL`, `IN`, `NOT_IN`, `PRESENT`, `BLANK`, `MISSING` | +| `assignee_id` | `assignee_id` | `EQUAL`, `IN`, `NOT_IN`, `PRESENT`, `BLANK`, `MISSING` | +| `team_id` | `team_id` | `EQUAL`, `IN`, `NOT_IN` | +| `title` | `title` | `CONTAINS`, `I_CONTAINS`, `NOT_CONTAINS`, `NOT_I_CONTAINS` | +| `body_html` | `body_html` | `CONTAINS`, `I_CONTAINS`, `NOT_CONTAINS`, `NOT_I_CONTAINS` | +| `tags` | `tags` | `IN`, `NOT_IN` | +| `created_at` | `created_at` | `GREATER_THAN`, `LESS_THAN` (and everything the agent rewrites into them) | +| `updated_at` | `updated_at` | Same | +| `resolution_time` | `resolved_at` | Same | +| `latest_message_time` | `latest_message_activity_at` | Same | + +Every other column of `PylonIssue` carries no filter operator at all, `POST /issues/search` covering nothing else. + + + + +| Column | Supported operators | +| ---------- | ------------------------------------------------------ | +| `id` | `EQUAL`, `IN`, `NOT_IN` (filtered server-side here) | +| `name` | `EQUAL`, `IN`, `NOT_IN`, `CONTAINS`, `I_CONTAINS` | +| `domains` | `IN`, `NOT_IN` | +| `tags` | `IN`, `NOT_IN` | +| `owner_id` | `EQUAL`, `IN`, `NOT_IN`, `PRESENT`, `BLANK`, `MISSING` | + +`name` gets no negated substring: the endpoint accepts `string_contains` but no negation of it. **No time field is filterable** — `created_at`, `updated_at` and `latest_customer_activity_time` are absent from the allow-list. `external_ids` is deliberately left out although the endpoint filters it: the API matches bare external-id strings while the column shows `{external_id, label}` objects, so the filter would run on something the operator cannot see. To reach an account by an external id, use it in place of the primary key — `GET /accounts/{id}` accepts one. + + + + +| Column | Supported operators | +| ------------ | ------------------------------------------------- | +| `id` | `EQUAL`, `IN`, `NOT_IN` (filtered server-side) | +| `name` | `EQUAL`, `IN`, `NOT_IN`, `CONTAINS`, `I_CONTAINS` | +| `email` | `EQUAL`, `IN`, `NOT_IN`, `CONTAINS`, `I_CONTAINS` | +| `account_id` | `EQUAL`, `IN`, `NOT_IN` | + +`email` filters the primary address only, not the `emails` list. The contacts search offers nothing else: no presence check, no filter on the phone numbers, the portal role or the external ids. + + + + +`GET /users` and `GET /teams` take no filter at all, so the whole response is filtered **in memory** — which is exact rather than approximate here, the records in hand being every record Pylon holds. + +Scalar columns therefore advertise the operators the agent can evaluate in memory (`EQUAL`, `NOT_EQUAL`, `IN`, `NOT_IN`, `PRESENT`, `BLANK`, `CONTAINS`, `I_CONTAINS`, `NOT_CONTAINS`, `STARTS_WITH`, `ENDS_WITH` on a `String`; the equality and presence family on a `Boolean`). `Json` columns (`emails`, `user_ids`) advertise none. + + + + +Notes that apply to every collection: + +- **Custom fields** are filtered through their slug, with the operators of the column the introspection built: the equality and presence families, plus substring on a `String` and the bare comparisons on a date. A `Number` gets no comparison (Pylon documents `time_is_after` / `time_is_before` and nothing else, so a numeric range would travel as a time filter), and a `multiselect` gets nothing. +- **Presence filters need a field that supports them.** Forest derives `PRESENT` / `BLANK` / `MISSING` from an equality filter above the datasource and rewrites them into a comparison with an empty value. Only a field carrying the presence family can answer one — Pylon matches an absent value through `is_set` / `is_unset` alone — so on every other field the translator refuses the rewritten condition and names the filter to change, rather than sending a comparison Pylon would answer as if the empty value were a value of its own. +- **A condition on a relation is resolved by reading the foreign collection** for its keys and sending them as an `in`. Past `MAX_RELATION_KEYS` (**500**) the condition is refused rather than truncated. A resolution matching no foreign record answers with no record at all. +- **`NOT_EQUAL` is offered wherever `NOT_IN` is**, although no table above lists it: the datasource declares the one spelling Pylon takes, and the agent republishes the other from it. The same rewrite is what puts `PRESENT` / `BLANK` / `MISSING` in front of the fields that carry them. +- **`In` and `NotIn` are refused when empty** rather than matched against everything. +- **Non-finite numbers are refused.** A `Number` filter the agent cast to `Infinity` or `NaN` is answered with a 400 naming the field. + +#### Primary-key lookups + +`POST /issues/search` has no `id` filter, so a primary-key lookup on `PylonIssue` is short-circuited to `GET /issues/{id}`, **one request per id**. Any `id` leaf of a top-level `AND` is taken — Forest sends `AND(id equal X, )` on a record detail as soon as a scope or a segment is set — and two of them are intersected, an `AND` naming the records all of its conditions name. + +That fan-out is capped **per page** at `MAX_ID_LOOKUPS` (**20**): the window is taken off the ids first, so a wider selection is read a page at a time rather than truncated at its first twenty. The one shape that cannot be paged that way is a lookup carrying a residual condition — which records the page holds could only be known by reading all of them — and it is refused past the cap. + +Two further refusals on this path: + +- **Combining free-text search with an `id` filter** is refused: search and id lookup are different endpoints, and neither can do the other's half. +- A **nested** `AND` carrying an `id` is refused instead of resolved. It fails closed, and the agent flattens one level of grouping, so every shape the UI builds keeps its `id` at the top level. + +`PylonAccount` and `PylonContact` need none of this: their search endpoints filter `id` server-side. + +### Sorting + +**No Pylon endpoint takes a sort parameter.** On `PylonIssue`, `PylonAccount` and `PylonContact` no column is advertised as sortable, and a requested order is reported in the log rather than silently swallowed — issues always come back newest first, accounts and contacts in whatever order the API imposes. + +`PylonUser` and `PylonTeam` are the exception: their endpoint hands back the whole dataset, so every scalar column is sortable and the sort is applied in memory over all of it (`Json` columns are not). + +### Pagination + +Forest's offset/limit window is translated into Pylon's cursor pagination: the client walks the cursor until it has collected the window the caller asked for, capped at `MAX_PAGES` (**20**) pages and `MAX_RECORDS` (**5 000**) records per walk, with a page size clamped to `MAX_SEARCH_LIMIT` (**1 000**). A walk cut short by those caps logs a truncation warning. + +A filter carrying no page asks for every record it matched, and travels as no limit at all rather than as a stand-in figure — so it cannot be mistaken for a window the caller asked for and truncated silently. + +For `PylonUser` and `PylonTeam`, the whole response is re-read on every list and the window is cut out of it in memory. `GET /users` is read with deactivated agents included, deliberately: a deactivated agent stays the assignee and the author of the issues they handled, and `is_deactivated` is exposed as a column so you can filter them out. + +### Aggregations + +**`PylonIssue`, `PylonAccount` and `PylonContact` cannot be aggregated at all.** Pylon exposes no aggregate endpoint and no total, and counting or grouping the pages a cursor walk collected would answer a fraction of a collection as if it were the whole of it. Every column is registered non-groupable so the UI never offers a group-by, and a chart built through the API anyway is refused with a message saying why. Those collections are not advertised as countable either, so the record count is not displayed. + +**`PylonUser` and `PylonTeam` are countable and groupable**, and exactly so: their endpoint hands back every record Pylon holds, so a count or a group over it is the figure a server-side aggregation would have given. That claim is checked rather than assumed — the read follows a cursor if Pylon ever advertises one, and **refuses outright** rather than answer over a fraction of the collection should it ever paginate past `MAX_COLLECTED_PAGES` (**10**) pages. + +### Search + +The free-text search bar is enabled on `PylonIssue`, `PylonAccount` and `PylonContact`: the search term travels to the matching `POST /*/search` endpoint alongside the filters, so the list is the one Pylon itself matched. + +`PylonUser` and `PylonTeam` are not searchable — their endpoint takes no search term — but every scalar column is filterable in memory instead. + +### Writes + +Create, update and delete are supported as the [collections table](#provided-collections) states. A write is **one record per request**, and everything below follows from that. + +- **A write reaching more records than one pass covers is refused up front**, at `MAX_WRITE_REQUESTS` (**20**) requests. On `PylonIssue`, where resolving the selection itself costs one `GET /issues/{id}` per record, the budget is divided accordingly — and never exceeds the primary-key page cap, so a resolution trimmed by paging cannot write to a subset of a selection while reporting the whole of it. +- **A write that fails halfway reports exactly which records were written**, so a retry can target the untouched ones rather than performing the write twice (`PartialWriteError`). +- **A read-only column sent alongside a real edit is dropped and the edit performed.** An edit naming *only* such columns is refused, naming them: it would write nothing, and the record the route reads back would show the operator their change reverting with no reason given. +- **A field Pylon only accepts in one direction is refused in the other**, naming it. On `PylonIssue`, `body_html` and `author_unverified` are create-only (they are the first message of the thread, which `PATCH /issues/{id}` does not carry) while `state` and `type` are update-only (Pylon creates every issue as `new`, of the type it decides). On `PylonContact`, `email` is create-only and `emails` update-only. On `PylonAccount`, `is_disabled` is update-only — an account is created enabled. +- **`Json` columns holding objects are read-only** even where the endpoint takes them, because the write shape is not the shape the column shows: `PylonAccount.external_ids` and `channels` / `crm_settings`, `PylonContact.phone_numbers` and `external_ids`. Writing one for the other would replace the data with something Pylon cannot read back. +- **Two projections of the same value are never both writable.** `PylonAccount.domain` / `primary_domain` are read-only (`domains` is the list the API takes); `PylonContact.portal_role` and `PylonUser.role_name` are read-only, their ids being what is written. +- **A Pylon 4xx on a write travels to the operator with Pylon's own message** (`WriteRejectedError`). A 5xx or a dropped connection is not the operator's to act on and stays an `APIError`. + + +A foreign key is forced read-only in the emitted schema whatever the datasource says, so the detail view shows one relation editor rather than two — but `account_id`, `requester_id`, `assignee_id`, `team_id` on `PylonIssue` and `account_id` on `PylonContact` are writable, which is exactly what opens that editor. + + +## Rate limits and retries + +Pylon meters **per endpoint**, not per token, from 30 to 300 requests a minute depending on the endpoint. The datasource ships the documented budget of every endpoint it calls and spaces requests out so each one is spent rather than exceeded — a sliding window per endpoint, in front of the 429 retry rather than instead of it. An endpoint absent from the table falls back to the lowest figure documented anywhere on the API (**30**/min), bucketed by its first path segment. + +The limiter is a smoother, not a guarantee: past `DEFAULT_MAX_WAIT` (**5s**) a request goes out anyway and the 429 retry takes over, with one log line per endpoint per window saying so. Under real saturation — several agents or processes on the same token — the retry is the defence. + +**The retry is bounded, deliberately.** A 429 carries a `Retry-After` of up to a full minute, and waiting one out on every attempt held the calling thread for minutes on a request the Forest server had already timed out. `RetryPolicy::DEFAULT_MAX_INTERVAL` (**12s**) caps what one attempt waits; past it the 429 surfaces as an error instead. So a saturated endpoint answers the operator with a message rather than with a page that arrives long after they gave up. + +Raise it — or lower `max_retries` — to trade the other way: + +```ruby +ForestAdminDatasourcePylon::Datasource.new( + api_key: ENV['PYLON_API_KEY'], + retry_policy: ForestAdminDatasourcePylon::RetryPolicy.new(max_retries: 1, max_interval: 65) +) +``` + +To meter on your own side instead, take the limiter out of the stack: + +```ruby +ForestAdminDatasourcePylon::Datasource.new(api_key: ENV['PYLON_API_KEY'], rate_limiter: nil) +``` + +Retries apply to `429`, `502`, `503` and `504`, plus timeouts and dropped connections. Only `GET`, `HEAD` and `OPTIONS` are replayed on a transport failure — a dropped connection on the way back from a `DELETE` Pylon did perform would otherwise be replayed into a 404 and reported as a deletion that failed when it landed. A `429` is retried on any verb, Pylon having rejected the request before processing it. + +## Errors + +| Class | Surfaces as | Raised by | +| ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `ConfigurationError` | boot failure | A missing or blank `api_key`, or a custom field declared on a collection that cannot carry one. | +| `UnsupportedOperatorError` | `400` | A filter Pylon cannot express: an unsupported operator, an empty `In`, a relation condition past the cap, a refused id lookup. | +| `UnsupportedWriteError` | `400` | A verb Pylon has no endpoint for, a field it only accepts in the other direction, or a write reaching too many records. | +| `WriteRejectedError` | `400` | A write Pylon itself refused, carrying the reason it gave. | +| `PartialWriteError` | `400` | A write performed on some records and then failed, naming the ones already written. | +| `APIError` | `500` | Any other failed Pylon call. Carries the HTTP `status` and the parsed response `body`. | + +The four `400` classes descend from the toolkit's `ValidationError`, so the agent answers with their message intact: each one names something the operator did and can undo, and the message is the only place they learn which condition to change. + +## Logging + +The datasource uses `Rails.logger` when available, and falls back to `Logger.new($stderr)`. You can override it explicitly: + +```ruby +ForestAdminDatasourcePylon.logger = MyLogger.new +``` + +Best-effort paths log a warning and degrade rather than failing the whole page render: custom-field introspection, a conversation thread that could not be read, a truncated cursor walk, an order no endpoint honours, a saturated rate-limit window, and the issue-id writeback of the `CreateIssueWithNotification` plugin. + +## Source code + +This connector is open source. Browse the code or contribute on GitHub: + +[`forest_admin_datasource_pylon`](https://github.com/ForestAdmin/agent-ruby/tree/main/packages/forest_admin_datasource_pylon) diff --git a/product/process/advanced-concepts/plugins/pylon.mdx b/product/process/advanced-concepts/plugins/pylon.mdx new file mode 100644 index 0000000..182a474 --- /dev/null +++ b/product/process/advanced-concepts/plugins/pylon.mdx @@ -0,0 +1,179 @@ +--- +title: "Pylon plugins" +description: "Surface Pylon operations as actions on any collection (create and close issues)" +--- + +The Pylon connector ships two plugins that surface Pylon operations as actions on any host collection of your back-end — typically a collection that already carries the Pylon requester's identity (an `email` column) or a Pylon issue id (a column like `pylon_issue_id`). + + +The Pylon connector is only available for Ruby (gem `forest_admin_datasource_pylon`). + + +Both plugins require the [Pylon datasource](/get-started/connect/data-sources/pylon) to be registered on your back-end: they need the `Datasource` instance to reach the Pylon API client. + +## Usage + +Nothing is registered automatically. Opt each plugin in per collection: + +```ruby +# app/lib/forest_admin_rails/create_agent.rb +pylon_datasource = ForestAdminDatasourcePylon::Datasource.new(api_key: ENV['PYLON_API_KEY']) +@agent = ForestAdminAgent::Builder::AgentFactory.instance.add_datasource(pylon_datasource, {}) + +@agent.collection :Customer do |collection| + collection.use( + ForestAdminDatasourcePylon::Plugins::CreateIssueWithNotification, + datasource: pylon_datasource, + sender_email: 'support@acme.com' + ) +end + +@agent.collection :Order do |collection| + collection.use( + ForestAdminDatasourcePylon::Plugins::CloseIssue, + datasource: pylon_datasource, + issue_id_field: 'pylon_issue_id' + ) +end +``` + +Both plugins take the `Datasource` instance you registered earlier as the required `datasource:` option, and both refuse to register without a collection — they only work at the collection level. You can attach the same plugin to several collections with different option sets. + +## Create an issue and notify + +A `Single`-scope action that opens a Pylon issue from the selected host record and delivers its first message to the requester. The host record does not need to be related to Pylon: the requester is identified by an email entered (or pre-filled) in the form, and Pylon creates the contact on the fly when it does not already exist (the action derives the contact's name from the email's local part, e.g. `john.doe@acme.com → john.doe`). + +Where a Zendesk notification is a side effect of a public comment, Pylon says it outright: `destination_metadata.destination` names the channel the issue's `body_html` is delivered through, and no metadata at all is what leaves the issue internal. + +```ruby +@agent.collection :Customer do |collection| + collection.use( + ForestAdminDatasourcePylon::Plugins::CreateIssueWithNotification, + datasource: pylon_datasource, + action_name: 'Open a support issue', + default_subject: 'Refund for {{ record.email }}', + default_message: '

Hi {{ record.name }},

', + requester_email_default: ->(record) { record['email'] }, + sender_email: 'support@acme.com', + priority_override: 'high', + issue_id_field: 'pylon_issue_id' + ) +end +``` + +| Option | Description | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `datasource` | **Required.** The `ForestAdminDatasourcePylon::Datasource` instance. | +| `action_name` | Overrides the action label. Defaults to `'Create Pylon issue and notify'`. | +| `destination` | Channel the first message is delivered through: `email` (default), `slack`, `in_app_chat`, `customer_portal`, `sms` or `whatsapp`. `internal` is the absence of a delivery rather than a channel: the issue is created and the requester is not contacted. An unknown value is refused at registration. | +| `sender_email` | **Required when `destination` is `email`** — `POST /issues` refuses an email delivery that does not name the address it is sent from. Must be one of the addresses configured in your Pylon email app. | +| `email_ccs` / `email_bccs` | Arrays of addresses copied on the outbound email. Only meaningful on an email delivery. | +| `default_subject` | String used to pre-fill the "Subject" field. Supports `{{ record. }}` tokens resolved against the selected record when the form opens. | +| `default_message` | String used to pre-fill the "Message" field. Same token syntax; rendered through a RichText widget and shipped as `body_html`. Token _values_ are HTML-escaped. | +| `email_templates` | Array of `{ title:, content: }` hashes. When non-empty, the form becomes a two-page wizard (see below). | +| `requester_email_default` | Default for the "Requester email" field. Accepts a String (same `{{ record. }}` tokens) or a `record -> email_string` Proc evaluated against the selected record when the form opens. | +| `priority_override` | One of `urgent`, `high`, `medium`, `low`. When set, the "Priority" dropdown is removed from the form and this value is forced in the payload. An unknown value is refused at registration. | +| `show_internal_note` | When truthy, adds the "Send as internal note" checkbox to the form. Hidden by default — the requester is notified unless this is opt-in. | +| `issue_id_field` | Writable column on the host collection that receives the freshly-created issue id. Best-effort: a writeback failure is logged and surfaced in the success message without rolling back the issue, Pylon having no transaction. | + +The form exposes the following fields: + +| Field | Type | Notes | +| --------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Requester email | String | Required. Pre-filled by `requester_email_default`. The Pylon contact is created on the fly when the address is unknown. | +| Subject | String | Required. Default supports `{{ record. }}` tokens. | +| Message | RichText | Required. Sent as the issue's `body_html`, and — unless it is an internal note — the message Pylon delivers to the requester. Token _values_ inside the default are HTML-escaped. | +| Priority | Enum | Optional. Values: `urgent`, `high`, `medium`, `low`. **Removed from the form when `priority_override` is set.** | +| Send as internal note | Boolean | **Hidden by default.** Surfaces only when `show_internal_note: true`. When checked, the issue is created without contacting the requester, whatever `destination` says — it is the operator's call on the record they are looking at. | + +There is no "Type" field, unlike the Zendesk form: `POST /issues` does not take one, Pylon accepting `type` on an update only. + + +Pylon never returns the priority of an issue, so no column carries it: what the operator picks in the form is applied on creation and shown nowhere in Forest afterwards. + + +### Email-templates wizard + +When `email_templates` is set, the form becomes a two-page wizard: + +1. **Page 1 — Template.** A `Template` field lists each template's `title` plus a sentinel `"No template"` entry. +2. **Page 2 — Body.** The same fields as above, with the Message recomputed from the page 1 selection. + +Picking a template fills the Message with its interpolated `content`; taking it back (`"No template"`) restores `default_message` rather than emptying a required field. Typing into Message in between is preserved across re-renders of the same selection. + +```ruby +collection.use( + ForestAdminDatasourcePylon::Plugins::CreateIssueWithNotification, + datasource: pylon_datasource, + sender_email: 'support@acme.com', + email_templates: [ + { title: 'Refund confirmation', + content: '

Hi {{ record.first_name }}, your refund has been processed.

' }, + { title: 'Shipping delay', + content: '

Hi {{ record.first_name }}, we apologise for the delay shipping order #{{ record.order_id }}.

' } + ] +) +``` + +Template titles must be unique and cannot be `"No template"`: the title is what the enum carries and what the content is looked up by, so a duplicate would send one template's content under another's name. Both are refused at registration rather than discovered by whoever sends the wrong message. + + +The message body is HTML the operator writes and Pylon delivers to the requester, and the requester address is a free-text field. Restrict this action to the roles that should be able to send mail on your organization's behalf. + + +### Outcome + +- A Pylon `4xx` reaches the operator as the action's own error, with Pylon's message intact — it names what they filled in. Anything else stays a 500. +- On success the message names the issue by its Pylon number, and says whether the requester was notified and through which channel, or that the issue is internal. +- A failed `issue_id_field` writeback is appended to that success message as a warning; the issue exists either way. + +## Close an issue + +Registers actions that move the selected Pylon issues to a state — `closed` unless told otherwise. The ids are read either from the primary keys of the selected records (when the action sits on `PylonIssue` itself) or from a configurable column of the host record, so you can close a Pylon issue straight from a business row that stores `pylon_issue_id`. + +```ruby +@agent.collection :Order do |collection| + collection.use( + ForestAdminDatasourcePylon::Plugins::CloseIssue, + datasource: pylon_datasource, + issue_id_field: 'pylon_issue_id' + ) +end +``` + +| Option | Description | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `datasource` | **Required.** The `ForestAdminDatasourcePylon::Datasource` instance. | +| `issue_id_field` | Column of the host record holding the Pylon issue id. **Omit it when the action sits on `PylonIssue` itself** — the ids are then the selected primary keys. | +| `state` | State the issues are moved to. Defaults to `'closed'`. Accepts the slug of a custom Pylon status: it is not checked against the built-in ones, which would refuse the very workflow your organization built. Cannot be empty. | +| `scopes` | Subset of `%i[single bulk]`. Defaults to both. Accepts symbols or strings interchangeably. | +| `action_name` | Label of the single-record action. Defaults to `'Close Pylon issue'`. | +| `bulk_action_name` | Label of the bulk action. Defaults to `'Close selected Pylon issues'`. | + +One action is registered per requested scope: + +| Scope | Default label | +| -------- | -------------------------------- | +| `single` | "Close Pylon issue" | +| `bulk` | "Close selected Pylon issues" | + +Pick a subset to register fewer variants, e.g. `scopes: %i[bulk]` registers a single bulk action. Registering both under the same name is refused at registration: a collection keys its actions by name, so the second would overwrite the first and answer with the wrong scope. + +Registering the plugin twice with two different `state` values on the same collection is how you offer several transitions — give each variant its own `action_name` / `bulk_action_name`. + +### Which scope bounds it + +The state is written straight through the Pylon client rather than through `PylonIssue`, because the action is registered on the host collection. But the ids were read **before** that write, through the collection the action sits on, and the agent intersects the operator's scope into the filter that read them. So what bounds this action is the scope of the **host** collection: + +- Mounted on `PylonIssue`, a scope or a segment on `PylonIssue` bounds exactly what it closes. +- Mounted on a business collection with `issue_id_field`, what bounds it is the records the operator may see there, and the issue ids those records carry. **In that form the column is the authority** — an operator who can write it can name any Pylon issue — so treat it as one. + +### Batch behaviour + +The batch is capped at **20 issues** (`MAX_TARGETS`), the same budget a filter-driven write gets: Pylon takes one request per issue, so a wider selection is a long run of sequential writes the request may time out on, leaving the issues closed up to that point closed and reporting which ones to nobody. Past the cap the run is refused **before its first write**, with a message naming the count and the cap. + +The cap counts the issues named, not the records selected: a column of issue ids is not a key, so a hundred host records naming ten issues is a batch of ten (duplicates are collapsed, so no issue is written — or counted — twice). + +Within the batch, each id is processed independently: a single issue Pylon refuses (deleted, or outside the token's scope) does not abort the rest, and the success message names how many were moved and which ones failed. If every id fails, the action surfaces as an error rather than as a partial success. + +If no usable id can be read from the selection — an empty `issue_id_field`, a renamed column, a record the scope hides — the action answers `No Pylon issue id found in ''.` rather than calling Pylon. A refusal coming from the datasource itself (a selection naming more issues by id than one page of lookups covers, for instance) travels to the operator with its own message instead: a selection they can see they made must never be reported as "nothing selected".