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
16 changes: 16 additions & 0 deletions .changeset/notify-nodes-speak-the-recipients-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'hotcrm': minor
---

**Every CRM notification now arrives in the recipient's own language.** The 19 `notify`
nodes across the sales, service and revenue flows stop sending a fixed English title and
body and reference a `sys_email_template` bundle instead — 19 templates, four locales
each (`en-US`, `zh-CN`, `ja-JP`, `es-ES`), 76 rows — supplying their record values as
render inputs. The delivery path resolves the template per recipient **after fan-out**,
off that person's own `sys_user.locale`, so one alert addressed to a Chinese-speaking rep
and a Japanese-speaking manager reaches each of them in their own language instead of
reaching both in English.

Wording is unchanged: every string is the text its node already sent, and no notification
says anything new. Administrators can now edit and re-translate this copy in Studio as
`sys_email_template` rows, rather than needing a code change for a typo.
21 changes: 21 additions & 0 deletions objectstack.composition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,10 @@ import * as marketingViews from './src/marketing/views/index.js';
import * as salesPages from './src/sales/pages/index.js';
import * as servicePages from './src/service/pages/index.js';

import * as salesEmailTemplates from './src/sales/email-templates/index.js';
import * as serviceEmailTemplates from './src/service/email-templates/index.js';
import * as revenueEmailTemplates from './src/revenue/email-templates/index.js';

import { SystemAdminProfile } from './src/sales/profiles/index.js';
import { TenantAdminProfile } from './src/sales/profiles/tenant-admin.profile.js';

Expand Down Expand Up @@ -131,6 +135,8 @@ import {
} from './src/service/sharing/index.js';
import { CampaignLeadershipSharingRules } from './src/marketing/sharing/index.js';

import type { EmailTemplateDefinition } from '@objectstack/spec/system';

// Seed rows — the replay order lives in `CrmSeedData` below.
import type { HotCrmComposition } from './src/sales/data/index.js';
import {
Expand Down Expand Up @@ -283,6 +289,21 @@ export const allApps = byExportName({ ...apps });
/** The locale packs. */
export const allTranslations = byExportName({ ...translations });

/**
* Every `sys_email_template` row the app registers — the localizable content
* path for `notify` (#9205).
*
* A package barrel exports ONE symbol carrying that package's rows, so
* `byExportName()` orders the three by export name and `.flat()` produces the
* single array `defineStack({ emailTemplates })` takes. Reaching the package
* barrel IS reaching the registration here: no hand-kept ordered list, because
* nothing about the order is load-bearing — a row is resolved by
* `(name, locale)` at delivery time, never by position.
*/
export const allEmailTemplates = byExportName<EmailTemplateDefinition[]>({
...salesEmailTemplates, ...serviceEmailTemplates, ...revenueEmailTemplates,
}).flat();

/**
* The permission sets, as the NAMESPACE rather than an array: the SaaS
* composition filters them by identity in `objectstack.config.ts`, which needs
Expand Down
9 changes: 8 additions & 1 deletion objectstack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { defineStack } from '@objectstack/spec';
// it against a `.strict` stack schema and fails on any key but the default).
import {
allObjects, allActions, allDashboards, allDatasets, allReports,
allMappings, allApps, allViews, allPages, allTranslations, allProfiles,
allMappings, allApps, allViews, allPages, allTranslations, allEmailTemplates, allProfiles,
allHooks, allFlows, allSkills,
CrmSharingRules, CrmPositions,
seedDataFor,
Expand Down Expand Up @@ -197,6 +197,13 @@ export default defineStack({

translations: allTranslations,

// The localizable content path for `notify` (#9205). A node names a template
// and supplies its `templateData`; the delivery path resolves
// `(name, locale)` against these rows PER RECIPIENT, after fan-out — the
// recipient's own `sys_user.locale` when set, else `i18n.defaultLocale`
// above — so two people on one notification can read it in two languages.
emailTemplates: allEmailTemplates,

sharingRules: CrmSharingRules,
// ADR-0090 D3: positions are flat capability-distribution groups — the v1
// role hierarchy's parent links are gone (hierarchy belongs to the
Expand Down
22 changes: 22 additions & 0 deletions src/revenue/email-templates/crm.email-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplateDefinition } from '@objectstack/spec/system';
import { bundle } from '../../sales/email-templates/_bundle';
import { en } from './en';
import { zhCN } from './zh-CN';
import { jaJP } from './ja-JP';
import { esES } from './es-ES';

/**
* Revenue — every `sys_email_template` row this package's `notify` nodes name.
*
* Four locale packs crossed with three template names. `bundle()` is imported
* along this module's edge into sales, the one deterministic home for a source
* more than one package needs.
*/
export const RevenueEmailTemplates: EmailTemplateDefinition[] = bundle({
'en-US': en,
'zh-CN': zhCN,
'ja-JP': jaJP,
'es-ES': esES,
});
29 changes: 29 additions & 0 deletions src/revenue/email-templates/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplatePack } from '../../sales/email-templates/_bundle';

