diff --git a/.changeset/25346-preference-topic-channel-help.md b/.changeset/25346-preference-topic-channel-help.md new file mode 100644 index 0000000000..9514758eb2 --- /dev/null +++ b/.changeset/25346-preference-topic-channel-help.md @@ -0,0 +1,22 @@ +--- +'@objectstack/service-messaging': patch +--- + +fix(service-messaging): Setup's Notification Preferences form says which topics and channels can be entered + +Clause-②: no +Metadata-protocol: none + +The Topic and Channel boxes on a notification preference only said "Notification topic" and "Channel id (inbox/email/push/…)", so an administrator had to know the machine names. One of the channels it named, `push`, does not exist. Both fields stay free text, because neither has a fixed list to pick from: + +- **Topics.** A topic is whatever name the sender uses, and nothing registers topics. A flow's notify step names its own topic, and platform features send fixed ones such as `approval.requested`. +- **Channels.** Channels are registered while the server runs, and any plugin can add one. A dropdown would accept only the values in its list, and it cannot store the `*` wildcard that means "all channels". + +The help now names the values: + +- **Channel:** `inbox` (always available), `email` (once an email service is configured), `sms` (once an SMS service is configured), or the id of a channel an installed plugin adds. +- **Topic:** where topics come from, with examples. + +Both helps also say `*` means all, and that a value nothing uses matches nothing. + +No stored row, accepted value, default or column changes. The translation bundles carry the new help. diff --git a/packages/services/service-messaging/src/objects/notification-preference-topic-channel-help.test.ts b/packages/services/service-messaging/src/objects/notification-preference-topic-channel-help.test.ts new file mode 100644 index 0000000000..42cd858023 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-preference-topic-channel-help.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_notification_preference.topic` and `.channel` stay free text, and their + * help says what to type (#25346, ruling A 2026-10-23). + * + * Setup → Notification Preferences offered two bare text boxes, so muting a + * topic meant knowing its machine name. Neither set can be a picker: topics + * are whatever string a sender writes (nothing registers them), and channels + * are registered at runtime through an open seam while a `select` is closed on + * the write path and cannot spell the stored `'*'` wildcard. The help carries + * the vocabulary instead, and these pins keep it honest: + * + * - both fields stay `text` with the `'*'` default the resolver reads as + * "all", so no stored row or write changes meaning; + * - the channel help names exactly the ids of the channels this package + * builds, read from the channel factories themselves, so a channel added + * or renamed here fails this file until the help says so (the old help + * named a `push` channel that never existed); + * - the topic help names where topics come from and the wildcard. + */ + +import { describe, expect, it } from 'vitest'; +import { NotificationPreference } from './notification-preference.object.js'; +import { createInboxChannel } from '../inbox-channel.js'; +import { createEmailChannel } from '../email-channel.js'; +import { createSmsChannel } from '../sms-channel.js'; +import { NotificationTemplateStore } from '../template-renderer.js'; + +const fields = NotificationPreference.fields as Record; + +/** The ids of the channels `MessagingServicePlugin` registers, read off the factories. */ +function builtInChannelIds(): string[] { + const getData = () => undefined; + const store = new NotificationTemplateStore({ getData }); + return [ + createInboxChannel({ getData }).id, + createEmailChannel({ getEmail: () => undefined, getData, store }).id, + createSmsChannel({ getSms: () => undefined, getData, store }).id, + ]; +} + +/** Lowercase words of a help text that read as a channel id (`inbox`, `email`, …). */ +function channelIdsNamedIn(help: string, candidates: readonly string[]): string[] { + return candidates.filter((id) => new RegExp(`(^|[^a-z_])${id}([^a-z_]|$)`).test(help)); +} + +describe('sys_notification_preference topic / channel help (#25346)', () => { + it('both stay free text defaulting to the * wildcard', () => { + for (const name of ['topic', 'channel']) { + expect(fields[name]?.type, name).toBe('text'); + expect(fields[name]?.defaultValue, name).toBe('*'); + expect(fields[name]?.description, name).toMatch(/Enter \* for all /); + } + }); + + it('the channel help names every channel this package builds, and no channel it does not', () => { + const help = fields.channel?.description ?? ''; + const ids = builtInChannelIds(); + expect(ids).toEqual(['inbox', 'email', 'sms']); + expect(channelIdsNamedIn(help, ids)).toEqual(ids); + // Ids the spec's channel vocabulary carries that no channel here implements. + expect(channelIdsNamedIn(help, ['push', 'webhook', 'slack', 'teams', 'in-app'])).toEqual([]); + // The seam is open: a plugin's own channel id is a valid value too. + expect(help).toMatch(/a channel an installed plugin adds/); + }); + + it('the topic help says where a topic comes from', () => { + const help = fields.topic?.description ?? ''; + expect(help).toMatch(/notify step/); + expect(help).toMatch(/approval\.requested/); + }); +}); diff --git a/packages/services/service-messaging/src/objects/notification-preference.object.ts b/packages/services/service-messaging/src/objects/notification-preference.object.ts index 04c3ebfd47..7810d6d711 100644 --- a/packages/services/service-messaging/src/objects/notification-preference.object.ts +++ b/packages/services/service-messaging/src/objects/notification-preference.object.ts @@ -133,7 +133,16 @@ export const NotificationPreference = ObjectSchema.create({ // stored topic could never match an event the platform can store. // '*' is 1 char. maxLength: 200, - description: "Notification topic, or '*' for all topics.", + // [#25346, ruling A 2026-10-23] Free text, not a picker: no list of + // topics exists to pick from. A topic is whatever string the sender + // writes — a flow's notify step names its own, and platform features + // send fixed ones (`approval.*` in plugin-approvals, `collab.mention` + // in plugin-audit, …) — and nothing registers them. So the help says + // where a topic comes from instead. `notification-preference- + // topic-channel-help.test.ts` pins both helps. + description: + 'The topic the notifications are sent under: the topic a flow\'s notify step names, or one the platform sends, ' + + 'such as approval.requested or collab.mention. Enter * for all topics. A topic nothing sends matches nothing.', }), channel: Field.text({ @@ -147,7 +156,17 @@ export const NotificationPreference = ObjectSchema.create({ // precedent (sys_session.revoke_reason, maxLength: 64). '*' is // 1 char. maxLength: 64, - description: "Channel id (inbox/email/push/…), or '*' for all channels.", + // [#25346, ruling A 2026-10-23] Free text, not a picker. The channels + // are known only at runtime, and the seam is open (any plugin may + // `registerChannel` its own id), while a `select` is closed on the + // write path and its option values are machine identifiers, which + // cannot spell the stored '*' wildcard. So the help names the ids + // this package registers instead; the pin holds that list equal to + // the `id`s of the three channels it builds (no `push` exists). + description: + 'The channel: inbox (notifications shown inside the app, always available), email (once an email service is configured), ' + + 'sms (once an SMS service is configured), or the id of a channel an installed plugin adds. ' + + 'Enter * for all channels. An id no channel uses matches nothing.', }), enabled: Field.boolean({ diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index ce9b8d4e9c..a781ce040a 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -210,11 +210,11 @@ export const enObjects: NonNullable = { }, topic: { label: "Topic", - help: "Notification topic, or '*' for all topics." + help: "The topic the notifications are sent under: the topic a flow's notify step names, or one the platform sends, such as approval.requested or collab.mention. Enter * for all topics. A topic nothing sends matches nothing." }, channel: { label: "Channel", - help: "Channel id (inbox/email/push/…), or '*' for all channels." + help: "The channel: inbox (notifications shown inside the app, always available), email (once an email service is configured), sms (once an SMS service is configured), or the id of a channel an installed plugin adds. Enter * for all channels. An id no channel uses matches nothing." }, enabled: { label: "Enabled", diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index ccfb7c95fe..94baa8226a 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -210,11 +210,11 @@ export const esESObjects: NonNullable = { }, topic: { label: "Topic", - help: "Notification topic, or '*' for all topics." + help: "El tema con el que se envían las notificaciones: el tema que indica el paso de notificación de un flujo, o uno que envía la plataforma, como approval.requested o collab.mention. Escriba * para todos los temas. Un tema que nadie envía no coincide con nada." }, channel: { label: "Channel", - help: "Channel id (inbox/email/push/…), or '*' for all channels." + help: "El canal: inbox (notificaciones dentro de la aplicación, siempre disponible), email (una vez configurado un servicio de correo electrónico), sms (una vez configurado un servicio de SMS) o el id de un canal que añada un plugin instalado. Escriba * para todos los canales. Un id que ningún canal usa no coincide con nada." }, enabled: { label: "Enabled", diff --git a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts index 44542a0594..857a198185 100644 --- a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts @@ -95,7 +95,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.label": "dab3eb6594ba3014", "objects.sys_notification_delivery.pluralLabel": "654247db5abbd7d2", "objects.sys_notification_preference.description": "48556455d3292290", - "objects.sys_notification_preference.fields.channel.help": "8c06b7c3f1a65fd7", "objects.sys_notification_preference.fields.channel.label": "979883fe73ee88cb", "objects.sys_notification_preference.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_preference.fields.digest.label": "76e9b35f27134f0b", @@ -107,7 +106,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_preference.fields.enabled.label": "41fb04f20e64dc4f", "objects.sys_notification_preference.fields.id.label": "429cdf8675ce2833", "objects.sys_notification_preference.fields.quiet_hours.label": "fab43f24806d8f6f", - "objects.sys_notification_preference.fields.topic.help": "cf04f6b5ad2a5a33", "objects.sys_notification_preference.fields.topic.label": "819afdb3853e9d80", "objects.sys_notification_preference.fields.updated_at.label": "aba63dc2a9c79b8d", "objects.sys_notification_preference.fields.user_id.label": "6c0d22a931e235bd", diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index df8724f259..8530783929 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -210,11 +210,11 @@ export const jaJPObjects: NonNullable = { }, topic: { label: "Topic", - help: "Notification topic, or '*' for all topics." + help: "通知の送信に使われるトピック:フローの通知ステップが指定するトピック、またはプラットフォームが送信するトピック(例: approval.requested、collab.mention)。すべてのトピックを対象にするには * を入力します。どこからも送信されないトピックは何にも一致しません。" }, channel: { label: "Channel", - help: "Channel id (inbox/email/push/…), or '*' for all channels." + help: "チャネル:inbox(アプリ内の通知、常に利用可能)、email(メールサービスの設定後)、sms(SMS サービスの設定後)、またはインストール済みプラグインが追加したチャネルの ID。すべてのチャネルを対象にするには * を入力します。どのチャネルも使用しない ID は何にも一致しません。" }, enabled: { label: "Enabled", diff --git a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts index 1e4ae0fdfd..8810915fcf 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts @@ -94,7 +94,6 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.label": "dab3eb6594ba3014", "objects.sys_notification_delivery.pluralLabel": "654247db5abbd7d2", "objects.sys_notification_preference.description": "48556455d3292290", - "objects.sys_notification_preference.fields.channel.help": "8c06b7c3f1a65fd7", "objects.sys_notification_preference.fields.channel.label": "979883fe73ee88cb", "objects.sys_notification_preference.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_preference.fields.digest.label": "76e9b35f27134f0b", @@ -106,7 +105,6 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_preference.fields.enabled.label": "41fb04f20e64dc4f", "objects.sys_notification_preference.fields.id.label": "429cdf8675ce2833", "objects.sys_notification_preference.fields.quiet_hours.label": "fab43f24806d8f6f", - "objects.sys_notification_preference.fields.topic.help": "cf04f6b5ad2a5a33", "objects.sys_notification_preference.fields.topic.label": "819afdb3853e9d80", "objects.sys_notification_preference.fields.updated_at.label": "aba63dc2a9c79b8d", "objects.sys_notification_preference.fields.user_id.label": "6c0d22a931e235bd", diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 84c006a09f..b74d4378cc 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -210,11 +210,11 @@ export const zhCNObjects: NonNullable = { }, topic: { label: "主题", - help: "通知主题,'*' 表示所有主题。" + help: "通知发送时使用的主题:流程中通知步骤指定的主题,或平台功能发出的主题,例如 approval.requested、collab.mention。填 * 表示所有主题。没有任何发送方使用的主题不会匹配任何通知。" }, channel: { label: "通道", - help: "通道 ID(inbox/email/push/…),'*' 表示所有通道。" + help: "通道:inbox(站内收件箱,始终可用)、email(配置邮件服务后可用)、sms(配置短信服务后可用),或已安装插件添加的通道 ID。填 * 表示所有通道。没有任何通道使用的 ID 不会匹配任何通知。" }, enabled: { label: "启用",