From 47c96b59d7481d2e513be0d2ad2ca90f16f19e00 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:08:48 +0000 Subject: [PATCH 1/4] fix(service-messaging): sys_notification_template takes only the channels that read it (email, sms) The channel column refuses any value but email or sms at write, with a message pointing in-app content to an email template. The object's description, empty state and channel/subject help say which channels read a row. The authentication docs' SMS paragraph drops the retired seed and states which organization's row an auth SMS reads. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- content/docs/permissions/authentication.mdx | 21 +++-- .../objects/notification-template.object.ts | 90 +++++++++++++++++-- 2 files changed, 100 insertions(+), 11 deletions(-) diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index e1755b9dc5d..14fe79fa143 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -443,11 +443,22 @@ sets their own password. #### SMS text customisation & localisation -The OTP and invitation bodies are localised and tenant-customisable: a -`sys_notification_template` row for `(auth.phone_otp | auth.phone_invite, -channel 'sms', locale)` wins — built-in English and Chinese rows are seeded -once (never overwriting your edits) and can be changed under Setup → -Notification Templates. Both bodies resolve the same way: the recipient's own +The OTP and invitation bodies are localised, and built-in English and Chinese +texts are always available. A `sys_notification_template` row for +`(auth.phone_otp | auth.phone_invite, channel 'sms', locale)`, authored under +Setup → Notification Templates, replaces the built-in text. No such row ships +with the platform: one exists only once someone authors it. + +An auth SMS belongs to no organization of its own, so the row is read in the +organization the send resolves to, `tenancy.defaultOrgId()`: + +- Under the `single` posture that is the Default Organization, so the Default + Organization's row is used. +- Under `isolated` and `group` it resolves to no organization: no row is read + and the built-in text is sent, so one organization's row never decides the + text another organization's user receives. + +Both bodies pick their language the same way: the recipient's own `sys_user.locale` when their account has one, and the deployment default (`localization.locale` setting) otherwise — the account is matched on its `phone_number`, so a number no account carries takes the deployment default diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 5dee9050adb..d86fbb8a387 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -3,7 +3,31 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * `sys_notification_template` — channel-agnostic render template (ADR-0030 + * The channels that READ a `sys_notification_template` row — the closed set + * `channel` accepts (the maintainer's ruling D on #24770: narrow templates to + * the channels that render them). + * + * Measured, not assumed — the readers are exactly three, all keyed on one of + * these two values: + * + * - the `email` channel, on its inline path only (`email-channel.ts`: a + * notification naming an email template takes `sendTemplate` and returns + * before the store is read); + * - the `sms` channel (`sms-channel.ts`), whether or not the notification + * names an email template — it has no template path of its own; + * - plugin-auth's phone OTP / invitation SMS (`phone-sms-texts.ts`, + * `channel: 'sms'`). + * + * The inbox reads no row on any path (`createInboxChannel` takes no store); its + * localised content is the `email_template` a notify step names in `template`, + * rendered through `IEmailService.renderTemplate`. So a row naming any other + * channel is stored and rendered nowhere — the silent no-op the ruling turns + * into a refusal at write. + */ +const TEMPLATE_CHANNELS = ['email', 'sms'] as const; + +/** + * `sys_notification_template` — the email / SMS render template (ADR-0030 * cross-cutting / P3). * * One row per `(topic, channel, locale)` carrying the `subject`/`body` a channel @@ -11,7 +35,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * — see `template-renderer.ts`). `format` tells the channel how to treat `body` * (markdown/html/text). When no template matches, channels fall back to * `payload.title` / `payload.body` (the P0/P1 behavior), so templates are purely - * additive. + * additive. `channel` is one of {@link TEMPLATE_CHANNELS}; see there for who + * reads a row and why no other value is accepted. * * Studio-configurable (contributed to the Setup → Configuration nav). Belongs to * `service-messaging`. @@ -28,7 +53,9 @@ export const NotificationTemplate = ObjectSchema.create({ // only; permission sets remain the authz. managedBy: 'system-data', // Admin-facing copy; one row per (topic, channel, locale) — see the docblock. - description: 'The subject and body each notification topic sends, per channel and language.', + // It names the two channels that read a row and says the inbox reads none + // (TEMPLATE_CHANNELS above is the measurement behind both halves). + description: 'The email and SMS text each notification topic sends, per language. The in-app inbox never uses these templates.', titleFormat: '{topic} · {channel} · {locale}', highlightFields: ['topic', 'channel', 'locale', 'is_active'], @@ -46,9 +73,13 @@ export const NotificationTemplate = ObjectSchema.create({ label: 'All', data: { provider: 'object', object: 'sys_notification_template' }, columns: [], + // Which channels read a row, what they send without one, and what + // a notification naming an email template does: email and the + // inbox render that email template and skip these rows, while SMS + // has no template path and still reads its row here. emptyState: { title: 'No templates yet', - message: 'Add a template per topic, channel and locale; until then, a notification is sent with the title and body its sender provides.', + message: 'Add a template per topic, channel (email or SMS) and language; until then, those channels send the title and body the notification carries. In-app content comes from an email template: a notification that names one sends it by email and in the inbox, and SMS still uses the template here.', }, }, }, @@ -75,7 +106,10 @@ export const NotificationTemplate = ObjectSchema.create({ // `MessagingChannel.id`s, 64 per the landed machine-vocabulary // precedent (sys_session.revoke_reason, maxLength: 64). maxLength: 64, - description: 'Channel id this template renders for (email/inbox/push/…).', + // [#24924] The accepted values are TEMPLATE_CHANNELS, enforced by + // the `channel_renders_template` rule below. The field stays `text` + // on purpose — see that rule for why a `select` is not the spelling. + description: 'Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in the notification\'s template.', }), locale: Field.text({ @@ -104,7 +138,10 @@ export const NotificationTemplate = ObjectSchema.create({ subject: Field.text({ label: 'Subject / Title', required: false, - description: 'Rendered into the email subject / inbox title. Supports {{ payload.x }}.', + // The inbox reads no row, so this is the email subject; the SMS + // channel sends the body, falling back to this only when the + // rendered body is empty (`sms-channel.ts`). + description: 'The email subject; an SMS sends it only when the body is empty. Supports {{ payload.x }}.', }), body: Field.markdown({ @@ -133,4 +170,45 @@ export const NotificationTemplate = ObjectSchema.create({ { fields: ['topic', 'channel', 'locale'] }, { fields: ['topic'] }, ], + + // [#24924] The closed `channel` set, refused at write with the prescription + // the ruling asks for. objectql's rule validator runs `validations` on + // insert, by-id update and bulk update — the import runner's writes + // included — so no write reaches the column without passing here. + // + // A `format` rule judges the WRITE, never the stored row: it reads only + // a payload that names `channel` (`checkFormat` returns early otherwise), + // so a row already stored with another channel keeps reading, listing, + // deleting and saving every other column exactly as before — and keeps + // rendering nowhere, as it always did. A save that NAMES another channel + // (a new row, a change of channel, or a client re-sending the stored + // value) is refused. + // + // Why not a `select` with options `email` / `sms`, measured at this + // commit: + // - its refusal is the generic catalog sentence ("Channel must be one + // of: email, sms"), keyed by message key only — no per-field override + // exists, so it cannot carry the email-template prescription; + // - an import with `createMissingOptions` admits a value outside a + // select's options (`keptOptionValues`), so the closed set would leak + // through the import door; + // - Setup's select widget (objectui `SelectField`, at the console pin) + // clears on load a stored value its options do not offer, so editing + // ANY column of a stored `inbox` row would save `channel: null` and be + // refused as a cleared required field — a stored-row path newly + // refused, which the release rule for narrowings keeps out of 18.x; + // - a select carries no `maxLength`, so the column's physical width and + // its keyed-text bound would move with it. + validations: [ + { + type: 'format', + name: 'channel_renders_template', + label: 'Channel renders the template', + description: 'Only the email and SMS channels read a notification template, so a template for any other channel would never be sent.', + field: 'channel', + regex: `^(?:${TEMPLATE_CHANNELS.join('|')})$`, + severity: 'error', + message: 'Channel must be email or sms: only those channels use a notification template, and a template for any other channel is never sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step\'s template; email and the inbox both render it.', + }, + ], }); From efc4a94b5f923578f381f4a21bb88d4168d19e99 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:19:49 +0000 Subject: [PATCH 2/4] test(service-messaging): pin the closed template channel set; translate the new copy The refusal pins cover insert, by-id update, predicate update and an import's kept option values (code VALIDATION_FAILED, served 400, attributed to channel, naming the email template). Their positive twins accept email and sms, and a row already stored with another channel is still read, edited in other columns, deleted, and rendered on no channel. The four bundles are regenerated, and the changed copy and the new rule message are translated in zh-CN, ja-JP and es-ES. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- ...ation-template-channel.integration.test.ts | 177 ++++++++++++++++++ .../objects/notification-template.object.ts | 2 +- .../src/translations/en.objects.generated.ts | 13 +- .../translations/es-ES.objects.generated.ts | 13 +- .../es-ES.source-hashes.generated.ts | 3 - .../translations/ja-JP.objects.generated.ts | 13 +- .../ja-JP.source-hashes.generated.ts | 3 - .../translations/zh-CN.objects.generated.ts | 13 +- 8 files changed, 214 insertions(+), 23 deletions(-) create mode 100644 packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts diff --git a/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts new file mode 100644 index 00000000000..7e3594a3c50 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `sys_notification_template.channel` takes only the channels that read a row: + * `email` and `sms` (the maintainer's ruling D on #24770, card #24924). + * + * Those two channels are the object's only readers (the email channel's inline + * path, the SMS channel, and plugin-auth's phone OTP / invitation SMS); the + * inbox reads no row on any path. A row naming any other channel used to be + * stored and then rendered nowhere, silently. The object's + * `channel_renders_template` rule now refuses such a save, pointing in-app + * content to the email template a notify step names. + * + * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3), through every + * engine write shape the rule validator guards: + * + * - REFUSAL: an insert, a by-id update and a predicate (`multi`) update that + * name another channel are refused with the ADR-0112 validation envelope — + * `code` `VALIDATION_FAILED`, served `400` — attributed to `channel`, whose + * message names the email template, and nothing is written. An import's + * kept option values (`keptOptionValues`) do not admit one either: the rule + * is not an option list. + * - PRESERVATION: `email` and `sms` rows are accepted (the positive twins, so + * no refusal pin passes against a rule that refuses everything). + * - STORED ROWS: a row already stored with another channel (written under + * the rule's absence, straight through the driver) is read, edited in any + * other column and deleted exactly as before, and still renders on no + * channel. Only a save that names its channel again is refused. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import type { ExecutionContext } from '@objectstack/spec/kernel'; +import { resolveThrownHttpError } from '@objectstack/types'; +import { NotificationTemplate } from './notification-template.object.js'; +import { NotificationTemplateStore, TEMPLATE_OBJECT } from '../template-renderer.js'; + +const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }; + +const ORG = 'org_a'; +const TOPIC = 'deal.won'; +const CTX = { isSystem: true, tenantId: ORG } as ExecutionContext; + +/** The first sentence of the refusal — the answer an author acts on. */ +const REFUSAL_HEAD = 'Channel must be email or sms'; + +let engine: ObjectQL | undefined; + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; +}); + +async function boot(): Promise<{ ql: ObjectQL; driver: SqlDriver }> { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const ql = new ObjectQL(); + ql.registerDriver(driver, true); + await ql.init(); + for (const def of [SYS_ORGANIZATION, NotificationTemplate]) { + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); + } + await ql.syncSchemas(); + ql.setTenancyPostureProvider(() => 'single'); + await ql.insert('sys_organization', { id: ORG, name: ORG }, { context: { isSystem: true } as ExecutionContext }); + engine = ql; + return { ql, driver }; +} + +function row(channel: string, text = channel): Record { + return { topic: TOPIC, channel, locale: 'en', subject: `${text} subject`, body: `${text} body`, format: 'text', is_active: true }; +} + +async function channelsStored(ql: ObjectQL): Promise { + const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['channel'] }, { context: CTX })) ?? []; + return rows.map((r: Record) => String(r.channel)).sort(); +} + +/** Runs `write`, which must be refused, and returns the thrown error. */ +async function refusal(write: () => Promise): Promise { + let thrown: unknown; + try { + await write(); + } catch (err) { + thrown = err; + } + expect(thrown, 'the write was accepted').toBeDefined(); + return thrown; +} + +/** The refusal's ADR-0112 shape: code + served status, attributed to `channel`, naming the email template. */ +function expectChannelRefusal(err: any): void { + const served = resolveThrownHttpError(err); + expect(served.code).toBe('VALIDATION_FAILED'); + expect(served.status).toBe(400); + expect(err.fields).toHaveLength(1); + expect(err.fields[0]).toMatchObject({ field: 'channel', code: 'invalid_format' }); + expect(String(err.fields[0].message).startsWith(REFUSAL_HEAD)).toBe(true); + // The prescription: in-app content belongs to an email template, named by + // a notify step's `template`. + expect(String(err.fields[0].message)).toContain('email_template'); +} + +describe('sys_notification_template.channel is the closed set of channels that read a row', () => { + it('accepts email and sms (the positive twins of every refusal below)', async () => { + const { ql } = await boot(); + await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); + await ql.insert(TEMPLATE_OBJECT, row('sms'), { context: CTX }); + expect(await channelsStored(ql)).toEqual(['email', 'sms']); + }); + + it.each(['inbox', 'push', 'webhook', 'Email', ' sms'])('refuses an insert naming %j, and stores nothing', async (channel) => { + const { ql } = await boot(); + expectChannelRefusal(await refusal(() => ql.insert(TEMPLATE_OBJECT, row(channel), { context: CTX }))); + expect(await channelsStored(ql)).toEqual([]); + }); + + it('refuses an insert naming another channel even when an import kept that value', async () => { + const { ql } = await boot(); + const importCtx = { ...CTX, keptOptionValues: { channel: ['inbox'] } } as ExecutionContext; + expectChannelRefusal(await refusal(() => ql.insert(TEMPLATE_OBJECT, row('inbox'), { context: importCtx }))); + expect(await channelsStored(ql)).toEqual([]); + }); + + it('refuses a by-id update that moves a row to another channel, and leaves the row as it was', async () => { + const { ql } = await boot(); + const created = await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); + expectChannelRefusal(await refusal(() => ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { id: created.id }, context: CTX }))); + expect(await channelsStored(ql)).toEqual(['email']); + }); + + it('refuses a predicate update that moves rows to another channel', async () => { + const { ql } = await boot(); + await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); + expectChannelRefusal(await refusal(() => ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { topic: TOPIC }, multi: true, context: CTX }))); + expect(await channelsStored(ql)).toEqual(['email']); + }); + + describe('a row already stored with another channel', () => { + /** Written straight through the driver: the shape a row authored before the rule has. */ + async function bootWithStoredInboxRow(): Promise<{ ql: ObjectQL; id: string }> { + const booted = await boot(); + const id = 'tpl_stored_inbox'; + await booted.driver.create(TEMPLATE_OBJECT, { id, organization_id: ORG, ...row('inbox', 'stored') }); + return { ql: booted.ql, id }; + } + + it('is read and edited in every other column as before', async () => { + const { ql, id } = await bootWithStoredInboxRow(); + await ql.update(TEMPLATE_OBJECT, { body: 'edited body', is_active: false }, { where: { id }, context: CTX }); + const [stored] = await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX }); + expect(stored).toMatchObject({ id, channel: 'inbox', body: 'edited body', is_active: false }); + }); + + it('still renders on no channel', async () => { + const { ql, id } = await bootWithStoredInboxRow(); + // Control: the row is really there for this topic and organization. + expect((await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX })).length).toBe(1); + const store = new NotificationTemplateStore({ getData: () => ql as any }); + expect(await store.load(TOPIC, 'email', 'en', ORG)).toBeNull(); + expect(await store.load(TOPIC, 'sms', 'en', ORG)).toBeNull(); + }); + + it('is deleted as before', async () => { + const { ql, id } = await bootWithStoredInboxRow(); + await ql.delete(TEMPLATE_OBJECT, { where: { id }, context: CTX }); + expect(await channelsStored(ql)).toEqual([]); + }); + + it('refuses a save that names its channel again — a save naming another channel is refused whatever the row held', async () => { + const { ql, id } = await bootWithStoredInboxRow(); + expectChannelRefusal(await refusal(() => ql.update(TEMPLATE_OBJECT, { channel: 'inbox', body: 'x' }, { where: { id }, context: CTX }))); + const [stored] = await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX }); + expect(stored).toMatchObject({ channel: 'inbox', body: 'stored body' }); + }); + }); +}); diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index d86fbb8a387..629a4520303 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -109,7 +109,7 @@ export const NotificationTemplate = ObjectSchema.create({ // [#24924] The accepted values are TEMPLATE_CHANNELS, enforced by // the `channel_renders_template` rule below. The field stays `text` // on purpose — see that rule for why a `select` is not the spelling. - description: 'Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in the notification\'s template.', + description: 'Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in a notify step\'s template.', }), locale: Field.text({ 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 761887ec521..6c6ed9b8824 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -286,7 +286,7 @@ export const enObjects: NonNullable = { sys_notification_template: { label: "Notification Template", pluralLabel: "Notification Templates", - description: "The subject and body each notification topic sends, per channel and language.", + description: "The email and SMS text each notification topic sends, per language. The in-app inbox never uses these templates.", fields: { id: { label: "Template ID" @@ -296,7 +296,7 @@ export const enObjects: NonNullable = { }, channel: { label: "Channel", - help: "Channel id this template renders for (email/inbox/push/…)." + help: "Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in a notify step's template." }, locale: { label: "Locale", @@ -307,7 +307,7 @@ export const enObjects: NonNullable = { }, subject: { label: "Subject / Title", - help: "Rendered into the email subject / inbox title. Supports {{ payload.x }}." + help: "The email subject; an SMS sends it only when the body is empty. Supports {{ payload.x }}." }, body: { label: "Body", @@ -338,9 +338,14 @@ export const enObjects: NonNullable = { label: "All", emptyState: { title: "No templates yet", - message: "Add a template per topic, channel and locale; until then, a notification is sent with the title and body its sender provides." + message: "Add a template per topic, channel (email or SMS) and language; until then, those channels send the title and body the notification carries. In-app content comes from an email template: a notification that names one sends it by email and in the inbox, and SMS still uses the template here." } } + }, + _validations: { + channel_renders_template: { + message: "Channel must be email or sms: only those channels use a notification template, and a template for any other channel is never sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step's template; email and the inbox both render it." + } } }, sys_http_delivery: { 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 cd6f44dfb1c..ab23263435d 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 @@ -286,7 +286,7 @@ export const esESObjects: NonNullable = { sys_notification_template: { label: "Notification Template", pluralLabel: "Notification Templates", - description: "The subject and body each notification topic sends, per channel and language.", + description: "El texto de correo electrónico y SMS que envía cada tema de notificación, por idioma. La bandeja de entrada de la aplicación nunca usa estas plantillas.", fields: { id: { label: "Template ID" @@ -296,7 +296,7 @@ export const esESObjects: NonNullable = { }, channel: { label: "Channel", - help: "Channel id this template renders for (email/inbox/push/…)." + help: "Qué canal usa esta plantilla: email o sms. La bandeja de entrada de la aplicación nunca la usa; para el contenido de la aplicación, escribe una plantilla de correo electrónico e indícala en el template de un paso de notificación." }, locale: { label: "Locale", @@ -307,7 +307,7 @@ export const esESObjects: NonNullable = { }, subject: { label: "Subject / Title", - help: "Rendered into the email subject / inbox title. Supports {{ payload.x }}." + help: "El asunto del correo electrónico; un SMS lo envía solo cuando el cuerpo está vacío. Admite {{ payload.x }}." }, body: { label: "Body", @@ -338,9 +338,14 @@ export const esESObjects: NonNullable = { label: "Todas", emptyState: { title: "Aún no hay plantillas", - message: "Añade una plantilla por tema, canal e idioma; mientras tanto, la notificación se envía con el título y el cuerpo que proporciona su remitente." + message: "Añade una plantilla por tema, canal (correo electrónico o SMS) e idioma; mientras tanto, esos canales envían el título y el cuerpo que lleva la notificación. El contenido de la aplicación procede de una plantilla de correo electrónico: una notificación que indica una la envía por correo electrónico y a la bandeja de entrada, y el SMS sigue usando la plantilla de aquí." } } + }, + _validations: { + channel_renders_template: { + message: "El canal debe ser email o sms: solo esos canales usan una plantilla de notificación, y una plantilla para cualquier otro canal nunca se envía. Para el contenido de la aplicación (bandeja de entrada), escribe una plantilla de correo electrónico (email_template) e indícala en el template del paso de notificación; el correo electrónico y la bandeja de entrada la muestran." + } } }, sys_http_delivery: { 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 32f3735a653..2d5368bfddd 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 @@ -145,10 +145,8 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_subscription.fields.topic.label": "819afdb3853e9d80", "objects.sys_notification_subscription.label": "cb96f976a7b36f34", "objects.sys_notification_subscription.pluralLabel": "907cb60dda7b39c8", - "objects.sys_notification_template.description": "d4e78f9fe9420a04", "objects.sys_notification_template.fields.body.help": "af87336e946106ce", "objects.sys_notification_template.fields.body.label": "d1753a024d6a9bed", - "objects.sys_notification_template.fields.channel.help": "f71964058486f39e", "objects.sys_notification_template.fields.channel.label": "979883fe73ee88cb", "objects.sys_notification_template.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_template.fields.format.label": "20d89df5129d933a", @@ -161,7 +159,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_template.fields.is_active.label": "eee107cf4e466779", "objects.sys_notification_template.fields.locale.help": "1d2b03117aa89de5", "objects.sys_notification_template.fields.locale.label": "92b39430e88d3b51", - "objects.sys_notification_template.fields.subject.help": "9eef4993f2d0fbb8", "objects.sys_notification_template.fields.subject.label": "92502991f5df9937", "objects.sys_notification_template.fields.topic.label": "819afdb3853e9d80", "objects.sys_notification_template.fields.updated_at.label": "aba63dc2a9c79b8d", 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 bd97e7e9c59..b4386695d4a 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 @@ -286,7 +286,7 @@ export const jaJPObjects: NonNullable = { sys_notification_template: { label: "Notification Template", pluralLabel: "Notification Templates", - description: "The subject and body each notification topic sends, per channel and language.", + description: "各通知トピックが言語ごとに送信するメールと SMS のテキストです。アプリ内の受信トレイはこれらのテンプレートを使用しません。", fields: { id: { label: "Template ID" @@ -296,7 +296,7 @@ export const jaJPObjects: NonNullable = { }, channel: { label: "Channel", - help: "Channel id this template renders for (email/inbox/push/…)." + help: "このテンプレートを使用するチャネル:email または sms。アプリ内の受信トレイは使用しません。アプリ内の内容は、メールテンプレートを作成して通知ステップの template で指定してください。" }, locale: { label: "Locale", @@ -307,7 +307,7 @@ export const jaJPObjects: NonNullable = { }, subject: { label: "Subject / Title", - help: "Rendered into the email subject / inbox title. Supports {{ payload.x }}." + help: "メールの件名です。SMS は本文が空の場合にのみこれを送信します。{{ payload.x }} を使用できます。" }, body: { label: "Body", @@ -338,9 +338,14 @@ export const jaJPObjects: NonNullable = { label: "すべて", emptyState: { title: "まだ通知テンプレートがありません", - message: "トピック、チャネル、ロケールごとにテンプレートを追加します。追加するまでは、送信元が指定したタイトルと本文で通知が送信されます。" + message: "トピック、チャネル(メールまたは SMS)、言語ごとにテンプレートを追加します。追加するまでは、これらのチャネルは通知のタイトルと本文を送信します。アプリ内の内容はメールテンプレートから取得されます。メールテンプレートを指定した通知はそれをメールと受信トレイで送信し、SMS は引き続きここのテンプレートを使用します。" } } + }, + _validations: { + channel_renders_template: { + message: "チャネルは email または sms である必要があります。通知テンプレートを使用するのはこの 2 つのチャネルだけで、ほかのチャネル向けのテンプレートは送信されません。アプリ内(受信トレイ)の内容は、メールテンプレート(email_template)を作成して通知ステップの template で指定してください。メールと受信トレイの両方がそれを表示します。" + } } }, sys_http_delivery: { 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 696feb3c340..52f7f672167 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 @@ -144,10 +144,8 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_subscription.fields.topic.label": "819afdb3853e9d80", "objects.sys_notification_subscription.label": "cb96f976a7b36f34", "objects.sys_notification_subscription.pluralLabel": "907cb60dda7b39c8", - "objects.sys_notification_template.description": "d4e78f9fe9420a04", "objects.sys_notification_template.fields.body.help": "af87336e946106ce", "objects.sys_notification_template.fields.body.label": "d1753a024d6a9bed", - "objects.sys_notification_template.fields.channel.help": "f71964058486f39e", "objects.sys_notification_template.fields.channel.label": "979883fe73ee88cb", "objects.sys_notification_template.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_template.fields.format.label": "20d89df5129d933a", @@ -160,7 +158,6 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_template.fields.is_active.label": "eee107cf4e466779", "objects.sys_notification_template.fields.locale.help": "1d2b03117aa89de5", "objects.sys_notification_template.fields.locale.label": "92b39430e88d3b51", - "objects.sys_notification_template.fields.subject.help": "9eef4993f2d0fbb8", "objects.sys_notification_template.fields.subject.label": "92502991f5df9937", "objects.sys_notification_template.fields.topic.label": "819afdb3853e9d80", "objects.sys_notification_template.fields.updated_at.label": "aba63dc2a9c79b8d", 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 a250c91fdcd..f2954c34750 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 @@ -286,7 +286,7 @@ export const zhCNObjects: NonNullable = { sys_notification_template: { label: "通知模板", pluralLabel: "通知模板", - description: "每个通知主题在各通道、各语言下发送的标题和正文。", + description: "每个通知主题按语言发送的邮件和短信文本。站内信从不使用这些模板。", fields: { id: { label: "模板 ID" @@ -296,7 +296,7 @@ export const zhCNObjects: NonNullable = { }, channel: { label: "通道", - help: "本模板渲染的通道 ID(email/inbox/push/…)。" + help: "使用本模板的通道:email 或 sms。站内信从不使用;站内信内容请编写邮件模板,并在通知步骤的 template 中指定它。" }, locale: { label: "语言", @@ -307,7 +307,7 @@ export const zhCNObjects: NonNullable = { }, subject: { label: "主题 / 标题", - help: "渲染为邮件主题 / 站内信标题。支持 {{ payload.x }}。" + help: "邮件主题;短信仅在正文为空时发送它。支持 {{ payload.x }}。" }, body: { label: "正文", @@ -338,9 +338,14 @@ export const zhCNObjects: NonNullable = { label: "全部", emptyState: { title: "尚无通知模板", - message: "按主题、通道和语言添加模板;在此之前,通知会使用发送方提供的标题和正文发送。" + message: "按主题、通道(邮件或短信)和语言添加模板;在此之前,这些通道发送通知自带的标题和正文。站内信内容来自邮件模板:指定了邮件模板的通知会通过邮件和站内信发送该模板,短信仍使用这里的模板。" } } + }, + _validations: { + channel_renders_template: { + message: "通道必须是 email 或 sms:只有这两个通道使用通知模板,其他通道的模板永远不会被发送。站内信内容请编写邮件模板(email_template),并在通知步骤的 template 中指定它;邮件和站内信都会渲染它。" + } } }, sys_http_delivery: { From 80c0c27b8b7190c57ac4872a3e48c47623fc171f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 19:25:42 +0000 Subject: [PATCH 3/4] =?UTF-8?q?chore(changeset):=20service-messaging=20min?= =?UTF-8?q?or,=20Clause-=E2=91=A1=20no=20(narrowing)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .changeset/24924-template-channel-email-sms.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 .changeset/24924-template-channel-email-sms.md diff --git a/.changeset/24924-template-channel-email-sms.md b/.changeset/24924-template-channel-email-sms.md new file mode 100644 index 00000000000..99fafbedec7 --- /dev/null +++ b/.changeset/24924-template-channel-email-sms.md @@ -0,0 +1,18 @@ +--- +'@objectstack/service-messaging': minor +--- + +fix(service-messaging)!: `sys_notification_template.channel` accepts only `email` and `sms`, the two channels that read a notification template + +A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` was free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so. The object now refuses that save. Its `channel_renders_template` rule answers `400 VALIDATION_FAILED`, attributed to `channel`, and the message points in-app content to an email template (`email_template`) named in a notify step's `template`. The rule guards insert, by-id update and bulk update, and an import's kept option values do not admit another channel. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does. + +Clause-②: no (narrowing) + + + +**BREAKING** (the accept set of one platform object's write door narrows), shipped as `minor` under the launch-window convention for breaking changes. + +- **Before.** A `sys_notification_template` save naming any `channel` up to 64 characters was accepted. A row for a channel other than `email` or `sms` was stored and never rendered. +- **Now.** A save naming a `channel` other than `email` or `sms` is refused: `400`, `code: 'VALIDATION_FAILED'`, one field error on `channel` (`invalid_format`) whose message names the email template. This holds for a new row, for moving a row to another channel, and for re-sending a stored row's other channel. +- **Unchanged.** `email` and `sms` rows, and how every channel renders. A row already stored with another channel is still read, listed, edited in any other column and deleted, and it still renders on no channel. The column and its width are unchanged. +- **What to do.** Nothing, for `email` and `sms` rows. For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. Delete a stored row for another channel if you no longer want it listed: no channel ever read it. From c7102b32fe4ee4d880635a3ac9d95b34aa79f4f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 20:17:20 +0000 Subject: [PATCH 4/4] fix(service-messaging): warn, not refuse, on a template channel no channel reads (18.x half) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruling refuses stored or new non-email/sms rows only where a stored-row conversion carries it; none carries data rows, so 18.x warns on write and the refusal waits for 19.0. channel_renders_template moves to severity warning with a warning's wording: the write is accepted and stored, the hit reaches the write's onValidationAdvisory listener (the single-record write answer's warnings) and the per-write log. The pins follow: every write shape is accepted with exactly one channel advisory, email and sms carry none, and stored rows stay untouched. The changeset drops to patch with Clause-② no. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude --- .../24924-template-channel-email-sms.md | 18 +-- ...ation-template-channel.integration.test.ts | 145 ++++++++++-------- .../objects/notification-template.object.ts | 76 ++++----- .../src/translations/en.objects.generated.ts | 2 +- .../translations/es-ES.objects.generated.ts | 2 +- .../translations/ja-JP.objects.generated.ts | 2 +- .../translations/zh-CN.objects.generated.ts | 2 +- 7 files changed, 133 insertions(+), 114 deletions(-) diff --git a/.changeset/24924-template-channel-email-sms.md b/.changeset/24924-template-channel-email-sms.md index 99fafbedec7..aaae6749614 100644 --- a/.changeset/24924-template-channel-email-sms.md +++ b/.changeset/24924-template-channel-email-sms.md @@ -1,18 +1,14 @@ --- -'@objectstack/service-messaging': minor +'@objectstack/service-messaging': patch --- -fix(service-messaging)!: `sys_notification_template.channel` accepts only `email` and `sms`, the two channels that read a notification template +fix(service-messaging): a `sys_notification_template` saved for a channel other than `email` or `sms` is now warned about, because no channel ever sends it -A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` was free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so. The object now refuses that save. Its `channel_renders_template` rule answers `400 VALIDATION_FAILED`, attributed to `channel`, and the message points in-app content to an email template (`email_template`) named in a notify step's `template`. The rule guards insert, by-id update and bulk update, and an import's kept option values do not admit another channel. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does. +A notification template row is read by exactly two channels: the email channel (when the notification names no email template) and the SMS channel, which also renders the phone OTP and invitation texts. The in-app inbox reads no row on any path. `channel` is free text, so a row saved for `inbox`, `push` or any other value was stored and then rendered nowhere, with nothing to say so. -Clause-②: no (narrowing) +The object now carries a `channel_renders_template` rule at `severity: 'warning'`. A write naming another channel is still accepted and stored, and it now reports the warning. A single-record create, clone or update answers it in the write answer's `warnings` (`rule`, `severity`, `field: 'channel'`, `code`, `message`), and the server logs it once per write. Bulk updates and imports log it. The message points in-app content to an email template (`email_template`) named in a notify step's `template`. The object's description, its empty state and the `channel` / `subject` help now say which channels use a row and that the inbox never does. - +Clause-②: no — nothing a write could send before is refused now; a save naming another channel gains one advisory in the write answer's existing `warnings`. -**BREAKING** (the accept set of one platform object's write door narrows), shipped as `minor` under the launch-window convention for breaking changes. - -- **Before.** A `sys_notification_template` save naming any `channel` up to 64 characters was accepted. A row for a channel other than `email` or `sms` was stored and never rendered. -- **Now.** A save naming a `channel` other than `email` or `sms` is refused: `400`, `code: 'VALIDATION_FAILED'`, one field error on `channel` (`invalid_format`) whose message names the email template. This holds for a new row, for moving a row to another channel, and for re-sending a stored row's other channel. -- **Unchanged.** `email` and `sms` rows, and how every channel renders. A row already stored with another channel is still read, listed, edited in any other column and deleted, and it still renders on no channel. The column and its width are unchanged. -- **What to do.** Nothing, for `email` and `sms` rows. For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. Delete a stored row for another channel if you no longer want it listed: no channel ever read it. +- **Unchanged.** Every write that was accepted is still accepted, and stored rows are not read, rewritten or flagged by anything but a write that names their channel. `email` and `sms` rows, how every channel renders, and the column and its width are unchanged. +- **What to do.** For in-app content, write an email template and name it in the notify step's `template`; the inbox and email both render it. A row for another channel was never sent, so delete it if you no longer want it listed. diff --git a/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts index 7e3594a3c50..b9acedb21b9 100644 --- a/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts +++ b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts @@ -1,38 +1,43 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `sys_notification_template.channel` takes only the channels that read a row: - * `email` and `sms` (the maintainer's ruling D on #24770, card #24924). + * `sys_notification_template.channel` names a channel that reads a row, or the + * write is WARNED about: the 18.x half of the maintainer's ruling D on #24770 + * (card #24924). * - * Those two channels are the object's only readers (the email channel's inline - * path, the SMS channel, and plugin-auth's phone OTP / invitation SMS); the - * inbox reads no row on any path. A row naming any other channel used to be - * stored and then rendered nowhere, silently. The object's - * `channel_renders_template` rule now refuses such a save, pointing in-app - * content to the email template a notify step names. + * Only `email` and `sms` read a row (the email channel's inline path, the SMS + * channel, and plugin-auth's phone OTP / invitation SMS); the inbox reads none + * on any path. A row naming any other channel used to be stored and then + * rendered nowhere, silently. The ruling refuses such rows, but no ADR-0087 + * conversion carries stored DATA rows, so in 18.x the write is ACCEPTED with a + * warning and the refusal waits for 19.0. ⛔ Nothing here asserts a refusal. + * + * The warning is the object's `channel_renders_template` rule at + * `severity: 'warning'` — objectql's advisory path. One hit is handed to the + * write's own `onValidationAdvisory` listener (the data protocol answers it as + * the single-record write answer's `warnings`) and logged once per write. * * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3), through every - * engine write shape the rule validator guards: + * engine write shape the rule validator runs on: * - * - REFUSAL: an insert, a by-id update and a predicate (`multi`) update that - * name another channel are refused with the ADR-0112 validation envelope — - * `code` `VALIDATION_FAILED`, served `400` — attributed to `channel`, whose - * message names the email template, and nothing is written. An import's - * kept option values (`keptOptionValues`) do not admit one either: the rule - * is not an option list. - * - PRESERVATION: `email` and `sms` rows are accepted (the positive twins, so - * no refusal pin passes against a rule that refuses everything). + * - WARNING: an insert, an insert carrying an import's kept option values, a + * by-id update and a predicate (`multi`) update that name another channel + * are accepted, the row is stored as written, and exactly one advisory names + * `channel_renders_template` / `channel` with the message head. + * - SILENCE: `email` and `sms` writes produce no advisory (the positive twins, + * so no warning pin passes against a rule that warns about everything). * - STORED ROWS: a row already stored with another channel (written under * the rule's absence, straight through the driver) is read, edited in any - * other column and deleted exactly as before, and still renders on no - * channel. Only a save that names its channel again is refused. + * other column and deleted with no advisory, and still renders on no + * channel. A write that names its channel again is warned about, and + * accepted. */ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import { resolveThrownHttpError } from '@objectstack/types'; +import type { ValidationAdvisoryEvent } from '@objectstack/spec/data'; import { NotificationTemplate } from './notification-template.object.js'; import { NotificationTemplateStore, TEMPLATE_OBJECT } from '../template-renderer.js'; @@ -42,8 +47,9 @@ const ORG = 'org_a'; const TOPIC = 'deal.won'; const CTX = { isSystem: true, tenantId: ORG } as ExecutionContext; -/** The first sentence of the refusal — the answer an author acts on. */ -const REFUSAL_HEAD = 'Channel must be email or sms'; +const RULE = 'channel_renders_template'; +/** The first sentence of the warning — what the person who saved reads. */ +const WARNING_HEAD = 'This channel never uses a notification template'; let engine: ObjectQL | undefined; @@ -76,64 +82,73 @@ async function channelsStored(ql: ObjectQL): Promise { return rows.map((r: Record) => String(r.channel)).sort(); } -/** Runs `write`, which must be refused, and returns the thrown error. */ -async function refusal(write: () => Promise): Promise { - let thrown: unknown; - try { - await write(); - } catch (err) { - thrown = err; - } - expect(thrown, 'the write was accepted').toBeDefined(); - return thrown; +/** A fresh listener for ONE write: the advisories that write reported. */ +function advisories(): { events: ValidationAdvisoryEvent[]; onValidationAdvisory: (e: ValidationAdvisoryEvent) => void } { + const events: ValidationAdvisoryEvent[] = []; + return { events, onValidationAdvisory: (e) => { events.push(e); } }; } -/** The refusal's ADR-0112 shape: code + served status, attributed to `channel`, naming the email template. */ -function expectChannelRefusal(err: any): void { - const served = resolveThrownHttpError(err); - expect(served.code).toBe('VALIDATION_FAILED'); - expect(served.status).toBe(400); - expect(err.fields).toHaveLength(1); - expect(err.fields[0]).toMatchObject({ field: 'channel', code: 'invalid_format' }); - expect(String(err.fields[0].message).startsWith(REFUSAL_HEAD)).toBe(true); +/** Exactly one advisory, and it is this rule's, on `channel`, with the warning's head and prescription. */ +function expectOneChannelWarning(events: readonly ValidationAdvisoryEvent[]): void { + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ rule: RULE, severity: 'warning', field: 'channel', code: 'invalid_format' }); + expect(String(events[0].message).startsWith(WARNING_HEAD)).toBe(true); // The prescription: in-app content belongs to an email template, named by // a notify step's `template`. - expect(String(err.fields[0].message)).toContain('email_template'); + expect(String(events[0].message)).toContain('email_template'); } -describe('sys_notification_template.channel is the closed set of channels that read a row', () => { - it('accepts email and sms (the positive twins of every refusal below)', async () => { +describe('sys_notification_template.channel: a channel that reads no row is accepted with a warning', () => { + it.each(['email', 'sms'])('an insert naming %j is stored with no advisory (the positive twins)', async (channel) => { const { ql } = await boot(); - await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); - await ql.insert(TEMPLATE_OBJECT, row('sms'), { context: CTX }); - expect(await channelsStored(ql)).toEqual(['email', 'sms']); + const seen = advisories(); + await ql.insert(TEMPLATE_OBJECT, row(channel), { context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expect(seen.events).toEqual([]); + expect(await channelsStored(ql)).toEqual([channel]); }); - it.each(['inbox', 'push', 'webhook', 'Email', ' sms'])('refuses an insert naming %j, and stores nothing', async (channel) => { + it.each(['inbox', 'push', 'webhook', 'Email', ' sms'])('an insert naming %j is stored, with one channel advisory', async (channel) => { const { ql } = await boot(); - expectChannelRefusal(await refusal(() => ql.insert(TEMPLATE_OBJECT, row(channel), { context: CTX }))); - expect(await channelsStored(ql)).toEqual([]); + const seen = advisories(); + await ql.insert(TEMPLATE_OBJECT, row(channel), { context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expectOneChannelWarning(seen.events); + expect(await channelsStored(ql)).toEqual([channel]); }); - it('refuses an insert naming another channel even when an import kept that value', async () => { + it('an insert carrying an import\'s kept option values is stored, with one channel advisory', async () => { const { ql } = await boot(); + const seen = advisories(); const importCtx = { ...CTX, keptOptionValues: { channel: ['inbox'] } } as ExecutionContext; - expectChannelRefusal(await refusal(() => ql.insert(TEMPLATE_OBJECT, row('inbox'), { context: importCtx }))); - expect(await channelsStored(ql)).toEqual([]); + await ql.insert(TEMPLATE_OBJECT, row('inbox'), { context: importCtx, onValidationAdvisory: seen.onValidationAdvisory }); + expectOneChannelWarning(seen.events); + expect(await channelsStored(ql)).toEqual(['inbox']); }); - it('refuses a by-id update that moves a row to another channel, and leaves the row as it was', async () => { + it('a by-id update moving a row to another channel is stored, with one channel advisory', async () => { const { ql } = await boot(); const created = await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); - expectChannelRefusal(await refusal(() => ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { id: created.id }, context: CTX }))); - expect(await channelsStored(ql)).toEqual(['email']); + const seen = advisories(); + await ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { id: created.id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expectOneChannelWarning(seen.events); + expect(await channelsStored(ql)).toEqual(['inbox']); }); - it('refuses a predicate update that moves rows to another channel', async () => { + it('a predicate update moving a row to another channel is stored, with one channel advisory', async () => { const { ql } = await boot(); await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); - expectChannelRefusal(await refusal(() => ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { topic: TOPIC }, multi: true, context: CTX }))); - expect(await channelsStored(ql)).toEqual(['email']); + const seen = advisories(); + await ql.update(TEMPLATE_OBJECT, { channel: 'inbox' }, { where: { topic: TOPIC }, multi: true, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expectOneChannelWarning(seen.events); + expect(await channelsStored(ql)).toEqual(['inbox']); + }); + + it('an update of an email row that does not move its channel produces no advisory', async () => { + const { ql } = await boot(); + const created = await ql.insert(TEMPLATE_OBJECT, row('email'), { context: CTX }); + const seen = advisories(); + await ql.update(TEMPLATE_OBJECT, { channel: 'sms', body: 'moved' }, { where: { id: created.id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expect(seen.events).toEqual([]); + expect(await channelsStored(ql)).toEqual(['sms']); }); describe('a row already stored with another channel', () => { @@ -145,9 +160,11 @@ describe('sys_notification_template.channel is the closed set of channels that r return { ql: booted.ql, id }; } - it('is read and edited in every other column as before', async () => { + it('is read and edited in every other column as before, with no advisory', async () => { const { ql, id } = await bootWithStoredInboxRow(); - await ql.update(TEMPLATE_OBJECT, { body: 'edited body', is_active: false }, { where: { id }, context: CTX }); + const seen = advisories(); + await ql.update(TEMPLATE_OBJECT, { body: 'edited body', is_active: false }, { where: { id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expect(seen.events).toEqual([]); const [stored] = await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX }); expect(stored).toMatchObject({ id, channel: 'inbox', body: 'edited body', is_active: false }); }); @@ -167,11 +184,13 @@ describe('sys_notification_template.channel is the closed set of channels that r expect(await channelsStored(ql)).toEqual([]); }); - it('refuses a save that names its channel again — a save naming another channel is refused whatever the row held', async () => { + it('a write that names its channel again is stored, with one channel advisory', async () => { const { ql, id } = await bootWithStoredInboxRow(); - expectChannelRefusal(await refusal(() => ql.update(TEMPLATE_OBJECT, { channel: 'inbox', body: 'x' }, { where: { id }, context: CTX }))); + const seen = advisories(); + await ql.update(TEMPLATE_OBJECT, { channel: 'inbox', body: 'x' }, { where: { id }, context: CTX, onValidationAdvisory: seen.onValidationAdvisory }); + expectOneChannelWarning(seen.events); const [stored] = await ql.find(TEMPLATE_OBJECT, { where: { id } }, { context: CTX }); - expect(stored).toMatchObject({ channel: 'inbox', body: 'stored body' }); + expect(stored).toMatchObject({ channel: 'inbox', body: 'x' }); }); }); }); diff --git a/packages/services/service-messaging/src/objects/notification-template.object.ts b/packages/services/service-messaging/src/objects/notification-template.object.ts index 629a4520303..84c52411f6b 100644 --- a/packages/services/service-messaging/src/objects/notification-template.object.ts +++ b/packages/services/service-messaging/src/objects/notification-template.object.ts @@ -3,9 +3,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; /** - * The channels that READ a `sys_notification_template` row — the closed set - * `channel` accepts (the maintainer's ruling D on #24770: narrow templates to - * the channels that render them). + * The channels that READ a `sys_notification_template` row (the maintainer's + * ruling D on #24770: narrow templates to the channels that render them). In + * 18.x a write naming any other channel is accepted with a warning — see the + * `channel_renders_template` rule below; its refusal waits for 19.0. * * Measured, not assumed — the readers are exactly three, all keyed on one of * these two values: @@ -21,8 +22,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * The inbox reads no row on any path (`createInboxChannel` takes no store); its * localised content is the `email_template` a notify step names in `template`, * rendered through `IEmailService.renderTemplate`. So a row naming any other - * channel is stored and rendered nowhere — the silent no-op the ruling turns - * into a refusal at write. + * channel is stored and rendered nowhere — a silent no-op until this rule made + * it loud. */ const TEMPLATE_CHANNELS = ['email', 'sms'] as const; @@ -106,9 +107,10 @@ export const NotificationTemplate = ObjectSchema.create({ // `MessagingChannel.id`s, 64 per the landed machine-vocabulary // precedent (sys_session.revoke_reason, maxLength: 64). maxLength: 64, - // [#24924] The accepted values are TEMPLATE_CHANNELS, enforced by - // the `channel_renders_template` rule below. The field stays `text` - // on purpose — see that rule for why a `select` is not the spelling. + // [#24924] The values a row is read for are TEMPLATE_CHANNELS; any + // other value is warned about by the `channel_renders_template` + // rule below. The field stays `text` on purpose — see that rule for + // why a `select` is not the spelling. description: 'Which channel uses this template: email or sms. The in-app inbox never does; for in-app content, write an email template and name it in a notify step\'s template.', }), @@ -171,34 +173,36 @@ export const NotificationTemplate = ObjectSchema.create({ { fields: ['topic'] }, ], - // [#24924] The closed `channel` set, refused at write with the prescription - // the ruling asks for. objectql's rule validator runs `validations` on - // insert, by-id update and bulk update — the import runner's writes - // included — so no write reaches the column without passing here. + // [#24924] The WARNING half of ruling D for 18.x. The ruling record reads + // "② refuses stored or new non-email/sms rows. If no stored-row + // conversion carries that, 18.x warns on write and corrects the copy, and + // the refusal is filed for 19.0." — and no ADR-0087 conversion carries + // stored DATA rows, so under the standing rule for narrowings after 18.0 + // GA this rule warns and never refuses. ⛔ Do not raise it to `error` in + // 18.x; the refusal is a 19.0 card. // - // A `format` rule judges the WRITE, never the stored row: it reads only - // a payload that names `channel` (`checkFormat` returns early otherwise), - // so a row already stored with another channel keeps reading, listing, - // deleting and saving every other column exactly as before — and keeps - // rendering nowhere, as it always did. A save that NAMES another channel - // (a new row, a change of channel, or a client re-sending the stored - // value) is refused. + // `severity: 'warning'` is objectql's advisory path: the write goes + // through, the engine logs one `Validation rule 'channel_renders_template' + // (warning): …` line per write and hands the hit to that write's + // `onValidationAdvisory` listener, which the data protocol answers as the + // single-record create / clone / update answer's `warnings`. The rule runs + // on insert, by-id update and bulk update. // - // Why not a `select` with options `email` / `sms`, measured at this - // commit: - // - its refusal is the generic catalog sentence ("Channel must be one - // of: email, sms"), keyed by message key only — no per-field override - // exists, so it cannot carry the email-template prescription; - // - an import with `createMissingOptions` admits a value outside a - // select's options (`keptOptionValues`), so the closed set would leak - // through the import door; - // - Setup's select widget (objectui `SelectField`, at the console pin) - // clears on load a stored value its options do not offer, so editing - // ANY column of a stored `inbox` row would save `channel: null` and be - // refused as a cleared required field — a stored-row path newly - // refused, which the release rule for narrowings keeps out of 18.x; - // - a select carries no `maxLength`, so the column's physical width and - // its keyed-text bound would move with it. + // A `format` rule judges the WRITE, never the stored row: it reads only a + // payload that names `channel` (`checkFormat` returns early otherwise), so + // a row already stored with another channel is read, listed, edited in any + // other column and deleted without a warning — and keeps rendering + // nowhere, as it always did. A write that NAMES another channel (a new + // row, a change of channel, or a client re-sending the stored value) is + // warned about. + // + // Why not a `select` with options `email` / `sms`: option membership is a + // field CONSTRAINT in objectql's record validator, which refuses and has + // no advisory severity, so it is the 19.0 refusal and not the 18.x + // warning. (For 19.0, measured at this commit: its refusal is the generic + // catalog sentence with no per-field override; an import's kept option + // values admit a value outside the options; and Setup's select widget + // clears on load a stored value its options do not offer.) validations: [ { type: 'format', @@ -207,8 +211,8 @@ export const NotificationTemplate = ObjectSchema.create({ description: 'Only the email and SMS channels read a notification template, so a template for any other channel would never be sent.', field: 'channel', regex: `^(?:${TEMPLATE_CHANNELS.join('|')})$`, - severity: 'error', - message: 'Channel must be email or sms: only those channels use a notification template, and a template for any other channel is never sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step\'s template; email and the inbox both render it.', + severity: 'warning', + message: 'This channel never uses a notification template: only email and sms do, so this template will never be sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step\'s template; email and the inbox both render it.', }, ], }); 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 6c6ed9b8824..692178fea29 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -344,7 +344,7 @@ export const enObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "Channel must be email or sms: only those channels use a notification template, and a template for any other channel is never sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step's template; email and the inbox both render it." + message: "This channel never uses a notification template: only email and sms do, so this template will never be sent. For in-app (inbox) content, write an email template (email_template) and name it in the notify step's template; email and the inbox both render it." } } }, 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 ab23263435d..48f64af8bf6 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 @@ -344,7 +344,7 @@ export const esESObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "El canal debe ser email o sms: solo esos canales usan una plantilla de notificación, y una plantilla para cualquier otro canal nunca se envía. Para el contenido de la aplicación (bandeja de entrada), escribe una plantilla de correo electrónico (email_template) e indícala en el template del paso de notificación; el correo electrónico y la bandeja de entrada la muestran." + message: "Este canal nunca usa una plantilla de notificación: solo email y sms la usan, así que esta plantilla nunca se enviará. Para el contenido de la aplicación (bandeja de entrada), escribe una plantilla de correo electrónico (email_template) e indícala en el template del paso de notificación; el correo electrónico y la bandeja de entrada la muestran." } } }, 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 b4386695d4a..f6a4b98ee8d 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 @@ -344,7 +344,7 @@ export const jaJPObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "チャネルは email または sms である必要があります。通知テンプレートを使用するのはこの 2 つのチャネルだけで、ほかのチャネル向けのテンプレートは送信されません。アプリ内(受信トレイ)の内容は、メールテンプレート(email_template)を作成して通知ステップの template で指定してください。メールと受信トレイの両方がそれを表示します。" + message: "このチャネルは通知テンプレートを使用しません。使用するのは email と sms だけなので、このテンプレートは送信されません。アプリ内(受信トレイ)の内容は、メールテンプレート(email_template)を作成して通知ステップの template で指定してください。メールと受信トレイの両方がそれを表示します。" } } }, 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 f2954c34750..7c5d86345b5 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 @@ -344,7 +344,7 @@ export const zhCNObjects: NonNullable = { }, _validations: { channel_renders_template: { - message: "通道必须是 email 或 sms:只有这两个通道使用通知模板,其他通道的模板永远不会被发送。站内信内容请编写邮件模板(email_template),并在通知步骤的 template 中指定它;邮件和站内信都会渲染它。" + message: "该通道从不使用通知模板:只有 email 和 sms 使用,因此此模板永远不会被发送。站内信内容请编写邮件模板(email_template),并在通知步骤的 template 中指定它;邮件和站内信都会渲染它。" } } },