/**
* English (en-US) — the revenue package's notification templates.
*
* Every string is the text its `notify` node already sent, with the flow's
* `{var.path}` interpolations re-expressed as the `{{key}}` holes the renderer
* fills from that node's `templateData`. ⚠️ Tagged `en-US`, not `en` — see the
* resolution-ladder note on `bundle()`.
*/
export const en: EmailTemplatePack = {
'crm.contract_expired': {
label: 'Contract Expired',
subject: 'Contract expired: {{contract_number}}',
bodyHtml: '<p>Contract {{contract_number}} reached its end date and has been marked expired.</p>',
},
'crm.contract_renewal': {
label: 'Contract Renewal Due',
subject: 'Contract renewal due: {{contract_number}}',
bodyHtml: '<p>Contract {{contract_number}} ends on {{end_date}}. Start the renewal conversation now.</p>',
},
'crm.quote_created': {
label: 'Quote Created',
subject: 'Quote created: {{quote_name}}',
bodyHtml: '<p>Your quote {{quote_name}} has been created from this opportunity.</p>',
},
};
22 changes: 22 additions & 0 deletions src/revenue/email-templates/es-ES.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplatePack } from '../../sales/email-templates/_bundle';

/** Español (es-ES) — the revenue package's notification templates. See `en.ts`. */
export const esES: EmailTemplatePack = {
'crm.contract_expired': {
label: 'Contrato vencido',
subject: 'Contrato vencido: {{contract_number}}',
bodyHtml: '<p>El contrato {{contract_number}} alcanzó su fecha de fin y se ha marcado como vencido.</p>',
},
'crm.contract_renewal': {
label: 'Renovación de contrato pendiente',
subject: 'Renovación de contrato pendiente: {{contract_number}}',
bodyHtml: '<p>El contrato {{contract_number}} finaliza el {{end_date}}. Inicia ya la conversación de renovación.</p>',
},
'crm.quote_created': {
label: 'Presupuesto creado',
subject: 'Presupuesto creado: {{quote_name}}',
bodyHtml: '<p>Tu presupuesto {{quote_name}} se ha creado a partir de esta oportunidad.</p>',
},
};
4 changes: 4 additions & 0 deletions src/revenue/email-templates/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/** Revenue email-template barrel. */
export { RevenueEmailTemplates } from './crm.email-template';
22 changes: 22 additions & 0 deletions src/revenue/email-templates/ja-JP.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplatePack } from '../../sales/email-templates/_bundle';

/** 日本語 (ja-JP) — the revenue package's notification templates. See `en.ts`. */
export const jaJP: EmailTemplatePack = {
'crm.contract_expired': {
label: '契約の満了',
subject: '契約が満了しました: {{contract_number}}',
bodyHtml: '<p>契約 {{contract_number}} は終了日に達し、満了として記録されました。</p>',
},
'crm.contract_renewal': {
label: '契約更新の時期',
subject: '契約の更新時期です: {{contract_number}}',
bodyHtml: '<p>契約 {{contract_number}} は {{end_date}} に終了します。更新の相談を始めてください。</p>',
},
'crm.quote_created': {
label: '見積の作成',
subject: '見積を作成しました: {{quote_name}}',
bodyHtml: '<p>この商談から見積 {{quote_name}} が作成されました。</p>',
},
};
22 changes: 22 additions & 0 deletions src/revenue/email-templates/zh-CN.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplatePack } from '../../sales/email-templates/_bundle';

/** 简体中文 (zh-CN) — the revenue package's notification templates. See `en.ts`. */
export const zhCN: EmailTemplatePack = {
'crm.contract_expired': {
label: '合同已到期',
subject: '合同已到期:{{contract_number}}',
bodyHtml: '<p>合同 {{contract_number}} 已到达结束日期,并已标记为到期。</p>',
},
'crm.contract_renewal': {
label: '合同续约提醒',
subject: '合同待续约:{{contract_number}}',
bodyHtml: '<p>合同 {{contract_number}} 将于 {{end_date}} 结束。请立即启动续约沟通。</p>',
},
'crm.quote_created': {
label: '报价单已创建',
subject: '报价单已创建:{{quote_name}}',
bodyHtml: '<p>您的报价单 {{quote_name}} 已从该商机创建。</p>',
},
};
4 changes: 2 additions & 2 deletions src/revenue/flows/contract-expiration.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ export const ContractExpirationFlow: Flow = {
recipients: ['{currentContract.owner_id}'],
channels: ['inbox', 'email'],
topic: 'contract_expired',
title: 'Contract expired: {currentContract.contract_number}',
message: 'Contract {currentContract.contract_number} reached its end date and has been marked expired.',
template: 'crm.contract_expired',
templateData: { contract_number: '{currentContract.contract_number}' },
actionUrl: '/crm_contract/{currentContract.id}',
},
},
Expand Down
6 changes: 4 additions & 2 deletions src/revenue/flows/contract-renewal.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@ export const ContractRenewalFlow: Flow = {
recipients: ['{currentContract.owner_id}'],
channels: ['inbox', 'email'],
topic: 'contract_renewal',
title: 'Contract renewal due: {currentContract.contract_number}',
message: 'Contract {currentContract.contract_number} ends on {currentContract.end_date}. Start the renewal conversation now.',
template: 'crm.contract_renewal',
templateData: {
contract_number: '{currentContract.contract_number}', end_date: '{currentContract.end_date}',
},
actionUrl: '/crm_contract/{currentContract.id}',
},
},
Expand Down
4 changes: 2 additions & 2 deletions src/revenue/flows/quote-generation.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ export const QuoteGenerationFlow: Flow = {
recipients: ['{$User.Id}'],
channels: ['inbox', 'email'],
topic: 'quote_created',
title: 'Quote created: {quoteName}',
message: 'Your quote {quoteName} has been created from this opportunity.',
template: 'crm.quote_created',
templateData: { quote_name: '{quoteName}' },
actionUrl: '/crm_quote/{quoteId.id}',
},
},
Expand Down
61 changes: 61 additions & 0 deletions src/sales/email-templates/_bundle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplateDefinition } from '@objectstack/spec/system';

