From 801493fc304083665f6d017cf54502cf2e570dbf Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Tue, 9 Jun 2026 15:09:44 +0200 Subject: [PATCH 1/5] feat(datasource-zendesk): add Zendesk datasource New @forestadmin/datasource-zendesk package: collections for tickets, users and organizations over the Zendesk API, condition-tree translation, custom-field introspection, and plugins (close-ticket, create-ticket-with-notification). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/datasource-zendesk/README.md | 142 ++++++ packages/datasource-zendesk/jest.config.ts | 8 + packages/datasource-zendesk/package.json | 33 ++ .../datasource-zendesk/src/client/index.ts | 386 +++++++++++++++++ .../collections/base-zendesk-collection.ts | 207 +++++++++ .../collections/organization-collection.ts | 173 ++++++++ .../src/collections/searchable-collection.ts | 72 ++++ .../src/collections/ticket-collection.ts | 190 ++++++++ .../collections/ticket/comments-embedder.ts | 57 +++ .../collections/ticket/relation-embedder.ts | 78 ++++ .../collections/ticket/schema-definition.ts | 151 +++++++ .../src/collections/ticket/serializer.ts | 108 +++++ .../src/collections/user-collection.ts | 189 ++++++++ packages/datasource-zendesk/src/datasource.ts | 68 +++ packages/datasource-zendesk/src/enums.ts | 14 + packages/datasource-zendesk/src/errors.ts | 31 ++ packages/datasource-zendesk/src/factory.ts | 21 + packages/datasource-zendesk/src/index.ts | 23 + .../src/plugins/close-ticket/errors.ts | 46 ++ .../src/plugins/close-ticket/index.ts | 107 +++++ .../src/plugins/close-ticket/messages.ts | 49 +++ .../form-builder.ts | 234 ++++++++++ .../create-ticket-with-notification/index.ts | 139 ++++++ .../src/query/condition-tree-translator.ts | 128 ++++++ .../src/schema/custom-fields-introspector.ts | 119 ++++++ packages/datasource-zendesk/src/types.ts | 28 ++ .../datasource-zendesk/test/client.test.ts | 257 +++++++++++ .../organization-collection.test.ts | 96 +++++ .../collections/ticket-collection.test.ts | 404 ++++++++++++++++++ .../test/collections/user-collection.test.ts | 144 +++++++ .../test/datasource.test.ts | 79 ++++ .../test/plugins/close-ticket.test.ts | 240 +++++++++++ .../create-ticket-with-notification.test.ts | 293 +++++++++++++ .../query/condition-tree-translator.test.ts | 215 ++++++++++ .../schema/custom-fields-introspector.test.ts | 224 ++++++++++ .../datasource-zendesk/tsconfig.eslint.json | 3 + packages/datasource-zendesk/tsconfig.json | 7 + 37 files changed, 4763 insertions(+) create mode 100644 packages/datasource-zendesk/README.md create mode 100644 packages/datasource-zendesk/jest.config.ts create mode 100644 packages/datasource-zendesk/package.json create mode 100644 packages/datasource-zendesk/src/client/index.ts create mode 100644 packages/datasource-zendesk/src/collections/base-zendesk-collection.ts create mode 100644 packages/datasource-zendesk/src/collections/organization-collection.ts create mode 100644 packages/datasource-zendesk/src/collections/searchable-collection.ts create mode 100644 packages/datasource-zendesk/src/collections/ticket-collection.ts create mode 100644 packages/datasource-zendesk/src/collections/ticket/comments-embedder.ts create mode 100644 packages/datasource-zendesk/src/collections/ticket/relation-embedder.ts create mode 100644 packages/datasource-zendesk/src/collections/ticket/schema-definition.ts create mode 100644 packages/datasource-zendesk/src/collections/ticket/serializer.ts create mode 100644 packages/datasource-zendesk/src/collections/user-collection.ts create mode 100644 packages/datasource-zendesk/src/datasource.ts create mode 100644 packages/datasource-zendesk/src/enums.ts create mode 100644 packages/datasource-zendesk/src/errors.ts create mode 100644 packages/datasource-zendesk/src/factory.ts create mode 100644 packages/datasource-zendesk/src/index.ts create mode 100644 packages/datasource-zendesk/src/plugins/close-ticket/errors.ts create mode 100644 packages/datasource-zendesk/src/plugins/close-ticket/index.ts create mode 100644 packages/datasource-zendesk/src/plugins/close-ticket/messages.ts create mode 100644 packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts create mode 100644 packages/datasource-zendesk/src/plugins/create-ticket-with-notification/index.ts create mode 100644 packages/datasource-zendesk/src/query/condition-tree-translator.ts create mode 100644 packages/datasource-zendesk/src/schema/custom-fields-introspector.ts create mode 100644 packages/datasource-zendesk/src/types.ts create mode 100644 packages/datasource-zendesk/test/client.test.ts create mode 100644 packages/datasource-zendesk/test/collections/organization-collection.test.ts create mode 100644 packages/datasource-zendesk/test/collections/ticket-collection.test.ts create mode 100644 packages/datasource-zendesk/test/collections/user-collection.test.ts create mode 100644 packages/datasource-zendesk/test/datasource.test.ts create mode 100644 packages/datasource-zendesk/test/plugins/close-ticket.test.ts create mode 100644 packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts create mode 100644 packages/datasource-zendesk/test/query/condition-tree-translator.test.ts create mode 100644 packages/datasource-zendesk/test/schema/custom-fields-introspector.test.ts create mode 100644 packages/datasource-zendesk/tsconfig.eslint.json create mode 100644 packages/datasource-zendesk/tsconfig.json diff --git a/packages/datasource-zendesk/README.md b/packages/datasource-zendesk/README.md new file mode 100644 index 0000000000..274614a8ed --- /dev/null +++ b/packages/datasource-zendesk/README.md @@ -0,0 +1,142 @@ +# @forestadmin/datasource-zendesk + +Forest Admin datasource for Zendesk Support, with optional smart-action plugins. + +Exposes three collections wired to the Zendesk REST + Search APIs: + +| Forest collection | Zendesk resource | CRUD | Aggregation | +| ----------------------- | ---------------- | ------------- | --------------- | +| `zendesk_ticket` | Ticket | List, create, update, delete | Count only | +| `zendesk_user` | User | List, create, update, delete | Count only | +| `zendesk_organization` | Organization | List, create, update, delete | Count only | + +The datasource introspects Zendesk **custom fields** on startup (ticket, user, organization) and registers them as Forest columns. + +## Installation + +```bash +yarn add @forestadmin/datasource-zendesk +``` + +## Quick start + +```ts +import { createAgent } from '@forestadmin/agent'; +import { + createZendeskClient, + createZendeskDataSource, + closeTicketPlugin, + createTicketWithNotificationPlugin, +} from '@forestadmin/datasource-zendesk'; + +const zendeskClient = createZendeskClient({ + subdomain: process.env.ZENDESK_SUBDOMAIN!, + email: process.env.ZENDESK_EMAIL!, + apiToken: process.env.ZENDESK_API_TOKEN!, +}); + +const agent = createAgent({ /* ... */ }) + .addDataSource(createZendeskDataSource({ client: zendeskClient })) + + // Smart action on Zendesk tickets: Mark as solved / closed. + .customizeCollection('zendesk_ticket', collection => + collection.use(closeTicketPlugin, { + client: zendeskClient, + ticketIdField: 'id', + }), + ) + + // Smart action on ANY collection (here: customers): create a Zendesk ticket + // and notify the requester by email. + .customizeCollection('customers', collection => + collection.use(createTicketWithNotificationPlugin, { + client: zendeskClient, + requesterEmailDefault: record => String(record.email ?? ''), + defaultSubject: 'Follow-up about your account', + ticketIdField: 'last_zendesk_ticket_id', + }), + ); +``` + +## Datasource + +### `createZendeskDataSource(options)` + +`options` accepts either an already-built `client` (recommended when also passing it to plugins) or raw credentials: + +```ts +createZendeskDataSource({ client: createZendeskClient({ subdomain, email, apiToken }) }); + +// or +createZendeskDataSource({ subdomain, email, apiToken }); +``` + +### Filter operators + +Zendesk Search supports a restricted set of operators. The datasource exposes: + +- `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank` for strings, enums and booleans +- `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank`, `GreaterThan`, `LessThan` for numbers +- `Equal`, `Before`, `After`, `Present`, `Blank` for dates + +Unsupported operators (`Contains`, `Or` aggregator, …) raise `UnsupportedOperatorError`. The Zendesk Search API caps result pagination at 1000 records — large skips raise the same error. + +### Custom fields + +On boot, the datasource calls `GET /ticket_fields.json`, `/user_fields.json` and `/organization_fields.json`. Active fields are mapped to Forest columns: + +| Zendesk type | Forest column | +| ------------ | ------------- | +| `text`, `textarea`, `regexp`, `partialcreditcard` | `String` | +| `integer`, `decimal`, `lookup` | `Number` | +| `date` | `Dateonly` | +| `checkbox` | `Boolean` | +| `dropdown`, `tagger` | `Enum` (or `String` if no options) | +| `multiselect` | `Json` | + +Ticket custom fields are exposed as `custom_`; user/organization ones use the Zendesk `key`. + +## Plugins + +### `closeTicketPlugin` + +Adds Single + Bulk actions to mark Zendesk tickets as `solved` or `closed`. Tickets that Zendesk reports as already closed are folded into the success message rather than counted as failures. + +```ts +collection.use(closeTicketPlugin, { + client, + ticketIdField: 'zendesk_id', + // optional: + statuses: ['solved'], // default: ['solved', 'closed'] + scopes: ['Single'], // default: ['Single', 'Bulk'] +}); +``` + +### `createTicketWithNotificationPlugin` + +Adds a Single action that creates a Zendesk ticket. Useful on a customer-facing collection so an agent can reach out without leaving Forest. With `emailTemplates`, the form becomes two-page (template picker → body). + +```ts +collection.use(createTicketWithNotificationPlugin, { + client, + actionName: 'Notify customer', // optional + emailTemplates: [ + { title: 'Welcome', content: 'Hi {{ record.first_name }}, welcome aboard.' }, + ], + requesterEmailDefault: record => String(record.email ?? ''), + defaultSubject: 'Follow-up', + priorityOverride: 'normal', // hides Priority from the form + ticketIdField: 'last_zendesk_ticket_id', // best-effort writeback after creation + showInternalNote: true, // adds a "Send as internal note" toggle +}); +``` + +Template interpolation supports `{{ record.field }}` (including dotted paths like `{{ record.org.name }}`). + +## Errors + +All exceptions raised by the datasource are subclasses of Forest Admin's `BusinessError` / `ValidationError`: + +- `ZendeskConfigurationError` — missing/invalid client options +- `ZendeskApiError` — Zendesk HTTP failure (carries the operation, status code and response body) +- `UnsupportedOperatorError` — filter / aggregation that Zendesk cannot express diff --git a/packages/datasource-zendesk/jest.config.ts b/packages/datasource-zendesk/jest.config.ts new file mode 100644 index 0000000000..d622773e8a --- /dev/null +++ b/packages/datasource-zendesk/jest.config.ts @@ -0,0 +1,8 @@ +/* eslint-disable import/no-relative-packages */ +import jestConfig from '../../jest.config'; + +export default { + ...jestConfig, + collectCoverageFrom: ['/src/**/*.ts'], + testMatch: ['/test/**/*.test.ts'], +}; diff --git a/packages/datasource-zendesk/package.json b/packages/datasource-zendesk/package.json new file mode 100644 index 0000000000..b0933ed988 --- /dev/null +++ b/packages/datasource-zendesk/package.json @@ -0,0 +1,33 @@ +{ + "name": "@forestadmin/datasource-zendesk", + "version": "1.0.0", + "main": "dist/index.js", + "license": "GPL-3.0", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ForestAdmin/agent-nodejs.git", + "directory": "packages/datasource-zendesk" + }, + "dependencies": { + "@forestadmin/datasource-toolkit": "1.53.1", + "superagent": "^10.3.0" + }, + "devDependencies": { + "@forestadmin/datasource-customizer": "1.69.3", + "@types/superagent": "^8.1.0" + }, + "files": [ + "dist/**/*.js", + "dist/**/*.d.ts" + ], + "scripts": { + "build": "tsc", + "build:watch": "tsc --watch", + "clean": "rm -rf coverage dist", + "lint": "eslint src test", + "test": "jest" + } +} diff --git a/packages/datasource-zendesk/src/client/index.ts b/packages/datasource-zendesk/src/client/index.ts new file mode 100644 index 0000000000..f7e259bba2 --- /dev/null +++ b/packages/datasource-zendesk/src/client/index.ts @@ -0,0 +1,386 @@ +import type { + CustomFieldEntry, + SearchParams, + ZendeskClientOptions, + ZendeskRecord, + ZendeskResource, +} from '../types'; +import type { Logger } from '@forestadmin/datasource-toolkit'; +import type { SuperAgentRequest } from 'superagent'; + +import superagent from 'superagent'; + +import { ZendeskApiError, ZendeskConfigurationError } from '../errors'; + +export const MAX_PER_PAGE = 100; +export const MAX_TOTAL_RESULTS = 1000; + +const RESOURCE_PLURAL: Record = { + ticket: 'tickets', + user: 'users', + organization: 'organizations', +}; + +export type RawCustomFieldDefinition = { + id: number; + key?: string; + title?: string; + raw_title?: string; + type: string; + active?: boolean; + removable?: boolean; + custom_field_options?: Array<{ value: string; name?: string }>; +}; + +export type ZendeskClient = { + search(type: ZendeskResource, params: SearchParams): Promise; + count(type: ZendeskResource, query: string): Promise; + findTicket(id: number | string): Promise; + findUser(id: number | string): Promise; + findOrganization(id: number | string): Promise; + fetchTicketsByIds(ids: Array): Promise>; + fetchUsersByIds(ids: Array): Promise>; + fetchOrganizationsByIds(ids: Array): Promise>; + fetchUserEmails(ids: Array): Promise>; + fetchTicketComments(ticketId: number | string): Promise; + fetchTicketFields(): Promise; + fetchUserFields(): Promise; + fetchOrganizationFields(): Promise; + createTicket(attrs: ZendeskRecord): Promise; + updateTicket(id: number | string, attrs: ZendeskRecord): Promise; + deleteTicket(id: number | string): Promise; + createUser(attrs: ZendeskRecord): Promise; + updateUser(id: number | string, attrs: ZendeskRecord): Promise; + deleteUser(id: number | string): Promise; + createOrganization(attrs: ZendeskRecord): Promise; + updateOrganization(id: number | string, attrs: ZendeskRecord): Promise; + deleteOrganization(id: number | string): Promise; + readonly baseUrl: string; + /** + * Best-effort wrapper that logs and returns the default if the action throws. + * Used internally and exposed so embedders/plugins can degrade gracefully. + */ + bestEffort(operation: string, defaultValue: T, fn: () => Promise): Promise; +}; + +function composeQuery(type: ZendeskResource | null, query: string): string { + const parts = [type ? `type:${type}` : null, (query ?? '').trim()].filter(Boolean) as string[]; + + return parts.join(' ').trim(); +} + +function extractResource( + body: ZendeskRecord, + key: ZendeskResource, + operation: string, +): ZendeskRecord { + const resource = body[key]; + + if (resource && typeof resource === 'object' && !Array.isArray(resource)) { + return resource as ZendeskRecord; + } + + throw new ZendeskApiError( + operation, + undefined, + body, + new Error(`Zendesk API returned an unexpected body shape (missing '${key}')`), + ); +} + +function validate(options: ZendeskClientOptions): void { + const missing: string[] = []; + + if (!options?.subdomain?.trim()) missing.push('subdomain'); + if (!options?.email?.trim()) missing.push('email'); + if (!options?.apiToken?.trim()) missing.push('apiToken'); + + if (missing.length > 0) { + throw new ZendeskConfigurationError( + `Zendesk client is missing required configuration: ${missing.join(', ')}`, + ); + } +} + +export class ZendeskHttpClient implements ZendeskClient { + readonly baseUrl: string; + private readonly authHeader: string; + private readonly logger?: Logger; + + constructor(options: ZendeskClientOptions, logger?: Logger) { + validate(options); + this.baseUrl = `https://${options.subdomain}.zendesk.com/api/v2`; + const credentials = Buffer.from(`${options.email}/token:${options.apiToken}`).toString( + 'base64', + ); + this.authHeader = `Basic ${credentials}`; + this.logger = logger; + } + + async search(type: ZendeskResource, params: SearchParams): Promise { + const query = composeQuery(type, params.query); + const perPage = Math.min(params.perPage ?? MAX_PER_PAGE, MAX_PER_PAGE); + + return this.mustSucceed(`search(${type})`, async () => { + const queryParams: Record = { + query, + per_page: perPage, + page: params.page ?? 1, + }; + + if (params.sortBy) queryParams.sort_by = params.sortBy; + if (params.sortOrder) queryParams.sort_order = params.sortOrder; + + const body = await this.get('/search.json', queryParams); + + return Array.isArray(body.results) ? (body.results as ZendeskRecord[]) : []; + }); + } + + async count(type: ZendeskResource, query: string): Promise { + return this.mustSucceed(`count(${type})`, async () => { + const body = await this.get('/search/count.json', { query: composeQuery(type, query) }); + + return typeof body.count === 'number' ? body.count : 0; + }); + } + + findTicket(id: number | string): Promise { + return this.findOne('ticket', id); + } + + findUser(id: number | string): Promise { + return this.findOne('user', id); + } + + findOrganization(id: number | string): Promise { + return this.findOne('organization', id); + } + + fetchTicketsByIds(ids: Array): Promise> { + return this.showMany('ticket', ids, (r: ZendeskRecord) => [Number(r.id), r]); + } + + fetchUsersByIds(ids: Array): Promise> { + return this.bestEffort('fetch_users_by_ids', new Map(), () => + this.showMany('user', ids, r => [Number(r.id), r]), + ); + } + + fetchOrganizationsByIds(ids: Array): Promise> { + return this.bestEffort('fetch_organizations_by_ids', new Map(), () => + this.showMany('organization', ids, r => [Number(r.id), r]), + ); + } + + fetchUserEmails(ids: Array): Promise> { + return this.bestEffort('fetch_user_emails', new Map(), () => + this.showMany('user', ids, r => [Number(r.id), String(r.email ?? '')]), + ); + } + + async fetchTicketComments(ticketId: number | string): Promise { + return this.mustSucceed(`fetch_ticket_comments(${ticketId})`, async () => { + const body = await this.get(`/tickets/${encodeURIComponent(String(ticketId))}/comments.json`); + + return Array.isArray(body.comments) ? (body.comments as ZendeskRecord[]) : []; + }); + } + + fetchTicketFields(): Promise { + return this.fetchFields('ticket_fields'); + } + + fetchUserFields(): Promise { + return this.fetchFields('user_fields'); + } + + fetchOrganizationFields(): Promise { + return this.fetchFields('organization_fields'); + } + + createTicket(attrs: ZendeskRecord): Promise { + return this.createOne('ticket', attrs); + } + + updateTicket(id: number | string, attrs: ZendeskRecord): Promise { + return this.updateOne('ticket', id, attrs); + } + + deleteTicket(id: number | string): Promise { + return this.deleteOne('ticket', id); + } + + createUser(attrs: ZendeskRecord): Promise { + return this.createOne('user', attrs); + } + + updateUser(id: number | string, attrs: ZendeskRecord): Promise { + return this.updateOne('user', id, attrs); + } + + deleteUser(id: number | string): Promise { + return this.deleteOne('user', id); + } + + createOrganization(attrs: ZendeskRecord): Promise { + return this.createOne('organization', attrs); + } + + updateOrganization(id: number | string, attrs: ZendeskRecord): Promise { + return this.updateOne('organization', id, attrs); + } + + deleteOrganization(id: number | string): Promise { + return this.deleteOne('organization', id); + } + + async bestEffort(operation: string, defaultValue: T, fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + this.logger?.( + 'Warn', + `[datasource-zendesk] ${operation} failed; degrading: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + + return defaultValue; + } + } + + // ===== Internal helpers ===== + + private async findOne(type: ZendeskResource, id: number | string): Promise { + const plural = RESOURCE_PLURAL[type]; + const path = `/${plural}/${encodeURIComponent(String(id))}.json`; + + try { + const body = await this.mustSucceed(`find(${plural}/${id})`, () => this.get(path)); + + return (body[type] as ZendeskRecord) ?? null; + } catch (err) { + if (err instanceof ZendeskApiError && err.status === 404) return null; + throw err; + } + } + + private async showMany( + type: ZendeskResource, + ids: Array, + project: (record: ZendeskRecord) => [number, TValue], + ): Promise> { + const plural = RESOURCE_PLURAL[type]; + const uniqueIds = Array.from(new Set(ids.filter(id => id !== null && id !== undefined))); + const result = new Map(); + + if (uniqueIds.length === 0) return result; + + for (let i = 0; i < uniqueIds.length; i += MAX_PER_PAGE) { + const chunk = uniqueIds.slice(i, i + MAX_PER_PAGE); + // eslint-disable-next-line no-await-in-loop + const body = await this.mustSucceed(`fetch_${plural}_by_ids`, () => + this.get(`/${plural}/show_many.json`, { ids: chunk.join(',') }), + ); + const records = Array.isArray(body[plural]) ? (body[plural] as ZendeskRecord[]) : []; + + for (const record of records) { + const [key, value] = project(record); + result.set(key, value); + } + } + + return result; + } + + private async fetchFields(path: 'ticket_fields' | 'user_fields' | 'organization_fields') { + return this.mustSucceed(`fetch_${path}`, async () => { + const body = await this.get(`/${path}.json`); + + return Array.isArray(body[path]) ? (body[path] as RawCustomFieldDefinition[]) : []; + }); + } + + private async createOne(type: ZendeskResource, attrs: ZendeskRecord): Promise { + const plural = RESOURCE_PLURAL[type]; + + return this.mustSucceed(`create(${plural})`, async () => { + const body = await this.post(`/${plural}.json`, { [type]: attrs }); + + return extractResource(body, type, `create(${plural})`); + }); + } + + private async updateOne( + type: ZendeskResource, + id: number | string, + attrs: ZendeskRecord, + ): Promise { + const plural = RESOURCE_PLURAL[type]; + + return this.mustSucceed(`update(${plural}/${id})`, async () => { + const body = await this.put(`/${plural}/${encodeURIComponent(String(id))}.json`, { + [type]: attrs, + }); + + return extractResource(body, type, `update(${plural}/${id})`); + }); + } + + private async deleteOne(type: ZendeskResource, id: number | string): Promise { + const plural = RESOURCE_PLURAL[type]; + + await this.mustSucceed(`delete(${plural}/${id})`, async () => { + await this.delete(`/${plural}/${encodeURIComponent(String(id))}.json`); + + return null; + }); + } + + private async mustSucceed(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof ZendeskApiError) throw err; + const status = (err as { status?: number })?.status; + const body = (err as { response?: { body?: unknown } })?.response?.body; + throw new ZendeskApiError(operation, status, body, err as Error); + } + } + + private get(path: string, query?: Record): Promise { + const request = superagent.get(`${this.baseUrl}${path}`); + if (query) request.query(query); + + return this.send(request); + } + + private post(path: string, body: unknown): Promise { + return this.send(superagent.post(`${this.baseUrl}${path}`).send(body as object)); + } + + private put(path: string, body: unknown): Promise { + return this.send(superagent.put(`${this.baseUrl}${path}`).send(body as object)); + } + + private delete(path: string): Promise { + return this.send(superagent.delete(`${this.baseUrl}${path}`)); + } + + private async send(request: SuperAgentRequest): Promise { + const response = await request + .set('Authorization', this.authHeader) + .set('Accept', 'application/json') + .set('Content-Type', 'application/json'); + + return (response.body ?? {}) as ZendeskRecord; + } +} + +export function createZendeskClient(options: ZendeskClientOptions, logger?: Logger): ZendeskClient { + return new ZendeskHttpClient(options, logger); +} + +// Re-export the type so consumers can import { CustomFieldEntry } from this module if desired. +export type { CustomFieldEntry }; diff --git a/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts b/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts new file mode 100644 index 0000000000..54b982af89 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts @@ -0,0 +1,207 @@ +import type { ZendeskClient } from '../client'; +import type ZendeskDataSource from '../datasource'; +import type { CustomFieldEntry, ZendeskResource } from '../types'; +import type { + AggregateResult, + Aggregation, + AggregationCapabilities, + Caller, + ConditionTree, + DataSource, + Filter, + Logger, + Operator, + Page, + PaginatedFilter, + Projection, + RecordData, + Sort, +} from '@forestadmin/datasource-toolkit'; + +import { + BaseCollection, + ConditionTreeBranch, + ConditionTreeLeaf, +} from '@forestadmin/datasource-toolkit'; + +import { MAX_TOTAL_RESULTS } from '../client'; +import { UnsupportedOperatorError } from '../errors'; +import { translateConditionTree } from '../query/condition-tree-translator'; + +export const STRING_OPS = new Set([ + 'Equal', + 'NotEqual', + 'In', + 'NotIn', + 'Present', + 'Blank', +]); +export const NUMBER_OPS = new Set([ + 'Equal', + 'NotEqual', + 'In', + 'NotIn', + 'Present', + 'Blank', + 'GreaterThan', + 'LessThan', +]); +export const DATE_OPS = new Set(['Equal', 'Before', 'After', 'Present', 'Blank']); + +export type TranslatedPage = { page: number; perPage: number }; + +export default abstract class BaseZendeskCollection extends BaseCollection { + protected readonly client: ZendeskClient; + protected readonly resource: ZendeskResource; + protected readonly sortableFields: Record; + protected readonly logger?: Logger; + protected readonly zendeskIdToColumnName: Map; + + constructor( + name: string, + datasource: DataSource, + client: ZendeskClient, + resource: ZendeskResource, + sortableFields: Record, + logger?: Logger, + ) { + super(name, datasource, client); + this.client = client; + this.resource = resource; + this.sortableFields = sortableFields; + this.logger = logger; + this.zendeskIdToColumnName = new Map(); + + // Zendesk Search only supports counting search results — disallow grouped aggregates. + const capabilities: AggregationCapabilities = { + supportGroups: false, + supportedDateOperations: new Set(), + }; + this.setAggregationCapabilities(capabilities); + this.enableCount(); + } + + // ===== Public API (BaseCollection) ===== + + abstract override create(caller: Caller, data: RecordData[]): Promise; + + abstract override list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise; + + abstract override update(caller: Caller, filter: Filter, patch: RecordData): Promise; + + abstract override delete(caller: Caller, filter: Filter): Promise; + + override async aggregate( + caller: Caller, + filter: Filter, + aggregation: Aggregation, + limit?: number, + ): Promise { + if (aggregation.operation !== 'Count' || aggregation.field || aggregation.groups?.length) { + throw new UnsupportedOperatorError( + `Zendesk only supports the 'Count' aggregation without field/groups (got ${aggregation.operation}).`, + ); + } + + const count = await this.aggregateCount(caller, filter); + const results: AggregateResult[] = count > 0 ? [{ value: count, group: {} }] : []; + + return typeof limit === 'number' ? results.slice(0, limit) : results; + } + + // ===== Helpers used by subclasses ===== + + protected abstract aggregateCount(caller: Caller, filter: Filter): Promise; + + protected addCustomFields(customFields: CustomFieldEntry[]): void { + for (const entry of customFields) { + if (this.schema.fields[entry.columnName] !== undefined) { + this.logger?.( + 'Warn', + `[datasource-zendesk] Custom field '${entry.columnName}' collides with a native field on '${this.name}'; skipping.`, + ); + } else { + this.addField(entry.columnName, entry.schema); + this.zendeskIdToColumnName.set(entry.zendeskId, entry.columnName); + } + } + } + + protected translateSort(sort: Sort | undefined): { + sortBy?: string; + sortOrder?: 'asc' | 'desc'; + } { + if (!sort || sort.length === 0) return {}; + + const [first] = sort; + const zendeskField = this.sortableFields[first.field]; + if (!zendeskField) return {}; + + return { sortBy: zendeskField, sortOrder: first.ascending ? 'asc' : 'desc' }; + } + + protected translatePage(page: Page | undefined): TranslatedPage { + const skip = page?.skip ?? 0; + const limit = page?.limit ?? 100; + + if (skip + limit > MAX_TOTAL_RESULTS) { + throw new UnsupportedOperatorError( + `Zendesk Search caps results at ${MAX_TOTAL_RESULTS}. Requested skip+limit=${ + skip + limit + }.`, + ); + } + + const perPage = Math.max(1, Math.min(limit, 100)); + const pageNumber = Math.floor(skip / perPage) + 1; + + return { page: pageNumber, perPage }; + } + + protected buildZendeskQuery(filter: Filter | PaginatedFilter | undefined): string { + if (!filter) return ''; + + const fromTree = translateConditionTree(filter.conditionTree, { + resource: this.resource, + customFieldMapping: (this.dataSource as ZendeskDataSource).customFieldMapping, + }); + const search = (filter.search ?? '').toString().trim(); + + return [fromTree, search] + .filter(part => part.length > 0) + .join(' ') + .trim(); + } + + /** + * Returns the list of primary-key values if the condition tree is an exact `id` + * lookup (Equal N or In [N1, N2, ...]), otherwise null. Lets `list/update/delete` + * bypass the Search API and hit the resource endpoint directly. + */ + protected extractIdLookup(tree: ConditionTree | undefined): number[] | null { + if (!tree) return null; + + if (tree instanceof ConditionTreeLeaf && tree.field === 'id') { + if (tree.operator === 'Equal' && tree.value !== null && tree.value !== undefined) { + return [Number(tree.value)]; + } + + if (tree.operator === 'In' && Array.isArray(tree.value) && tree.value.length > 0) { + return tree.value.map(v => Number(v)); + } + } + + if (tree instanceof ConditionTreeBranch && tree.aggregator === 'And') { + for (const condition of tree.conditions) { + const ids = this.extractIdLookup(condition); + if (ids) return ids; + } + } + + return null; + } +} diff --git a/packages/datasource-zendesk/src/collections/organization-collection.ts b/packages/datasource-zendesk/src/collections/organization-collection.ts new file mode 100644 index 0000000000..13446e6787 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/organization-collection.ts @@ -0,0 +1,173 @@ +import type { ZendeskClient } from '../client'; +import type { CustomFieldEntry, ZendeskRecord } from '../types'; +import type { + Caller, + DataSource, + FieldSchema, + Filter, + Logger, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +import { COLLECTION_NAMES } from '../datasource'; +import { DATE_OPS, NUMBER_OPS, STRING_OPS } from './base-zendesk-collection'; +import SearchableCollection from './searchable-collection'; +import { serializeOrganization } from './ticket/serializer'; + +const SORTABLE: Record = { + created_at: 'created_at', + updated_at: 'updated_at', + name: 'name', +}; + +const READ_ONLY_INPUTS = new Set(['id', 'created_at', 'updated_at']); + +function getOrganizationFieldSchemas(): Record { + return { + id: { + type: 'Column', + columnType: 'Number', + isPrimaryKey: true, + isReadOnly: true, + filterOperators: new Set(NUMBER_OPS), + }, + name: { + type: 'Column', + columnType: 'String', + isSortable: true, + filterOperators: new Set(STRING_OPS), + }, + domain_names: { + type: 'Column', + columnType: ['String'], + filterOperators: new Set(), + }, + details: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + notes: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + group_id: { + type: 'Column', + columnType: 'Number', + filterOperators: new Set(NUMBER_OPS), + }, + shared_tickets: { + type: 'Column', + columnType: 'Boolean', + filterOperators: new Set(), + }, + created_at: { + type: 'Column', + columnType: 'Date', + isReadOnly: true, + isSortable: true, + filterOperators: new Set(DATE_OPS), + }, + updated_at: { + type: 'Column', + columnType: 'Date', + isReadOnly: true, + isSortable: true, + filterOperators: new Set(DATE_OPS), + }, + users: { + type: 'OneToMany', + foreignCollection: COLLECTION_NAMES.user, + originKey: 'organization_id', + originKeyTarget: 'id', + }, + tickets: { + type: 'OneToMany', + foreignCollection: COLLECTION_NAMES.ticket, + originKey: 'organization_id', + originKeyTarget: 'id', + }, + }; +} + +export default class OrganizationCollection extends SearchableCollection { + private readonly customFields: CustomFieldEntry[]; + + constructor( + datasource: DataSource, + client: ZendeskClient, + customFields: CustomFieldEntry[], + logger?: Logger, + ) { + super(COLLECTION_NAMES.organization, datasource, client, 'organization', SORTABLE, logger); + this.customFields = customFields; + + this.addFields(getOrganizationFieldSchemas()); + this.addCustomFields(customFields); + } + + override async create(caller: Caller, data: RecordData[]): Promise { + const created: RecordData[] = []; + + for (const item of data) { + // eslint-disable-next-line no-await-in-loop + const raw = await this.client.createOrganization(this.buildPayload(item)); + created.push(serializeOrganization(raw)); + } + + return created; + } + + override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { + const ids = await this.resolveIds(filter); + const payload = this.buildPayload(patch); + + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await this.client.updateOrganization(id, payload); + } + } + + override async delete(caller: Caller, filter: Filter): Promise { + const ids = await this.resolveIds(filter); + + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await this.client.deleteOrganization(id); + } + } + + protected override findOne(id: number | string): Promise { + return this.client.findOrganization(id); + } + + protected override serializeRecord(record: ZendeskRecord): RecordData { + return serializeOrganization(record); + } + + private buildPayload(data: RecordData): ZendeskRecord { + const payload: ZendeskRecord = {}; + const organizationFields: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (READ_ONLY_INPUTS.has(key) || key === 'users' || key === 'tickets') { + // ignored + } else { + const customField = this.customFields.find(cf => cf.columnName === key); + + if (customField?.zendeskKey) { + organizationFields[customField.zendeskKey] = value; + } else { + payload[key] = value; + } + } + } + + if (Object.keys(organizationFields).length > 0) { + payload.organization_fields = organizationFields; + } + + return payload; + } +} diff --git a/packages/datasource-zendesk/src/collections/searchable-collection.ts b/packages/datasource-zendesk/src/collections/searchable-collection.ts new file mode 100644 index 0000000000..8b64da30d5 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/searchable-collection.ts @@ -0,0 +1,72 @@ +import type { ZendeskRecord } from '../types'; +import type { + Caller, + Filter, + PaginatedFilter, + Projection, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +import BaseZendeskCollection from './base-zendesk-collection'; + +export default abstract class SearchableCollection extends BaseZendeskCollection { + override async list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise { + const rawRecords = await this.fetchRawRecords(filter); + const records = rawRecords.map(raw => this.serializeRecord(raw)); + + return projection.apply(records); + } + + protected override async aggregateCount(caller: Caller, filter: Filter): Promise { + const ids = this.extractIdLookup(filter?.conditionTree); + + if (ids) { + const records = await Promise.all(ids.map(id => this.findOne(id))); + + return records.filter(Boolean).length; + } + + return this.client.count(this.resource, this.buildZendeskQuery(filter)); + } + + protected async resolveIds(filter: Filter): Promise { + const direct = this.extractIdLookup(filter?.conditionTree); + if (direct) return direct; + + const records = await this.client.search(this.resource, { + query: this.buildZendeskQuery(filter), + perPage: 100, + }); + + return records.map(record => Number(record.id)).filter(id => Number.isFinite(id)); + } + + protected async fetchRawRecords(filter: PaginatedFilter): Promise { + const ids = this.extractIdLookup(filter?.conditionTree); + + if (ids) { + const results = await Promise.all(ids.map(id => this.findOne(id))); + + return results.filter((record): record is ZendeskRecord => record !== null); + } + + const { page, perPage } = this.translatePage(filter?.page); + const { sortBy, sortOrder } = this.translateSort(filter?.sort); + + return this.client.search(this.resource, { + query: this.buildZendeskQuery(filter), + page, + perPage, + sortBy, + sortOrder, + }); + } + + protected abstract findOne(id: number | string): Promise; + + protected abstract serializeRecord(record: ZendeskRecord): RecordData; +} diff --git a/packages/datasource-zendesk/src/collections/ticket-collection.ts b/packages/datasource-zendesk/src/collections/ticket-collection.ts new file mode 100644 index 0000000000..75059c1a00 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/ticket-collection.ts @@ -0,0 +1,190 @@ +import type { ZendeskClient } from '../client'; +import type { CustomFieldEntry, ZendeskRecord } from '../types'; +import type { + Caller, + DataSource, + Filter, + Logger, + PaginatedFilter, + Projection, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +import { COLLECTION_NAMES } from '../datasource'; +import BaseZendeskCollection from './base-zendesk-collection'; +import { embedComments, isCommentsRequested } from './ticket/comments-embedder'; +import { embedRelations, findRequestedRelations } from './ticket/relation-embedder'; +import { TICKET_SORTABLE, getTicketFieldSchemas } from './ticket/schema-definition'; +import { serializeTicket } from './ticket/serializer'; + +const READ_ONLY_INPUTS = new Set([ + 'id', + 'url', + 'created_at', + 'updated_at', + 'requester_email', + 'comments', + 'requester', + 'assignee', + 'organization', +]); + +function collectIds(records: ZendeskRecord[], field: string): number[] { + const ids: number[] = []; + + for (const record of records) { + const value = record[field]; + if (typeof value === 'number') ids.push(value); + else if (typeof value === 'string' && !Number.isNaN(Number(value))) ids.push(Number(value)); + } + + return Array.from(new Set(ids)); +} + +export default class TicketCollection extends BaseZendeskCollection { + private readonly customFields: CustomFieldEntry[]; + + constructor( + datasource: DataSource, + client: ZendeskClient, + customFields: CustomFieldEntry[], + logger?: Logger, + ) { + super(COLLECTION_NAMES.ticket, datasource, client, 'ticket', TICKET_SORTABLE, logger); + this.customFields = customFields; + + this.addFields(getTicketFieldSchemas()); + this.addCustomFields(customFields); + } + + override async list( + caller: Caller, + filter: PaginatedFilter, + projection: Projection, + ): Promise { + const tickets = await this.fetchRawTickets(filter); + + const needsEmail = projection.includes('requester_email'); + const emails = needsEmail + ? await this.client.fetchUserEmails(collectIds(tickets, 'requester_id')) + : new Map(); + + const records = tickets.map(ticket => + serializeTicket(ticket, emails, this.zendeskIdToColumnName), + ); + + const relations = findRequestedRelations(projection); + + if (relations.requester || relations.assignee || relations.organization) { + await embedRelations(records, relations, this.client); + } + + if (isCommentsRequested(projection)) { + await embedComments(records, this.client); + } + + return projection.apply(records); + } + + override async create(caller: Caller, data: RecordData[]): Promise { + const created: RecordData[] = []; + + for (const item of data) { + // eslint-disable-next-line no-await-in-loop + const raw = await this.client.createTicket(this.buildPayload(item, { onCreate: true })); + created.push(serializeTicket(raw, new Map(), this.zendeskIdToColumnName)); + } + + return created; + } + + override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { + const ids = await this.resolveIds(filter); + const payload = this.buildPayload(patch, { onCreate: false }); + + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await this.client.updateTicket(id, payload); + } + } + + override async delete(caller: Caller, filter: Filter): Promise { + const ids = await this.resolveIds(filter); + + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await this.client.deleteTicket(id); + } + } + + protected override async aggregateCount(caller: Caller, filter: Filter): Promise { + const ids = this.extractIdLookup(filter?.conditionTree); + if (ids) return ids.length; + + return this.client.count('ticket', this.buildZendeskQuery(filter)); + } + + // ===== Helpers ===== + + private async fetchRawTickets(filter: PaginatedFilter): Promise { + const ids = this.extractIdLookup(filter?.conditionTree); + + if (ids) { + const results = await Promise.all(ids.map(id => this.client.findTicket(id))); + + return results.filter((ticket): ticket is ZendeskRecord => ticket !== null); + } + + const { page, perPage } = this.translatePage(filter?.page); + const { sortBy, sortOrder } = this.translateSort(filter?.sort); + + return this.client.search('ticket', { + query: this.buildZendeskQuery(filter), + page, + perPage, + sortBy, + sortOrder, + }); + } + + private async resolveIds(filter: Filter): Promise { + const direct = this.extractIdLookup(filter?.conditionTree); + if (direct) return direct; + + const records = await this.client.search('ticket', { + query: this.buildZendeskQuery(filter), + perPage: 100, + }); + + return records.map(record => Number(record.id)).filter(id => Number.isFinite(id)); + } + + private buildPayload(data: RecordData, { onCreate }: { onCreate: boolean }): ZendeskRecord { + const payload: ZendeskRecord = {}; + const customFields: Array<{ id: number; value: unknown }> = []; + + for (const [key, value] of Object.entries(data)) { + if (READ_ONLY_INPUTS.has(key)) { + // ignore read-only inputs + } else if (key === 'ticket_type') { + payload.type = value; + } else { + const ticketCustomField = this.customFields.find(cf => cf.columnName === key); + + if (ticketCustomField) { + customFields.push({ id: ticketCustomField.zendeskId, value }); + } else { + payload[key] = value; + } + } + } + + if (customFields.length > 0) payload.custom_fields = customFields; + + if (onCreate && typeof data.description === 'string') { + payload.comment = { body: data.description }; + } + + return payload; + } +} diff --git a/packages/datasource-zendesk/src/collections/ticket/comments-embedder.ts b/packages/datasource-zendesk/src/collections/ticket/comments-embedder.ts new file mode 100644 index 0000000000..bf6cf0d9e9 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/ticket/comments-embedder.ts @@ -0,0 +1,57 @@ +import type { ZendeskClient } from '../../client'; +import type { ZendeskRecord } from '../../types'; +import type { Projection, RecordData } from '@forestadmin/datasource-toolkit'; + +function serializeComment( + comment: ZendeskRecord, + authorsById: Map, +): RecordData { + const authorId = comment.author_id as number | undefined; + const author = authorId !== undefined ? authorsById.get(authorId) : undefined; + + return { + id: comment.id as RecordData[string], + body: comment.body as RecordData[string], + html_body: comment.html_body as RecordData[string], + public: comment.public as RecordData[string], + author_email: (author?.email as RecordData[string]) ?? null, + author_name: (author?.name as RecordData[string]) ?? null, + created_at: comment.created_at as RecordData[string], + }; +} + +export function isCommentsRequested(projection: Projection): boolean { + return projection.some(path => path === 'comments' || path.startsWith('comments:')); +} + +export async function embedComments(records: RecordData[], client: ZendeskClient): Promise { + if (records.length === 0) return; + + const commentsByTicket = await Promise.all( + records.map(record => + typeof record.id === 'number' || typeof record.id === 'string' + ? client.bestEffort(`fetch_ticket_comments(${record.id})`, [] as ZendeskRecord[], () => + client.fetchTicketComments(record.id as number | string), + ) + : Promise.resolve([] as ZendeskRecord[]), + ), + ); + + const authorIds = new Set(); + + for (const comments of commentsByTicket) { + for (const comment of comments) { + const authorId = comment.author_id; + if (typeof authorId === 'number') authorIds.add(authorId); + } + } + + const authorsById = + authorIds.size > 0 ? await client.fetchUsersByIds(Array.from(authorIds)) : new Map(); + + records.forEach((record, idx) => { + record.comments = (commentsByTicket[idx] ?? []).map(comment => + serializeComment(comment, authorsById), + ); + }); +} diff --git a/packages/datasource-zendesk/src/collections/ticket/relation-embedder.ts b/packages/datasource-zendesk/src/collections/ticket/relation-embedder.ts new file mode 100644 index 0000000000..13a0078596 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/ticket/relation-embedder.ts @@ -0,0 +1,78 @@ +import type { ZendeskClient } from '../../client'; +import type { Projection, RecordData } from '@forestadmin/datasource-toolkit'; + +import { serializeOrganization, serializeUser } from './serializer'; + +const RELATION_NAMES = new Set(['requester', 'assignee', 'organization']); + +export type RelationKeys = { + requester: boolean; + assignee: boolean; + organization: boolean; +}; + +function pushIfNumber(target: number[], value: unknown): void { + if (typeof value === 'number') { + target.push(value); + } else if (typeof value === 'string' && value.length > 0 && !Number.isNaN(Number(value))) { + target.push(Number(value)); + } +} + +function unique(values: number[]): number[] { + return Array.from(new Set(values)); +} + +export function findRequestedRelations(projection: Projection): RelationKeys { + const requested: RelationKeys = { requester: false, assignee: false, organization: false }; + + for (const path of projection) { + const index = path.indexOf(':'); + + if (index !== -1) { + const relation = path.substring(0, index); + if (RELATION_NAMES.has(relation)) requested[relation as keyof RelationKeys] = true; + } + } + + return requested; +} + +export async function embedRelations( + records: RecordData[], + relations: RelationKeys, + client: ZendeskClient, +): Promise { + if (records.length === 0) return; + + const userIds: Array = []; + const orgIds: Array = []; + + for (const record of records) { + if (relations.requester) pushIfNumber(userIds, record.requester_id); + if (relations.assignee) pushIfNumber(userIds, record.assignee_id); + if (relations.organization) pushIfNumber(orgIds, record.organization_id); + } + + const [usersById, orgsById] = await Promise.all([ + userIds.length > 0 ? client.fetchUsersByIds(unique(userIds)) : Promise.resolve(new Map()), + orgIds.length > 0 ? client.fetchOrganizationsByIds(unique(orgIds)) : Promise.resolve(new Map()), + ]); + + for (const record of records) { + if (relations.requester) { + const raw = usersById.get(Number(record.requester_id)); + record.requester = raw ? serializeUser(raw) : null; + } + + if (relations.assignee) { + const raw = usersById.get(Number(record.assignee_id)); + record.assignee = raw ? serializeUser(raw) : null; + } + + if (relations.organization) { + const raw = orgsById.get(Number(record.organization_id)); + record.organization = raw ? serializeOrganization(raw) : null; + } + } +} diff --git a/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts b/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts new file mode 100644 index 0000000000..4bb08d3b30 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts @@ -0,0 +1,151 @@ +import type { FieldSchema } from '@forestadmin/datasource-toolkit'; + +import { COLLECTION_NAMES } from '../../datasource'; +import { TICKET_PRIORITIES, TICKET_STATUSES, TICKET_TYPES } from '../../enums'; +import { DATE_OPS, NUMBER_OPS, STRING_OPS } from '../base-zendesk-collection'; + +export const TICKET_SORTABLE: Record = { + updated_at: 'updated_at', + created_at: 'created_at', + priority: 'priority', + status: 'status', + ticket_type: 'ticket_type', +}; + +export const TICKET_COMMENT_THREAD_SCHEMA = { + id: 'Number', + body: 'String', + html_body: 'String', + public: 'Boolean', + author_email: 'String', + author_name: 'String', + created_at: 'Date', +} as const; + +export function getTicketFieldSchemas(): Record { + return { + id: { + type: 'Column', + columnType: 'Number', + isPrimaryKey: true, + isReadOnly: true, + isSortable: false, + filterOperators: new Set(NUMBER_OPS), + }, + subject: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(STRING_OPS), + }, + description: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + status: { + type: 'Column', + columnType: 'Enum', + enumValues: [...TICKET_STATUSES], + isSortable: true, + filterOperators: new Set(STRING_OPS), + }, + priority: { + type: 'Column', + columnType: 'Enum', + enumValues: [...TICKET_PRIORITIES], + isSortable: true, + filterOperators: new Set(STRING_OPS), + }, + ticket_type: { + type: 'Column', + columnType: 'Enum', + enumValues: [...TICKET_TYPES], + isSortable: true, + filterOperators: new Set(STRING_OPS), + }, + requester_id: { + type: 'Column', + columnType: 'Number', + isSortable: true, + filterOperators: new Set(NUMBER_OPS), + }, + assignee_id: { + type: 'Column', + columnType: 'Number', + isSortable: true, + filterOperators: new Set(NUMBER_OPS), + }, + group_id: { + type: 'Column', + columnType: 'Number', + isSortable: true, + filterOperators: new Set(NUMBER_OPS), + }, + organization_id: { + type: 'Column', + columnType: 'Number', + isSortable: true, + filterOperators: new Set(NUMBER_OPS), + }, + external_id: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(STRING_OPS), + }, + requester_email: { + type: 'Column', + columnType: 'String', + isReadOnly: true, + filterOperators: new Set(['Equal']), + }, + tags: { + type: 'Column', + columnType: ['String'], + filterOperators: new Set(), + }, + url: { + type: 'Column', + columnType: 'String', + isReadOnly: true, + filterOperators: new Set(), + }, + created_at: { + type: 'Column', + columnType: 'Date', + isReadOnly: true, + isSortable: true, + filterOperators: new Set(DATE_OPS), + }, + updated_at: { + type: 'Column', + columnType: 'Date', + isReadOnly: true, + isSortable: true, + filterOperators: new Set(DATE_OPS), + }, + comments: { + type: 'Column', + columnType: [TICKET_COMMENT_THREAD_SCHEMA], + isReadOnly: true, + filterOperators: new Set(), + }, + requester: { + type: 'ManyToOne', + foreignCollection: COLLECTION_NAMES.user, + foreignKey: 'requester_id', + foreignKeyTarget: 'id', + }, + assignee: { + type: 'ManyToOne', + foreignCollection: COLLECTION_NAMES.user, + foreignKey: 'assignee_id', + foreignKeyTarget: 'id', + }, + organization: { + type: 'ManyToOne', + foreignCollection: COLLECTION_NAMES.organization, + foreignKey: 'organization_id', + foreignKeyTarget: 'id', + }, + }; +} diff --git a/packages/datasource-zendesk/src/collections/ticket/serializer.ts b/packages/datasource-zendesk/src/collections/ticket/serializer.ts new file mode 100644 index 0000000000..6cf22ed623 --- /dev/null +++ b/packages/datasource-zendesk/src/collections/ticket/serializer.ts @@ -0,0 +1,108 @@ +import type { ZendeskRecord } from '../../types'; +import type { RecordData } from '@forestadmin/datasource-toolkit'; + +const TICKET_BASE_FIELDS = [ + 'id', + 'subject', + 'description', + 'status', + 'priority', + 'requester_id', + 'assignee_id', + 'group_id', + 'organization_id', + 'external_id', + 'tags', + 'url', + 'created_at', + 'updated_at', +]; + +/** + * Map a raw Zendesk ticket payload to the Forest column layout exposed by TicketCollection. + * + * - Zendesk's `type` field is renamed `ticket_type` to avoid collision with the JS keyword. + * - Custom fields (`custom_fields: [{ id, value }]`) are flattened to columns named via the + * `customFieldIdToColumnName` map. Unknown custom field ids are silently ignored. + * - `requester_email` is provided by the caller through `emails` because the raw ticket + * payload only carries `requester_id`. + */ +export function serializeTicket( + ticket: ZendeskRecord, + emails: Map, + customFieldIdToColumnName: Map, +): RecordData { + const record: RecordData = {}; + + for (const key of TICKET_BASE_FIELDS) { + if (ticket[key] !== undefined) record[key] = ticket[key] as RecordData[string]; + } + + record.ticket_type = (ticket.type as RecordData[string]) ?? null; + + const requesterId = ticket.requester_id; + + if (typeof requesterId === 'number' && emails.has(requesterId)) { + record.requester_email = emails.get(requesterId); + } else { + record.requester_email = null; + } + + const rawCustomFields = Array.isArray(ticket.custom_fields) + ? (ticket.custom_fields as Array<{ id: number; value: unknown }>) + : []; + + for (const entry of rawCustomFields) { + const columnName = customFieldIdToColumnName.get(entry.id); + if (columnName) record[columnName] = entry.value as RecordData[string]; + } + + return record; +} + +export function serializeUser(user: ZendeskRecord): RecordData { + const record: RecordData = { + id: user.id as RecordData[string], + email: user.email as RecordData[string], + name: user.name as RecordData[string], + role: user.role as RecordData[string], + phone: user.phone as RecordData[string], + organization_id: user.organization_id as RecordData[string], + time_zone: user.time_zone as RecordData[string], + locale: user.locale as RecordData[string], + verified: user.verified as RecordData[string], + suspended: user.suspended as RecordData[string], + created_at: user.created_at as RecordData[string], + updated_at: user.updated_at as RecordData[string], + }; + + const userFields = (user.user_fields ?? {}) as Record; + + for (const [key, value] of Object.entries(userFields)) { + if (record[key] === undefined) record[key] = value as RecordData[string]; + } + + return record; +} + +export function serializeOrganization(organization: ZendeskRecord): RecordData { + const record: RecordData = { + id: organization.id as RecordData[string], + name: organization.name as RecordData[string], + domain_names: organization.domain_names as RecordData[string], + details: organization.details as RecordData[string], + notes: organization.notes as RecordData[string], + group_id: organization.group_id as RecordData[string], + shared_tickets: organization.shared_tickets as RecordData[string], + created_at: organization.created_at as RecordData[string], + updated_at: organization.updated_at as RecordData[string], + }; + + const orgFields = (organization.organization_fields ?? {}) as Record; + + for (const [key, value] of Object.entries(orgFields)) { + if (record[key] === undefined) record[key] = value as RecordData[string]; + } + + return record; +} diff --git a/packages/datasource-zendesk/src/collections/user-collection.ts b/packages/datasource-zendesk/src/collections/user-collection.ts new file mode 100644 index 0000000000..13335ffadd --- /dev/null +++ b/packages/datasource-zendesk/src/collections/user-collection.ts @@ -0,0 +1,189 @@ +import type { ZendeskClient } from '../client'; +import type { CustomFieldEntry, ZendeskRecord } from '../types'; +import type { + Caller, + DataSource, + FieldSchema, + Filter, + Logger, + RecordData, +} from '@forestadmin/datasource-toolkit'; + +import { COLLECTION_NAMES } from '../datasource'; +import { USER_ROLES } from '../enums'; +import { DATE_OPS, NUMBER_OPS, STRING_OPS } from './base-zendesk-collection'; +import SearchableCollection from './searchable-collection'; +import { serializeUser } from './ticket/serializer'; + +const SORTABLE: Record = { + created_at: 'created_at', + updated_at: 'updated_at', + name: 'name', +}; + +const READ_ONLY_INPUTS = new Set(['id', 'created_at', 'updated_at']); + +function getUserFieldSchemas(): Record { + return { + id: { + type: 'Column', + columnType: 'Number', + isPrimaryKey: true, + isReadOnly: true, + isSortable: false, + filterOperators: new Set(NUMBER_OPS), + }, + email: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(STRING_OPS), + }, + name: { + type: 'Column', + columnType: 'String', + isSortable: true, + filterOperators: new Set(STRING_OPS), + }, + role: { + type: 'Column', + columnType: 'Enum', + enumValues: [...USER_ROLES], + filterOperators: new Set(STRING_OPS), + }, + phone: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(STRING_OPS), + }, + organization_id: { + type: 'Column', + columnType: 'Number', + filterOperators: new Set(NUMBER_OPS), + }, + time_zone: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + locale: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + verified: { + type: 'Column', + columnType: 'Boolean', + filterOperators: new Set(STRING_OPS), + }, + suspended: { + type: 'Column', + columnType: 'Boolean', + filterOperators: new Set(STRING_OPS), + }, + created_at: { + type: 'Column', + columnType: 'Date', + isReadOnly: true, + isSortable: true, + filterOperators: new Set(DATE_OPS), + }, + updated_at: { + type: 'Column', + columnType: 'Date', + isReadOnly: true, + isSortable: true, + filterOperators: new Set(DATE_OPS), + }, + organization: { + type: 'ManyToOne', + foreignCollection: COLLECTION_NAMES.organization, + foreignKey: 'organization_id', + foreignKeyTarget: 'id', + }, + requested_tickets: { + type: 'OneToMany', + foreignCollection: COLLECTION_NAMES.ticket, + originKey: 'requester_id', + originKeyTarget: 'id', + }, + }; +} + +export default class UserCollection extends SearchableCollection { + private readonly customFields: CustomFieldEntry[]; + + constructor( + datasource: DataSource, + client: ZendeskClient, + customFields: CustomFieldEntry[], + logger?: Logger, + ) { + super(COLLECTION_NAMES.user, datasource, client, 'user', SORTABLE, logger); + this.customFields = customFields; + + this.addFields(getUserFieldSchemas()); + this.addCustomFields(customFields); + } + + override async create(caller: Caller, data: RecordData[]): Promise { + const created: RecordData[] = []; + + for (const item of data) { + // eslint-disable-next-line no-await-in-loop + const raw = await this.client.createUser(this.buildPayload(item)); + created.push(serializeUser(raw)); + } + + return created; + } + + override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { + const ids = await this.resolveIds(filter); + const payload = this.buildPayload(patch); + + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await this.client.updateUser(id, payload); + } + } + + override async delete(caller: Caller, filter: Filter): Promise { + const ids = await this.resolveIds(filter); + + for (const id of ids) { + // eslint-disable-next-line no-await-in-loop + await this.client.deleteUser(id); + } + } + + protected override findOne(id: number | string): Promise { + return this.client.findUser(id); + } + + protected override serializeRecord(record: ZendeskRecord): RecordData { + return serializeUser(record); + } + + private buildPayload(data: RecordData): ZendeskRecord { + const payload: ZendeskRecord = {}; + const userFields: Record = {}; + + for (const [key, value] of Object.entries(data)) { + if (READ_ONLY_INPUTS.has(key) || key === 'requested_tickets' || key === 'organization') { + // ignored + } else { + const customField = this.customFields.find(cf => cf.columnName === key); + + if (customField?.zendeskKey) { + userFields[customField.zendeskKey] = value; + } else { + payload[key] = value; + } + } + } + + if (Object.keys(userFields).length > 0) payload.user_fields = userFields; + + return payload; + } +} diff --git a/packages/datasource-zendesk/src/datasource.ts b/packages/datasource-zendesk/src/datasource.ts new file mode 100644 index 0000000000..55f35d5a8d --- /dev/null +++ b/packages/datasource-zendesk/src/datasource.ts @@ -0,0 +1,68 @@ +import type { ZendeskClient } from './client'; +import type { CustomFieldEntry, CustomFieldMapping } from './types'; +import type { Logger } from '@forestadmin/datasource-toolkit'; + +import { BaseDataSource } from '@forestadmin/datasource-toolkit'; + +import OrganizationCollection from './collections/organization-collection'; +import TicketCollection from './collections/ticket-collection'; +import UserCollection from './collections/user-collection'; +import { CustomFieldsIntrospector } from './schema/custom-fields-introspector'; + +export const COLLECTION_NAMES = { + ticket: 'zendesk_ticket', + user: 'zendesk_user', + organization: 'zendesk_organization', +} as const; + +function buildCustomFieldMapping( + ticketFields: CustomFieldEntry[], + userFields: CustomFieldEntry[], + organizationFields: CustomFieldEntry[], +): CustomFieldMapping { + const mapping: CustomFieldMapping = new Map(); + + for (const field of ticketFields) { + mapping.set(field.columnName, `custom_field_${field.zendeskId}`); + } + + for (const field of [...userFields, ...organizationFields]) { + if (field.zendeskKey && !mapping.has(field.columnName)) { + mapping.set(field.columnName, field.zendeskKey); + } + } + + return mapping; +} + +export default class ZendeskDataSource extends BaseDataSource { + readonly client: ZendeskClient; + readonly customFieldMapping: CustomFieldMapping; + + static async create(client: ZendeskClient, logger?: Logger): Promise { + const introspector = new CustomFieldsIntrospector(client, logger); + const [ticketFields, userFields, organizationFields] = await Promise.all([ + introspector.ticketCustomFields(), + introspector.userCustomFields(), + introspector.organizationCustomFields(), + ]); + + return new ZendeskDataSource(client, ticketFields, userFields, organizationFields, logger); + } + + private constructor( + client: ZendeskClient, + ticketFields: CustomFieldEntry[], + userFields: CustomFieldEntry[], + organizationFields: CustomFieldEntry[], + logger?: Logger, + ) { + super(); + this.client = client; + this.customFieldMapping = buildCustomFieldMapping(ticketFields, userFields, organizationFields); + + this.addCollection(new TicketCollection(this, client, ticketFields, logger)); + this.addCollection(new UserCollection(this, client, userFields, logger)); + this.addCollection(new OrganizationCollection(this, client, organizationFields, logger)); + } +} diff --git a/packages/datasource-zendesk/src/enums.ts b/packages/datasource-zendesk/src/enums.ts new file mode 100644 index 0000000000..1b321ab01a --- /dev/null +++ b/packages/datasource-zendesk/src/enums.ts @@ -0,0 +1,14 @@ +export const TICKET_STATUSES = ['new', 'open', 'pending', 'hold', 'solved', 'closed'] as const; +export const TICKET_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const; +export const TICKET_TYPES = ['problem', 'incident', 'question', 'task'] as const; +export const USER_ROLES = ['end-user', 'agent', 'admin'] as const; + +export type TicketStatus = (typeof TICKET_STATUSES)[number]; +export type TicketPriority = (typeof TICKET_PRIORITIES)[number]; +export type TicketType = (typeof TICKET_TYPES)[number]; +export type UserRole = (typeof USER_ROLES)[number]; + +export const CLOSEABLE_STATUSES: ReadonlyArray> = [ + 'solved', + 'closed', +]; diff --git a/packages/datasource-zendesk/src/errors.ts b/packages/datasource-zendesk/src/errors.ts new file mode 100644 index 0000000000..e8b5935657 --- /dev/null +++ b/packages/datasource-zendesk/src/errors.ts @@ -0,0 +1,31 @@ +/* eslint-disable max-classes-per-file */ +import { BusinessError, ValidationError } from '@forestadmin/datasource-toolkit'; + +export class ZendeskConfigurationError extends ValidationError { + constructor(message: string) { + super(message); + this.name = 'ZendeskConfigurationError'; + } +} + +export class ZendeskApiError extends BusinessError { + readonly operation: string; + readonly status?: number; + readonly body?: unknown; + + constructor(operation: string, status: number | undefined, body: unknown, cause?: Error) { + const reason = status ? `HTTP ${status}` : cause?.message ?? 'unknown error'; + super(`Zendesk API call failed (${operation}): ${reason}`); + this.name = 'ZendeskApiError'; + this.operation = operation; + this.status = status; + this.body = body; + } +} + +export class UnsupportedOperatorError extends BusinessError { + constructor(message: string) { + super(message); + this.name = 'UnsupportedOperatorError'; + } +} diff --git a/packages/datasource-zendesk/src/factory.ts b/packages/datasource-zendesk/src/factory.ts new file mode 100644 index 0000000000..e6a9c9498b --- /dev/null +++ b/packages/datasource-zendesk/src/factory.ts @@ -0,0 +1,21 @@ +import type { ZendeskClient } from './client'; +import type { ZendeskClientOptions } from './types'; +import type { DataSource, DataSourceFactory } from '@forestadmin/datasource-toolkit'; + +import { createZendeskClient } from './client'; +import ZendeskDataSource from './datasource'; + +export type ZendeskDataSourceOptions = + | { client: ZendeskClient } + | ({ client?: never } & ZendeskClientOptions); + +export function createZendeskDataSource(options: ZendeskDataSourceOptions): DataSourceFactory { + return async (logger): Promise => { + const client = + 'client' in options && options.client + ? options.client + : createZendeskClient(options as ZendeskClientOptions, logger); + + return ZendeskDataSource.create(client, logger); + }; +} diff --git a/packages/datasource-zendesk/src/index.ts b/packages/datasource-zendesk/src/index.ts new file mode 100644 index 0000000000..7bb75c92ed --- /dev/null +++ b/packages/datasource-zendesk/src/index.ts @@ -0,0 +1,23 @@ +export { createZendeskDataSource } from './factory'; +export type { ZendeskDataSourceOptions } from './factory'; +export { default as ZendeskDataSource, COLLECTION_NAMES } from './datasource'; +export { createZendeskClient, ZendeskHttpClient, MAX_PER_PAGE, MAX_TOTAL_RESULTS } from './client'; +export type { ZendeskClient, RawCustomFieldDefinition } from './client'; +export { closeTicketPlugin } from './plugins/close-ticket'; +export type { CloseTicketOptions } from './plugins/close-ticket'; +export { createTicketWithNotificationPlugin } from './plugins/create-ticket-with-notification'; +export type { + CreateTicketWithNotificationOptions, + EmailTemplate, +} from './plugins/create-ticket-with-notification'; +export { TICKET_PRIORITIES, TICKET_STATUSES, TICKET_TYPES, USER_ROLES } from './enums'; +export type { TicketPriority, TicketStatus, TicketType, UserRole } from './enums'; +export type { + CustomFieldEntry, + CustomFieldMapping, + SearchParams, + ZendeskClientOptions, + ZendeskRecord, + ZendeskResource, +} from './types'; +export { UnsupportedOperatorError, ZendeskApiError, ZendeskConfigurationError } from './errors'; diff --git a/packages/datasource-zendesk/src/plugins/close-ticket/errors.ts b/packages/datasource-zendesk/src/plugins/close-ticket/errors.ts new file mode 100644 index 0000000000..cc41f820d7 --- /dev/null +++ b/packages/datasource-zendesk/src/plugins/close-ticket/errors.ts @@ -0,0 +1,46 @@ +import { ZendeskApiError } from '../../errors'; + +const ALREADY_CLOSED_PATTERN = /closed prevents ticket update/i; + +function hasAlreadyClosedSignature(body: unknown): boolean { + if (!body || typeof body !== 'object') return false; + + const { details } = body as { details?: unknown }; + + if (details && typeof details === 'object') { + const statusErrors = (details as { status?: unknown }).status; + + if (Array.isArray(statusErrors)) { + for (const item of statusErrors) { + if (item && typeof item === 'object') { + const { description } = item as { description?: unknown }; + + if (typeof description === 'string' && ALREADY_CLOSED_PATTERN.test(description)) { + return true; + } + } + } + } + } + + const { description } = body as { description?: unknown }; + if (typeof description === 'string' && ALREADY_CLOSED_PATTERN.test(description)) return true; + + const { error } = body as { error?: unknown }; + if (typeof error === 'string' && ALREADY_CLOSED_PATTERN.test(error)) return true; + + return false; +} + +/** + * Zendesk surfaces "you cannot edit a closed ticket" as a 422 with a status-field + * details entry containing the phrase below. We need to peel back the API error + * envelope to detect it and report those tickets as already closed rather than failed. + */ +// eslint-disable-next-line import/prefer-default-export +export function isAlreadyClosedError(error: unknown): boolean { + if (!(error instanceof ZendeskApiError)) return false; + if (error.status !== 422) return false; + + return hasAlreadyClosedSignature(error.body); +} diff --git a/packages/datasource-zendesk/src/plugins/close-ticket/index.ts b/packages/datasource-zendesk/src/plugins/close-ticket/index.ts new file mode 100644 index 0000000000..2812bea7a4 --- /dev/null +++ b/packages/datasource-zendesk/src/plugins/close-ticket/index.ts @@ -0,0 +1,107 @@ +import type { CloseTicketOutcome, CloseTicketStatus } from './messages'; +import type { ZendeskClient } from '../../client'; +import type { + CollectionCustomizer, + DataSourceCustomizer, + Plugin, +} from '@forestadmin/datasource-customizer'; +import type { ActionScope } from '@forestadmin/datasource-toolkit'; + +import { isAlreadyClosedError } from './errors'; +import { buildErrorMessage, buildSuccessMessage } from './messages'; +import { CLOSEABLE_STATUSES } from '../../enums'; + +export type CloseTicketOptions = { + client: ZendeskClient; + ticketIdField: string; + statuses?: ReadonlyArray; + scopes?: ReadonlyArray>; +}; + +const DEFAULT_SCOPES: ReadonlyArray> = ['Single', 'Bulk']; + +function actionLabel(scope: ActionScope, status: CloseTicketStatus): string { + return scope === 'Single' + ? `Mark Zendesk ticket as ${status}` + : `Mark selected Zendesk tickets as ${status}`; +} + +export async function closeTickets( + client: ZendeskClient, + ids: Array, + status: CloseTicketStatus, +): Promise { + const outcome: CloseTicketOutcome = { + succeeded: [], + alreadyClosed: [], + failed: [], + }; + + for (const id of ids) { + try { + // eslint-disable-next-line no-await-in-loop + await client.updateTicket(id, { status }); + outcome.succeeded.push(id); + } catch (error) { + if (isAlreadyClosedError(error)) { + outcome.alreadyClosed.push(id); + } else { + outcome.failed.push({ + id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + return outcome; +} + +export const closeTicketPlugin: Plugin = async ( + _dataSource: DataSourceCustomizer, + collection: CollectionCustomizer, + options?: CloseTicketOptions, +) => { + if (!collection) { + throw new Error('closeTicketPlugin can only be used on collections.'); + } + + if (!options?.client || !options.ticketIdField) { + throw new Error('closeTicketPlugin requires `client` and `ticketIdField` options.'); + } + + const statuses = options.statuses ?? CLOSEABLE_STATUSES; + const scopes = options.scopes ?? DEFAULT_SCOPES; + + for (const status of statuses) { + for (const scope of scopes) { + collection.addAction(actionLabel(scope, status), { + scope, + execute: async (context, resultBuilder) => { + const records = (await context.getRecords([options.ticketIdField])) as Array< + Record + >; + + const ids = records + .map(record => record[options.ticketIdField]) + .filter( + (id): id is number | string => + typeof id === 'number' || (typeof id === 'string' && id.length > 0), + ); + + if (ids.length === 0) { + return resultBuilder.error('No ticket id available on the selected record(s).'); + } + + const outcome = await closeTickets(options.client, ids, status); + + if (outcome.succeeded.length === 0 && outcome.alreadyClosed.length === 0) { + return resultBuilder.error(buildErrorMessage(outcome, status)); + } + + return resultBuilder.success(buildSuccessMessage(outcome, status)); + }, + }); + } + } +}; diff --git a/packages/datasource-zendesk/src/plugins/close-ticket/messages.ts b/packages/datasource-zendesk/src/plugins/close-ticket/messages.ts new file mode 100644 index 0000000000..779cb85e4b --- /dev/null +++ b/packages/datasource-zendesk/src/plugins/close-ticket/messages.ts @@ -0,0 +1,49 @@ +export type CloseTicketStatus = 'solved' | 'closed'; + +export type CloseTicketOutcome = { + succeeded: Array; + alreadyClosed: Array; + failed: Array<{ id: number | string; error: string }>; +}; + +export function buildSuccessMessage( + outcome: CloseTicketOutcome, + status: CloseTicketStatus, +): string { + const verb = status === 'solved' ? 'solved' : 'closed'; + const parts: string[] = []; + + if (outcome.succeeded.length === 1 && outcome.alreadyClosed.length === 0) { + parts.push(`Ticket #${outcome.succeeded[0]} marked as ${verb}.`); + } else if (outcome.succeeded.length > 0) { + parts.push(`${outcome.succeeded.length} ticket(s) marked as ${verb}.`); + } + + if (outcome.alreadyClosed.length > 0) { + parts.push( + `${outcome.alreadyClosed.length} ticket(s) were already closed (Zendesk does not allow editing closed tickets).`, + ); + } + + if (outcome.failed.length > 0) { + parts.push(`${outcome.failed.length} ticket(s) failed to update.`); + } + + return parts.join(' '); +} + +export function buildErrorMessage(outcome: CloseTicketOutcome, status: CloseTicketStatus): string { + const verb = status === 'solved' ? 'solve' : 'close'; + + if (outcome.failed.length === 1) { + const [{ id, error }] = outcome.failed; + + return `Failed to ${verb} ticket #${id}: ${error}`; + } + + if (outcome.failed.length > 1) { + return `Failed to ${verb} ${outcome.failed.length} ticket(s).`; + } + + return `No tickets were ${verb === 'solve' ? 'solved' : 'closed'}.`; +} diff --git a/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts b/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts new file mode 100644 index 0000000000..548440c28e --- /dev/null +++ b/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts @@ -0,0 +1,234 @@ +import type { + ActionContextSingle, + DynamicField, + DynamicForm, +} from '@forestadmin/datasource-customizer'; +import type { RecordData } from '@forestadmin/datasource-toolkit'; + +import { TICKET_PRIORITIES, TICKET_TYPES } from '../../enums'; + +export type EmailTemplate = { title: string; content: string }; + +export type FormBuilderOptions = { + emailTemplates?: EmailTemplate[]; + requesterEmailDefault?: string | ((record: RecordData) => string); + defaultSubject?: string | ((record: RecordData) => string); + defaultMessage?: string | ((record: RecordData) => string); + priorityOverride?: (typeof TICKET_PRIORITIES)[number]; + typeOverride?: (typeof TICKET_TYPES)[number]; + showInternalNote?: boolean; +}; + +export const FORM_FIELDS = { + template: 'Template', + requesterEmail: 'Requester email', + subject: 'Subject', + message: 'Message', + priority: 'Priority', + type: 'Type', + internalNote: 'Send as internal note', +} as const; + +const NO_TEMPLATE = 'No template'; + +type Ctx = ActionContextSingle; + +const TOKEN_PATTERN = /{{\s*record\.([\w.]+)\s*}}/g; + +function readPath(record: RecordData, path: string): unknown { + return path + .split('.') + .reduce( + (acc, key) => + acc && typeof acc === 'object' ? (acc as Record)[key] : undefined, + record, + ); +} + +export function interpolate(template: string, record: RecordData): string { + return template.replace(TOKEN_PATTERN, (_match, path: string) => { + const value = readPath(record, path); + + return value === undefined || value === null ? '' : String(value); + }); +} + +async function getSingleRecord(ctx: Ctx): Promise { + if ('getRecord' in ctx && typeof ctx.getRecord === 'function') { + try { + return ((await ctx.getRecord([])) ?? {}) as RecordData; + } catch { + return {}; + } + } + + return {}; +} + +function withSingleRecord( + source: string | ((record: RecordData) => string), +): (ctx: Ctx) => Promise { + return async (ctx: Ctx) => { + const record = await getSingleRecord(ctx); + + if (typeof source === 'function') return interpolate(source(record), record); + + return interpolate(source, record); + }; +} + +function applyMessageValue( + field: DynamicField, + options: FormBuilderOptions, + useTemplate: boolean, +): void { + if (!useTemplate) { + if (typeof options.defaultMessage === 'string') { + field.defaultValue = options.defaultMessage; + } else if (typeof options.defaultMessage === 'function') { + const fn = options.defaultMessage; + + field.defaultValue = async (ctx: Ctx) => { + const record = await getSingleRecord(ctx); + + return interpolate(fn(record), record); + }; + } + + return; + } + + const templates = options.emailTemplates ?? []; + const fallback = options.defaultMessage; + + field.value = async (ctx: Ctx): Promise => { + const selected = ctx.formValues[FORM_FIELDS.template]; + + if (typeof selected === 'string' && selected !== NO_TEMPLATE) { + const template = templates.find(t => t.title === selected); + + if (template) { + const record = await getSingleRecord(ctx); + + return interpolate(template.content, record); + } + } + + if (fallback) { + const record = await getSingleRecord(ctx); + const resolved = typeof fallback === 'function' ? fallback(record) : fallback; + + return interpolate(resolved, record); + } + + return ''; + }; +} + +function buildBodyFields( + options: FormBuilderOptions, + { useTemplate }: { useTemplate: boolean }, +): DynamicField[] { + const fields: DynamicField[] = []; + + const emailField: DynamicField = { + label: FORM_FIELDS.requesterEmail, + type: 'String', + isRequired: true, + description: + 'Email of the Zendesk requester. Pre-filled from the selected record when available.', + }; + + if (options.requesterEmailDefault !== undefined) { + emailField.defaultValue = withSingleRecord(options.requesterEmailDefault); + } + + fields.push(emailField); + + const subjectField: DynamicField = { + label: FORM_FIELDS.subject, + type: 'String', + isRequired: true, + }; + + if (options.defaultSubject !== undefined) { + subjectField.defaultValue = withSingleRecord(options.defaultSubject); + } + + fields.push(subjectField); + + const messageField: DynamicField = { + label: FORM_FIELDS.message, + type: 'String', + widget: 'RichText', + isRequired: true, + description: + // eslint-disable-next-line max-len + "Sent as the ticket's first comment (HTML). Public comments trigger the default Zendesk notification email to the requester.", + }; + applyMessageValue(messageField, options, useTemplate); + fields.push(messageField); + + if (!options.priorityOverride) { + fields.push({ + label: FORM_FIELDS.priority, + type: 'Enum', + enumValues: [...TICKET_PRIORITIES], + defaultValue: 'normal', + }); + } + + if (!options.typeOverride) { + fields.push({ + label: FORM_FIELDS.type, + type: 'Enum', + enumValues: [...TICKET_TYPES], + }); + } + + if (options.showInternalNote) { + fields.push({ + label: FORM_FIELDS.internalNote, + type: 'Boolean', + defaultValue: false, + description: + 'When checked, the first comment is private and no email is sent to the requester.', + }); + } + + return fields; +} + +function buildMultiPageForm(options: FormBuilderOptions): DynamicForm { + const templates = options.emailTemplates ?? []; + const titles = templates.map(t => t.title); + + return [ + { + type: 'Layout', + component: 'Page', + nextButtonLabel: 'Continue', + elements: [ + { + label: FORM_FIELDS.template, + type: 'Enum', + isRequired: true, + enumValues: [NO_TEMPLATE, ...titles], + defaultValue: NO_TEMPLATE, + }, + ], + }, + { + type: 'Layout', + component: 'Page', + previousButtonLabel: 'Back', + elements: buildBodyFields(options, { useTemplate: true }), + }, + ]; +} + +export function buildForm(options: FormBuilderOptions): DynamicForm { + return options.emailTemplates?.length + ? buildMultiPageForm(options) + : buildBodyFields(options, { useTemplate: false }); +} diff --git a/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/index.ts b/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/index.ts new file mode 100644 index 0000000000..850ea1a9ad --- /dev/null +++ b/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/index.ts @@ -0,0 +1,139 @@ +import type { EmailTemplate, FormBuilderOptions } from './form-builder'; +import type { ZendeskClient } from '../../client'; +import type { TicketPriority, TicketType } from '../../enums'; +import type { ZendeskRecord } from '../../types'; +import type { + ActionContextSingle, + CollectionCustomizer, + DataSourceCustomizer, + Plugin, +} from '@forestadmin/datasource-customizer'; + +import { FORM_FIELDS, buildForm } from './form-builder'; + +export type { EmailTemplate }; + +export type CreateTicketWithNotificationOptions = { + client: ZendeskClient; + actionName?: string; + emailTemplates?: EmailTemplate[]; + requesterEmailDefault?: FormBuilderOptions['requesterEmailDefault']; + defaultSubject?: FormBuilderOptions['defaultSubject']; + defaultMessage?: FormBuilderOptions['defaultMessage']; + priorityOverride?: TicketPriority; + typeOverride?: TicketType; + senderEmail?: string; + ticketIdField?: string; + showInternalNote?: boolean; +}; + +function stringValue(value: unknown): string | undefined { + if (typeof value === 'string' && value.length > 0) return value; + + return undefined; +} + +function deriveName(email: string): string { + const at = email.indexOf('@'); + + return at > 0 ? email.substring(0, at) : email; +} + +async function tryWritebackTicketId( + context: ActionContextSingle, + field: string, + ticketId: number | string, +): Promise { + try { + await context.collection.update(context.filter, { [field]: ticketId }); + + return ''; + } catch (error) { + return `(Warning: failed to write ticket id back to '${field}': ${ + error instanceof Error ? error.message : String(error) + })`; + } +} + +export const createTicketWithNotificationPlugin: Plugin< + CreateTicketWithNotificationOptions +> = async ( + _dataSource: DataSourceCustomizer, + collection: CollectionCustomizer, + options?: CreateTicketWithNotificationOptions, +) => { + if (!collection) { + throw new Error('createTicketWithNotificationPlugin can only be used on collections.'); + } + + if (!options?.client) { + throw new Error('createTicketWithNotificationPlugin requires a `client` option.'); + } + + const actionName = options.actionName ?? 'Create ticket and notify'; + + collection.addAction(actionName, { + scope: 'Single', + form: buildForm(options), + execute: async (context, resultBuilder) => { + const { formValues } = context; + const requesterEmail = stringValue(formValues[FORM_FIELDS.requesterEmail]); + + if (!requesterEmail) { + return resultBuilder.error('Requester email is required.'); + } + + const internalNote = options.showInternalNote + ? Boolean(formValues[FORM_FIELDS.internalNote]) + : false; + + const payload: ZendeskRecord = { + requester: { email: requesterEmail, name: deriveName(requesterEmail) }, + subject: stringValue(formValues[FORM_FIELDS.subject]), + comment: { + html_body: stringValue(formValues[FORM_FIELDS.message]) ?? '', + public: !internalNote, + }, + }; + + const priority = options.priorityOverride ?? stringValue(formValues[FORM_FIELDS.priority]); + if (priority) payload.priority = priority; + + const type = options.typeOverride ?? stringValue(formValues[FORM_FIELDS.type]); + if (type) payload.type = type; + + if (options.senderEmail) payload.recipient = options.senderEmail; + + let created: ZendeskRecord; + + try { + created = await options.client.createTicket(payload); + } catch (error) { + return resultBuilder.error( + `Failed to create Zendesk ticket: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + + const ticketId = created.id; + let writebackWarning = ''; + + if (options.ticketIdField && (typeof ticketId === 'number' || typeof ticketId === 'string')) { + writebackWarning = await tryWritebackTicketId( + context as ActionContextSingle, + options.ticketIdField, + ticketId, + ); + } + + const baseMessage = internalNote + ? `Ticket #${ticketId} created (internal note, no email).` + : `Ticket #${ticketId} created and requester notified.`; + + return resultBuilder.success( + writebackWarning ? `${baseMessage} ${writebackWarning}` : baseMessage, + ); + }, + }); +}; diff --git a/packages/datasource-zendesk/src/query/condition-tree-translator.ts b/packages/datasource-zendesk/src/query/condition-tree-translator.ts new file mode 100644 index 0000000000..cb56d576d4 --- /dev/null +++ b/packages/datasource-zendesk/src/query/condition-tree-translator.ts @@ -0,0 +1,128 @@ +import type { CustomFieldMapping, ZendeskResource } from '../types'; +import type { ConditionTree, Operator } from '@forestadmin/datasource-toolkit'; + +import { ConditionTreeBranch, ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; + +import { UnsupportedOperatorError } from '../errors'; + +export type TranslateOptions = { + resource: ZendeskResource; + customFieldMapping?: CustomFieldMapping; + timezone?: string; +}; + +const SPECIAL_CHARS_PATTERN = /[\s"():-]/; + +function ensureNonEmptyArray(value: unknown, operator: string): unknown[] { + if (!Array.isArray(value) || value.length === 0) { + throw new UnsupportedOperatorError(`Operator '${operator}' requires a non-empty array value.`); + } + + return value; +} + +function formatValue(value: unknown): string { + if (value instanceof Date) return value.toISOString(); + + if (typeof value === 'boolean') return value ? 'true' : 'false'; + + if (typeof value === 'number') return String(value); + + const stringValue = String(value); + + if (SPECIAL_CHARS_PATTERN.test(stringValue)) { + return `"${stringValue.replace(/"/g, '\\"')}"`; + } + + return stringValue; +} + +function mapFieldName(field: string, operator: Operator, options: TranslateOptions): string { + // Tickets expose `requester_email` as a derived field; on the Zendesk Search side it is + // queried through the `requester` shortcut, but only with an equality match. + if (options.resource === 'ticket' && field === 'requester_email') { + if (operator !== 'Equal') { + throw new UnsupportedOperatorError( + `'requester_email' on tickets only supports the 'Equal' operator (got '${operator}').`, + ); + } + + return 'requester'; + } + + return options.customFieldMapping?.get(field) ?? field; +} + +function translateLeaf(leaf: ConditionTreeLeaf, options: TranslateOptions): string { + const fieldName = mapFieldName(leaf.field, leaf.operator, options); + const { operator, value } = leaf; + + if (operator === 'Present') return `${fieldName}:*`; + if (operator === 'Blank') return `-${fieldName}:*`; + + if (value === null || value === undefined) { + throw new UnsupportedOperatorError( + `Operator '${operator}' with null/undefined value is not supported by Zendesk Search.`, + ); + } + + switch (operator) { + case 'Equal': + return `${fieldName}:${formatValue(value)}`; + case 'NotEqual': + return `-${fieldName}:${formatValue(value)}`; + + case 'In': { + const list = ensureNonEmptyArray(value, operator); + + return list.map(v => `${fieldName}:${formatValue(v)}`).join(' '); + } + + case 'NotIn': { + const list = ensureNonEmptyArray(value, operator); + + return list.map(v => `-${fieldName}:${formatValue(v)}`).join(' '); + } + + case 'GreaterThan': + case 'After': + return `${fieldName}>${formatValue(value)}`; + case 'LessThan': + case 'Before': + return `${fieldName}<${formatValue(value)}`; + default: + throw new UnsupportedOperatorError( + `Operator '${operator}' is not supported by Zendesk Search.`, + ); + } +} + +function translateNode(tree: ConditionTree, options: TranslateOptions): string { + if (tree instanceof ConditionTreeBranch) { + if (tree.aggregator !== 'And') { + throw new UnsupportedOperatorError( + `Zendesk Search does not support the '${tree.aggregator}' aggregator. Only 'And' is supported.`, + ); + } + + return tree.conditions + .map(condition => translateNode(condition, options)) + .filter(part => part.length > 0) + .join(' '); + } + + if (tree instanceof ConditionTreeLeaf) { + return translateLeaf(tree, options); + } + + throw new UnsupportedOperatorError(`Unknown condition tree node: ${tree.constructor.name}`); +} + +export function translateConditionTree( + tree: ConditionTree | undefined, + options: TranslateOptions, +): string { + if (!tree) return ''; + + return translateNode(tree, options).trim(); +} diff --git a/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts b/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts new file mode 100644 index 0000000000..376b73252f --- /dev/null +++ b/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts @@ -0,0 +1,119 @@ +import type { RawCustomFieldDefinition, ZendeskClient } from '../client'; +import type { CustomFieldEntry } from '../types'; +import type { ColumnSchema, Logger, Operator } from '@forestadmin/datasource-toolkit'; + +const STRING_OPS = new Set(['Equal', 'NotEqual', 'In', 'NotIn', 'Present', 'Blank']); +const NUMBER_OPS = new Set([ + 'Equal', + 'NotEqual', + 'In', + 'NotIn', + 'Present', + 'Blank', + 'GreaterThan', + 'LessThan', +]); +const DATE_OPS = new Set(['Equal', 'Before', 'After', 'Present', 'Blank']); +const BOOLEAN_OPS = new Set(['Equal', 'NotEqual']); + +type FieldKind = 'ticket' | 'user' | 'organization'; + +function column(columnType: ColumnSchema['columnType'], operators: Set): ColumnSchema { + return { + type: 'Column', + columnType, + filterOperators: new Set(operators), + isReadOnly: false, + isSortable: false, + }; +} + +function enumColumn(field: RawCustomFieldDefinition): ColumnSchema { + const enumValues = (field.custom_field_options ?? []) + .map(option => option.value) + .filter(value => typeof value === 'string' && value.length > 0); + + if (enumValues.length === 0) return column('String', STRING_OPS); + + return { ...column('Enum', STRING_OPS), enumValues }; +} + +function toColumnSchema(field: RawCustomFieldDefinition): ColumnSchema | null { + switch (field.type) { + case 'text': + case 'textarea': + case 'regexp': + case 'partialcreditcard': + return column('String', STRING_OPS); + case 'integer': + case 'decimal': + case 'lookup': + return column('Number', NUMBER_OPS); + case 'date': + return column('Dateonly', DATE_OPS); + case 'checkbox': + return column('Boolean', BOOLEAN_OPS); + case 'multiselect': + return { ...column('Json', new Set()), isGroupable: false }; + case 'dropdown': + case 'tagger': + return enumColumn(field); + + default: + return null; + } +} + +// eslint-disable-next-line import/prefer-default-export +export class CustomFieldsIntrospector { + constructor(private readonly client: ZendeskClient, private readonly logger?: Logger) {} + + async ticketCustomFields(): Promise { + const fields = await this.client.fetchTicketFields(); + + return fields + .filter(field => field.active && field.removable) + .map(field => this.toEntry(field, 'ticket')) + .filter((entry): entry is CustomFieldEntry => entry !== null); + } + + async userCustomFields(): Promise { + const fields = await this.client.fetchUserFields(); + + return fields + .filter(field => field.active) + .map(field => this.toEntry(field, 'user')) + .filter((entry): entry is CustomFieldEntry => entry !== null); + } + + async organizationCustomFields(): Promise { + const fields = await this.client.fetchOrganizationFields(); + + return fields + .filter(field => field.active) + .map(field => this.toEntry(field, 'organization')) + .filter((entry): entry is CustomFieldEntry => entry !== null); + } + + private toEntry(field: RawCustomFieldDefinition, kind: FieldKind): CustomFieldEntry | null { + const schema = toColumnSchema(field); + + if (!schema) { + this.logger?.( + 'Warn', + `[datasource-zendesk] Skipping custom field ${field.id} (${field.type}): unsupported type`, + ); + + return null; + } + + const columnName = kind === 'ticket' ? `custom_${field.id}` : field.key ?? `custom_${field.id}`; + + return { + columnName, + zendeskId: field.id, + zendeskKey: field.key, + schema, + }; + } +} diff --git a/packages/datasource-zendesk/src/types.ts b/packages/datasource-zendesk/src/types.ts new file mode 100644 index 0000000000..f470aee8e7 --- /dev/null +++ b/packages/datasource-zendesk/src/types.ts @@ -0,0 +1,28 @@ +import type { ColumnSchema } from '@forestadmin/datasource-toolkit'; + +export type ZendeskClientOptions = { + subdomain: string; + email: string; + apiToken: string; +}; + +export type ZendeskResource = 'ticket' | 'user' | 'organization'; + +export type SearchParams = { + query: string; + page?: number; + perPage?: number; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +}; + +export type ZendeskRecord = Record; + +export type CustomFieldEntry = { + columnName: string; + zendeskId: number; + zendeskKey?: string; + schema: ColumnSchema; +}; + +export type CustomFieldMapping = Map; diff --git a/packages/datasource-zendesk/test/client.test.ts b/packages/datasource-zendesk/test/client.test.ts new file mode 100644 index 0000000000..b78bce248c --- /dev/null +++ b/packages/datasource-zendesk/test/client.test.ts @@ -0,0 +1,257 @@ +import nock from 'nock'; + +import { MAX_PER_PAGE, ZendeskHttpClient, createZendeskClient } from '../src/client'; +import { ZendeskApiError, ZendeskConfigurationError } from '../src/errors'; + +const BASE_URL = 'https://acme.zendesk.com/api/v2'; + +const OPTIONS = { + subdomain: 'acme', + email: 'jane@acme.com', + apiToken: 'tk_123', +}; + +const AUTH_HEADER = + // base64('jane@acme.com/token:tk_123') + `Basic ${Buffer.from('jane@acme.com/token:tk_123').toString('base64')}`; + +beforeEach(() => { + nock.cleanAll(); +}); + +afterAll(() => { + nock.restore(); +}); + +describe('createZendeskClient', () => { + it('throws ZendeskConfigurationError when subdomain is missing', () => { + expect(() => createZendeskClient({ ...OPTIONS, subdomain: '' })).toThrow( + ZendeskConfigurationError, + ); + }); + + it('throws ZendeskConfigurationError when email is missing', () => { + expect(() => createZendeskClient({ ...OPTIONS, email: '' })).toThrow(ZendeskConfigurationError); + }); + + it('throws ZendeskConfigurationError when apiToken is missing', () => { + expect(() => createZendeskClient({ ...OPTIONS, apiToken: '' })).toThrow( + ZendeskConfigurationError, + ); + }); + + it('exposes the base URL derived from the subdomain', () => { + const client = createZendeskClient(OPTIONS); + + expect(client.baseUrl).toBe(BASE_URL); + }); +}); + +describe('ZendeskHttpClient.search', () => { + it('calls the search endpoint with prefixed type and returns results array', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .matchHeader('authorization', AUTH_HEADER) + .get('/search.json') + .query({ query: 'type:ticket status:open', per_page: 100, page: 1 }) + .reply(200, { results: [{ id: 1 }, { id: 2 }] }); + + const result = await client.search('ticket', { query: 'status:open' }); + + expect(result).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('caps perPage to MAX_PER_PAGE', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .get('/search.json') + .query(actual => Number(actual.per_page) === MAX_PER_PAGE) + .reply(200, { results: [] }); + + await client.search('ticket', { query: '', perPage: 500 }); + + expect(nock.isDone()).toBe(true); + }); + + it('forwards sortBy and sortOrder when provided', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .get('/search.json') + .query(actual => actual.sort_by === 'created_at' && actual.sort_order === 'desc') + .reply(200, { results: [] }); + + await client.search('ticket', { query: '', sortBy: 'created_at', sortOrder: 'desc' }); + + expect(nock.isDone()).toBe(true); + }); +}); + +describe('ZendeskHttpClient.count', () => { + it('returns the numeric count from the search/count endpoint', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .get('/search/count.json') + .query({ query: 'type:ticket status:open' }) + .reply(200, { count: 42 }); + + expect(await client.count('ticket', 'status:open')).toBe(42); + }); + + it('returns 0 when the body has no count', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL).get('/search/count.json').query(true).reply(200, {}); + + expect(await client.count('ticket', '')).toBe(0); + }); +}); + +describe('ZendeskHttpClient.findTicket', () => { + it('returns the ticket payload on success', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .get('/tickets/123.json') + .reply(200, { ticket: { id: 123, subject: 'hi' } }); + + expect(await client.findTicket(123)).toEqual({ id: 123, subject: 'hi' }); + }); + + it('returns null when the API responds 404', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL).get('/tickets/404.json').reply(404, { error: 'RecordNotFound' }); + + expect(await client.findTicket(404)).toBeNull(); + }); + + it('throws ZendeskApiError on a 500', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL).get('/tickets/1.json').reply(500, { error: 'Internal' }); + + await expect(client.findTicket(1)).rejects.toThrow(ZendeskApiError); + }); +}); + +describe('ZendeskHttpClient.fetchTicketsByIds', () => { + it('chunks ids by MAX_PER_PAGE and merges results', async () => { + const client = new ZendeskHttpClient(OPTIONS); + const ids = Array.from({ length: 150 }, (_, i) => i + 1); + + nock(BASE_URL) + .get('/tickets/show_many.json') + .query(actual => { + const list = String(actual.ids).split(','); + + return list.length === 100 && list[0] === '1'; + }) + .reply(200, { + tickets: Array.from({ length: 100 }, (_, i) => ({ id: i + 1, subject: 'a' })), + }); + + nock(BASE_URL) + .get('/tickets/show_many.json') + .query(actual => { + const list = String(actual.ids).split(','); + + return list.length === 50 && list[0] === '101'; + }) + .reply(200, { + tickets: Array.from({ length: 50 }, (_, i) => ({ id: 101 + i, subject: 'b' })), + }); + + const map = await client.fetchTicketsByIds(ids); + + expect(map.size).toBe(150); + expect(map.get(1)).toEqual({ id: 1, subject: 'a' }); + expect(map.get(150)).toEqual({ id: 150, subject: 'b' }); + }); + + it('returns an empty map when called with no ids', async () => { + const client = new ZendeskHttpClient(OPTIONS); + const map = await client.fetchTicketsByIds([]); + + expect(map.size).toBe(0); + }); +}); + +describe('ZendeskHttpClient.fetchUserEmails', () => { + it('returns a map of id -> email and degrades to empty map on failure', async () => { + const warn = jest.fn(); + const client = new ZendeskHttpClient(OPTIONS, (level, message) => warn(level, message)); + + nock(BASE_URL).get('/users/show_many.json').query(true).reply(500, { error: 'Boom' }); + + const map = await client.fetchUserEmails([1, 2]); + + expect(map.size).toBe(0); + expect(warn).toHaveBeenCalledWith('Warn', expect.stringContaining('fetch_user_emails')); + }); +}); + +describe('ZendeskHttpClient.createTicket', () => { + it('returns the created resource from the wrapped response', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .post('/tickets.json', { ticket: { subject: 'hi' } }) + .reply(201, { ticket: { id: 5, subject: 'hi' } }); + + const result = await client.createTicket({ subject: 'hi' }); + + expect(result).toEqual({ id: 5, subject: 'hi' }); + }); + + it('throws ZendeskApiError when the body is missing the expected envelope', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL).post('/tickets.json').reply(201, { unexpected: true }); + + await expect(client.createTicket({})).rejects.toThrow(ZendeskApiError); + }); +}); + +describe('ZendeskHttpClient.updateTicket', () => { + it('PUTs the ticket envelope and returns the updated resource', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .put('/tickets/9.json', { ticket: { status: 'solved' } }) + .reply(200, { ticket: { id: 9, status: 'solved' } }); + + const result = await client.updateTicket(9, { status: 'solved' }); + + expect(result).toEqual({ id: 9, status: 'solved' }); + }); +}); + +describe('ZendeskHttpClient.deleteTicket', () => { + it('DELETEs the ticket without raising', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL).delete('/tickets/9.json').reply(204); + + await expect(client.deleteTicket(9)).resolves.toBeUndefined(); + }); +}); + +describe('ZendeskHttpClient.fetchTicketFields', () => { + it('returns the ticket_fields array', async () => { + const client = new ZendeskHttpClient(OPTIONS); + + nock(BASE_URL) + .get('/ticket_fields.json') + .reply(200, { + ticket_fields: [{ id: 1, type: 'text' }], + }); + + const fields = await client.fetchTicketFields(); + + expect(fields).toEqual([{ id: 1, type: 'text' }]); + }); +}); diff --git a/packages/datasource-zendesk/test/collections/organization-collection.test.ts b/packages/datasource-zendesk/test/collections/organization-collection.test.ts new file mode 100644 index 0000000000..d01d91bc9e --- /dev/null +++ b/packages/datasource-zendesk/test/collections/organization-collection.test.ts @@ -0,0 +1,96 @@ +import type { ZendeskClient } from '../../src/client'; +import type { Caller, DataSource } from '@forestadmin/datasource-toolkit'; + +import { + ConditionTreeLeaf, + Filter, + PaginatedFilter, + Projection, +} from '@forestadmin/datasource-toolkit'; + +import OrganizationCollection from '../../src/collections/organization-collection'; + +const CALLER = { timezone: 'UTC' } as unknown as Caller; + +function makeClient(): jest.Mocked { + return { + search: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + findOrganization: jest.fn().mockResolvedValue(null), + createOrganization: jest.fn().mockResolvedValue({}), + updateOrganization: jest.fn().mockResolvedValue({}), + deleteOrganization: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; +} + +function makeDatasource(): DataSource { + return { customFieldMapping: new Map() } as unknown as DataSource; +} + +describe('OrganizationCollection', () => { + describe('schema', () => { + it('declares OneToMany relations to users and tickets', () => { + const collection = new OrganizationCollection(makeDatasource(), makeClient(), []); + + expect(collection.schema.fields.users).toMatchObject({ + type: 'OneToMany', + foreignCollection: 'zendesk_user', + originKey: 'organization_id', + }); + expect(collection.schema.fields.tickets).toMatchObject({ + type: 'OneToMany', + foreignCollection: 'zendesk_ticket', + originKey: 'organization_id', + }); + }); + }); + + describe('list', () => { + it('serializes organization fields and exposes custom fields by key', async () => { + const client = makeClient(); + client.findOrganization.mockResolvedValue({ + id: 5, + name: 'Acme', + organization_fields: { industry: 'software' }, + }); + const collection = new OrganizationCollection(makeDatasource(), client, []); + + const records = await collection.list( + CALLER, + new PaginatedFilter({ conditionTree: new ConditionTreeLeaf('id', 'Equal', 5) }), + new Projection('id', 'name', 'industry'), + ); + + expect(records).toEqual([{ id: 5, name: 'Acme', industry: 'software' }]); + }); + }); + + describe('update', () => { + it('routes custom fields under organization_fields by key', async () => { + const client = makeClient(); + const collection = new OrganizationCollection(makeDatasource(), client, [ + { + columnName: 'industry', + zendeskId: 1, + zendeskKey: 'industry', + schema: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + }, + ]); + + await collection.update( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('id', 'Equal', 7) }), + { name: 'New name', industry: 'fintech' }, + ); + + expect(client.updateOrganization).toHaveBeenCalledWith(7, { + name: 'New name', + organization_fields: { industry: 'fintech' }, + }); + }); + }); +}); diff --git a/packages/datasource-zendesk/test/collections/ticket-collection.test.ts b/packages/datasource-zendesk/test/collections/ticket-collection.test.ts new file mode 100644 index 0000000000..8fd0e74d35 --- /dev/null +++ b/packages/datasource-zendesk/test/collections/ticket-collection.test.ts @@ -0,0 +1,404 @@ +import type { ZendeskClient } from '../../src/client'; +import type { Caller, DataSource } from '@forestadmin/datasource-toolkit'; + +import { + Aggregation, + ConditionTreeBranch, + ConditionTreeLeaf, + Filter, + Page, + PaginatedFilter, + Projection, + Sort, +} from '@forestadmin/datasource-toolkit'; + +import TicketCollection from '../../src/collections/ticket-collection'; +import { UnsupportedOperatorError, ZendeskApiError } from '../../src/errors'; + +const CALLER = { timezone: 'UTC' } as unknown as Caller; + +function makeClient(): jest.Mocked { + return { + search: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + findTicket: jest.fn().mockResolvedValue(null), + findUser: jest.fn().mockResolvedValue(null), + findOrganization: jest.fn().mockResolvedValue(null), + fetchTicketsByIds: jest.fn().mockResolvedValue(new Map()), + fetchUsersByIds: jest.fn().mockResolvedValue(new Map()), + fetchOrganizationsByIds: jest.fn().mockResolvedValue(new Map()), + fetchUserEmails: jest.fn().mockResolvedValue(new Map()), + fetchTicketComments: jest.fn().mockResolvedValue([]), + fetchTicketFields: jest.fn().mockResolvedValue([]), + fetchUserFields: jest.fn().mockResolvedValue([]), + fetchOrganizationFields: jest.fn().mockResolvedValue([]), + createTicket: jest.fn().mockResolvedValue({}), + updateTicket: jest.fn().mockResolvedValue({}), + deleteTicket: jest.fn().mockResolvedValue(undefined), + createUser: jest.fn().mockResolvedValue({}), + updateUser: jest.fn().mockResolvedValue({}), + deleteUser: jest.fn().mockResolvedValue(undefined), + createOrganization: jest.fn().mockResolvedValue({}), + updateOrganization: jest.fn().mockResolvedValue({}), + deleteOrganization: jest.fn().mockResolvedValue(undefined), + bestEffort: jest.fn(async (_op, _default, fn) => fn()), + baseUrl: 'https://acme.zendesk.com/api/v2', + } as unknown as jest.Mocked; +} + +function makeDatasource(): DataSource { + return { customFieldMapping: new Map() } as unknown as DataSource; +} + +function buildCollection(client: ZendeskClient): TicketCollection { + return new TicketCollection(makeDatasource(), client, []); +} + +describe('TicketCollection', () => { + describe('schema', () => { + it('declares id as primary key with number filter operators', () => { + const collection = buildCollection(makeClient()); + const idField = collection.schema.fields.id; + + expect(idField).toMatchObject({ type: 'Column', columnType: 'Number', isPrimaryKey: true }); + }); + + it('declares status as Enum with the ticket statuses', () => { + const collection = buildCollection(makeClient()); + const { status } = collection.schema.fields; + + expect(status).toMatchObject({ + type: 'Column', + columnType: 'Enum', + enumValues: ['new', 'open', 'pending', 'hold', 'solved', 'closed'], + }); + }); + + it('declares relations to user and organization collections', () => { + const collection = buildCollection(makeClient()); + + expect(collection.schema.fields.requester).toMatchObject({ + type: 'ManyToOne', + foreignCollection: 'zendesk_user', + foreignKey: 'requester_id', + }); + expect(collection.schema.fields.organization).toMatchObject({ + type: 'ManyToOne', + foreignCollection: 'zendesk_organization', + }); + }); + + it('registers a custom field schema and exposes it under the custom_ column', () => { + const datasource = makeDatasource(); + const collection = new TicketCollection(datasource, makeClient(), [ + { + columnName: 'custom_123', + zendeskId: 123, + schema: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(['Equal']), + isReadOnly: false, + isSortable: false, + }, + }, + ]); + + expect(collection.schema.fields.custom_123).toBeDefined(); + }); + + it('skips a custom field whose column name collides with a native field', () => { + const logger = jest.fn(); + const collection = new TicketCollection( + makeDatasource(), + makeClient(), + [ + { + columnName: 'subject', + zendeskId: 999, + schema: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + }, + ], + logger, + ); + + // The native `subject` column remains String — but the test ensures the warn fires. + expect(collection.schema.fields.subject.type).toBe('Column'); + expect(logger).toHaveBeenCalledWith( + 'Warn', + expect.stringContaining("Custom field 'subject' collides"), + ); + }); + }); + + describe('list', () => { + it('short-circuits to findTicket when the filter is an exact id Equal lookup', async () => { + const client = makeClient(); + client.findTicket.mockResolvedValue({ id: 5, subject: 'hello', type: 'question' }); + const collection = buildCollection(client); + + const records = await collection.list( + CALLER, + new PaginatedFilter({ conditionTree: new ConditionTreeLeaf('id', 'Equal', 5) }), + new Projection('id', 'subject', 'ticket_type'), + ); + + expect(client.findTicket).toHaveBeenCalledWith(5); + expect(client.search).not.toHaveBeenCalled(); + expect(records).toEqual([{ id: 5, subject: 'hello', ticket_type: 'question' }]); + }); + + it('uses search and translates the condition tree when no id lookup', async () => { + const client = makeClient(); + client.search.mockResolvedValue([ + { id: 7, subject: 'one', status: 'open', type: 'incident' }, + ]); + const collection = buildCollection(client); + + await collection.list( + CALLER, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('status', 'Equal', 'open'), + page: new Page(0, 25), + sort: new Sort({ field: 'created_at', ascending: false }), + }), + new Projection('id', 'subject'), + ); + + expect(client.search).toHaveBeenCalledWith('ticket', { + query: 'status:open', + page: 1, + perPage: 25, + sortBy: 'created_at', + sortOrder: 'desc', + }); + }); + + it('fetches user emails when the projection includes requester_email', async () => { + const client = makeClient(); + client.search.mockResolvedValue([{ id: 1, requester_id: 99, subject: 's' }]); + client.fetchUserEmails.mockResolvedValue(new Map([[99, 'foo@example.com']])); + const collection = buildCollection(client); + + const records = await collection.list( + CALLER, + new PaginatedFilter({}), + new Projection('id', 'requester_email'), + ); + + expect(client.fetchUserEmails).toHaveBeenCalledWith([99]); + expect(records).toEqual([{ id: 1, requester_email: 'foo@example.com' }]); + }); + + it('embeds the requester relation when projection contains requester:*', async () => { + const client = makeClient(); + client.search.mockResolvedValue([{ id: 1, requester_id: 99, subject: 's' }]); + client.fetchUsersByIds.mockResolvedValue( + new Map([[99, { id: 99, email: 'foo@x.com', name: 'Foo' }]]), + ); + const collection = buildCollection(client); + + const records = await collection.list( + CALLER, + new PaginatedFilter({}), + new Projection('id', 'requester:id', 'requester:email'), + ); + + expect(client.fetchUsersByIds).toHaveBeenCalledWith([99]); + expect(records[0].requester).toEqual({ id: 99, email: 'foo@x.com' }); + }); + + it('embeds comments when projection includes comments', async () => { + const client = makeClient(); + client.search.mockResolvedValue([{ id: 1, subject: 's' }]); + client.fetchTicketComments.mockResolvedValue([ + { + id: 10, + body: 'b', + html_body: '

