diff --git a/objectstack.config.ts b/objectstack.config.ts index 88cf3f8..f73ef1d 100644 --- a/objectstack.config.ts +++ b/objectstack.config.ts @@ -22,6 +22,7 @@ import { dulyDashboards } from './src/dashboards/index.js'; import { dulyDatasets } from './src/datasets/index.js'; import { dulyMappings } from './src/mappings/index.js'; import { dulyTranslations } from './src/translations/index.js'; +import { dulyEmailTemplates } from './src/email-templates/index.js'; import { dulySeeds } from './src/data/index.js'; // [#7036] Lifecycle hooks are NOT collected from the objects barrel — the // runtime reads them from `defineStack({ hooks })` only. A `*.hook.ts` that is @@ -141,6 +142,7 @@ export default defineStack({ datasets: dulyDatasets, mappings: dulyMappings, translations: dulyTranslations, + emailTemplates: dulyEmailTemplates, data: dulySeeds, hooks: dulyHooks, functions: dulyFunctions, diff --git a/package.json b/package.json index 1a2b123..acb939a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ }, "devDependencies": { "@objectstack/cli": "^17.2.0", + "@objectstack/plugin-email": "^17.2.0", + "@objectstack/service-automation": "^17.2.0", + "@objectstack/service-job": "^17.2.0", + "@objectstack/service-messaging": "^17.2.0", + "@objectstack/trigger-schedule": "^17.2.0", "typescript": "^6.0.0", "vitest": "^4.1.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c36355b..11aa839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,21 @@ importers: '@objectstack/cli': specifier: ^17.2.0 version: 17.2.0(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(kysely@0.29.5)(mongodb@7.6.0)(nanostores@1.5.2)(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/plugin-email': + specifier: ^17.2.0 + version: 17.2.0(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/service-automation': + specifier: ^17.2.0 + version: 17.2.0 + '@objectstack/service-job': + specifier: ^17.2.0 + version: 17.2.0(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/service-messaging': + specifier: ^17.2.0 + version: 17.2.0(vitest@4.1.11(vite@8.2.2(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))) + '@objectstack/trigger-schedule': + specifier: ^17.2.0 + version: 17.2.0 typescript: specifier: ^6.0.0 version: 6.0.3 diff --git a/src/email-templates/index.ts b/src/email-templates/index.ts new file mode 100644 index 0000000..77c758a --- /dev/null +++ b/src/email-templates/index.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Barrel for src/email-templates/. +// +// Every metadata directory is pre-created and already wired into +// objectstack.config.ts — including the empty ones — so a feature branch adds +// its entry HERE and never edits the config. The config is the one file every +// parallel task would otherwise collide on. +// +// The collection is a named array rather than `Object.values(barrel)`: on an +// empty namespace `Object.values` has nothing to infer from and TypeScript +// resolves it against the keyed branch of `MetadataCollectionInput`, which +// makes `name` optional and fails the assignment. A named array is `never[]` +// while empty and infers correctly the moment something is pushed into it. +// +// ── What a member of this collection is ───────────────────────────────── +// ONE `(name, locale)` ROW, not one template. A template "bundle" is the set +// of rows sharing a `name`: `IEmailService` resolves `(name, locale)` at +// delivery and renders the row it picks, so `duly.task_lead_time` appears +// here twice — once as `en`, once as `zh-CN`. Adding a locale is adding an +// entry to this array, never editing an existing one. + +import { + TaskDueSoonReminderEn, + TaskDueSoonReminderZhCN, + TaskLeadTimeReminderEn, + TaskLeadTimeReminderZhCN, + TaskOverdueEscalationEn, + TaskOverdueEscalationZhCN, + dulyReminderEmailTemplates, +} from './reminders.email-template.js'; + +export { + TaskLeadTimeReminderEn, + TaskLeadTimeReminderZhCN, + TaskDueSoonReminderEn, + TaskDueSoonReminderZhCN, + TaskOverdueEscalationEn, + TaskOverdueEscalationZhCN, + dulyReminderEmailTemplates, +}; + +export const dulyEmailTemplates = [...dulyReminderEmailTemplates]; diff --git a/src/email-templates/reminders.email-template.ts b/src/email-templates/reminders.email-template.ts new file mode 100644 index 0000000..50bf480 --- /dev/null +++ b/src/email-templates/reminders.email-template.ts @@ -0,0 +1,168 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { defineEmailTemplateDefinition } from '@objectstack/spec'; + +/** + * The notification text of the three owner-facing reminder sweeps + * (`src/flows/reminders.flow.ts`), as `sys_email_template` bundles. + * + * ── Why the text lives here and not on the notify node ─────────────────── + * A `notify` node has exactly two content paths and `NotifyConfigSchema`'s + * `superRefine` makes them mutually exclusive: + * + * - inline `title` + `message` — sent to every recipient verbatim. The + * schema's own `.describe()` calls this "not localizable". + * - `template` — names a bundle here; the delivery path resolves + * `(name, locale)` per delivery and renders subject/body from the row it + * picks. + * + * AGENTS.md §8 ("English is the source language … do not hard-code display + * text in a hook or flow") is only satisfiable on the second path, so the + * three sweeps' strings live here. Each name carries TWO rows — `en` and + * `zh-CN` — which is what a template "bundle" is: same `name`, one row per + * locale, exactly the two locales `objectstack.config.ts` declares in + * `i18n.supportedLocales`. + * + * ── Why `{{{…}}}` in `subject` / `bodyText` and `{{…}}` in `bodyHtml` ──── + * `renderTemplate` HTML-escapes a `{{hole}}` and leaves a `{{{hole}}}` raw + * (`template-engine.ts`). Measured on `@objectstack/plugin-email` 17.2.0 with + * `subject: "Owner's monthly return"`: + * + * subject: '{{subject}}' → Owner's monthly return + * subject: '{{{subject}}}' → Owner's monthly return + * + * The inbox channel writes the rendered SUBJECT into `sys_inbox_message.title` + * and the rendered TEXT into `body_md` — neither is an HTML document — so the + * escaping form would put entities on the screen for any duty whose subject + * contains an apostrophe or an `&`, which the inline `title: '{record.subject}'` + * it replaces never did. `bodyHtml` IS markup and keeps the escaping form. + * `test/email-templates.test.ts` pins both halves. + * + * ── `bodyText` is authored, not derived ────────────────────────────────── + * With `bodyText` omitted the service derives the text alternative by + * stripping tags from `bodyHtml`. The inbox body is the DERIVED string, so + * leaving it out would make the in-app notification a side effect of the HTML + * markup. It is authored so the body the owner reads is the string written + * here — the same sentence the inline `message` sent before this card. + * + * ── `variables` ───────────────────────────────────────────────────────── + * `required: true` is enforced at render (`requireVars` → `MISSING_VARIABLES`, + * which the inbox channel classifies as PERMANENT — a dead delivery, not a + * retry). So it is declared only where the object itself already guarantees + * the value: `duly_task.subject` is `required: true`. `due_date` is NOT + * required on the object, and a reminder that says "Due ." is worth more to + * its owner than a reminder that was never delivered — which is exactly what + * a required-but-absent hole would produce. + */ + +/** Shared by all three: the task's own subject line, rendered unescaped. */ +const SUBJECT_LINE = '{{{subject}}}'; + +/** Declared render inputs. Kept in one place so the six rows cannot drift. */ +const REMINDER_VARIABLES = [ + { + name: 'subject', + type: 'string' as const, + required: true, + description: "The task's subject — duly_task.subject, required on the object.", + }, + { + name: 'due_date', + type: 'date' as const, + required: false, + description: 'The task due date as stored (ISO-8601). Optional on duly_task.', + }, +]; + +// ─── 1 · Lead-time reminder ────────────────────────────────────────────── + +export const TaskLeadTimeReminderEn = defineEmailTemplateDefinition({ + name: 'duly.task_lead_time', + label: 'Task lead-time reminder', + category: 'notification', + locale: 'en', + subject: SUBJECT_LINE, + bodyHtml: '

This is now on your list. Due {{due_date}}.

', + bodyText: 'This is now on your list. Due {{{due_date}}}.', + variables: REMINDER_VARIABLES, + description: "Sent once, on the day a task crosses visible_from, to the task's owner.", +}); + +export const TaskLeadTimeReminderZhCN = defineEmailTemplateDefinition({ + name: 'duly.task_lead_time', + label: '任务提前提醒', + category: 'notification', + locale: 'zh-CN', + subject: SUBJECT_LINE, + bodyHtml: '

这项任务已进入你的待办列表,截止日期 {{due_date}}。

', + bodyText: '这项任务已进入你的待办列表,截止日期 {{{due_date}}}。', + variables: REMINDER_VARIABLES, + description: '任务到达 visible_from 当天,向负责人发送一次。', +}); + +// ─── 2 · Due-soon reminder ─────────────────────────────────────────────── + +export const TaskDueSoonReminderEn = defineEmailTemplateDefinition({ + name: 'duly.task_due_soon', + label: 'Task due-soon reminder', + category: 'notification', + locale: 'en', + subject: SUBJECT_LINE, + bodyHtml: '

Due in 2 days, on {{due_date}}.

', + bodyText: 'Due in 2 days, on {{{due_date}}}.', + variables: REMINDER_VARIABLES, + description: "Sent once, two days before a task is due, to the task's owner.", +}); + +export const TaskDueSoonReminderZhCN = defineEmailTemplateDefinition({ + name: 'duly.task_due_soon', + label: '任务即将到期提醒', + category: 'notification', + locale: 'zh-CN', + subject: SUBJECT_LINE, + bodyHtml: '

