Files
inkreach-teable-cli/src/commands/subscription.ts
T
yeuimu 3f38914de0 feat(subscription): record subscription center with triggerOnEnter semantics
- Full subscription CRUD + digest/instant email scheduling
- filter-evaluator delegates to core evaluateFilterItem (all operators +
  date sub-modes with timezone, value normalization, fail-closed)
- triggerOnEnter: notify only on the transition INTO the filter condition
- filter field visibility validated on create/update
- CLI: subscription list/get/create/update/delete/events/send
2026-08-22 02:34:00 +00:00

376 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* `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' 由后端解析为订阅创建者)。
*/
import { Command } from 'commander';
import { run, requireBaseId } from '../lib/ctx';
import { printJson, printTable, printKv, truncate } from '../lib/output';
import type {
IRecordSubscriptionRo,
IRecordSubscriptionVo,
IRecordSubscriptionEventVo,
} from '../lib/client';
function parseJsonFlag<T>(raw: string | undefined, flagName: string): T | undefined {
if (raw === undefined) return undefined;
try {
return JSON.parse(raw) as T;
} catch (err) {
throw new Error(`--${flagName} is not valid JSON: ${(err as Error).message}\n got: ${raw}`);
}
}
/** 'a@x.com,b@y.com' | '["a@x.com"]' → string[] */
function parseRecipients(raw: string | undefined): string[] | undefined {
if (raw === undefined) return undefined;
const trimmed = raw.trim();
if (trimmed.startsWith('[')) {
return parseJsonFlag<string[]>(raw, 'recipients');
}
return trimmed
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
interface SharedOpts {
baseId?: string;
name?: string;
tableId?: string;
watchCreated?: boolean;
watchUpdated?: boolean;
fieldIds?: string;
recordIds?: string;
displayFieldId?: string;
attachCsv?: boolean;
filter?: string;
triggerOnEnter?: boolean;
sendMode?: string;
recipients?: string;
notifyInApp?: boolean;
scheduleType?: string;
scheduleTime?: string;
scheduleWeekday?: string;
scheduleInterval?: string;
isActive?: boolean;
}
/** 从 CLI 选项构建 RO body;create 时 name/tableId 必填。 */
function buildRo(opts: SharedOpts, forCreate: boolean): Partial<IRecordSubscriptionRo> {
const body: Partial<IRecordSubscriptionRo> = {};
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<string[]>(opts.fieldIds, 'field-ids');
if (fieldIds !== undefined) body.fieldIds = fieldIds;
const recordIds = parseJsonFlag<string[]>(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<Record<string, unknown>>(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 as IRecordSubscriptionRo['scheduleType'];
}
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: Command): Command {
return cmd
.option('--base-id <id>', 'Base id (overrides default)')
.option('--name <name>', 'Subscription name')
.option('--table-id <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 <json>',
'Watched field ids as JSON array, e.g. \'["fldA","fldB"]\' (omit = all fields)'
)
.option(
'--record-ids <json>',
'Record-level subscription: tracked record ids as JSON array (omit = all records)'
)
.option('--display-field-id <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 <json>',
'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 <mode>', 'digest | instant')
.option(
'--recipients <list>',
'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 <type>', 'daily | weekly | hours | minutes')
.option('--schedule-time <hh:mm>', 'Send time for daily/weekly, e.g. 09:00')
.option('--schedule-weekday <n>', '0-6 (0=Sunday), for weekly')
.option('--schedule-interval <n>', 'Interval for hours/minutes')
.option('--active', 'Subscription active (default)')
.option('--inactive', 'Pause the subscription');
}
function sendModeBadge(s: IRecordSubscriptionVo): string {
return s.sendMode === 'instant' ? 'instant' : 'digest';
}
export function subscriptionCommand(): Command {
const cmd = new 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 <id>', 'Filter by table')
.option('--base-id <id>', 'Base id (overrides default)')
.action(
run(async (ctx, _args, o) => {
const opts = o as { tableId?: string; baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
const list = await ctx.client.listSubscriptions(baseId, opts.tableId);
if (ctx.json) printJson(list);
else {
if (!list.length) {
process.stdout.write('No subscriptions found.\n');
return;
}
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: 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 <id>' for details.\n`
);
}
})
);
const getCmd = cmd.command('get <subscriptionId>').description('Show full subscription config');
getCmd.option('--base-id <id>', 'Base id (overrides default)').action(
run(async (ctx, args, o) => {
const id = args[0] as string;
const opts = o as { baseId?: string };
const baseId = 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) printJson(sub);
else {
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(
run(async (ctx, _args, o) => {
const opts = o as SharedOpts;
const baseId = requireBaseId(ctx, opts.baseId);
const body = buildRo(opts, true) as IRecordSubscriptionRo;
const vo = await ctx.client.createSubscription(baseId, body);
if (ctx.json) printJson(vo);
else
printKv([
['id', vo.id],
['name', vo.name],
['created', '✓'],
]);
})
);
const updateCmd = cmd
.command('update <subscriptionId>')
.description('Update a subscription (only provided options change)');
addConfigOptions(updateCmd).action(
run(async (ctx, args, o) => {
const id = args[0] as string;
const opts = o as SharedOpts;
const baseId = 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) printJson(vo);
else
printKv([
['id', vo.id],
['updated', '✓'],
]);
})
);
cmd
.command('delete <subscriptionId>')
.description('Delete a subscription')
.option('--base-id <id>', 'Base id (overrides default)')
.action(
run(async (ctx, args, o) => {
const id = args[0] as string;
const opts = o as { baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
await ctx.client.deleteSubscription(baseId, id);
if (ctx.json) printJson({ id });
else process.stdout.write(`Deleted ${id}\n`);
})
);
cmd
.command('events <subscriptionId>')
.description('List captured events (pending + sent) of a subscription')
.option('--base-id <id>', 'Base id (overrides default)')
.action(
run(async (ctx, args, o) => {
const id = args[0] as string;
const opts = o as { baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
const events = (await ctx.client.getSubscriptionEvents(
baseId,
id
)) as IRecordSubscriptionEventVo[];
if (ctx.json) printJson(events);
else {
if (!events.length) {
process.stdout.write('No events captured.\n');
return;
}
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 <subscriptionId>')
.description('Send pending digest events immediately (flush now)')
.option('--base-id <id>', 'Base id (overrides default)')
.action(
run(async (ctx, args, o) => {
const id = args[0] as string;
const opts = o as { baseId?: string };
const baseId = requireBaseId(ctx, opts.baseId);
const result = await ctx.client.sendSubscriptionNow(baseId, id);
if (ctx.json) printJson(result ?? { id });
else
printKv([
['id', id],
['sent', '✓'],
]);
})
);
return cmd;
}