diff --git a/.changeset/24924-template-channel-email-sms.md b/.changeset/24924-template-channel-email-sms.md new file mode 100644 index 00000000000..aaae6749614 --- /dev/null +++ b/.changeset/24924-template-channel-email-sms.md @@ -0,0 +1,14 @@ +--- +'@objectstack/service-messaging': patch +--- + +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` 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. + +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`. + +- **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/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-channel.integration.test.ts b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts new file mode 100644 index 00000000000..b9acedb21b9 --- /dev/null +++ b/packages/services/service-messaging/src/objects/notification-template-channel.integration.test.ts @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `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). + * + * 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 runs on: + * + * - 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 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 type { ValidationAdvisoryEvent } from '@objectstack/spec/data'; +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; + +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; + +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(); +} + +/** 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); } }; +} + +/** 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(events[0].message)).toContain('email_template'); +} + +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(); + 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'])('an insert naming %j is stored, with one channel advisory', async (channel) => { + const { ql } = await boot(); + 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('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; + await ql.insert(TEMPLATE_OBJECT, row('inbox'), { context: importCtx, onValidationAdvisory: seen.onValidationAdvisory }); + expectOneChannelWarning(seen.events); + expect(await channelsStored(ql)).toEqual(['inbox']); + }); + + 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 }); + 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('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 }); + 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', () => { + /** 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, with no advisory', async () => { + const { ql, id } = await bootWithStoredInboxRow(); + 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 }); + }); + + 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('a write that names its channel again is stored, with one channel advisory', async () => { + const { ql, id } = await bootWithStoredInboxRow(); + 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: '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 5dee9050adb..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,7 +3,32 @@ 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 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: + * + * - 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 — a silent no-op until this rule made + * it loud. + */ +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 +36,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 +54,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 +74,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 +107,11 @@ 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 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.', }), locale: Field.text({ @@ -104,7 +140,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 +172,47 @@ export const NotificationTemplate = ObjectSchema.create({ { fields: ['topic', 'channel', 'locale'] }, { fields: ['topic'] }, ], + + // [#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. + // + // `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. + // + // 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', + 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: '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 761887ec521..692178fea29 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: "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." + } } }, 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..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 @@ -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: "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." + } } }, 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..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 @@ -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 だけなので、このテンプレートは送信されません。アプリ内(受信トレイ)の内容は、メールテンプレート(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..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 @@ -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: {