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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,6 +142,7 @@ export default defineStack({
datasets: dulyDatasets,
mappings: dulyMappings,
translations: dulyTranslations,
emailTemplates: dulyEmailTemplates,
data: dulySeeds,
hooks: dulyHooks,
functions: dulyFunctions,
Expand Down
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 43 additions & 0 deletions src/email-templates/index.ts
Original file line number Diff line number Diff line change
@@ -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];
168 changes: 168 additions & 0 deletions src/email-templates/reminders.email-template.ts
Original file line number Diff line number Diff line change
@@ -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: '<p>This is now on your list. Due {{due_date}}.</p>',
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: '<p>这项任务已进入你的待办列表,截止日期 {{due_date}}。</p>',
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: '<p>Due in 2 days, on {{due_date}}.</p>',
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: '<p>还有 2 天到期,截止日期 {{due_date}}。</p>',
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: '<p>Past due since {{due_date}}.</p>',
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: '<p>自 {{due_date}} 起已逾期。</p>',
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,
];
70 changes: 52 additions & 18 deletions src/flows/reminders.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 ──────────────────────────────────────────
Expand Down Expand Up @@ -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 ───────

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading