ToolJet/marketplace/plugins/intercom/lib/index.ts
Akshay 5f180c91c3
Feature: Add Intercom marketplace plugin (#15953)
* feat: add QuickBooks Online marketplace plugin

Add a QuickBooks Online Accounting API plugin with OAuth2 authentication,
86 API operations via OpenAPI spec, and @spec/ convention for DB-stored
spec files. Includes server-side spec hosting infrastructure and a fix
for duplicate footer rendering on marketplace OAuth2 datasource config pages.

* chore: update submodule pointers

* fix: use parent transaction for spec file DB operations

storeSpecFiles and updateSpecFilesForReload were wrapping each file
insert/update in a separate dbTransactionWrap call, creating independent
transactions instead of participating in the outer install/upgrade
transaction. This could leave orphan File entities if the plugin save
failed. Now uses the parent manager directly.

* fix: use sandbox API URL, remove testConnection, add state param

- Default to sandbox-quickbooks.api.intuit.com (development apps require it)
- Remove testConnection and customTesting (OAuth flow validates connection)
- Add state parameter to auth URL (required by QuickBooks)
- Add access_token validation guard in run()
- Preserve existing refresh_token if provider doesn't reissue
- Remove environment dropdown and company_id from manifest
- Add debug logging for OAuth flow tracing

* feat: add Intercom marketplace plugin and fix path-level param rendering

Add Intercom API v2.15 marketplace plugin with 162 endpoints across 30
resource groups using react-component-api-endpoint with @spec/ convention.

Fix ApiEndpointInput widget to merge path-level OpenAPI parameters into
each operation's parameters per the OpenAPI 3.0 inheritance rule. This
ensures path params declared at the path-item level (used by Intercom,
AWS, Stripe, and many others) are rendered as input fields.

* fix: set customTesting to false so framework renders Test Connection button

customTesting: false tells the framework to render its standard Test
Connection button which calls testConnection() on the backend.
customTesting: true (counterintuitively) hides the standard button,
expecting the plugin to provide custom testing UI.

* Chore: Migrate all OpenAPI plugins from external URLs to @spec/ convention (#15904)

* chore: migrate all OpenAPI plugins from external URLs to @spec/ convention

Downloads 78 OpenAPI spec files from external URLs (7 plugins from
adishM98/base-repo-testing personal repo, 2 from official provider repos,
1 from S3) and stores them locally in openapi-specs/ directories. Updates
all operations.json files to use @spec/<kind>/<name> references, which
are resolved to DB-stored specs at install time.

Eliminates runtime dependency on external GitHub repos for spec rendering.

* chore: remove one-time spec migration script

* chore: update package-locks for intercom plugin

Adds @tooljet-marketplace/intercom workspace links and dependency
entries that were missing from the prior commits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: update intercom plugin description

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 17:41:38 +05:30

133 lines
4.3 KiB
TypeScript

import { QueryError, QueryResult, QueryService, ConnectionTestResult } from '@tooljet-marketplace/common';
import got, { HTTPError } from 'got';
import { SourceOptions, QueryOptions } from './types';
interface IntercomError {
type: string;
errors?: Array<{ code: string; message: string }>;
message?: string;
}
export default class Intercom implements QueryService {
private readonly BASE_URL = 'https://api.intercom.io';
private readonly API_VERSION = '2.15';
private buildHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
'Intercom-Version': this.API_VERSION,
Accept: 'application/json',
};
}
async run(sourceOptions: SourceOptions, queryOptions: QueryOptions, dataSourceId: string): Promise<QueryResult> {
const accessToken = sourceOptions['access_token'];
if (!accessToken || typeof accessToken !== 'string') {
throw new QueryError(
'Authentication required',
'No access token found. Please provide a valid Intercom access token.',
{ code: 'MISSING_ACCESS_TOKEN' }
);
}
const operation = queryOptions.operation?.toLowerCase?.();
const path = queryOptions.path;
const pathParams = queryOptions.params?.path ?? {};
const queryParams = queryOptions.params?.query ?? {};
const bodyParams = queryOptions.params?.request ?? {};
// Build URL with path param interpolation
let url = `${this.BASE_URL}${path}`;
for (const [param, value] of Object.entries(pathParams)) {
url = url.replace(`{${param}}`, encodeURIComponent(String(value)));
}
const requestOptions: Record<string, unknown> = {
method: operation,
headers: this.buildHeaders(accessToken),
responseType: 'json',
};
// Add query params
if (queryParams && Object.keys(queryParams).length > 0) {
const searchParams = new URLSearchParams();
Object.entries(queryParams).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
if (searchParams.toString()) {
requestOptions.searchParams = searchParams;
}
}
// Add request body for non-GET/DELETE operations
if (operation && !['get', 'delete'].includes(operation) && bodyParams && Object.keys(bodyParams).length > 0) {
requestOptions.json = bodyParams;
}
try {
const response = await got(url, requestOptions);
return { status: 'ok', data: (response.body as unknown as Record<string, unknown>) ?? {} };
} catch (error: unknown) {
return this.handleError(error);
}
}
async testConnection(sourceOptions: SourceOptions): Promise<ConnectionTestResult> {
const accessToken = sourceOptions['access_token'];
if (!accessToken || typeof accessToken !== 'string') {
return { status: 'failed', message: 'Access token is required' };
}
try {
await got(`${this.BASE_URL}/me`, {
method: 'get',
headers: this.buildHeaders(accessToken),
responseType: 'json',
});
return { status: 'ok' };
} catch (error: unknown) {
const message = this.extractErrorMessage(error);
return { status: 'failed', message };
}
}
private extractErrorMessage(error: unknown): string {
if (error instanceof HTTPError) {
const body = error.response?.body as IntercomError | undefined;
if (body?.type === 'error.list' && Array.isArray(body?.errors) && body.errors.length > 0) {
const firstError = body.errors[0];
return `${firstError.code}: ${firstError.message}`;
}
if (body?.message) {
return body.message;
}
return `HTTP ${error.response?.statusCode}: ${error.message}`;
}
if (error instanceof Error) {
return error.message;
}
return 'An unknown error occurred';
}
private handleError(error: unknown): never {
const httpError = error instanceof HTTPError ? error : null;
const statusCode = httpError?.response?.statusCode;
const body = httpError?.response?.body as IntercomError | undefined;
const message = this.extractErrorMessage(error);
throw new QueryError('Query execution failed', message, {
statusCode,
errors: body?.errors,
code: httpError?.code,
});
}
}