diff --git a/.agents/skills/inkreach-teable/SKILL.md b/.agents/skills/inkreach-teable/SKILL.md index 5b45ee8..aae8755 100644 --- a/.agents/skills/inkreach-teable/SKILL.md +++ b/.agents/skills/inkreach-teable/SKILL.md @@ -53,8 +53,15 @@ Respond in the user's language. Keep answers concise and action-oriented. | Manual Trigger | Test by firing button-click/webhook | `fire button-click`, `fire webhook` | [automation-guide.md § Testing](guides/automation-guide.md#testing--triggering) | | SQL | Read-only analytics, JOINs, aggregation | `sql ` | [cli-reference.md § sql](guides/cli-reference.md#sql) | | Plugins | Install/manage dynamic plugins, developer whitelist, CSP | `plugin list/install/uninstall`, `plugin dev allow/revoke` | [cli-reference.md § plugin](guides/cli-reference.md#plugin) | +| Subscription Center | Record subscriptions (digest/instant email) | `subscription list/get/create/update/delete/events/send` | — (see below) | | Dev/Deploy | Build, restart services, known gotchas | (systemd / pnpm build) | [development.md](guides/development.md) | +**Subscription quick reference** (full config via `subscription create/update`): +`--name --table-id --no-watch-created --no-watch-updated --field-ids '' --record-ids '' --display-field-id --attach-csv/--no-attach-csv --filter '' --trigger-on-enter --send-mode digest|instant --recipients '' --no-notify-in-app --schedule-type daily|weekly|hours|minutes --schedule-time HH:mm --schedule-weekday 0-6 --schedule-interval n --inactive` +- `--filter` uses the SAME IFilter shape as view filters; date values are `{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"}`, user values are id arrays (`"Me"` resolves to the subscription creator). +- `--trigger-on-enter` = notify only on the transition INTO the filter condition (requires `--filter`); default is state semantics (every matching change). +- Filter fields must be visible to the creator (enforced server-side, fail-closed: unknown operator/deleted field never matches). + ### 2.2 Routing Rules > **Before executing**: read the documents marked "Required" in the guide for the module. diff --git a/dist/bin.js b/dist/bin.js index 61ffa7e..2c1520e 100755 --- a/dist/bin.js +++ b/dist/bin.js @@ -13,6 +13,7 @@ const auth_1 = require("./commands/auth"); const workflow_1 = require("./commands/workflow"); const resources_1 = require("./commands/resources"); const extras_1 = require("./commands/extras"); +const subscription_1 = require("./commands/subscription"); const program = new commander_1.Command(); program .name('inkreach-teable') @@ -34,6 +35,7 @@ for (const c of (0, resources_1.baseResourceCommands)()) program.addCommand((0, workflow_1.workflowCommand)()); program.addCommand((0, workflow_1.triggerCommand)()); program.addCommand((0, workflow_1.actionCommand)()); +program.addCommand((0, subscription_1.subscriptionCommand)()); program.addCommand((0, extras_1.secretCommand)()); program.addCommand((0, extras_1.runsCommand)()); program.addCommand((0, extras_1.fireCommand)()); diff --git a/dist/commands/subscription.js b/dist/commands/subscription.js new file mode 100644 index 0000000..dd7cbfc --- /dev/null +++ b/dist/commands/subscription.js @@ -0,0 +1,315 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.subscriptionCommand = void 0; +/** + * `inkreach-teable subscription` — 订阅中心全配置项 CRUD + 事件查看 + 立即发送。 + * + * 覆盖订阅中心全部配置:name/table/watchCreated/watchUpdated/fieldIds/recordIds/ + * displayFieldId/attachCsv/filter(原生 IFilter JSON)/triggerOnEnter(进入条件语义)/ + * sendMode/recipients/notifyInApp/scheduleType/scheduleTime/scheduleWeekday/ + * scheduleInterval/isActive。 + * + * filter 示例(与表格视图筛选同构): + * --filter '{"conjunction":"and","filterSet":[{"fieldId":"fldX","operator":"isExactly","value":"已完成"}]}' + * 日期筛选值结构:{"mode":"exactDate","exactDate":"2026-08-21","timeZone":"Asia/Shanghai"} + * 用户筛选值:["usrXXX"]('Me' 由后端解析为订阅创建者)。 + */ +const commander_1 = require("commander"); +const ctx_1 = require("../lib/ctx"); +const output_1 = require("../lib/output"); +function parseJsonFlag(raw, flagName) { + if (raw === undefined) + return undefined; + try { + return JSON.parse(raw); + } + catch (err) { + throw new Error(`--${flagName} is not valid JSON: ${err.message}\n got: ${raw}`); + } +} +/** 'a@x.com,b@y.com' | '["a@x.com"]' → string[] */ +function parseRecipients(raw) { + if (raw === undefined) + return undefined; + const trimmed = raw.trim(); + if (trimmed.startsWith('[')) { + return parseJsonFlag(raw, 'recipients'); + } + return trimmed + .split(',') + .map((s) => s.trim()) + .filter(Boolean); +} +/** 从 CLI 选项构建 RO body;create 时 name/tableId 必填。 */ +function buildRo(opts, forCreate) { + const body = {}; + if (opts.name !== undefined) + body.name = opts.name; + if (opts.tableId !== undefined) + body.tableId = opts.tableId; + if (forCreate) { + if (!body.name) + throw new Error('--name is required for create'); + if (!body.tableId) + throw new Error('--table-id is required for create'); + } + if (opts.watchCreated !== undefined) + body.watchCreated = opts.watchCreated; + if (opts.watchUpdated !== undefined) + body.watchUpdated = opts.watchUpdated; + const fieldIds = parseJsonFlag(opts.fieldIds, 'field-ids'); + if (fieldIds !== undefined) + body.fieldIds = fieldIds; + const recordIds = parseJsonFlag(opts.recordIds, 'record-ids'); + if (recordIds !== undefined) + body.recordIds = recordIds; + if (opts.displayFieldId !== undefined) { + body.displayFieldId = opts.displayFieldId === '' ? null : opts.displayFieldId; + } + if (opts.attachCsv !== undefined) + body.attachCsv = opts.attachCsv; + const filter = parseJsonFlag(opts.filter, 'filter'); + if (filter !== undefined) + body.filter = filter; + if (opts.triggerOnEnter !== undefined) + body.triggerOnEnter = opts.triggerOnEnter; + if (opts.sendMode !== undefined) { + if (opts.sendMode !== 'digest' && opts.sendMode !== 'instant') { + throw new Error(`--send-mode must be digest|instant, got: ${opts.sendMode}`); + } + body.sendMode = opts.sendMode; + } + const recipients = parseRecipients(opts.recipients); + if (recipients !== undefined) + body.recipients = recipients; + if (opts.notifyInApp !== undefined) + body.notifyInApp = opts.notifyInApp; + if (opts.scheduleType !== undefined) { + const t = opts.scheduleType; + if (!['daily', 'weekly', 'hours', 'minutes'].includes(t)) { + throw new Error(`--schedule-type must be daily|weekly|hours|minutes, got: ${t}`); + } + body.scheduleType = t; + } + if (opts.scheduleTime !== undefined) + body.scheduleTime = opts.scheduleTime; + if (opts.scheduleWeekday !== undefined) { + const n = Number(opts.scheduleWeekday); + if (!Number.isInteger(n) || n < 0 || n > 6) { + throw new Error(`--schedule-weekday must be 0-6 (0=Sunday), got: ${opts.scheduleWeekday}`); + } + body.scheduleWeekday = n; + } + if (opts.scheduleInterval !== undefined) { + const n = Number(opts.scheduleInterval); + if (!Number.isInteger(n) || n < 1) { + throw new Error(`--schedule-interval must be a positive integer, got: ${opts.scheduleInterval}`); + } + body.scheduleInterval = n; + } + if (opts.isActive !== undefined) + body.isActive = opts.isActive; + return body; +} +function addConfigOptions(cmd) { + return cmd + .option('--base-id ', 'Base id (overrides default)') + .option('--name ', 'Subscription name') + .option('--table-id ', 'Watched table id (tblXXX)') + .option('--no-watch-created', 'Disable watching record creation (default: on)') + .option('--no-watch-updated', 'Disable watching record updates (default: on)') + .option('--field-ids ', 'Watched field ids as JSON array, e.g. \'["fldA","fldB"]\' (omit = all fields)') + .option('--record-ids ', 'Record-level subscription: tracked record ids as JSON array (omit = all records)') + .option('--display-field-id ', 'Field shown as record title in emails ("" = primary field)') + .option('--attach-csv', 'Attach CSV of changes to digest emails (needs export permission)') + .option('--no-attach-csv', 'Do not attach CSV') + .option('--filter ', 'Native IFilter JSON (same shape as view filters); null/omit = all records') + .option('--trigger-on-enter', 'Only notify on the transition INTO the filter condition (requires --filter)') + .option('--no-trigger-on-enter', 'Notify on every matching change (state semantics)') + .option('--send-mode ', 'digest | instant') + .option('--recipients ', 'Recipient emails: comma-separated or JSON array (default = creator)') + .option('--notify-in-app', 'Also send in-app notifications (default)') + .option('--no-notify-in-app', 'Disable in-app notifications') + .option('--schedule-type ', 'daily | weekly | hours | minutes') + .option('--schedule-time ', 'Send time for daily/weekly, e.g. 09:00') + .option('--schedule-weekday ', '0-6 (0=Sunday), for weekly') + .option('--schedule-interval ', 'Interval for hours/minutes') + .option('--active', 'Subscription active (default)') + .option('--inactive', 'Pause the subscription'); +} +function sendModeBadge(s) { + return s.sendMode === 'instant' ? 'instant' : 'digest'; +} +function subscriptionCommand() { + const cmd = new commander_1.Command('subscription'); + cmd.description('Manage the subscription center (record subscriptions) — full config support'); + cmd + .command('list') + .description('List subscriptions in the base') + .option('--table-id ', 'Filter by table') + .option('--base-id ', 'Base id (overrides default)') + .action((0, ctx_1.run)(async (ctx, _args, o) => { + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + const list = await ctx.client.listSubscriptions(baseId, opts.tableId); + if (ctx.json) + (0, output_1.printJson)(list); + else { + if (!list.length) { + process.stdout.write('No subscriptions found.\n'); + return; + } + (0, output_1.printTable)([ + { key: 'id', header: 'ID' }, + { key: 'name', header: 'NAME' }, + { key: 'tableId', header: 'TABLE' }, + { key: 'mode', header: 'MODE' }, + { key: 'onEnter', header: 'ON-ENTER' }, + { key: 'schedule', header: 'SCHEDULE' }, + { key: 'pending', header: 'PENDING' }, + { key: 'active', header: 'ACTIVE' }, + ], list.map((s) => ({ + id: s.id, + name: (0, output_1.truncate)(s.name, 24), + tableId: s.tableId, + mode: sendModeBadge(s), + onEnter: s.triggerOnEnter ? '✓' : '', + schedule: s.sendMode === 'instant' ? '-' : s.scheduleType, + pending: s.pendingCount ?? 0, + active: s.isActive ? '✓' : '', + }))); + process.stdout.write(`\n${list.length} subscription(s). Use 'subscription get ' for details.\n`); + } + })); + const getCmd = cmd.command('get ').description('Show full subscription config'); + getCmd + .option('--base-id ', 'Base id (overrides default)') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const id = args[0]; + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + const list = await ctx.client.listSubscriptions(baseId); + const sub = list.find((s) => s.id === id); + if (!sub) + throw new Error(`Subscription ${id} not found in base ${baseId}`); + if (ctx.json) + (0, output_1.printJson)(sub); + else { + (0, output_1.printKv)([ + ['id', sub.id], + ['name', sub.name], + ['tableId', sub.tableId], + ['watchCreated', String(sub.watchCreated)], + ['watchUpdated', String(sub.watchUpdated)], + ['fieldIds', sub.fieldIds?.length ? sub.fieldIds.join(', ') : '(all fields)'], + [ + 'recordIds', + sub.recordIds?.length ? `${sub.recordIds.length} record(s)` : '(all records)', + ], + ['displayFieldId', sub.displayFieldId ?? '(primary)'], + ['attachCsv', String(sub.attachCsv)], + ['filter', sub.filter ? JSON.stringify(sub.filter) : '(none)'], + ['triggerOnEnter', String(sub.triggerOnEnter)], + ['sendMode', sendModeBadge(sub)], + ['recipients', (sub.recipients ?? []).join(', ')], + ['notifyInApp', String(sub.notifyInApp)], + ['scheduleType', sub.scheduleType], + ['scheduleTime', sub.scheduleTime ?? ''], + ['scheduleWeekday', sub.scheduleWeekday != null ? String(sub.scheduleWeekday) : ''], + ['scheduleInterval', sub.scheduleInterval != null ? String(sub.scheduleInterval) : ''], + ['isActive', String(sub.isActive)], + ['pendingCount', String(sub.pendingCount ?? 0)], + ['lastSentTime', sub.lastSentTime ?? ''], + ]); + } + })); + const createCmd = cmd + .command('create') + .description('Create a subscription (--name and --table-id required)'); + addConfigOptions(createCmd).action((0, ctx_1.run)(async (ctx, _args, o) => { + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + const body = buildRo(opts, true); + const vo = await ctx.client.createSubscription(baseId, body); + if (ctx.json) + (0, output_1.printJson)(vo); + else + (0, output_1.printKv)([['id', vo.id], ['name', vo.name], ['created', '✓']]); + })); + const updateCmd = cmd + .command('update ') + .description('Update a subscription (only provided options change)'); + addConfigOptions(updateCmd).action((0, ctx_1.run)(async (ctx, args, o) => { + const id = args[0]; + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + const body = buildRo(opts, false); + if (Object.keys(body).length === 0) { + throw new Error('No options provided — nothing to update'); + } + const vo = await ctx.client.updateSubscription(baseId, id, body); + if (ctx.json) + (0, output_1.printJson)(vo); + else + (0, output_1.printKv)([['id', vo.id], ['updated', '✓']]); + })); + cmd + .command('delete ') + .description('Delete a subscription') + .option('--base-id ', 'Base id (overrides default)') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const id = args[0]; + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + await ctx.client.deleteSubscription(baseId, id); + if (ctx.json) + (0, output_1.printJson)({ id }); + else + process.stdout.write(`Deleted ${id}\n`); + })); + cmd + .command('events ') + .description('List captured events (pending + sent) of a subscription') + .option('--base-id ', 'Base id (overrides default)') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const id = args[0]; + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + const events = (await ctx.client.getSubscriptionEvents(baseId, id)); + if (ctx.json) + (0, output_1.printJson)(events); + else { + if (!events.length) { + process.stdout.write('No events captured.\n'); + return; + } + (0, output_1.printTable)([ + { key: 'recordId', header: 'RECORD' }, + { key: 'changeType', header: 'CHANGE' }, + { key: 'createdTime', header: 'CAPTURED' }, + { key: 'sentTime', header: 'SENT' }, + ], events.map((e) => ({ + recordId: e.recordId, + changeType: e.changeType, + createdTime: e.createdTime, + sentTime: e.sentTime ?? '(pending)', + }))); + } + })); + cmd + .command('send ') + .description('Send pending digest events immediately (flush now)') + .option('--base-id ', 'Base id (overrides default)') + .action((0, ctx_1.run)(async (ctx, args, o) => { + const id = args[0]; + const opts = o; + const baseId = (0, ctx_1.requireBaseId)(ctx, opts.baseId); + const result = await ctx.client.sendSubscriptionNow(baseId, id); + if (ctx.json) + (0, output_1.printJson)(result ?? { id }); + else + (0, output_1.printKv)([['id', id], ['sent', '✓']]); + })); + return cmd; +} +exports.subscriptionCommand = subscriptionCommand; diff --git a/dist/lib/client.js b/dist/lib/client.js index f942677..92174aa 100644 --- a/dist/lib/client.js +++ b/dist/lib/client.js @@ -271,5 +271,26 @@ class Client { removeDeveloper(userId) { return this.call('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`); } + // ---- subscription center (record subscriptions) ---- + listSubscriptions(baseId, tableId) { + const q = tableId ? `?tableId=${encodeURIComponent(tableId)}` : ''; + return this.call('GET', `/api/base/${baseId}/subscription${q}`); + } + getSubscriptionEvents(baseId, subscriptionId) { + return this.call('GET', `/api/base/${baseId}/subscription/${subscriptionId}/event`); + } + createSubscription(baseId, body) { + return this.call('POST', `/api/base/${baseId}/subscription`, body); + } + updateSubscription(baseId, subscriptionId, body) { + return this.call('PATCH', `/api/base/${baseId}/subscription/${subscriptionId}`, body); + } + deleteSubscription(baseId, subscriptionId) { + return this.call('DELETE', `/api/base/${baseId}/subscription/${subscriptionId}`); + } + /** 立即发送(flush pending digest events) */ + sendSubscriptionNow(baseId, subscriptionId) { + return this.call('POST', `/api/base/${baseId}/subscription/${subscriptionId}/send`); + } } exports.Client = Client; diff --git a/src/bin.ts b/src/bin.ts index 3098659..f5b1d7a 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -11,6 +11,7 @@ import { authCommand } from './commands/auth'; import { workflowCommand, triggerCommand, actionCommand } from './commands/workflow'; import { baseResourceCommands } from './commands/resources'; import { secretCommand, runsCommand, fireCommand, sqlCommand } from './commands/extras'; +import { subscriptionCommand } from './commands/subscription'; const program = new Command(); @@ -37,6 +38,7 @@ for (const c of baseResourceCommands()) program.addCommand(c); program.addCommand(workflowCommand()); program.addCommand(triggerCommand()); program.addCommand(actionCommand()); +program.addCommand(subscriptionCommand()); program.addCommand(secretCommand()); program.addCommand(runsCommand()); program.addCommand(fireCommand()); diff --git a/src/lib/client.ts b/src/lib/client.ts index ea6cf16..75bf654 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -91,11 +91,7 @@ export class Client { /** PUT convert: the ONLY API path that persists field options (choices, * visibilityFilter dependency config, etc.). Requires the field's current type. */ convertField(tableId: string, fieldId: string, body: unknown) { - return this.call( - 'PUT', - `/api/table/${tableId}/field/${fieldId}/convert`, - body - ); + return this.call('PUT', `/api/table/${tableId}/field/${fieldId}/convert`, body); } deleteField(tableId: string, fieldId: string) { return this.call<{ id: string }>('DELETE', `/api/table/${tableId}/field/${fieldId}`); @@ -342,6 +338,38 @@ export class Client { removeDeveloper(userId: string) { return this.call('DELETE', `/api/plugin-developers/${encodeURIComponent(userId)}`); } + + // ---- subscription center (record subscriptions) ---- + listSubscriptions(baseId: string, tableId?: string) { + const q = tableId ? `?tableId=${encodeURIComponent(tableId)}` : ''; + return this.call('GET', `/api/base/${baseId}/subscription${q}`); + } + getSubscriptionEvents(baseId: string, subscriptionId: string) { + return this.call( + 'GET', + `/api/base/${baseId}/subscription/${subscriptionId}/event` + ); + } + createSubscription(baseId: string, body: IRecordSubscriptionRo) { + return this.call('POST', `/api/base/${baseId}/subscription`, body); + } + updateSubscription(baseId: string, subscriptionId: string, body: Partial) { + return this.call( + 'PATCH', + `/api/base/${baseId}/subscription/${subscriptionId}`, + body + ); + } + deleteSubscription(baseId: string, subscriptionId: string) { + return this.call<{ id: string }>( + 'DELETE', + `/api/base/${baseId}/subscription/${subscriptionId}` + ); + } + /** 立即发送(flush pending digest events) */ + sendSubscriptionNow(baseId: string, subscriptionId: string) { + return this.call('POST', `/api/base/${baseId}/subscription/${subscriptionId}/send`); + } } // ---- shared types (mirror backend workflow_node / workflow_edge shapes) ---- @@ -398,3 +426,45 @@ export interface IWorkflowVo { } export { ApiError }; + +// ---- subscription types (mirror backend record_subscription contract) ---- +export interface IRecordSubscriptionRo { + name: string; + tableId: string; + watchCreated?: boolean; + watchUpdated?: boolean; + fieldIds?: string[]; + recordIds?: string[]; + displayFieldId?: string | null; + attachCsv?: boolean; + filter?: Record | null; + triggerOnEnter?: boolean; + sendMode?: 'digest' | 'instant'; + recipients?: string[]; + notifyInApp?: boolean; + scheduleType?: 'daily' | 'weekly' | 'hours' | 'minutes'; + scheduleTime?: string; + scheduleWeekday?: number; + scheduleInterval?: number; + isActive?: boolean; +} + +export interface IRecordSubscriptionVo extends IRecordSubscriptionRo { + id: string; + baseId: string; + createdBy: string; + createdTime: string; + lastModifiedTime?: string | null; + lastSentTime?: string | null; + pendingCount?: number; +} + +export interface IRecordSubscriptionEventVo { + id: string; + subscriptionId: string; + recordId: string; + changeType: 'created' | 'updated'; + changedFieldIds?: string[] | null; + createdTime: string; + sentTime?: string | null; +}