mirror of
https://github.com/ToolJet/ToolJet
synced 2026-05-24 09:28:31 +00:00
* 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>
133 lines
4.3 KiB
TypeScript
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,
|
|
});
|
|
}
|
|
}
|