- 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
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
/**
|
||
* `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;
|
||
}
|