还有 2 天到期,截止日期 {{due_date}}。

', + bodyText: '还有 2 天到期,截止日期 {{{due_date}}}。', + variables: REMINDER_VARIABLES, + description: '任务到期前两天,向负责人发送一次。', +}); + +// ─── 3 · Overdue escalation, stage one ─────────────────────────────────── + +export const TaskOverdueEscalationEn = defineEmailTemplateDefinition({ + name: 'duly.task_overdue', + label: 'Task overdue escalation — owner', + category: 'notification', + locale: 'en', + subject: SUBJECT_LINE, + bodyHtml: '

Past due since {{due_date}}.

', + bodyText: 'Past due since {{{due_date}}}.', + variables: REMINDER_VARIABLES, + description: + "Sent once, on the first day a task is past due_date plus the duty's grace, to the task's owner.", +}); + +export const TaskOverdueEscalationZhCN = defineEmailTemplateDefinition({ + name: 'duly.task_overdue', + label: '任务逾期升级提醒 — 负责人', + category: 'notification', + locale: 'zh-CN', + subject: SUBJECT_LINE, + bodyHtml: '

自 {{due_date}} 起已逾期。

', + bodyText: '自 {{{due_date}}} 起已逾期。', + variables: REMINDER_VARIABLES, + description: '任务超过截止日期加宽限期的第一天,向负责人发送一次。', +}); + +/** + * Everything in this file, in the same order as `dulyReminderFlows`, so the + * barrel spreads one name and the pairing with `reminders.flow.ts` stays + * readable: one flow, one template name, two locale rows. + */ +export const dulyReminderEmailTemplates = [ + TaskLeadTimeReminderEn, + TaskLeadTimeReminderZhCN, + TaskDueSoonReminderEn, + TaskDueSoonReminderZhCN, + TaskOverdueEscalationEn, + TaskOverdueEscalationZhCN, +]; diff --git a/src/flows/reminders.flow.ts b/src/flows/reminders.flow.ts index 0d31559..977942c 100644 --- a/src/flows/reminders.flow.ts +++ b/src/flows/reminders.flow.ts @@ -115,8 +115,8 @@ import { defineFlow } from '@objectstack/spec'; * above compose safely. Predicates written out in full may keep using P`…`; * the rule is only that a `${…}` hole in P is a value, never CEL. * - * ── Two platform gaps this file does NOT work around ───────────────────── - * **1. Digest by recipient is not authorable.** The manager-facing flows are + * ── The one platform gap this file does NOT work around ────────────────── + * **Digest by recipient is not authorable.** The manager-facing flows are * absent because the node vocabulary cannot express "one message per manager * listing their N tasks": * - there is no aggregate / group-by node, so records can only be bucketed @@ -133,16 +133,27 @@ import { defineFlow } from '@objectstack/spec'; * satisfied "one message, not thirty" while quietly dropping "listing 30", and * that is the kind of workaround that makes a platform gap permanent. * - * **2. Notification text cannot be localized in this app.** The localizable - * `notify` path is `template`, naming a `sys_email_template` bundle; - * `defineStack` accepts an `emailTemplates` collection but this app wires no - * barrel for one, and `objectstack.config.ts` is not editable from a feature - * branch. So the inline `title`/`message` below are the only content path - * available, and they are the explicitly NON-localizable one — a declared - * deviation from the house rule "do not hard-code display text in a flow" - * (AGENTS.md §8), not an oversight. English is the source language, so the - * strings are at least the right source text; they are simply untranslatable - * until the app has an email-template barrel. + * (A second gap stood here — "notification text cannot be localized in this + * app" — declaring the inline `title`/`message` on the three notify nodes as + * a deviation from AGENTS.md §8. Both are gone: the app wires + * `src/email-templates/`, the three sweeps name a `template`, and no display + * text is authored in this file at all.) + * + * ── Where the notification text lives ──────────────────────────────────── + * In `src/email-templates/reminders.email-template.ts`, one bundle per sweep, + * two `(name, locale)` rows each (`en`, `zh-CN`). The node carries only the + * bundle NAME plus `templateData`, and the two content paths are mutually + * exclusive by schema — `template` alongside `title`/`message` is a parse + * error, not a precedence rule — so re-adding a string here is refused at the + * gate rather than silently winning over the bundle. + * + * `template` is read RAW (no `{token}` interpolation): it is a static metadata + * cross-reference. `templateData` VALUES are interpolated per run, which is + * how `{record.subject}` still reaches the rendered text. A name that matches + * no row is the one silent failure this swap introduces — the inbox channel + * classifies `TEMPLATE_NOT_FOUND` as PERMANENT, so the delivery dead-letters + * and the run still reports success. `test/email-templates.test.ts` pins every + * `template` on every notify node against the barrel for exactly that reason. */ // ─── Shared authoring constants ────────────────────────────────────────── @@ -253,6 +264,26 @@ const DUTY_GRACE = /** Days elapsed since the due date. Positive once the task is late. */ const DAYS_PAST_DUE = 'daysBetween(record.due_date, today())'; +/** + * Render context handed to every reminder bundle — the `{{var}}` holes the + * three templates declare. + * + * One constant for all three because the three bundles declare the SAME two + * variables; a per-flow copy would let one drift into passing a hole its + * template does not read (rendered as nothing, silently) or omitting one it + * does. The keys are the template's variable names; the values are flow + * templates resolved per run against the swept record. + * + * `due_date` is deliberately passed raw rather than pre-formatted: the + * rendering side owns presentation per locale, and a value formatted here + * would be formatted once, in one language, for every recipient — which is + * the property this whole card exists to remove. + */ +const TASK_TEMPLATE_DATA = { + subject: '{record.subject}', + due_date: '{record.due_date}', +} as const; + // ─── 1 · Lead-time reminder — the task appears on the owner's list ─────── /** @@ -323,8 +354,11 @@ export const LeadTimeReminder = defineFlow({ label: 'Tell the owner', config: { recipients: '{record.owner}', - title: '{record.subject}', - message: 'This is now on your list. Due {record.due_date}.', + // The localizable content path. `template` names the bundle in + // src/email-templates/; `templateData` is its render context and IS + // interpolated per run, so the record still supplies the values. + template: 'duly.task_lead_time', + templateData: TASK_TEMPLATE_DATA, severity: 'info', topic: 'duly.task_lead_time', // The pair only takes effect together; a half-specified target is @@ -425,8 +459,8 @@ export const DueSoonReminder = defineFlow({ label: 'Tell the owner', config: { recipients: '{record.owner}', - title: '{record.subject}', - message: 'Due in 2 days, on {record.due_date}.', + template: 'duly.task_due_soon', + templateData: TASK_TEMPLATE_DATA, severity: 'info', topic: 'duly.task_due_soon', sourceObject: 'duly_task', @@ -535,8 +569,8 @@ export const OverdueOwnerEscalation = defineFlow({ label: 'Tell the owner it is late', config: { recipients: '{record.owner}', - title: '{record.subject}', - message: 'Past due since {record.due_date}.', + template: 'duly.task_overdue', + templateData: TASK_TEMPLATE_DATA, severity: 'warning', topic: 'duly.task_overdue', sourceObject: 'duly_task', diff --git a/test/email-templates.test.ts b/test/email-templates.test.ts new file mode 100644 index 0000000..cdd71c7 --- /dev/null +++ b/test/email-templates.test.ts @@ -0,0 +1,270 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { AppPlugin, ObjectKernel, createStandaloneStack } from '@objectstack/runtime'; +import { AutomationServicePlugin } from '@objectstack/service-automation'; +import { JobServicePlugin } from '@objectstack/service-job'; +import { MessagingServicePlugin } from '@objectstack/service-messaging'; +import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { TimeRelativeTriggerPlugin } from '@objectstack/trigger-schedule'; + +import stack from '../objectstack.config.js'; +import { dulyEmailTemplates } from '../src/email-templates/index.js'; +import { dulyFlows } from '../src/flows/index.js'; + +/** + * The reminder sweeps' notification text, as `sys_email_template` bundles. + * + * Two things are pinned here and nothing else in the toolchain pins either: + * + * - **The cross-reference.** A notify node's `template` is read RAW and is a + * metadata cross-reference nothing resolves at author time. `pnpm validate` + * parses the bundles (measured: an unknown key inside one takes validate to + * exit 1 naming the key) but never checks that the NAME on a notify node + * matches one, so a typo is caught by no gate. At runtime the inbox channel + * classifies `TEMPLATE_NOT_FOUND` as PERMANENT — the delivery dead-letters + * while the flow run still reports success. That is strictly worse than the + * inline English this card replaced, and it is invisible. + * + * - **That the notification still arrives, with the right words in it.** The + * end-to-end leg below boots a real kernel with the real automation, job, + * messaging and email services, drives the REAL `TimeRelativeTrigger` + * sweep, and reads the row the inbox channel wrote. The assertion is on the + * resolved BODY, not on "a notification was emitted": a template that + * resolves to nothing emits a notification too. + * + * ── Why the plugin packages are devDependencies of this app ────────────── + * `email`, `messaging` and `job` are in `PLATFORM_ALWAYS_ON_CAPABILITIES` and + * `triggers` is declared in `objectstack.config.ts`, so `objectstack dev` + * mounts all five of these for real — but it is the CLI that owns that list, + * and `createStandaloneStack()` returns only the datasource/metadata/ObjectQL + * base. Mounting them here is what makes this suite exercise the delivery path + * the app actually runs on. They are dev-only: nothing in `src/` imports them. + */ + +type AnyRec = Record; +interface NodeLike { id: string; type: string; config?: AnyRec } +interface FlowLike { name: string; nodes: NodeLike[] } + +const notifyNodes = (): { flow: string; node: NodeLike }[] => + (dulyFlows as unknown as FlowLike[]).flatMap((f) => + f.nodes.filter((n) => n.type === 'notify').map((node) => ({ flow: f.name, node })), + ); + +/** Every `(name, locale)` row in the barrel, keyed by name. */ +const rowsByName = (): Map => { + const out = new Map(); + for (const row of dulyEmailTemplates as unknown as AnyRec[]) { + const name = String(row.name); + out.set(name, [...(out.get(name) ?? []), row]); + } + return out; +}; + +// A subject with an apostrophe, on purpose — see the escaping test. +const TASK_SUBJECT = "File the operator's emissions return"; +const OWNER = 'user_alice'; + +const utcDay = (offsetDays: number): string => { + const d = new Date(); + d.setUTCDate(d.getUTCDate() + offsetDays); + return d.toISOString().slice(0, 10); +}; + +let kernel: any; +let data: any; +let email: any; +let job: any; + +const waitFor = async (probe: () => Promise, label: string, ms = 8_000): Promise => { + const deadline = Date.now() + ms; + for (;;) { + const hit = await probe(); + if (hit !== undefined) return hit; + if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`); + await new Promise((r) => setTimeout(r, 100)); + } +}; + +beforeAll(async () => { + const { plugins } = await createStandaloneStack({ + databaseDriver: 'memory', + skipSeedData: true, + // Same reason as test/task-hook.test.ts: left to its default this resolves + // `/dist/objectstack.json`, and a local `pnpm build` would make the + // suite report on the last BUILD instead of on `src/`. + artifactPath: 'dist/objectstack.this-suite-must-not-load-an-artifact.json', + }); + kernel = new ObjectKernel(); + for (const plugin of plugins) await kernel.use(plugin); + await kernel.use(new AppPlugin(stack, undefined, { skipSeedData: true })); + await kernel.use(new JobServicePlugin()); + await kernel.use(new AutomationServicePlugin()); + await kernel.use(new MessagingServicePlugin()); + await kernel.use(new EmailServicePlugin()); + await kernel.use(new TimeRelativeTriggerPlugin()); + await kernel.bootstrap(); + data = kernel.getService('data'); + email = kernel.getService('email'); + job = kernel.getService('job'); +}, 180_000); + +afterAll(async () => { + await kernel?.shutdown?.(); +}); + +// ── Wiring: the barrel, and the reference from the flows ────────────────── + +describe('the email-template barrel', () => { + it('reaches defineStack({ emailTemplates }) — the only place the runtime reads', () => { + // Compared by `(name, locale)` and not by identity: `defineStack` stamps + // provenance (`_packageId`, `_provenance`) onto each row, so what the stack + // carries is a stamped COPY of the barrel, never the same array. + const key = (r: AnyRec) => `${String(r.name)}@${String(r.locale)}`; + const onStack = ((stack as AnyRec).emailTemplates ?? []) as AnyRec[]; + expect(onStack.map(key)).toEqual((dulyEmailTemplates as unknown as AnyRec[]).map(key)); + expect(onStack.length).toBe(6); + }); + + it('is a named ARRAY of rows that each carry a name', () => { + // The barrel convention: `Object.values(barrel)` resolves against the keyed + // branch of `MetadataCollectionInput`, which makes `name` optional. Every + // row carrying one is the property that branch would have surrendered. + expect(Array.isArray(dulyEmailTemplates)).toBe(true); + expect(dulyEmailTemplates.length).toBeGreaterThan(0); + for (const row of dulyEmailTemplates as unknown as AnyRec[]) { + expect(typeof row.name, JSON.stringify(row.label)).toBe('string'); + } + }); + + it('every notify node names a template that EXISTS in the barrel', () => { + // The silent failure this card introduces if it is got wrong: `template` is + // read raw, resolved at delivery, and a miss dead-letters permanently while + // the run reports success. + const names = rowsByName(); + const seen: string[] = []; + for (const { flow, node } of notifyNodes()) { + const name = String(node.config?.template ?? ''); + expect(name, `flow '${flow}' notify '${node.id}' names no template`).not.toBe(''); + expect( + names.has(name), + `flow '${flow}' notify '${node.id}' names '${name}', which no row in dulyEmailTemplates declares`, + ).toBe(true); + seen.push(name); + } + expect(seen.length).toBe(3); + }); + + it('every referenced bundle has an `en` row — the source language (AGENTS.md §8)', () => { + // Locale COVERAGE beyond this is #18's shape-discovering gate, not this + // suite's. What is pinned here is the source-language anchor: without an + // `en` row the app's own default locale resolves through the service's + // en-US ladder to whatever sorts first. + const names = rowsByName(); + for (const { node } of notifyNodes()) { + const rows = names.get(String(node.config?.template)) ?? []; + expect(rows.map((r) => r.locale)).toContain('en'); + } + }); + + it('no notify node authors inline display text any more', () => { + // `NotifyConfigSchema.superRefine` already refuses `template` alongside + // `title`/`message`, so this cannot regress quietly — it is pinned because + // the §8 deviation it removes is what the card is about. + for (const { flow, node } of notifyNodes()) { + expect(Object.keys(node.config ?? {}), `flow '${flow}'`).not.toContain('title'); + expect(Object.keys(node.config ?? {}), `flow '${flow}'`).not.toContain('message'); + } + }); +}); + +// ── The bundles reached the store the delivery path reads ──────────────── + +describe('the declared bundles materialize into sys_email_template', () => { + it('every authored row is a row in the table', async () => { + for (const authored of dulyEmailTemplates as unknown as AnyRec[]) { + const row = await data.findOne('sys_email_template', { + where: { name: authored.name, locale: authored.locale }, + }); + expect(row, `${String(authored.name)} @ ${String(authored.locale)}`).toBeTruthy(); + expect(row.subject).toBe(authored.subject); + } + }); +}); + +// ── End to end: the sweep still notifies, and with WHICH words ─────────── + +describe('the lead-time sweep delivers a RESOLVED body', () => { + let taskId: string; + + beforeAll(async () => { + const created = await data.insert('duly_task', { + subject: TASK_SUBJECT, + owner: OWNER, + source: 'catalog', + status: 'open', + visible_from: utcDay(0), + due_date: utcDay(7), + }); + taskId = String(Array.isArray(created) ? created[0]?.id : created?.id); + + // Fire the sweep NOW instead of at 08:00 UTC. The job service owns the + // cadence and nothing else: `trigger()` runs the same handler the cron + // would, so the sweep, the window math, the dispatch claim, the flow run + // and the notify node are all the real ones. + await job.trigger('flow-time-relative:duly_task_lead_time_reminder'); + }, 120_000); + + it('wrote an inbox row for the owner', async () => { + const row = await waitFor(async () => { + const rows = await data.find('sys_inbox_message', { + where: { user_id: OWNER, topic: 'duly.task_lead_time' }, + limit: 5, + }); + return rows?.length ? rows[0] : undefined; + }, 'the inbox row the lead-time sweep delivers'); + + // The BODY, not "a notification happened": a template resolving to nothing + // still emits a notification, and the inline path this replaced could not + // fail this way. + expect(row.title).toBe(TASK_SUBJECT); + expect(row.body_md).toBe(`This is now on your list. Due ${utcDay(7)}.`); + expect(row.severity).toBe('info'); + expect(String(row.body_md)).not.toContain('{{'); + // Budget above `waitFor`'s, so a missing delivery fails with the labelled + // message rather than vitest's generic 5s timeout. Measured green in ~1s. + }, 20_000); + + it('renders the SAME bundle in zh-CN — which is what this card bought', async () => { + const rendered = await email.renderTemplate({ + template: 'duly.task_lead_time', + locale: 'zh-CN', + data: { subject: TASK_SUBJECT, due_date: utcDay(7) }, + }); + expect(rendered.subject).toBe(TASK_SUBJECT); + expect(rendered.text).toBe(`这项任务已进入你的待办列表,截止日期 ${utcDay(7)}。`); + }); + + it('leaves the subject and the text body UNESCAPED, and escapes the html', async () => { + // `renderTemplate` HTML-escapes a `{{hole}}`. The inbox writes the rendered + // SUBJECT into `title` and the rendered TEXT into `body_md`; neither is an + // HTML document, so an escaping hole there puts `'` on the screen for + // every subject with an apostrophe — which `title: '{record.subject}'` + // never did. `bodyHtml` IS markup and keeps the escaping form. + const rendered = await email.renderTemplate({ + template: 'duly.task_lead_time', + locale: 'en', + data: { subject: TASK_SUBJECT, due_date: utcDay(7) }, + }); + expect(rendered.subject).toBe(TASK_SUBJECT); + expect(rendered.subject).not.toContain('''); + expect(rendered.text).not.toContain('''); + expect(rendered.html).toContain('This is now on your list.'); + }); + + it('a name no row declares FAILS LOUDLY rather than rendering nothing', async () => { + await expect( + email.renderTemplate({ template: 'duly.task_lead_tim', locale: 'en', data: {} }), + ).rejects.toThrow(/TEMPLATE_NOT_FOUND/); + }); +});