b

', + public: true, + author_id: 99, + created_at: 'now', + }, + ]); + client.fetchUsersByIds.mockResolvedValue( + new Map([[99, { id: 99, email: 'foo@x.com', name: 'Foo' }]]), + ); + const collection = buildCollection(client); + + const records = await collection.list( + CALLER, + new PaginatedFilter({}), + new Projection('id', 'comments'), + ); + + expect(client.fetchTicketComments).toHaveBeenCalledWith(1); + expect((records[0] as { comments: Array> }).comments[0]).toEqual({ + id: 10, + body: 'b', + html_body: '

b

', + public: true, + author_email: 'foo@x.com', + author_name: 'Foo', + created_at: 'now', + }); + }); + + it('throws when skip+limit exceeds Zendesk Search 1000-result cap', async () => { + const client = makeClient(); + const collection = buildCollection(client); + + await expect( + collection.list( + CALLER, + new PaginatedFilter({ page: new Page(950, 100) }), + new Projection('id'), + ), + ).rejects.toThrow(UnsupportedOperatorError); + }); + }); + + describe('create', () => { + it('strips read-only fields and maps ticket_type to type', async () => { + const client = makeClient(); + client.createTicket.mockResolvedValue({ id: 7, subject: 'hi', type: 'question' }); + const collection = buildCollection(client); + + await collection.create(CALLER, [ + { + id: 999, + subject: 'hi', + ticket_type: 'question', + description: 'body', + requester_email: 'ignored@example.com', + url: 'ignored-url', + }, + ]); + + expect(client.createTicket).toHaveBeenCalledWith({ + subject: 'hi', + type: 'question', + description: 'body', + comment: { body: 'body' }, + }); + }); + + it('puts custom fields under the custom_fields array', async () => { + const client = makeClient(); + client.createTicket.mockResolvedValue({ id: 1 }); + const collection = new TicketCollection(makeDatasource(), client, [ + { + columnName: 'custom_123', + zendeskId: 123, + schema: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + }, + ]); + + await collection.create(CALLER, [{ subject: 'hi', custom_123: 'red' }]); + + expect(client.createTicket).toHaveBeenCalledWith({ + subject: 'hi', + custom_fields: [{ id: 123, value: 'red' }], + }); + }); + }); + + describe('update', () => { + it('PUTs the patch to each id from the filter', async () => { + const client = makeClient(); + const collection = buildCollection(client); + + await collection.update( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('id', 'In', [1, 2]) }), + { status: 'solved' }, + ); + + expect(client.updateTicket).toHaveBeenCalledWith(1, { status: 'solved' }); + expect(client.updateTicket).toHaveBeenCalledWith(2, { status: 'solved' }); + expect(client.updateTicket).toHaveBeenCalledTimes(2); + }); + }); + + describe('delete', () => { + it('issues a delete for each id in the filter', async () => { + const client = makeClient(); + const collection = buildCollection(client); + + await collection.delete( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('id', 'Equal', 42) }), + ); + + expect(client.deleteTicket).toHaveBeenCalledWith(42); + }); + }); + + describe('aggregate', () => { + it('returns ids.length when the filter is an exact id-lookup', async () => { + const client = makeClient(); + const collection = buildCollection(client); + + const result = await collection.aggregate( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('id', 'In', [1, 2, 3]) }), + new Aggregation({ operation: 'Count' }), + ); + + expect(client.count).not.toHaveBeenCalled(); + expect(result).toEqual([{ value: 3, group: {} }]); + }); + + it('calls client.count with the translated query for non-id filters', async () => { + const client = makeClient(); + client.count.mockResolvedValue(17); + const collection = buildCollection(client); + + const result = await collection.aggregate( + CALLER, + new Filter({ + conditionTree: new ConditionTreeBranch('And', [ + new ConditionTreeLeaf('status', 'Equal', 'open'), + ]), + }), + new Aggregation({ operation: 'Count' }), + ); + + expect(client.count).toHaveBeenCalledWith('ticket', 'status:open'); + expect(result).toEqual([{ value: 17, group: {} }]); + }); + + it('throws on grouped or non-Count aggregations', async () => { + const client = makeClient(); + const collection = buildCollection(client); + + await expect( + collection.aggregate( + CALLER, + new Filter({}), + new Aggregation({ operation: 'Count', groups: [{ field: 'status' }] }), + ), + ).rejects.toThrow(UnsupportedOperatorError); + }); + }); + + describe('error propagation', () => { + it('surfaces ZendeskApiError raised during list', async () => { + const client = makeClient(); + client.search.mockRejectedValue(new ZendeskApiError('boom', 500, {})); + const collection = buildCollection(client); + + await expect( + collection.list(CALLER, new PaginatedFilter({}), new Projection('id')), + ).rejects.toThrow(ZendeskApiError); + }); + }); +}); diff --git a/packages/datasource-zendesk/test/collections/user-collection.test.ts b/packages/datasource-zendesk/test/collections/user-collection.test.ts new file mode 100644 index 0000000000..d6e8f4ec52 --- /dev/null +++ b/packages/datasource-zendesk/test/collections/user-collection.test.ts @@ -0,0 +1,144 @@ +import type { ZendeskClient } from '../../src/client'; +import type { Caller, DataSource } from '@forestadmin/datasource-toolkit'; + +import { + Aggregation, + ConditionTreeLeaf, + Filter, + PaginatedFilter, + Projection, +} from '@forestadmin/datasource-toolkit'; + +import UserCollection from '../../src/collections/user-collection'; + +const CALLER = { timezone: 'UTC' } as unknown as Caller; + +function makeClient(): jest.Mocked { + return { + search: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + findUser: jest.fn().mockResolvedValue(null), + createUser: jest.fn().mockResolvedValue({}), + updateUser: jest.fn().mockResolvedValue({}), + deleteUser: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; +} + +function makeDatasource(): DataSource { + return { customFieldMapping: new Map() } as unknown as DataSource; +} + +describe('UserCollection', () => { + describe('schema', () => { + it('declares role as Enum with end-user, agent and admin', () => { + const collection = new UserCollection(makeDatasource(), makeClient(), []); + + expect(collection.schema.fields.role).toMatchObject({ + type: 'Column', + columnType: 'Enum', + enumValues: ['end-user', 'agent', 'admin'], + }); + }); + + it('declares a ManyToOne to zendesk_organization', () => { + const collection = new UserCollection(makeDatasource(), makeClient(), []); + + expect(collection.schema.fields.organization).toMatchObject({ + type: 'ManyToOne', + foreignCollection: 'zendesk_organization', + foreignKey: 'organization_id', + }); + }); + + it('declares a OneToMany requested_tickets pointing back to tickets', () => { + const collection = new UserCollection(makeDatasource(), makeClient(), []); + + expect(collection.schema.fields.requested_tickets).toMatchObject({ + type: 'OneToMany', + foreignCollection: 'zendesk_ticket', + originKey: 'requester_id', + }); + }); + }); + + describe('list', () => { + it('short-circuits findUser on id lookup', async () => { + const client = makeClient(); + client.findUser.mockResolvedValue({ id: 5, email: 'a@b.com', name: 'Alice' }); + const collection = new UserCollection(makeDatasource(), client, []); + + const records = await collection.list( + CALLER, + new PaginatedFilter({ conditionTree: new ConditionTreeLeaf('id', 'Equal', 5) }), + new Projection('id', 'email', 'name'), + ); + + expect(client.findUser).toHaveBeenCalledWith(5); + expect(client.search).not.toHaveBeenCalled(); + expect(records).toEqual([{ id: 5, email: 'a@b.com', name: 'Alice' }]); + }); + + it('searches with the translated query when no id lookup', async () => { + const client = makeClient(); + client.search.mockResolvedValue([{ id: 1, email: 'foo@x.com', name: 'Foo' }]); + const collection = new UserCollection(makeDatasource(), client, []); + + await collection.list( + CALLER, + new PaginatedFilter({ conditionTree: new ConditionTreeLeaf('role', 'Equal', 'admin') }), + new Projection('id'), + ); + + expect(client.search).toHaveBeenCalledWith( + 'user', + expect.objectContaining({ query: 'role:admin' }), + ); + }); + }); + + describe('create', () => { + it('strips id/timestamps and routes custom fields into user_fields', async () => { + const client = makeClient(); + client.createUser.mockResolvedValue({ id: 1, email: 'a@b.com' }); + const collection = new UserCollection(makeDatasource(), client, [ + { + columnName: 'pref_lang', + zendeskId: 11, + zendeskKey: 'pref_lang', + schema: { + type: 'Column', + columnType: 'String', + filterOperators: new Set(), + }, + }, + ]); + + await collection.create(CALLER, [ + { id: 99, email: 'a@b.com', name: 'Alice', pref_lang: 'fr', created_at: 'x' }, + ]); + + expect(client.createUser).toHaveBeenCalledWith({ + email: 'a@b.com', + name: 'Alice', + user_fields: { pref_lang: 'fr' }, + }); + }); + }); + + describe('aggregate', () => { + it('counts via the client when filter is not an id lookup', async () => { + const client = makeClient(); + client.count.mockResolvedValue(11); + const collection = new UserCollection(makeDatasource(), client, []); + + const result = await collection.aggregate( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('role', 'Equal', 'admin') }), + new Aggregation({ operation: 'Count' }), + ); + + expect(client.count).toHaveBeenCalledWith('user', 'role:admin'); + expect(result).toEqual([{ value: 11, group: {} }]); + }); + }); +}); diff --git a/packages/datasource-zendesk/test/datasource.test.ts b/packages/datasource-zendesk/test/datasource.test.ts new file mode 100644 index 0000000000..fdca74921d --- /dev/null +++ b/packages/datasource-zendesk/test/datasource.test.ts @@ -0,0 +1,79 @@ +import type { ZendeskClient } from '../src/client'; + +import ZendeskDataSource, { COLLECTION_NAMES } from '../src/datasource'; +import { createZendeskDataSource } from '../src/factory'; + +function makeClient(overrides: Partial = {}): ZendeskClient { + return { + fetchTicketFields: jest.fn().mockResolvedValue([]), + fetchUserFields: jest.fn().mockResolvedValue([]), + fetchOrganizationFields: jest.fn().mockResolvedValue([]), + ...overrides, + } as unknown as ZendeskClient; +} + +describe('ZendeskDataSource.create', () => { + it('registers the three collections under their canonical names', async () => { + const ds = await ZendeskDataSource.create(makeClient()); + + expect(ds.collections.map(c => c.name).sort()).toEqual([ + COLLECTION_NAMES.organization, + COLLECTION_NAMES.ticket, + COLLECTION_NAMES.user, + ]); + }); + + it('builds a custom field mapping with ticket ids → custom_field_ and user/org keys', async () => { + const client = makeClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([{ id: 123, type: 'text', active: true, removable: true }]), + fetchUserFields: jest + .fn() + .mockResolvedValue([{ id: 5, key: 'preferred_lang', type: 'text', active: true }]), + fetchOrganizationFields: jest + .fn() + .mockResolvedValue([{ id: 7, key: 'industry', type: 'text', active: true }]), + }); + + const ds = await ZendeskDataSource.create(client); + + expect(ds.customFieldMapping.get('custom_123')).toBe('custom_field_123'); + expect(ds.customFieldMapping.get('preferred_lang')).toBe('preferred_lang'); + expect(ds.customFieldMapping.get('industry')).toBe('industry'); + }); + + it('registers custom field columns on the Ticket collection', async () => { + const client = makeClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([{ id: 42, type: 'text', active: true, removable: true }]), + }); + + const ds = await ZendeskDataSource.create(client); + const ticket = ds.getCollection(COLLECTION_NAMES.ticket); + + expect(ticket.schema.fields.custom_42).toBeDefined(); + }); + + it('exposes the client used during introspection', async () => { + const client = makeClient(); + const ds = await ZendeskDataSource.create(client); + + expect(ds.client).toBe(client); + }); +}); + +describe('createZendeskDataSource (factory)', () => { + it('reuses a pre-built client when provided', async () => { + const client = makeClient(); + const factory = createZendeskDataSource({ client }); + + const ds = await factory( + () => undefined, + async () => undefined, + ); + + expect((ds as ZendeskDataSource).client).toBe(client); + }); +}); diff --git a/packages/datasource-zendesk/test/plugins/close-ticket.test.ts b/packages/datasource-zendesk/test/plugins/close-ticket.test.ts new file mode 100644 index 0000000000..70929e1d8f --- /dev/null +++ b/packages/datasource-zendesk/test/plugins/close-ticket.test.ts @@ -0,0 +1,240 @@ +import type { ZendeskClient } from '../../src/client'; +import type { + ActionContext, + ActionContextSingle, + ActionDefinition, + CollectionCustomizer, + DataSourceCustomizer, +} from '@forestadmin/datasource-customizer'; + +import ResultBuilder from '@forestadmin/datasource-customizer/dist/decorators/actions/result-builder'; + +import { ZendeskApiError } from '../../src/errors'; +import { closeTicketPlugin, closeTickets } from '../../src/plugins/close-ticket'; +import { isAlreadyClosedError } from '../../src/plugins/close-ticket/errors'; + +function makeClient(): jest.Mocked { + return { + updateTicket: jest.fn().mockResolvedValue({}), + } as unknown as jest.Mocked; +} + +type CapturedAction = { name: string; definition: ActionDefinition }; + +function captureCollectionCustomizer(): { + collection: CollectionCustomizer; + actions: CapturedAction[]; +} { + const actions: CapturedAction[] = []; + const collection = { + name: 'mock', + addAction: jest.fn((name: string, definition: ActionDefinition) => { + actions.push({ name, definition }); + + return collection; + }), + } as unknown as CollectionCustomizer; + + return { collection, actions }; +} + +const NOOP_DATASOURCE = {} as DataSourceCustomizer; + +describe('closeTickets (helper)', () => { + it('classifies a successful update as succeeded', async () => { + const client = makeClient(); + const outcome = await closeTickets(client, [1, 2], 'solved'); + + expect(client.updateTicket).toHaveBeenCalledWith(1, { status: 'solved' }); + expect(client.updateTicket).toHaveBeenCalledWith(2, { status: 'solved' }); + expect(outcome.succeeded).toEqual([1, 2]); + expect(outcome.failed).toEqual([]); + expect(outcome.alreadyClosed).toEqual([]); + }); + + it('classifies a Zendesk 422 "closed prevents ticket update" as already closed', async () => { + const client = makeClient(); + client.updateTicket.mockImplementationOnce(async () => { + throw new ZendeskApiError('update(tickets/1)', 422, { + details: { + status: [{ description: 'Status: closed prevents ticket update' }], + }, + }); + }); + const outcome = await closeTickets(client, [1], 'solved'); + + expect(outcome.alreadyClosed).toEqual([1]); + expect(outcome.failed).toEqual([]); + }); + + it('captures arbitrary failures as failed with their message', async () => { + const client = makeClient(); + client.updateTicket.mockRejectedValueOnce(new ZendeskApiError('boom', 500, {})); + const outcome = await closeTickets(client, [9], 'closed'); + + expect(outcome.failed).toEqual([{ id: 9, error: expect.stringContaining('boom') }]); + expect(outcome.succeeded).toEqual([]); + }); +}); + +describe('isAlreadyClosedError', () => { + it('returns true when description matches the closed-pattern under details.status', () => { + const err = new ZendeskApiError('update', 422, { + details: { status: [{ description: 'Status: closed prevents ticket update' }] }, + }); + + expect(isAlreadyClosedError(err)).toBe(true); + }); + + it('returns false when the body has a different 422 reason', () => { + const err = new ZendeskApiError('update', 422, { description: 'permission denied' }); + + expect(isAlreadyClosedError(err)).toBe(false); + }); + + it('returns false for non-ZendeskApiError errors', () => { + expect(isAlreadyClosedError(new Error('boom'))).toBe(false); + }); +}); + +describe('closeTicketPlugin', () => { + it('throws if invoked at the datasource level', async () => { + const client = makeClient(); + + await expect( + closeTicketPlugin(NOOP_DATASOURCE, null as unknown as CollectionCustomizer, { + client, + ticketIdField: 'id', + }), + ).rejects.toThrow('only be used on collections'); + }); + + it('throws when options are missing', async () => { + const { collection } = captureCollectionCustomizer(); + + await expect(closeTicketPlugin(NOOP_DATASOURCE, collection)).rejects.toThrow( + 'requires `client` and `ticketIdField`', + ); + }); + + it('registers Single and Bulk actions for each requested status (default 2 statuses x 2 scopes = 4 actions)', async () => { + const { collection, actions } = captureCollectionCustomizer(); + const client = makeClient(); + + await closeTicketPlugin(NOOP_DATASOURCE, collection, { client, ticketIdField: 'id' }); + + expect(actions.map(a => a.name)).toEqual([ + 'Mark Zendesk ticket as solved', + 'Mark selected Zendesk tickets as solved', + 'Mark Zendesk ticket as closed', + 'Mark selected Zendesk tickets as closed', + ]); + expect(actions.map(a => a.definition.scope)).toEqual(['Single', 'Bulk', 'Single', 'Bulk']); + }); + + it('respects the `statuses` and `scopes` options', async () => { + const { actions, collection } = captureCollectionCustomizer(); + const client = makeClient(); + + await closeTicketPlugin(NOOP_DATASOURCE, collection, { + client, + ticketIdField: 'id', + statuses: ['solved'], + scopes: ['Single'], + }); + + expect(actions).toHaveLength(1); + expect(actions[0].name).toBe('Mark Zendesk ticket as solved'); + expect(actions[0].definition.scope).toBe('Single'); + }); + + describe('execute', () => { + async function runExecute( + pluginOptions: { ticketIdField: string }, + records: Array>, + client: ZendeskClient, + scope: 'Single' | 'Bulk' = 'Single', + ) { + const { collection, actions } = captureCollectionCustomizer(); + await closeTicketPlugin(NOOP_DATASOURCE, collection, { + client, + ticketIdField: pluginOptions.ticketIdField, + statuses: ['solved'], + scopes: [scope], + }); + const definition = actions[0].definition as ActionDefinition & { + execute: (ctx: ActionContext | ActionContextSingle, rb: ResultBuilder) => Promise; + }; + const context = { + getRecords: jest.fn().mockResolvedValue(records), + } as unknown as ActionContext; + + return definition.execute(context, new ResultBuilder()); + } + + it('returns success when the only ticket was updated', async () => { + const client = makeClient(); + const result = (await runExecute( + { ticketIdField: 'zendesk_id' }, + [{ zendesk_id: 7 }], + client, + )) as { + type: string; + message?: string; + }; + + expect(client.updateTicket).toHaveBeenCalledWith(7, { status: 'solved' }); + expect(result.type).toBe('Success'); + expect(result.message).toContain('Ticket #7 marked as solved.'); + }); + + it('returns error when there is no id on the record', async () => { + const client = makeClient(); + const result = (await runExecute({ ticketIdField: 'zendesk_id' }, [{}], client)) as { + type: string; + message?: string; + }; + + expect(result.type).toBe('Error'); + expect(result.message).toContain('No ticket id'); + }); + + it('returns success with mixed counts when some tickets were already closed', async () => { + const client = makeClient(); + client.updateTicket.mockImplementationOnce(async () => ({})); + client.updateTicket.mockImplementationOnce(async () => { + throw new ZendeskApiError('update(tickets/8)', 422, { + details: { status: [{ description: 'Status: closed prevents ticket update' }] }, + }); + }); + + const result = (await runExecute( + { ticketIdField: 'zendesk_id' }, + [{ zendesk_id: 7 }, { zendesk_id: 8 }], + client, + 'Bulk', + )) as { type: string; message?: string }; + + expect(result.type).toBe('Success'); + expect(result.message).toContain('1 ticket(s) marked as solved.'); + expect(result.message).toContain('1 ticket(s) were already closed'); + }); + + it('returns error when every update failed without already-closed signature', async () => { + const client = makeClient(); + client.updateTicket.mockRejectedValue(new ZendeskApiError('boom', 500, {})); + + const result = (await runExecute( + { ticketIdField: 'zendesk_id' }, + [{ zendesk_id: 9 }], + client, + )) as { + type: string; + message?: string; + }; + + expect(result.type).toBe('Error'); + expect(result.message).toContain('Failed to solve ticket #9'); + }); + }); +}); diff --git a/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts b/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts new file mode 100644 index 0000000000..205a1e6c2a --- /dev/null +++ b/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts @@ -0,0 +1,293 @@ +import type { ZendeskClient } from '../../src/client'; +import type { + ActionContextSingle, + ActionDefinition, + CollectionCustomizer, + DataSourceCustomizer, + DynamicForm, +} from '@forestadmin/datasource-customizer'; + +import ResultBuilder from '@forestadmin/datasource-customizer/dist/decorators/actions/result-builder'; + +import { createTicketWithNotificationPlugin } from '../../src/plugins/create-ticket-with-notification'; +import { + FORM_FIELDS, + buildForm, + interpolate, +} from '../../src/plugins/create-ticket-with-notification/form-builder'; + +const NOOP_DATASOURCE = {} as DataSourceCustomizer; + +type CapturedAction = { name: string; definition: ActionDefinition }; + +function captureCollectionCustomizer(): { + collection: CollectionCustomizer; + actions: CapturedAction[]; +} { + const actions: CapturedAction[] = []; + const collection = { + name: 'mock', + addAction: jest.fn((name: string, definition: ActionDefinition) => { + actions.push({ name, definition }); + + return collection; + }), + } as unknown as CollectionCustomizer; + + return { collection, actions }; +} + +function makeClient(): jest.Mocked { + return { + createTicket: jest.fn().mockResolvedValue({ id: 99 }), + } as unknown as jest.Mocked; +} + +function buildContext( + formValues: Record, + overrides: Partial = {}, +): ActionContextSingle { + return { + formValues, + getRecord: jest.fn().mockResolvedValue({}), + collection: { + update: jest.fn().mockResolvedValue(undefined), + }, + filter: {}, + ...overrides, + } as unknown as ActionContextSingle; +} + +describe('interpolate', () => { + it('replaces {{ record.field }} with the record value', () => { + expect(interpolate('Hello {{ record.name }}', { name: 'Alice' })).toBe('Hello Alice'); + }); + + it('supports dotted paths', () => { + expect(interpolate('{{ record.org.name }}', { org: { name: 'Acme' } })).toBe('Acme'); + }); + + it('renders an empty string when the path is missing', () => { + expect(interpolate('Hello {{ record.missing }}!', {})).toBe('Hello !'); + }); + + it('leaves text without tokens unchanged', () => { + expect(interpolate('no token', {})).toBe('no token'); + }); +}); + +describe('buildForm', () => { + it('returns a single-page form with no Template field when emailTemplates is missing', () => { + const form = buildForm({}) as Array<{ label?: string }>; + + const labels = form.map(f => f.label).filter(Boolean); + expect(labels).toContain(FORM_FIELDS.requesterEmail); + expect(labels).toContain(FORM_FIELDS.subject); + expect(labels).toContain(FORM_FIELDS.message); + expect(labels).not.toContain(FORM_FIELDS.template); + }); + + it('omits the Priority field when priorityOverride is provided', () => { + const form = buildForm({ priorityOverride: 'high' }) as Array<{ label?: string }>; + + expect(form.map(f => f.label)).not.toContain(FORM_FIELDS.priority); + }); + + it('omits the Type field when typeOverride is provided', () => { + const form = buildForm({ typeOverride: 'task' }) as Array<{ label?: string }>; + + expect(form.map(f => f.label)).not.toContain(FORM_FIELDS.type); + }); + + it('includes the internal-note toggle when showInternalNote is true', () => { + const form = buildForm({ showInternalNote: true }) as Array<{ label?: string }>; + + expect(form.map(f => f.label)).toContain(FORM_FIELDS.internalNote); + }); + + it('returns a multi-page form when emailTemplates is provided', () => { + const form = buildForm({ + emailTemplates: [{ title: 'Welcome', content: 'Hi {{ record.name }}' }], + }) as DynamicForm; + + expect(form).toHaveLength(2); + expect((form[0] as { component?: string }).component).toBe('Page'); + expect((form[1] as { component?: string }).component).toBe('Page'); + }); +}); + +describe('createTicketWithNotificationPlugin', () => { + it('throws when called on the datasource (no collection)', async () => { + await expect( + createTicketWithNotificationPlugin(NOOP_DATASOURCE, null as unknown as CollectionCustomizer, { + client: makeClient(), + }), + ).rejects.toThrow('only be used on collections'); + }); + + it('throws when no client is given', async () => { + const { collection } = captureCollectionCustomizer(); + + await expect(createTicketWithNotificationPlugin(NOOP_DATASOURCE, collection)).rejects.toThrow( + 'requires a `client`', + ); + }); + + it('registers a single action with the configured name', async () => { + const { collection, actions } = captureCollectionCustomizer(); + + await createTicketWithNotificationPlugin(NOOP_DATASOURCE, collection, { + client: makeClient(), + actionName: 'Send a follow-up', + }); + + expect(actions).toHaveLength(1); + expect(actions[0].name).toBe('Send a follow-up'); + expect(actions[0].definition.scope).toBe('Single'); + }); + + describe('execute', () => { + async function runExecute( + options: Parameters[2], + formValues: Record, + contextOverrides: Partial = {}, + ): Promise<{ type: string; message?: string }> { + const { collection, actions } = captureCollectionCustomizer(); + await createTicketWithNotificationPlugin(NOOP_DATASOURCE, collection, options); + const { definition } = actions[0]; + const context = buildContext(formValues, contextOverrides); + const { execute } = definition as ActionDefinition & { + execute: ( + ctx: ActionContextSingle, + rb: ResultBuilder, + ) => Promise<{ type: string; message?: string }>; + }; + + return (await execute(context, new ResultBuilder())) as { type: string; message?: string }; + } + + it('rejects when the requester email is missing', async () => { + const result = await runExecute( + { client: makeClient() }, + { [FORM_FIELDS.subject]: 'hi', [FORM_FIELDS.message]: 'body' }, + ); + + expect(result.type).toBe('Error'); + expect(result.message).toContain('Requester email is required'); + }); + + it('creates a public ticket and returns a notification-style success message', async () => { + const client = makeClient(); + const result = await runExecute( + { client }, + { + [FORM_FIELDS.requesterEmail]: 'foo@example.com', + [FORM_FIELDS.subject]: 'Subject', + [FORM_FIELDS.message]: '

