Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/25346-preference-topic-channel-help.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<string, { type?: string; defaultValue?: unknown; description?: string }>;

/** 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/);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,6 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand All @@ -107,7 +106,6 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,6 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand All @@ -106,7 +105,6 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
"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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,11 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
topic: {
label: "主题",
help: "通知主题,'*' 表示所有主题。"
help: "通知发送时使用的主题:流程中通知步骤指定的主题,或平台功能发出的主题,例如 approval.requested、collab.mention。填 * 表示所有主题。没有任何发送方使用的主题不会匹配任何通知。"
},
channel: {
label: "通道",
help: "通道 ID(inbox/email/push/…),'*' 表示所有通道。"
help: "通道:inbox(站内收件箱,始终可用)、email(配置邮件服务后可用)、sms(配置短信服务后可用),或已安装插件添加的通道 ID。填 * 表示所有通道。没有任何通道使用的 ID 不会匹配任何通知。"
},
enabled: {
label: "启用",
Expand Down
Loading