/**
* The one spelling of "a locale pack becomes `sys_email_template` rows",
* shared by every package that owns notification templates.
*
* `EmailTemplateDefinition` carries ONE `locale` per row, so an n-language
* bundle is n rows of the same `name` — that is the spec's own key, not a
* convention this repo picked. Writing those rows out by hand would mean
* repeating `name`, `category` and the row scaffolding four times per
* template; here each locale pack states only what actually differs between
* languages (the label and the two bodies) and this helper produces the rows.
*
* It lives in `src/sales/` because `src/service/` and `src/revenue/` both need
* it: a source more than one package needs has one deterministic home, the
* lowest package all its consumers depend on (AGENTS.md import rule 2), and
* modules import it along their edge into sales — the same placement as
* `flows/_guarded-iteration.ts` and `objects/_hook-api.ts`.
*/

/**
* What one template looks like in ONE language — every field of
* `EmailTemplateDefinition` whose value is genuinely per-locale.
*
* `label` is in here because it is the Studio display name an administrator
* reads, so it localizes with the content; `name`, `category` and `active` are
* not, and {@link bundle} supplies them once per row.
*/
export interface LocalizedEmailTemplate {
/** Studio display label for this template, in this language. */
label: string;
/** Subject line; `{{var}}` holes are filled from the notify node's `templateData`. */
subject: string;
/** HTML body; same `{{var}}` holes. `bodyText` is derived by the service when omitted. */
bodyHtml: string;
}

/** One language's face of every template a package owns, keyed by template name. */
export type EmailTemplatePack = Record<string, LocalizedEmailTemplate>;

/**
* Cross a package's locale packs into the flat `(name, locale)` rows
* `defineStack({ emailTemplates })` takes.
*
* ⚠️ The ENGLISH pack is registered under `en-US`, not `en`, and that is the
* resolution ladder's doing rather than a style choice.
* `EmailService.resolveAndRenderTemplate` matches `(name, locale)` EXACTLY,
* then retries exactly once against its own `DEFAULT_TEMPLATE_LOCALE` — the
* literal `'en-US'`. A recipient whose `sys_user.locale` is this app's default
* `'en'` therefore misses on the first probe and lands on the second; a
* recipient carrying `'en-US'` (or any locale this app ships no pack for)
* lands on it directly. Tagging the rows `'en'` instead would invert that:
* `'en-US'` is a truthy preferred locale, so the third probe is skipped and
* the send fails with `TEMPLATE_NOT_FOUND`. `'en-US'` is the tag that cannot
* strand a recipient.
*/
export const bundle = (packs: Record<string, EmailTemplatePack>): EmailTemplateDefinition[] =>
Object.entries(packs).flatMap(([locale, pack]) =>
Object.entries(pack).map(([name, row]) => ({ name, locale, category: 'workflow' as const, ...row })));
23 changes: 23 additions & 0 deletions src/sales/email-templates/crm.email-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { EmailTemplateDefinition } from '@objectstack/spec/system';
import { bundle } from './_bundle';
import { en } from './en';
import { zhCN } from './zh-CN';
import { jaJP } from './ja-JP';
import { esES } from './es-ES';

/**
* Sales — every `sys_email_template` row this package's `notify` nodes name.
*
* Four locale packs crossed with fourteen template names: the rows a
* recipient's own `sys_user.locale` is resolved against, per recipient, after
* fan-out. Assembled here the way `translations/crm.translation.ts` assembles
* the UI locale packs — one file per language, one file that collects them.
*/
export const SalesEmailTemplates: EmailTemplateDefinition[] = bundle({
'en-US': en,
'zh-CN': zhCN,
'ja-JP': jaJP,
'es-ES': esES,
});
Loading
Loading