Hi

', + [FORM_FIELDS.priority]: 'high', + [FORM_FIELDS.type]: 'question', + }, + ); + + expect(client.createTicket).toHaveBeenCalledWith({ + requester: { email: 'foo@example.com', name: 'foo' }, + subject: 'Subject', + comment: { html_body: '

Hi

', public: true }, + priority: 'high', + type: 'question', + }); + expect(result.type).toBe('Success'); + expect(result.message).toContain('created and requester notified'); + }); + + it('honours priorityOverride and typeOverride (form values are ignored)', async () => { + const client = makeClient(); + await runExecute( + { client, priorityOverride: 'urgent', typeOverride: 'task' }, + { + [FORM_FIELDS.requesterEmail]: 'a@b.com', + [FORM_FIELDS.subject]: 's', + [FORM_FIELDS.message]: 'b', + [FORM_FIELDS.priority]: 'low', + [FORM_FIELDS.type]: 'incident', + }, + ); + + expect(client.createTicket).toHaveBeenCalledWith( + expect.objectContaining({ priority: 'urgent', type: 'task' }), + ); + }); + + it('marks the comment as private and tweaks the success message when internal note is checked', async () => { + const client = makeClient(); + const result = await runExecute( + { client, showInternalNote: true }, + { + [FORM_FIELDS.requesterEmail]: 'a@b.com', + [FORM_FIELDS.subject]: 's', + [FORM_FIELDS.message]: 'b', + [FORM_FIELDS.internalNote]: true, + }, + ); + + expect(client.createTicket).toHaveBeenCalledWith( + expect.objectContaining({ comment: expect.objectContaining({ public: false }) }), + ); + expect(result.message).toContain('internal note, no email'); + }); + + it('writes the created ticket id back when ticketIdField is configured', async () => { + const client = makeClient(); + client.createTicket.mockResolvedValue({ id: 555 }); + const update = jest.fn().mockResolvedValue(undefined); + + await runExecute( + { client, ticketIdField: 'zendesk_id' }, + { + [FORM_FIELDS.requesterEmail]: 'a@b.com', + [FORM_FIELDS.subject]: 's', + [FORM_FIELDS.message]: 'b', + }, + { + collection: { update } as unknown as ActionContextSingle['collection'], + }, + ); + + expect(update).toHaveBeenCalledWith({}, { zendesk_id: 555 }); + }); + + it('appends a warning when the writeback fails', async () => { + const client = makeClient(); + const update = jest.fn().mockRejectedValue(new Error('permission denied')); + + const result = await runExecute( + { client, ticketIdField: 'zendesk_id' }, + { + [FORM_FIELDS.requesterEmail]: 'a@b.com', + [FORM_FIELDS.subject]: 's', + [FORM_FIELDS.message]: 'b', + }, + { collection: { update } as unknown as ActionContextSingle['collection'] }, + ); + + expect(result.message).toContain('Warning: failed to write ticket id'); + }); + + it('returns error when the API call fails', async () => { + const client = makeClient(); + client.createTicket.mockRejectedValue(new Error('boom')); + + const result = await runExecute( + { client }, + { + [FORM_FIELDS.requesterEmail]: 'a@b.com', + [FORM_FIELDS.subject]: 's', + [FORM_FIELDS.message]: 'b', + }, + ); + + expect(result.type).toBe('Error'); + expect(result.message).toContain('Failed to create Zendesk ticket: boom'); + }); + }); +}); diff --git a/packages/datasource-zendesk/test/query/condition-tree-translator.test.ts b/packages/datasource-zendesk/test/query/condition-tree-translator.test.ts new file mode 100644 index 0000000000..0fdc10f0ba --- /dev/null +++ b/packages/datasource-zendesk/test/query/condition-tree-translator.test.ts @@ -0,0 +1,215 @@ +import { ConditionTreeBranch, ConditionTreeLeaf } from '@forestadmin/datasource-toolkit'; + +import { UnsupportedOperatorError } from '../../src/errors'; +import { translateConditionTree } from '../../src/query/condition-tree-translator'; + +describe('translateConditionTree', () => { + describe('simple leaves', () => { + it('translates Equal to "field:value"', () => { + const tree = new ConditionTreeLeaf('status', 'Equal', 'open'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('status:open'); + }); + + it('translates NotEqual to "-field:value"', () => { + const tree = new ConditionTreeLeaf('status', 'NotEqual', 'closed'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('-status:closed'); + }); + + it('translates GreaterThan to "field>value"', () => { + const tree = new ConditionTreeLeaf('id', 'GreaterThan', 100); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('id>100'); + }); + + it('translates LessThan to "field { + const tree = new ConditionTreeLeaf('id', 'LessThan', 100); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('id<100'); + }); + + it('translates After to "field>value"', () => { + const date = new Date('2024-01-15T10:00:00.000Z'); + const tree = new ConditionTreeLeaf('created_at', 'After', date); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'created_at>2024-01-15T10:00:00.000Z', + ); + }); + + it('translates Before to "field { + const date = new Date('2024-01-15T10:00:00.000Z'); + const tree = new ConditionTreeLeaf('created_at', 'Before', date); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'created_at<2024-01-15T10:00:00.000Z', + ); + }); + + it('translates Present to "field:*"', () => { + const tree = new ConditionTreeLeaf('priority', 'Present'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('priority:*'); + }); + + it('translates Blank to "-field:*"', () => { + const tree = new ConditionTreeLeaf('priority', 'Blank'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('-priority:*'); + }); + + it('translates In to space-separated field:value pairs', () => { + const tree = new ConditionTreeLeaf('status', 'In', ['open', 'pending']); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'status:open status:pending', + ); + }); + + it('translates NotIn to space-separated negated pairs', () => { + const tree = new ConditionTreeLeaf('status', 'NotIn', ['open', 'pending']); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + '-status:open -status:pending', + ); + }); + }); + + describe('value formatting', () => { + it('quotes string values containing spaces', () => { + const tree = new ConditionTreeLeaf('subject', 'Equal', 'hello world'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('subject:"hello world"'); + }); + + it('quotes string values containing colons', () => { + const tree = new ConditionTreeLeaf('subject', 'Equal', 'foo:bar'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe('subject:"foo:bar"'); + }); + + it('escapes quotes inside string values', () => { + const tree = new ConditionTreeLeaf('subject', 'Equal', 'hello "world"'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'subject:"hello \\"world\\""', + ); + }); + + it('formats boolean true as "true"', () => { + const tree = new ConditionTreeLeaf('verified', 'Equal', true); + + expect(translateConditionTree(tree, { resource: 'user' })).toBe('verified:true'); + }); + + it('formats Date instances as ISO 8601 UTC', () => { + const tree = new ConditionTreeLeaf( + 'updated_at', + 'Equal', + new Date('2024-06-01T12:34:56.789Z'), + ); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'updated_at:2024-06-01T12:34:56.789Z', + ); + }); + }); + + describe('special fields', () => { + it('maps ticket requester_email Equal to requester:value', () => { + const tree = new ConditionTreeLeaf('requester_email', 'Equal', 'foo@example.com'); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'requester:foo@example.com', + ); + }); + + it('throws on requester_email with non-Equal operator', () => { + const tree = new ConditionTreeLeaf('requester_email', 'NotEqual', 'foo@example.com'); + + expect(() => translateConditionTree(tree, { resource: 'ticket' })).toThrow( + UnsupportedOperatorError, + ); + }); + + it('maps custom field column name to Zendesk custom_field_ID', () => { + const mapping = new Map([['custom_123', 'custom_field_123']]); + const tree = new ConditionTreeLeaf('custom_123', 'Equal', 'foo'); + + expect( + translateConditionTree(tree, { resource: 'ticket', customFieldMapping: mapping }), + ).toBe('custom_field_123:foo'); + }); + }); + + describe('And branches', () => { + it('joins And conditions with a space', () => { + const tree = new ConditionTreeBranch('And', [ + new ConditionTreeLeaf('status', 'Equal', 'open'), + new ConditionTreeLeaf('priority', 'Equal', 'high'), + ]); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'status:open priority:high', + ); + }); + + it('handles nested And branches', () => { + const tree = new ConditionTreeBranch('And', [ + new ConditionTreeLeaf('status', 'Equal', 'open'), + new ConditionTreeBranch('And', [ + new ConditionTreeLeaf('priority', 'Equal', 'high'), + new ConditionTreeLeaf('assignee_id', 'GreaterThan', 10), + ]), + ]); + + expect(translateConditionTree(tree, { resource: 'ticket' })).toBe( + 'status:open priority:high assignee_id>10', + ); + }); + }); + + describe('unsupported cases', () => { + it('throws when an Or branch is encountered', () => { + const tree = new ConditionTreeBranch('Or', [ + new ConditionTreeLeaf('status', 'Equal', 'open'), + new ConditionTreeLeaf('status', 'Equal', 'pending'), + ]); + + expect(() => translateConditionTree(tree, { resource: 'ticket' })).toThrow( + UnsupportedOperatorError, + ); + }); + + it('throws on an empty In array', () => { + const tree = new ConditionTreeLeaf('status', 'In', []); + + expect(() => translateConditionTree(tree, { resource: 'ticket' })).toThrow( + UnsupportedOperatorError, + ); + }); + + it('throws on a null value with Equal', () => { + const tree = new ConditionTreeLeaf('status', 'Equal', null); + + expect(() => translateConditionTree(tree, { resource: 'ticket' })).toThrow( + UnsupportedOperatorError, + ); + }); + + it('throws on an unsupported operator (Contains)', () => { + const tree = new ConditionTreeLeaf('subject', 'Contains', 'foo'); + + expect(() => translateConditionTree(tree, { resource: 'ticket' })).toThrow( + UnsupportedOperatorError, + ); + }); + }); + + describe('empty tree', () => { + it('returns an empty string when no tree is given', () => { + expect(translateConditionTree(undefined, { resource: 'ticket' })).toBe(''); + }); + }); +}); diff --git a/packages/datasource-zendesk/test/schema/custom-fields-introspector.test.ts b/packages/datasource-zendesk/test/schema/custom-fields-introspector.test.ts new file mode 100644 index 0000000000..5865e34662 --- /dev/null +++ b/packages/datasource-zendesk/test/schema/custom-fields-introspector.test.ts @@ -0,0 +1,224 @@ +import type { RawCustomFieldDefinition, ZendeskClient } from '../../src/client'; + +import { CustomFieldsIntrospector } from '../../src/schema/custom-fields-introspector'; + +function mockClient(overrides: Partial = {}): ZendeskClient { + return { + fetchTicketFields: jest.fn().mockResolvedValue([]), + fetchUserFields: jest.fn().mockResolvedValue([]), + fetchOrganizationFields: jest.fn().mockResolvedValue([]), + ...overrides, + } as unknown as ZendeskClient; +} + +function ticketField(overrides: Partial = {}): RawCustomFieldDefinition { + return { + id: 100, + type: 'text', + active: true, + removable: true, + ...overrides, + }; +} + +describe('CustomFieldsIntrospector', () => { + describe('ticketCustomFields', () => { + it('keeps only active and removable fields', async () => { + const client = mockClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([ + ticketField({ id: 1, type: 'text' }), + ticketField({ id: 2, type: 'text', active: false }), + ticketField({ id: 3, type: 'text', removable: false }), + ticketField({ id: 4, type: 'text', active: true, removable: true }), + ]), + }); + const introspector = new CustomFieldsIntrospector(client); + + const fields = await introspector.ticketCustomFields(); + + expect(fields.map(f => f.zendeskId)).toEqual([1, 4]); + }); + + it('names ticket columns as custom_', async () => { + const client = mockClient({ + fetchTicketFields: jest.fn().mockResolvedValue([ticketField({ id: 42, type: 'text' })]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(entry.columnName).toBe('custom_42'); + }); + + it('maps text/textarea/regexp/partialcreditcard to String column', async () => { + const client = mockClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([ + ticketField({ id: 1, type: 'text' }), + ticketField({ id: 2, type: 'textarea' }), + ticketField({ id: 3, type: 'regexp' }), + ticketField({ id: 4, type: 'partialcreditcard' }), + ]), + }); + + const fields = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(fields.map(f => f.schema.columnType)).toEqual([ + 'String', + 'String', + 'String', + 'String', + ]); + }); + + it('maps integer/decimal/lookup to Number column with number operators', async () => { + const client = mockClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([ + ticketField({ id: 1, type: 'integer' }), + ticketField({ id: 2, type: 'decimal' }), + ticketField({ id: 3, type: 'lookup' }), + ]), + }); + + const fields = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(fields.map(f => f.schema.columnType)).toEqual(['Number', 'Number', 'Number']); + expect([...(fields[0].schema.filterOperators ?? [])]).toContain('GreaterThan'); + }); + + it('maps date to Dateonly column', async () => { + const client = mockClient({ + fetchTicketFields: jest.fn().mockResolvedValue([ticketField({ id: 1, type: 'date' })]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(entry.schema.columnType).toBe('Dateonly'); + }); + + it('maps checkbox to Boolean column with Equal/NotEqual', async () => { + const client = mockClient({ + fetchTicketFields: jest.fn().mockResolvedValue([ticketField({ id: 1, type: 'checkbox' })]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(entry.schema.columnType).toBe('Boolean'); + expect([...(entry.schema.filterOperators ?? [])].sort()).toEqual(['Equal', 'NotEqual']); + }); + + it('maps multiselect to Json column with no filter operators', async () => { + const client = mockClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([ticketField({ id: 1, type: 'multiselect' })]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(entry.schema.columnType).toBe('Json'); + expect(entry.schema.filterOperators?.size ?? 0).toBe(0); + }); + + it('maps dropdown with options to Enum column with declared enum values', async () => { + const client = mockClient({ + fetchTicketFields: jest.fn().mockResolvedValue([ + ticketField({ + id: 1, + type: 'dropdown', + custom_field_options: [{ value: 'red' }, { value: 'blue' }], + }), + ]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(entry.schema.columnType).toBe('Enum'); + expect(entry.schema.enumValues).toEqual(['red', 'blue']); + }); + + it('falls back to String column when dropdown has no options', async () => { + const client = mockClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([ticketField({ id: 1, type: 'dropdown', custom_field_options: [] })]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(entry.schema.columnType).toBe('String'); + }); + + it('skips unsupported field types', async () => { + const client = mockClient({ + fetchTicketFields: jest + .fn() + .mockResolvedValue([ + ticketField({ id: 1, type: 'unsupported-type' }), + ticketField({ id: 2, type: 'text' }), + ]), + }); + + const fields = await new CustomFieldsIntrospector(client).ticketCustomFields(); + + expect(fields.map(f => f.zendeskId)).toEqual([2]); + }); + }); + + describe('userCustomFields', () => { + it('keeps only active fields (no removable filter)', async () => { + const client = mockClient({ + fetchUserFields: jest.fn().mockResolvedValue([ + { id: 1, key: 'my_field', type: 'text', active: true, removable: false }, + { id: 2, key: 'inactive', type: 'text', active: false, removable: false }, + ]), + }); + + const fields = await new CustomFieldsIntrospector(client).userCustomFields(); + + expect(fields.map(f => f.zendeskId)).toEqual([1]); + }); + + it('uses the field key as Forest column name', async () => { + const client = mockClient({ + fetchUserFields: jest + .fn() + .mockResolvedValue([{ id: 1, key: 'preferred_language', type: 'text', active: true }]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).userCustomFields(); + + expect(entry.columnName).toBe('preferred_language'); + expect(entry.zendeskKey).toBe('preferred_language'); + }); + + it('falls back to custom_ when key is missing', async () => { + const client = mockClient({ + fetchUserFields: jest.fn().mockResolvedValue([{ id: 7, type: 'text', active: true }]), + }); + + const [entry] = await new CustomFieldsIntrospector(client).userCustomFields(); + + expect(entry.columnName).toBe('custom_7'); + }); + }); + + describe('organizationCustomFields', () => { + it('returns active organization custom fields with their key', async () => { + const client = mockClient({ + fetchOrganizationFields: jest + .fn() + .mockResolvedValue([{ id: 1, key: 'industry', type: 'text', active: true }]), + }); + + const fields = await new CustomFieldsIntrospector(client).organizationCustomFields(); + + expect(fields).toHaveLength(1); + expect(fields[0].columnName).toBe('industry'); + }); + }); +}); diff --git a/packages/datasource-zendesk/tsconfig.eslint.json b/packages/datasource-zendesk/tsconfig.eslint.json new file mode 100644 index 0000000000..9bdc52705d --- /dev/null +++ b/packages/datasource-zendesk/tsconfig.eslint.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.eslint.json" +} diff --git a/packages/datasource-zendesk/tsconfig.json b/packages/datasource-zendesk/tsconfig.json new file mode 100644 index 0000000000..e0d66374ae --- /dev/null +++ b/packages/datasource-zendesk/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src/**/*"] +} From df946276d547202a9633c6be9d4cde5982c509d7 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 12:37:04 +0200 Subject: [PATCH 2/5] fix(datasource-zendesk): honor scope, pagination and operator limits Address correctness issues found in review of the Zendesk datasource: - id-lookups combined with sibling conditions (scope/segment) are now re-checked in memory, so list/update/delete/count never bypass the caller's perimeter - update/delete page through every match up to the Search cap instead of silently affecting only the first 100, warning when the cap is exceeded - In/NotIn dropped from Search-backed fields (Zendesk has no OR); kept on the primary key, which is resolved by id lookup - sort and pagination are applied in memory on the id-lookup path - aggregateCount fetches and filters the referenced ids instead of trusting their count blindly - searchRecords fetches the pages covering a non-aligned window and slices, instead of snapping skip to a page boundary - description is ignored on update (Zendesk derives it from the first comment and cannot edit it afterwards) - schema made honest: id advertises {Equal, In}, booleans {Equal, NotEqual}, non-sortable foreign keys no longer claim isSortable - operator sets extracted to a single shared module Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/datasource-zendesk/README.md | 8 +- .../collections/base-zendesk-collection.ts | 165 ++++++++++++++---- .../collections/organization-collection.ts | 8 +- .../src/collections/searchable-collection.ts | 53 +----- .../src/collections/ticket-collection.ts | 64 +++---- .../collections/ticket/schema-definition.ts | 8 +- .../src/collections/user-collection.ts | 12 +- .../datasource-zendesk/src/query/operators.ts | 26 +++ .../src/schema/custom-fields-introspector.ts | 14 +- .../collections/ticket-collection.test.ts | 72 +++++++- 10 files changed, 275 insertions(+), 155 deletions(-) create mode 100644 packages/datasource-zendesk/src/query/operators.ts diff --git a/packages/datasource-zendesk/README.md b/packages/datasource-zendesk/README.md index 274614a8ed..ae86f1132b 100644 --- a/packages/datasource-zendesk/README.md +++ b/packages/datasource-zendesk/README.md @@ -75,11 +75,13 @@ createZendeskDataSource({ subdomain, email, apiToken }); Zendesk Search supports a restricted set of operators. The datasource exposes: -- `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank` for strings, enums and booleans -- `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank`, `GreaterThan`, `LessThan` for numbers +- `Equal`, `In` for the primary key (`id`), resolved by id lookup outside of Search +- `Equal`, `NotEqual`, `Present`, `Blank` for strings and enums +- `Equal`, `NotEqual` for booleans +- `Equal`, `NotEqual`, `Present`, `Blank`, `GreaterThan`, `LessThan` for numbers - `Equal`, `Before`, `After`, `Present`, `Blank` for dates -Unsupported operators (`Contains`, `Or` aggregator, …) raise `UnsupportedOperatorError`. The Zendesk Search API caps result pagination at 1000 records — large skips raise the same error. +Zendesk Search has no `OR` operator, so multi-value membership (`In`/`NotIn`) is only available on `id`. Unsupported operators (`Contains`, `Or` aggregator, …) raise `UnsupportedOperatorError`. The Zendesk Search API caps result pagination at 1000 records — large skips raise the same error. ### Custom fields diff --git a/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts b/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts index 54b982af89..2a7ea31580 100644 --- a/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts +++ b/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts @@ -1,6 +1,6 @@ import type { ZendeskClient } from '../client'; import type ZendeskDataSource from '../datasource'; -import type { CustomFieldEntry, ZendeskResource } from '../types'; +import type { CustomFieldEntry, ZendeskRecord, ZendeskResource } from '../types'; import type { AggregateResult, Aggregation, @@ -10,8 +10,6 @@ import type { DataSource, Filter, Logger, - Operator, - Page, PaginatedFilter, Projection, RecordData, @@ -24,31 +22,11 @@ import { ConditionTreeLeaf, } from '@forestadmin/datasource-toolkit'; -import { MAX_TOTAL_RESULTS } from '../client'; +import { MAX_PER_PAGE, MAX_TOTAL_RESULTS } from '../client'; import { UnsupportedOperatorError } from '../errors'; import { translateConditionTree } from '../query/condition-tree-translator'; -export const STRING_OPS = new Set([ - 'Equal', - 'NotEqual', - 'In', - 'NotIn', - 'Present', - 'Blank', -]); -export const NUMBER_OPS = new Set([ - 'Equal', - 'NotEqual', - 'In', - 'NotIn', - 'Present', - 'Blank', - 'GreaterThan', - 'LessThan', -]); -export const DATE_OPS = new Set(['Equal', 'Before', 'After', 'Present', 'Blank']); - -export type TranslatedPage = { page: number; perPage: number }; +export { BOOLEAN_OPS, DATE_OPS, ID_OPS, NUMBER_OPS, STRING_OPS } from '../query/operators'; export default abstract class BaseZendeskCollection extends BaseCollection { protected readonly client: ZendeskClient; @@ -113,9 +91,35 @@ export default abstract class BaseZendeskCollection extends BaseCollection { return typeof limit === 'number' ? results.slice(0, limit) : results; } + // ===== Resource access implemented by the concrete collections ===== + + /** Fetch a single resource by primary key, or null when it does not exist (404). */ + protected abstract findOne(id: number | string): Promise; + + /** Convert a raw Zendesk payload into the Forest column layout this collection exposes. */ + protected abstract serializeRecord(record: ZendeskRecord): RecordData; + // ===== Helpers used by subclasses ===== - protected abstract aggregateCount(caller: Caller, filter: Filter): Promise; + /** + * Count the records matching the filter. An exact id-lookup is resolved by fetching the + * referenced records and re-applying the full condition tree in memory, so that non-existent + * ids and sibling conditions (scope/segment) are honored rather than blindly trusted. + */ + protected async aggregateCount(caller: Caller, filter: Filter): Promise { + const ids = this.extractIdLookup(filter?.conditionTree); + + if (ids) { + const records = (await this.fetchRecordsByIds(ids)).map(raw => this.serializeRecord(raw)); + const matching = filter?.conditionTree + ? filter.conditionTree.apply(records, this, caller.timezone) + : records; + + return matching.length; + } + + return this.client.count(this.resource, this.buildZendeskQuery(filter)); + } protected addCustomFields(customFields: CustomFieldEntry[]): void { for (const entry of customFields) { @@ -144,9 +148,11 @@ export default abstract class BaseZendeskCollection extends BaseCollection { return { sortBy: zendeskField, sortOrder: first.ascending ? 'asc' : 'desc' }; } - protected translatePage(page: Page | undefined): TranslatedPage { - const skip = page?.skip ?? 0; - const limit = page?.limit ?? 100; + // Zendesk Search is page-based; a window not aligned to a page boundary is satisfied by + // fetching the covering pages and slicing in memory rather than snapping to a boundary. + protected async searchRecords(filter: PaginatedFilter | undefined): Promise { + const skip = filter?.page?.skip ?? 0; + const limit = filter?.page?.limit ?? MAX_PER_PAGE; if (skip + limit > MAX_TOTAL_RESULTS) { throw new UnsupportedOperatorError( @@ -156,10 +162,51 @@ export default abstract class BaseZendeskCollection extends BaseCollection { ); } - const perPage = Math.max(1, Math.min(limit, 100)); - const pageNumber = Math.floor(skip / perPage) + 1; + const perPage = Math.max(1, Math.min(limit, MAX_PER_PAGE)); + const firstPage = Math.floor(skip / perPage) + 1; + const offset = skip - (firstPage - 1) * perPage; + const pageCount = Math.max(1, Math.ceil((offset + limit) / perPage)); + const { sortBy, sortOrder } = this.translateSort(filter?.sort); + const query = this.buildZendeskQuery(filter); + + const pages = await Promise.all( + Array.from({ length: pageCount }, (_unused, index) => + this.client.search(this.resource, { + query, + page: firstPage + index, + perPage, + sortBy, + sortOrder, + }), + ), + ); + + const records = pages.flat(); + + return offset === 0 && pageCount === 1 ? records : records.slice(offset, offset + limit); + } + + // Drops ids that no longer exist (findOne returns null on 404). + protected async fetchRecordsByIds(ids: number[]): Promise { + const results = await Promise.all(ids.map(id => this.findOne(id))); + + return results.filter((record): record is ZendeskRecord => record !== null); + } + + // Re-apply the filter, sort and pagination in memory (used on the id-lookup path, which + // bypasses the Search API and therefore never had them applied server-side). + protected refine( + records: RecordData[], + filter: PaginatedFilter | undefined, + timezone: string, + ): RecordData[] { + let result = records; + + if (filter?.conditionTree) result = filter.conditionTree.apply(result, this, timezone); + if (filter?.sort) result = filter.sort.apply(result); + if (filter?.page) result = filter.page.apply(result); - return { page: pageNumber, perPage }; + return result; } protected buildZendeskQuery(filter: Filter | PaginatedFilter | undefined): string { @@ -204,4 +251,58 @@ export default abstract class BaseZendeskCollection extends BaseCollection { return null; } + + /** + * Resolve the primary keys targeted by update/delete. A pure id-lookup is authoritative; an + * id-lookup combined with other conditions (scope/segment) is re-checked in memory so we never + * mutate a record outside the caller's perimeter. Otherwise every matching page is collected. + */ + protected async resolveIds(filter: Filter | undefined, timezone: string): Promise { + const ids = this.extractIdLookup(filter?.conditionTree); + + if (ids) { + const onlyIds = filter?.conditionTree?.everyLeaf(leaf => leaf.field === 'id') ?? true; + if (onlyIds) return ids; + + const records = (await this.fetchRecordsByIds(ids)).map(raw => this.serializeRecord(raw)); + + return filter.conditionTree + .apply(records, this, timezone) + .map(record => Number(record.id)) + .filter(id => Number.isFinite(id)); + } + + return this.searchAllIds(filter); + } + + // Page through every matching result up to the Zendesk Search cap, warning if it is exceeded + // rather than silently affecting only the first page. + private async searchAllIds(filter: Filter | undefined): Promise { + const query = this.buildZendeskQuery(filter); + const maxPages = Math.ceil(MAX_TOTAL_RESULTS / MAX_PER_PAGE); + const ids: number[] = []; + + for (let page = 1; page <= maxPages; page += 1) { + // eslint-disable-next-line no-await-in-loop + const records = await this.client.search(this.resource, { + query, + page, + perPage: MAX_PER_PAGE, + }); + + for (const record of records) { + const id = Number(record.id); + if (Number.isFinite(id)) ids.push(id); + } + + if (records.length < MAX_PER_PAGE) return ids; + } + + this.logger?.( + 'Warn', + `[datasource-zendesk] A bulk operation on '${this.name}' matched more than ${MAX_TOTAL_RESULTS} records; Zendesk Search only returns the first ${MAX_TOTAL_RESULTS}, so the rest are left untouched.`, + ); + + return ids; + } } diff --git a/packages/datasource-zendesk/src/collections/organization-collection.ts b/packages/datasource-zendesk/src/collections/organization-collection.ts index 13446e6787..b01efc69c6 100644 --- a/packages/datasource-zendesk/src/collections/organization-collection.ts +++ b/packages/datasource-zendesk/src/collections/organization-collection.ts @@ -10,7 +10,7 @@ import type { } from '@forestadmin/datasource-toolkit'; import { COLLECTION_NAMES } from '../datasource'; -import { DATE_OPS, NUMBER_OPS, STRING_OPS } from './base-zendesk-collection'; +import { DATE_OPS, ID_OPS, NUMBER_OPS, STRING_OPS } from './base-zendesk-collection'; import SearchableCollection from './searchable-collection'; import { serializeOrganization } from './ticket/serializer'; @@ -29,7 +29,7 @@ function getOrganizationFieldSchemas(): Record { columnType: 'Number', isPrimaryKey: true, isReadOnly: true, - filterOperators: new Set(NUMBER_OPS), + filterOperators: new Set(ID_OPS), }, name: { type: 'Column', @@ -120,7 +120,7 @@ export default class OrganizationCollection extends SearchableCollection { } override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { - const ids = await this.resolveIds(filter); + const ids = await this.resolveIds(filter, caller.timezone); const payload = this.buildPayload(patch); for (const id of ids) { @@ -130,7 +130,7 @@ export default class OrganizationCollection extends SearchableCollection { } override async delete(caller: Caller, filter: Filter): Promise { - const ids = await this.resolveIds(filter); + const ids = await this.resolveIds(filter, caller.timezone); for (const id of ids) { // eslint-disable-next-line no-await-in-loop diff --git a/packages/datasource-zendesk/src/collections/searchable-collection.ts b/packages/datasource-zendesk/src/collections/searchable-collection.ts index 8b64da30d5..e95ac9860f 100644 --- a/packages/datasource-zendesk/src/collections/searchable-collection.ts +++ b/packages/datasource-zendesk/src/collections/searchable-collection.ts @@ -1,7 +1,5 @@ -import type { ZendeskRecord } from '../types'; import type { Caller, - Filter, PaginatedFilter, Projection, RecordData, @@ -15,58 +13,17 @@ export default abstract class SearchableCollection extends BaseZendeskCollection filter: PaginatedFilter, projection: Projection, ): Promise { - const rawRecords = await this.fetchRawRecords(filter); - const records = rawRecords.map(raw => this.serializeRecord(raw)); - - return projection.apply(records); - } - - protected override async aggregateCount(caller: Caller, filter: Filter): Promise { const ids = this.extractIdLookup(filter?.conditionTree); if (ids) { - const records = await Promise.all(ids.map(id => this.findOne(id))); + const raw = await this.fetchRecordsByIds(ids); + const records = raw.map(record => this.serializeRecord(record)); - return records.filter(Boolean).length; + return projection.apply(this.refine(records, filter, caller.timezone)); } - return this.client.count(this.resource, this.buildZendeskQuery(filter)); - } - - protected async resolveIds(filter: Filter): Promise { - const direct = this.extractIdLookup(filter?.conditionTree); - if (direct) return direct; + const raw = await this.searchRecords(filter); - const records = await this.client.search(this.resource, { - query: this.buildZendeskQuery(filter), - perPage: 100, - }); - - return records.map(record => Number(record.id)).filter(id => Number.isFinite(id)); + return projection.apply(raw.map(record => this.serializeRecord(record))); } - - protected async fetchRawRecords(filter: PaginatedFilter): Promise { - const ids = this.extractIdLookup(filter?.conditionTree); - - if (ids) { - const results = await Promise.all(ids.map(id => this.findOne(id))); - - return results.filter((record): record is ZendeskRecord => record !== null); - } - - const { page, perPage } = this.translatePage(filter?.page); - const { sortBy, sortOrder } = this.translateSort(filter?.sort); - - return this.client.search(this.resource, { - query: this.buildZendeskQuery(filter), - page, - perPage, - sortBy, - sortOrder, - }); - } - - protected abstract findOne(id: number | string): Promise; - - protected abstract serializeRecord(record: ZendeskRecord): RecordData; } diff --git a/packages/datasource-zendesk/src/collections/ticket-collection.ts b/packages/datasource-zendesk/src/collections/ticket-collection.ts index 75059c1a00..35e70f37cb 100644 --- a/packages/datasource-zendesk/src/collections/ticket-collection.ts +++ b/packages/datasource-zendesk/src/collections/ticket-collection.ts @@ -62,17 +62,21 @@ export default class TicketCollection extends BaseZendeskCollection { filter: PaginatedFilter, projection: Projection, ): Promise { - const tickets = await this.fetchRawTickets(filter); + const ids = this.extractIdLookup(filter?.conditionTree); + const tickets = ids ? await this.fetchRecordsByIds(ids) : await this.searchRecords(filter); const needsEmail = projection.includes('requester_email'); const emails = needsEmail ? await this.client.fetchUserEmails(collectIds(tickets, 'requester_id')) : new Map(); - const records = tickets.map(ticket => + let records = tickets.map(ticket => serializeTicket(ticket, emails, this.zendeskIdToColumnName), ); + // The id-lookup path bypassed Zendesk Search, so honor the filter, sort and pagination here. + if (ids) records = this.refine(records, filter, caller.timezone); + const relations = findRequestedRelations(projection); if (relations.requester || relations.assignee || relations.organization) { @@ -99,7 +103,7 @@ export default class TicketCollection extends BaseZendeskCollection { } override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { - const ids = await this.resolveIds(filter); + const ids = await this.resolveIds(filter, caller.timezone); const payload = this.buildPayload(patch, { onCreate: false }); for (const id of ids) { @@ -109,7 +113,7 @@ export default class TicketCollection extends BaseZendeskCollection { } override async delete(caller: Caller, filter: Filter): Promise { - const ids = await this.resolveIds(filter); + const ids = await this.resolveIds(filter, caller.timezone); for (const id of ids) { // eslint-disable-next-line no-await-in-loop @@ -117,47 +121,15 @@ export default class TicketCollection extends BaseZendeskCollection { } } - protected override async aggregateCount(caller: Caller, filter: Filter): Promise { - const ids = this.extractIdLookup(filter?.conditionTree); - if (ids) return ids.length; - - return this.client.count('ticket', this.buildZendeskQuery(filter)); + protected override findOne(id: number | string): Promise { + return this.client.findTicket(id); } - // ===== Helpers ===== - - private async fetchRawTickets(filter: PaginatedFilter): Promise { - const ids = this.extractIdLookup(filter?.conditionTree); - - if (ids) { - const results = await Promise.all(ids.map(id => this.client.findTicket(id))); - - return results.filter((ticket): ticket is ZendeskRecord => ticket !== null); - } - - const { page, perPage } = this.translatePage(filter?.page); - const { sortBy, sortOrder } = this.translateSort(filter?.sort); - - return this.client.search('ticket', { - query: this.buildZendeskQuery(filter), - page, - perPage, - sortBy, - sortOrder, - }); + protected override serializeRecord(record: ZendeskRecord): RecordData { + return serializeTicket(record, new Map(), this.zendeskIdToColumnName); } - private async resolveIds(filter: Filter): Promise { - const direct = this.extractIdLookup(filter?.conditionTree); - if (direct) return direct; - - const records = await this.client.search('ticket', { - query: this.buildZendeskQuery(filter), - perPage: 100, - }); - - return records.map(record => Number(record.id)).filter(id => Number.isFinite(id)); - } + // ===== Helpers ===== private buildPayload(data: RecordData, { onCreate }: { onCreate: boolean }): ZendeskRecord { const payload: ZendeskRecord = {}; @@ -166,6 +138,16 @@ export default class TicketCollection extends BaseZendeskCollection { for (const [key, value] of Object.entries(data)) { if (READ_ONLY_INPUTS.has(key)) { // ignore read-only inputs + } else if (key === 'description') { + // Zendesk derives `description` from the first comment; it can only be set at creation. + if (onCreate) { + payload.description = value; + } else { + this.logger?.( + 'Warn', + `[datasource-zendesk] 'description' cannot be edited after creation; ignoring it.`, + ); + } } else if (key === 'ticket_type') { payload.type = value; } else { diff --git a/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts b/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts index 4bb08d3b30..b94cb658a9 100644 --- a/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts +++ b/packages/datasource-zendesk/src/collections/ticket/schema-definition.ts @@ -2,7 +2,7 @@ import type { FieldSchema } from '@forestadmin/datasource-toolkit'; import { COLLECTION_NAMES } from '../../datasource'; import { TICKET_PRIORITIES, TICKET_STATUSES, TICKET_TYPES } from '../../enums'; -import { DATE_OPS, NUMBER_OPS, STRING_OPS } from '../base-zendesk-collection'; +import { DATE_OPS, ID_OPS, NUMBER_OPS, STRING_OPS } from '../base-zendesk-collection'; export const TICKET_SORTABLE: Record = { updated_at: 'updated_at', @@ -30,7 +30,7 @@ export function getTicketFieldSchemas(): Record { isPrimaryKey: true, isReadOnly: true, isSortable: false, - filterOperators: new Set(NUMBER_OPS), + filterOperators: new Set(ID_OPS), }, subject: { type: 'Column', @@ -66,25 +66,21 @@ export function getTicketFieldSchemas(): Record { requester_id: { type: 'Column', columnType: 'Number', - isSortable: true, filterOperators: new Set(NUMBER_OPS), }, assignee_id: { type: 'Column', columnType: 'Number', - isSortable: true, filterOperators: new Set(NUMBER_OPS), }, group_id: { type: 'Column', columnType: 'Number', - isSortable: true, filterOperators: new Set(NUMBER_OPS), }, organization_id: { type: 'Column', columnType: 'Number', - isSortable: true, filterOperators: new Set(NUMBER_OPS), }, external_id: { diff --git a/packages/datasource-zendesk/src/collections/user-collection.ts b/packages/datasource-zendesk/src/collections/user-collection.ts index 13335ffadd..32683706f6 100644 --- a/packages/datasource-zendesk/src/collections/user-collection.ts +++ b/packages/datasource-zendesk/src/collections/user-collection.ts @@ -11,7 +11,7 @@ import type { import { COLLECTION_NAMES } from '../datasource'; import { USER_ROLES } from '../enums'; -import { DATE_OPS, NUMBER_OPS, STRING_OPS } from './base-zendesk-collection'; +import { BOOLEAN_OPS, DATE_OPS, ID_OPS, NUMBER_OPS, STRING_OPS } from './base-zendesk-collection'; import SearchableCollection from './searchable-collection'; import { serializeUser } from './ticket/serializer'; @@ -31,7 +31,7 @@ function getUserFieldSchemas(): Record { isPrimaryKey: true, isReadOnly: true, isSortable: false, - filterOperators: new Set(NUMBER_OPS), + filterOperators: new Set(ID_OPS), }, email: { type: 'Column', @@ -73,12 +73,12 @@ function getUserFieldSchemas(): Record { verified: { type: 'Column', columnType: 'Boolean', - filterOperators: new Set(STRING_OPS), + filterOperators: new Set(BOOLEAN_OPS), }, suspended: { type: 'Column', columnType: 'Boolean', - filterOperators: new Set(STRING_OPS), + filterOperators: new Set(BOOLEAN_OPS), }, created_at: { type: 'Column', @@ -138,7 +138,7 @@ export default class UserCollection extends SearchableCollection { } override async update(caller: Caller, filter: Filter, patch: RecordData): Promise { - const ids = await this.resolveIds(filter); + const ids = await this.resolveIds(filter, caller.timezone); const payload = this.buildPayload(patch); for (const id of ids) { @@ -148,7 +148,7 @@ export default class UserCollection extends SearchableCollection { } override async delete(caller: Caller, filter: Filter): Promise { - const ids = await this.resolveIds(filter); + const ids = await this.resolveIds(filter, caller.timezone); for (const id of ids) { // eslint-disable-next-line no-await-in-loop diff --git a/packages/datasource-zendesk/src/query/operators.ts b/packages/datasource-zendesk/src/query/operators.ts new file mode 100644 index 0000000000..c529fae8f4 --- /dev/null +++ b/packages/datasource-zendesk/src/query/operators.ts @@ -0,0 +1,26 @@ +import type { Operator } from '@forestadmin/datasource-toolkit'; + +/** + * Operator vocabularies supported by the Zendesk Search API, declared once and shared by + * the static collection schemas and the custom-field introspector so the two can never drift. + * + * Zendesk Search has no `OR` operator, so multi-value membership (`In`/`NotIn`) cannot be + * expressed for fields resolved through Search. Only the primary key advertises `In`, because + * id lookups bypass Search entirely (see {@link BaseZendeskCollection.extractIdLookup}). + */ +export const ID_OPS = new Set(['Equal', 'In']); + +export const STRING_OPS = new Set(['Equal', 'NotEqual', 'Present', 'Blank']); + +export const NUMBER_OPS = new Set([ + 'Equal', + 'NotEqual', + 'Present', + 'Blank', + 'GreaterThan', + 'LessThan', +]); + +export const DATE_OPS = new Set(['Equal', 'Before', 'After', 'Present', 'Blank']); + +export const BOOLEAN_OPS = new Set(['Equal', 'NotEqual']); diff --git a/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts b/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts index 376b73252f..96c86ab851 100644 --- a/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts +++ b/packages/datasource-zendesk/src/schema/custom-fields-introspector.ts @@ -2,19 +2,7 @@ import type { RawCustomFieldDefinition, ZendeskClient } from '../client'; import type { CustomFieldEntry } from '../types'; import type { ColumnSchema, Logger, Operator } from '@forestadmin/datasource-toolkit'; -const STRING_OPS = new Set(['Equal', 'NotEqual', 'In', 'NotIn', 'Present', 'Blank']); -const NUMBER_OPS = new Set([ - 'Equal', - 'NotEqual', - 'In', - 'NotIn', - 'Present', - 'Blank', - 'GreaterThan', - 'LessThan', -]); -const DATE_OPS = new Set(['Equal', 'Before', 'After', 'Present', 'Blank']); -const BOOLEAN_OPS = new Set(['Equal', 'NotEqual']); +import { BOOLEAN_OPS, DATE_OPS, NUMBER_OPS, STRING_OPS } from '../query/operators'; type FieldKind = 'ticket' | 'user' | 'organization'; diff --git a/packages/datasource-zendesk/test/collections/ticket-collection.test.ts b/packages/datasource-zendesk/test/collections/ticket-collection.test.ts index 8fd0e74d35..80952f46bd 100644 --- a/packages/datasource-zendesk/test/collections/ticket-collection.test.ts +++ b/packages/datasource-zendesk/test/collections/ticket-collection.test.ts @@ -152,6 +152,25 @@ describe('TicketCollection', () => { expect(records).toEqual([{ id: 5, subject: 'hello', ticket_type: 'question' }]); }); + it('applies sort and pagination in memory on an id-lookup list', async () => { + const client = makeClient(); + client.findTicket.mockImplementation(async id => ({ id, subject: `s${id}` })); + const collection = buildCollection(client); + + const records = await collection.list( + CALLER, + new PaginatedFilter({ + conditionTree: new ConditionTreeLeaf('id', 'In', [1, 2, 3]), + page: new Page(1, 1), + sort: new Sort({ field: 'id', ascending: false }), + }), + new Projection('id'), + ); + + expect(client.search).not.toHaveBeenCalled(); + expect(records).toEqual([{ id: 2 }]); + }); + it('uses search and translates the condition tree when no id lookup', async () => { const client = makeClient(); client.search.mockResolvedValue([ @@ -326,6 +345,19 @@ describe('TicketCollection', () => { expect(client.updateTicket).toHaveBeenCalledWith(2, { status: 'solved' }); expect(client.updateTicket).toHaveBeenCalledTimes(2); }); + + it('ignores description on update since Zendesk cannot edit it after creation', async () => { + const client = makeClient(); + const collection = buildCollection(client); + + await collection.update( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('id', 'Equal', 7) }), + { status: 'solved', description: 'new body' }, + ); + + expect(client.updateTicket).toHaveBeenCalledWith(7, { status: 'solved' }); + }); }); describe('delete', () => { @@ -340,11 +372,47 @@ describe('TicketCollection', () => { expect(client.deleteTicket).toHaveBeenCalledWith(42); }); + + it('does not delete an id that fails a sibling (scope) condition', async () => { + const client = makeClient(); + client.findTicket.mockResolvedValue({ id: 5, status: 'open' }); + const collection = buildCollection(client); + + await collection.delete( + CALLER, + new Filter({ + conditionTree: new ConditionTreeBranch('And', [ + new ConditionTreeLeaf('id', 'Equal', 5), + new ConditionTreeLeaf('status', 'Equal', 'closed'), + ]), + }), + ); + + expect(client.findTicket).toHaveBeenCalledWith(5); + expect(client.deleteTicket).not.toHaveBeenCalled(); + }); + + it('paginates through every matching page when the filter is not an id-lookup', async () => { + const client = makeClient(); + const firstPage = Array.from({ length: 100 }, (_unused, index) => ({ id: index + 1 })); + const secondPage = Array.from({ length: 50 }, (_unused, index) => ({ id: index + 101 })); + client.search.mockResolvedValueOnce(firstPage).mockResolvedValueOnce(secondPage); + const collection = buildCollection(client); + + await collection.delete( + CALLER, + new Filter({ conditionTree: new ConditionTreeLeaf('status', 'Equal', 'open') }), + ); + + expect(client.search).toHaveBeenCalledTimes(2); + expect(client.deleteTicket).toHaveBeenCalledTimes(150); + }); }); describe('aggregate', () => { - it('returns ids.length when the filter is an exact id-lookup', async () => { + it('counts only the ids that actually exist on an id-lookup', async () => { const client = makeClient(); + client.findTicket.mockImplementation(async id => (id === 3 ? null : { id })); const collection = buildCollection(client); const result = await collection.aggregate( @@ -354,7 +422,7 @@ describe('TicketCollection', () => { ); expect(client.count).not.toHaveBeenCalled(); - expect(result).toEqual([{ value: 3, group: {} }]); + expect(result).toEqual([{ value: 2, group: {} }]); }); it('calls client.count with the translated query for non-id filters', async () => { From af5a2f8125ef8d3a4d27eda4f29c9de1119aeca8 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 15:23:33 +0200 Subject: [PATCH 3/5] fix(datasource-zendesk): load record fields in create-ticket form The multi-step "create ticket and notify" form resolved its requester email default and message template against an empty record: getSingleRecord called getRecord([]) with an empty projection, so {{record.x}} tokens and record-based defaults always rendered blank on the second step. Request the collection's column fields from the action context schema so the record is populated before interpolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../form-builder.ts | 16 +++++- .../create-ticket-with-notification.test.ts | 53 +++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts b/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts index 548440c28e..899fc6c2a5 100644 --- a/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts +++ b/packages/datasource-zendesk/src/plugins/create-ticket-with-notification/form-builder.ts @@ -53,10 +53,24 @@ export function interpolate(template: string, record: RecordData): string { }); } +// The column field names of the action's collection. `getRecord` only loads the fields it is +// asked for, so an empty projection would yield a record with no usable values to interpolate. +function recordColumns(ctx: Ctx): string[] { + const fields = ( + ctx as unknown as { collection?: { schema?: { fields?: Record } } } + ).collection?.schema?.fields; + + if (!fields) return []; + + return Object.entries(fields) + .filter(([, schema]) => schema?.type === 'Column') + .map(([name]) => name); +} + async function getSingleRecord(ctx: Ctx): Promise { if ('getRecord' in ctx && typeof ctx.getRecord === 'function') { try { - return ((await ctx.getRecord([])) ?? {}) as RecordData; + return ((await ctx.getRecord(recordColumns(ctx))) ?? {}) as RecordData; } catch { return {}; } diff --git a/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts b/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts index 205a1e6c2a..f722d13084 100644 --- a/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts +++ b/packages/datasource-zendesk/test/plugins/create-ticket-with-notification.test.ts @@ -116,6 +116,59 @@ describe('buildForm', () => { }); }); +describe('record interpolation on the form', () => { + type FormField = { label?: string; defaultValue?: unknown; value?: unknown }; + + function findField(form: DynamicForm, label: string): FormField { + const pages = Array.isArray(form) ? form : [form]; + + for (const page of pages) { + const elements = ((page as { elements?: FormField[] }).elements ?? [page]) as FormField[]; + const found = elements.find(element => element.label === label); + if (found) return found; + } + + throw new Error(`Field '${label}' not found`); + } + + function ctxWithRecord( + record: Record, + formValues: Record = {}, + ): ActionContextSingle { + return { + formValues, + getRecord: jest.fn().mockResolvedValue(record), + collection: { schema: { fields: { email: { type: 'Column' }, job: { type: 'Column' } } } }, + } as unknown as ActionContextSingle; + } + + it('pre-fills the requester email from the selected record', async () => { + const form = buildForm({ + emailTemplates: [{ title: 'Welcome', content: 'Hi' }], + requesterEmailDefault: record => String(record.email ?? ''), + }); + const field = findField(form, FORM_FIELDS.requesterEmail); + const ctx = ctxWithRecord({ email: 'a@b.com' }); + + const value = await (field.defaultValue as (c: ActionContextSingle) => Promise)(ctx); + + expect(ctx.getRecord).toHaveBeenCalledWith(['email', 'job']); + expect(value).toBe('a@b.com'); + }); + + it('interpolates the record into the selected template message', async () => { + const form = buildForm({ + emailTemplates: [{ title: 'Welcome', content: 'Hi {{ record.email }}!' }], + }); + const field = findField(form, FORM_FIELDS.message); + const ctx = ctxWithRecord({ email: 'a@b.com' }, { [FORM_FIELDS.template]: 'Welcome' }); + + const value = await (field.value as (c: ActionContextSingle) => Promise)(ctx); + + expect(value).toBe('Hi a@b.com!'); + }); +}); + describe('createTicketWithNotificationPlugin', () => { it('throws when called on the datasource (no collection)', async () => { await expect( From b5d73055a414373676226a3bea1f28cf34dbc760 Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 15:36:37 +0200 Subject: [PATCH 4/5] fix(datasource-zendesk): restore In/NotIn on Search fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing In/NotIn from the string/number operator sets backfired: the operators-equivalence decorator re-advertises them (derivable from Equal / NotEqual) and rewrites incoming In into an Or tree, which the translator rejects — turning a common `status In [...]` filter into a 500 instead of the previous (empty) result. Advertise In/NotIn again so the decorator leaves them untouched and the translator handles them natively. Multi-value membership on Search-resolved fields keeps Zendesk's AND semantics (documented); only the primary key matches each value, via the id-lookup path. ID_OPS and the isSortable cleanup are kept. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/datasource-zendesk/README.md | 6 +++--- .../datasource-zendesk/src/query/operators.ts | 18 ++++++++++++++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/datasource-zendesk/README.md b/packages/datasource-zendesk/README.md index ae86f1132b..2ac03959c3 100644 --- a/packages/datasource-zendesk/README.md +++ b/packages/datasource-zendesk/README.md @@ -76,12 +76,12 @@ createZendeskDataSource({ subdomain, email, apiToken }); Zendesk Search supports a restricted set of operators. The datasource exposes: - `Equal`, `In` for the primary key (`id`), resolved by id lookup outside of Search -- `Equal`, `NotEqual`, `Present`, `Blank` for strings and enums +- `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank` for strings and enums - `Equal`, `NotEqual` for booleans -- `Equal`, `NotEqual`, `Present`, `Blank`, `GreaterThan`, `LessThan` for numbers +- `Equal`, `NotEqual`, `In`, `NotIn`, `Present`, `Blank`, `GreaterThan`, `LessThan` for numbers - `Equal`, `Before`, `After`, `Present`, `Blank` for dates -Zendesk Search has no `OR` operator, so multi-value membership (`In`/`NotIn`) is only available on `id`. Unsupported operators (`Contains`, `Or` aggregator, …) raise `UnsupportedOperatorError`. The Zendesk Search API caps result pagination at 1000 records — large skips raise the same error. +Zendesk Search has no `OR` operator. Multi-value membership (`In`/`NotIn`) on the primary key matches each value exactly (id lookup), but on other fields Zendesk ANDs the terms — so `status In ['open', 'pending']` returns nothing. Unsupported operators (`Contains`, `Or` aggregator, …) raise `UnsupportedOperatorError`. The Zendesk Search API caps result pagination at 1000 records — large skips raise the same error. ### Custom fields diff --git a/packages/datasource-zendesk/src/query/operators.ts b/packages/datasource-zendesk/src/query/operators.ts index c529fae8f4..d771c6eb34 100644 --- a/packages/datasource-zendesk/src/query/operators.ts +++ b/packages/datasource-zendesk/src/query/operators.ts @@ -4,17 +4,27 @@ import type { Operator } from '@forestadmin/datasource-toolkit'; * Operator vocabularies supported by the Zendesk Search API, declared once and shared by * the static collection schemas and the custom-field introspector so the two can never drift. * - * Zendesk Search has no `OR` operator, so multi-value membership (`In`/`NotIn`) cannot be - * expressed for fields resolved through Search. Only the primary key advertises `In`, because - * id lookups bypass Search entirely (see {@link BaseZendeskCollection.extractIdLookup}). + * `In`/`NotIn` are advertised so the operators-equivalence decorator does not rewrite them into + * an `Or` tree (which the translator cannot express — Zendesk Search has no `OR`). Zendesk treats + * the resulting terms as a conjunction, so multi-value membership on Search-resolved fields is a + * known limitation; only the primary key matches each value exactly, via the id-lookup path. */ export const ID_OPS = new Set(['Equal', 'In']); -export const STRING_OPS = new Set(['Equal', 'NotEqual', 'Present', 'Blank']); +export const STRING_OPS = new Set([ + 'Equal', + 'NotEqual', + 'In', + 'NotIn', + 'Present', + 'Blank', +]); export const NUMBER_OPS = new Set([ 'Equal', 'NotEqual', + 'In', + 'NotIn', 'Present', 'Blank', 'GreaterThan', From f9e1f861795d275eaca3003ba6297ac242548a8f Mon Sep 17 00:00:00 2001 From: Brun Christophe Date: Thu, 11 Jun 2026 15:46:39 +0200 Subject: [PATCH 5/5] fix(datasource-zendesk): honor requester_email scope on id-lookup path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit serializeRecord builds tickets with an empty email map, so re-applying a scope/segment condition on requester_email in memory (aggregateCount and resolveIds) always saw null and dropped every record — making count return 0 and update/delete no-op for tickets that legitimately matched. Add a serializeForFilter hook the ticket collection overrides to resolve requester_email before in-memory filtering. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../collections/base-zendesk-collection.ts | 9 +++++++-- .../src/collections/ticket-collection.ts | 15 +++++++++++++- .../collections/ticket-collection.test.ts | 20 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts b/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts index 2a7ea31580..219e2f9d44 100644 --- a/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts +++ b/packages/datasource-zendesk/src/collections/base-zendesk-collection.ts @@ -110,7 +110,7 @@ export default abstract class BaseZendeskCollection extends BaseCollection { const ids = this.extractIdLookup(filter?.conditionTree); if (ids) { - const records = (await this.fetchRecordsByIds(ids)).map(raw => this.serializeRecord(raw)); + const records = await this.serializeForFilter(await this.fetchRecordsByIds(ids)); const matching = filter?.conditionTree ? filter.conditionTree.apply(records, this, caller.timezone) : records; @@ -193,6 +193,11 @@ export default abstract class BaseZendeskCollection extends BaseCollection { return results.filter((record): record is ZendeskRecord => record !== null); } + // Overridable so collections can resolve derived columns before in-memory filtering. + protected async serializeForFilter(records: ZendeskRecord[]): Promise { + return records.map(record => this.serializeRecord(record)); + } + // Re-apply the filter, sort and pagination in memory (used on the id-lookup path, which // bypasses the Search API and therefore never had them applied server-side). protected refine( @@ -264,7 +269,7 @@ export default abstract class BaseZendeskCollection extends BaseCollection { const onlyIds = filter?.conditionTree?.everyLeaf(leaf => leaf.field === 'id') ?? true; if (onlyIds) return ids; - const records = (await this.fetchRecordsByIds(ids)).map(raw => this.serializeRecord(raw)); + const records = await this.serializeForFilter(await this.fetchRecordsByIds(ids)); return filter.conditionTree .apply(records, this, timezone) diff --git a/packages/datasource-zendesk/src/collections/ticket-collection.ts b/packages/datasource-zendesk/src/collections/ticket-collection.ts index 35e70f37cb..25df023524 100644 --- a/packages/datasource-zendesk/src/collections/ticket-collection.ts +++ b/packages/datasource-zendesk/src/collections/ticket-collection.ts @@ -65,7 +65,9 @@ export default class TicketCollection extends BaseZendeskCollection { const ids = this.extractIdLookup(filter?.conditionTree); const tickets = ids ? await this.fetchRecordsByIds(ids) : await this.searchRecords(filter); - const needsEmail = projection.includes('requester_email'); + const needsEmail = + projection.includes('requester_email') || + (ids !== null && this.filtersOnRequesterEmail(filter)); const emails = needsEmail ? await this.client.fetchUserEmails(collectIds(tickets, 'requester_id')) : new Map(); @@ -129,8 +131,19 @@ export default class TicketCollection extends BaseZendeskCollection { return serializeTicket(record, new Map(), this.zendeskIdToColumnName); } + // Resolve requester_email so scope/segment conditions on it are honored when filtering in memory. + protected override async serializeForFilter(records: ZendeskRecord[]): Promise { + const emails = await this.client.fetchUserEmails(collectIds(records, 'requester_id')); + + return records.map(record => serializeTicket(record, emails, this.zendeskIdToColumnName)); + } + // ===== Helpers ===== + private filtersOnRequesterEmail(filter: PaginatedFilter | undefined): boolean { + return filter?.conditionTree?.someLeaf(leaf => leaf.field === 'requester_email') ?? false; + } + private buildPayload(data: RecordData, { onCreate }: { onCreate: boolean }): ZendeskRecord { const payload: ZendeskRecord = {}; const customFields: Array<{ id: number; value: unknown }> = []; diff --git a/packages/datasource-zendesk/test/collections/ticket-collection.test.ts b/packages/datasource-zendesk/test/collections/ticket-collection.test.ts index 80952f46bd..c91eddafe1 100644 --- a/packages/datasource-zendesk/test/collections/ticket-collection.test.ts +++ b/packages/datasource-zendesk/test/collections/ticket-collection.test.ts @@ -392,6 +392,26 @@ describe('TicketCollection', () => { expect(client.deleteTicket).not.toHaveBeenCalled(); }); + it('resolves a requester_email scope condition against fetched emails on an id-lookup', async () => { + const client = makeClient(); + client.findTicket.mockResolvedValue({ id: 5, requester_id: 99 }); + client.fetchUserEmails.mockResolvedValue(new Map([[99, 'vip@acme.com']])); + const collection = buildCollection(client); + + await collection.delete( + CALLER, + new Filter({ + conditionTree: new ConditionTreeBranch('And', [ + new ConditionTreeLeaf('id', 'Equal', 5), + new ConditionTreeLeaf('requester_email', 'Equal', 'vip@acme.com'), + ]), + }), + ); + + expect(client.fetchUserEmails).toHaveBeenCalledWith([99]); + expect(client.deleteTicket).toHaveBeenCalledWith(5); + }); + it('paginates through every matching page when the filter is not an id-lookup', async () => { const client = makeClient(); const firstPage = Array.from({ length: 100 }, (_unused, index) => ({ id: index + 1 }));