- CLI: plugin dev list/allow/revoke (userId or email) - Backend: PluginDeveloperService.addDeveloper is now idempotent (returns existing record instead of unique-constraint 500) - Backend: removeDeveloper silently no-ops on P2025 (not found) - Controller: route api/plugin-developers (avoids :pluginId collision), @DisabledPermission so admin check is the sole gate
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
/**
|
|
* High-level API client. Each method maps to one REST endpoint of the
|
|
* inkreach-teable backend, using the SELF-BUILT contract (not official teable).
|
|
*
|
|
* Base/table/field/record/view endpoints are compatible with official teable
|
|
* open-api; workflow/automation endpoints use the project's own contract.
|
|
*/
|
|
import { request, ApiError } from './request';
|
|
import { IConfig } from './config';
|
|
|
|
export class Client {
|
|
constructor(private cfg: IConfig) {}
|
|
|
|
private get headers(): Record<string, string> {
|
|
return this.cfg.token ? { Authorization: `Bearer ${this.cfg.token}` } : {};
|
|
}
|
|
|
|
private async call<T>(method: string, p: string, body?: unknown): Promise<T> {
|
|
const res = await request<T>(this.cfg.endpoint, p, { method, headers: this.headers, body });
|
|
return res.data;
|
|
}
|
|
|
|
// ---- auth / user ----
|
|
getMe() {
|
|
// GET /api/auth/user — @TokenAccess() enabled, works with PAT.
|
|
// (/api/auth/user/me is session-only and 403s for PAT.)
|
|
return this.call<unknown>('GET', '/api/auth/user');
|
|
}
|
|
|
|
// ---- access tokens (PAT) ----
|
|
listAccessTokens() {
|
|
return this.call<unknown[]>('GET', '/api/access-token');
|
|
}
|
|
createAccessToken(body: {
|
|
name: string;
|
|
description?: string;
|
|
scopes: string[];
|
|
baseIds?: string[];
|
|
spaceIds?: string[];
|
|
hasFullAccess?: boolean;
|
|
expiredTime: string;
|
|
}) {
|
|
return this.call<{ id: string; token: string }>('POST', '/api/access-token', body);
|
|
}
|
|
deleteAccessToken(id: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/access-token/${id}`);
|
|
}
|
|
|
|
// ---- base ----
|
|
listBases() {
|
|
// GET /api/base/access/all — lists bases the user can access.
|
|
return this.call<unknown[]>('GET', '/api/base/access/all');
|
|
}
|
|
getBase(baseId: string) {
|
|
return this.call<unknown>('GET', `/api/base/${baseId}`);
|
|
}
|
|
|
|
// ---- table ----
|
|
listTables(baseId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/table`);
|
|
}
|
|
getTable(tableId: string) {
|
|
return this.call<unknown>('GET', `/api/table/${tableId}`);
|
|
}
|
|
createTable(baseId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/table`, body);
|
|
}
|
|
updateTable(tableId: string, body: unknown) {
|
|
return this.call<unknown>('PUT', `/api/table/${tableId}`, body);
|
|
}
|
|
deleteTable(tableId: string, baseId?: string) {
|
|
const base = baseId ?? this.cfg.baseId;
|
|
return this.call<{ id: string }>('DELETE', `/api/base/${base}/table/${tableId}`);
|
|
}
|
|
|
|
// ---- field ----
|
|
listFields(tableId: string) {
|
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/field`);
|
|
}
|
|
getField(fieldId: string) {
|
|
return this.call<unknown>('GET', `/api/field/${fieldId}`);
|
|
}
|
|
createField(tableId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/table/${tableId}/field`, body);
|
|
}
|
|
updateField(fieldId: string, body: unknown) {
|
|
return this.call<unknown>('PUT', `/api/field/${fieldId}`, body);
|
|
}
|
|
deleteField(fieldId: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/field/${fieldId}`);
|
|
}
|
|
|
|
// ---- record ----
|
|
getRecords(tableId: string, query?: Record<string, unknown>) {
|
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
|
return this.call<unknown>('GET', `/api/table/${tableId}/record${qs}`);
|
|
}
|
|
/** Flatten search[] tuple arrays into repeated query keys (Teable expects search[]=...). */
|
|
private flattenQuery(query: Record<string, unknown>): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
for (const [k, v] of Object.entries(query)) {
|
|
if (v === undefined || v === null) continue;
|
|
if (Array.isArray(v)) {
|
|
v.forEach((item) => {
|
|
out[`${k}[]`] = typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
});
|
|
} else if (typeof v === 'object') {
|
|
out[k] = JSON.stringify(v);
|
|
} else if (k === 'search' && typeof v === 'string') {
|
|
// The records list API expects `search[]=value` (tuple), not a plain
|
|
// `search=value` string. https://github.com/colinhacks/zod
|
|
out['search[]'] = v;
|
|
} else {
|
|
out[k] = String(v);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
getRecord(recordId: string, query?: Record<string, unknown>) {
|
|
const qs = query ? '?' + new URLSearchParams(this.flattenQuery(query)).toString() : '';
|
|
return this.call<unknown>('GET', `/api/record/${recordId}${qs}`);
|
|
}
|
|
createRecord(tableId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/table/${tableId}/record`, body);
|
|
}
|
|
updateRecord(tableId: string, body: unknown) {
|
|
return this.call<unknown>('PATCH', `/api/table/${tableId}/record`, body);
|
|
}
|
|
updateRecordOne(recordId: string, body: unknown) {
|
|
return this.call<unknown>('PATCH', `/api/record/${recordId}`, body);
|
|
}
|
|
deleteRecords(tableId: string, recordIds: string[]) {
|
|
const qs = recordIds.map((id) => `recordIds=${encodeURIComponent(id)}`).join('&');
|
|
return this.call<unknown>('DELETE', `/api/table/${tableId}/record?${qs}`);
|
|
}
|
|
|
|
// ---- view ----
|
|
listViews(tableId: string) {
|
|
return this.call<unknown[]>('GET', `/api/table/${tableId}/view`);
|
|
}
|
|
getView(viewId: string) {
|
|
return this.call<unknown>('GET', `/api/view/${viewId}`);
|
|
}
|
|
createView(tableId: string, body: unknown) {
|
|
return this.call<unknown>('POST', `/api/table/${tableId}/view`, body);
|
|
}
|
|
updateView(viewId: string, body: unknown) {
|
|
return this.call<unknown>('PATCH', `/api/view/${viewId}`, body);
|
|
}
|
|
deleteView(viewId: string) {
|
|
return this.call<{ id: string }>('DELETE', `/api/view/${viewId}`);
|
|
}
|
|
|
|
// ---- workflow (self-built contract) ----
|
|
listWorkflows(baseId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow`);
|
|
}
|
|
getWorkflow(workflowId: string) {
|
|
return this.call<IWorkflowVo>('GET', `/api/base/any/workflow/${workflowId}`);
|
|
}
|
|
/**
|
|
* Get workflow by explicit baseId + workflowId. getWorkflow() above hits a
|
|
* route that resolves base from the workflow itself, but some auth/permission
|
|
* paths need the base-scoped route. Prefer this when baseId is known.
|
|
*/
|
|
getWorkflowInBase(baseId: string, workflowId: string) {
|
|
return this.call<IWorkflowVo>('GET', `/api/base/${baseId}/workflow/${workflowId}`);
|
|
}
|
|
createWorkflow(baseId: string, body: { name: string; description?: string }) {
|
|
return this.call<IWorkflowVo>('POST', `/api/base/${baseId}/workflow`, body);
|
|
}
|
|
updateWorkflow(
|
|
baseId: string,
|
|
workflowId: string,
|
|
body: { name?: string; description?: string }
|
|
) {
|
|
return this.call<IWorkflowVo>('PATCH', `/api/base/${baseId}/workflow/${workflowId}`, body);
|
|
}
|
|
deleteWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<unknown>('DELETE', `/api/base/${baseId}/workflow/${workflowId}`);
|
|
}
|
|
activateWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<{ snapshotId: string }>(
|
|
'POST',
|
|
`/api/base/${baseId}/workflow/${workflowId}/activate`
|
|
);
|
|
}
|
|
deactivateWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/deactivate`);
|
|
}
|
|
testWorkflow(baseId: string, workflowId: string) {
|
|
return this.call<{ nodeResults: unknown[] }>(
|
|
'POST',
|
|
`/api/base/${baseId}/workflow/${workflowId}/test`
|
|
);
|
|
}
|
|
/** PUT /node — whole-graph replacement (delete-all + create-all in a txn). */
|
|
replaceNodes(
|
|
baseId: string,
|
|
workflowId: string,
|
|
body: { nodes: IWorkflowNodeVo[]; edges: IWorkflowEdgeVo[] }
|
|
) {
|
|
return this.call<IWorkflowVo>('PUT', `/api/base/${baseId}/workflow/${workflowId}/node`, body);
|
|
}
|
|
|
|
// ---- secrets ----
|
|
listSecrets(baseId: string, workflowId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/secret`);
|
|
}
|
|
createSecret(
|
|
baseId: string,
|
|
workflowId: string,
|
|
body: { name: string; description?: string; value: string }
|
|
) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/workflow/${workflowId}/secret`, body);
|
|
}
|
|
updateSecret(
|
|
baseId: string,
|
|
workflowId: string,
|
|
secretId: string,
|
|
body: { name?: string; description?: string; value?: string }
|
|
) {
|
|
return this.call<unknown>(
|
|
'PATCH',
|
|
`/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`,
|
|
body
|
|
);
|
|
}
|
|
deleteSecret(baseId: string, workflowId: string, secretId: string) {
|
|
return this.call<unknown>(
|
|
'DELETE',
|
|
`/api/base/${baseId}/workflow/${workflowId}/secret/${secretId}`
|
|
);
|
|
}
|
|
|
|
// ---- execution history ----
|
|
listExecutions(baseId: string, workflowId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/workflow/${workflowId}/execution`);
|
|
}
|
|
getExecution(baseId: string, workflowId: string, executionId: string) {
|
|
return this.call<unknown>(
|
|
'GET',
|
|
`/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}`
|
|
);
|
|
}
|
|
rerunExecution(baseId: string, workflowId: string, executionId: string) {
|
|
return this.call<unknown>(
|
|
'POST',
|
|
`/api/base/${baseId}/workflow/${workflowId}/execution/${executionId}/rerun`
|
|
);
|
|
}
|
|
|
|
// ---- trigger (manual fire for testing) ----
|
|
fireButtonClick(tableId: string, recordId: string, fieldId: string) {
|
|
return this.call<{ runId: string; tableId: string; fieldId: string; record: unknown }>(
|
|
'POST',
|
|
`/api/table/${tableId}/record/${recordId}/${fieldId}/button-click`
|
|
);
|
|
}
|
|
fireWebhook(baseId: string, workflowId: string, body?: unknown) {
|
|
// NOTE: real backend route is /api/webhook/{baseId}/{workflowId} (no /base/ /workflow/ segments)
|
|
return this.call<unknown>('POST', `/api/webhook/${baseId}/${workflowId}`, body);
|
|
}
|
|
|
|
// ---- SQL (read-only) ----
|
|
sql(baseId: string, sql: string) {
|
|
return this.call<unknown[]>('POST', `/api/base/${baseId}/sql`, { sql });
|
|
}
|
|
|
|
// ---- plugin ----
|
|
getPlugins() {
|
|
return this.call<unknown[]>('GET', '/api/plugin');
|
|
}
|
|
registerPlugin(body: Record<string, unknown>) {
|
|
return this.call<unknown>('POST', '/api/plugin', body);
|
|
}
|
|
listPluginConfigs(baseId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-config`);
|
|
}
|
|
upsertPluginConfig(baseId: string, pluginId: string, body: Record<string, unknown>) {
|
|
return this.call<unknown>('PUT', `/api/base/${baseId}/plugin-config/${pluginId}`, body);
|
|
}
|
|
|
|
// ---- plugin installs ----
|
|
listInstalls(baseId: string) {
|
|
return this.call<unknown[]>('GET', `/api/base/${baseId}/plugin-installs`);
|
|
}
|
|
createInstall(baseId: string, body: Record<string, unknown>) {
|
|
return this.call<unknown>('POST', `/api/base/${baseId}/plugin-installs`, body);
|
|
}
|
|
deleteInstall(baseId: string, installId: string) {
|
|
return this.call<unknown>('DELETE', `/api/base/${baseId}/plugin-installs/${installId}`);
|
|
}
|
|
|
|
// ---- plugin developers ----
|
|
listDevelopers() {
|
|
return this.call<unknown[]>('GET', '/api/plugin-developers');
|
|
}
|
|
addDeveloper(userIdOrEmail: string) {
|
|
// Backend reads @Body('userId') or @Body('email'). Send whichever looks right.
|
|
const isEmail = userIdOrEmail.includes('@');
|
|
const body = isEmail ? { email: userIdOrEmail } : { userId: userIdOrEmail };
|
|
return this.call<unknown>('POST', '/api/plugin-developers', body);
|
|
}
|
|
removeDeveloper(userId: string) {
|
|
return this.call<unknown>('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`);
|
|
}
|
|
}
|
|
|
|
// ---- shared types (mirror backend workflow_node / workflow_edge shapes) ----
|
|
export type WorkflowNodeType = 'trigger' | 'action' | 'control';
|
|
export type WorkflowNodeCategory =
|
|
| 'recordCreated'
|
|
| 'recordUpdated'
|
|
| 'recordCondition'
|
|
| 'recordCreatedOrUpdated'
|
|
| 'formSubmitted'
|
|
| 'scheduledTime'
|
|
| 'buttonClick'
|
|
| 'webhook'
|
|
| 'script'
|
|
| 'sendEmail'
|
|
| 'httpRequest'
|
|
| 'createRecord'
|
|
| 'updateRecord'
|
|
| 'getRecords'
|
|
| 'triggerEnd';
|
|
|
|
export interface IWorkflowNodeVo {
|
|
id: string;
|
|
workflowId?: string;
|
|
type: WorkflowNodeType;
|
|
category: WorkflowNodeCategory | string;
|
|
config: Record<string, unknown> | null;
|
|
name?: string | null;
|
|
testResult?: Record<string, unknown> | null;
|
|
inputVariables?: unknown;
|
|
outputVariables?: unknown;
|
|
}
|
|
|
|
export interface IWorkflowEdgeVo {
|
|
id: string;
|
|
workflowId?: string;
|
|
sourceNodeId: string;
|
|
targetNodeId: string;
|
|
}
|
|
|
|
export interface IWorkflowVo {
|
|
id: string;
|
|
baseId: string;
|
|
name: string;
|
|
description?: string | null;
|
|
isActive: boolean;
|
|
hasDraft: boolean;
|
|
nodes: IWorkflowNodeVo[];
|
|
edges: IWorkflowEdgeVo[];
|
|
createdBy?: string;
|
|
createdTime?: string;
|
|
lastModifiedBy?: string | null;
|
|
lastModifiedTime?: string | null;
|
|
}
|
|
|
|
export { ApiError };
|