Skip to content

Commit 11d02ef

Browse files
committed
feat(#13881): sys_user.locale + per-recipient notification locale — translations, docs regen, retry-path fix
1 parent 0504ec4 commit 11d02ef

13 files changed

Lines changed: 42 additions & 8 deletions

‎content/docs/references/automation/io-node-config.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ const result = HttpConfigSchema.parse(data);
9696
| **recipients** | `string \| string[]` | ✅ | Recipient user id(s) / audience selector(s); `{token}` templates resolve per run |
9797
| **title** | `string` | optional | Notification title, sent to every recipient verbatim (not localizable — use `template` for per-locale content). Either this or `template` is required; the two are mutually exclusive. |
9898
| **message** | `string` | optional | Notification body, sent verbatim like `title` (not localizable). Only valid with inline `title`, never with `template`. |
99-
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is ONE value for the whole notification, not one per recipient: `payload.locale` if the producer set one, else the deployment default (`II18nService.getDefaultLocale()`) — the platform has no per-user locale, so recipients with different personal languages all receive the same row (deferred by the 2026-08-13 ruling; it layers in as an override when it lands). Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
99+
| **template** | `string` | optional | Email template name (`sys_email_template.name`, e.g. `crm.large_deal_won`) — the localizable content path: the delivery path resolves `(name, locale)` against sys_email_template at delivery time and renders subject/body from that row. The locale is resolved per recipient, after fan-out: the recipient's own `sys_user.locale` when set, else the deployment default (`II18nService.getDefaultLocale()`) — so recipients whose personal languages differ receive different rows of the same bundle (maintainer ruling 2026-09-01). A producer-set `payload.locale` is not consulted. Mutually exclusive with inline `title`/`message`, which are the non-localizable path. Read raw — no `{token}` interpolation. |
100100
| **templateData** | `Record<string, any>` | optional | Render context for the referenced template's `{{var}}` placeholders; values interpolate `{token}` templates per run. Only valid together with `template`. |
101101
| **channels** | `string \| string[]` | optional | Channels to fan out to (default: inbox) |
102102
| **topic** | `string` | optional | Event topic (default: "notify") |

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
9191
image: {
9292
label: "Profile Image"
9393
},
94+
locale: {
95+
label: "Locale",
96+
help: "Preferred language for notifications, as a BCP-47 tag (e.g. zh-CN, ja-JP). Read per recipient at delivery time; when unset the deployment default applies. Owned by objectql (better-auth is oblivious to this column)."
97+
},
9498
manager_id: {
9599
label: "Manager",
96100
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
9191
image: {
9292
label: "Imagen de perfil"
9393
},
94+
locale: {
95+
label: "Idioma",
96+
help: "Idioma preferido para las notificaciones, como etiqueta BCP-47 (p. ej. zh-CN, ja-JP). Se lee por destinatario en el momento de la entrega; si no está definido, se aplica el idioma predeterminado del despliegue. Propiedad de objectql (better-auth ignora esta columna)."
97+
},
9498
manager_id: {
9599
label: "Gerente",
96100
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."

‎packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,8 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
589589
"objects.sys_two_factor.fields.locked_until.label": "c7f013ad9b7208d2",
590590
"objects.sys_two_factor.fields.verified.help": "0d351ba4c31608c2",
591591
"objects.sys_two_factor.fields.verified.label": "866e3a245ef04734",
592+
"objects.sys_user.fields.locale.help": "51a0ce37d494be54",
593+
"objects.sys_user.fields.locale.label": "92b39430e88d3b51",
592594
"objects.sys_user.fields.manager_id.help": "a53dec9ccd9d131f",
593595
"objects.sys_user.fields.primary_business_unit_id.help": "e01690f4a9b83956",
594596
"objects.sys_view_definition.fields.id.label": "00b0385c9c152888",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
9191
image: {
9292
label: "プロフィール画像"
9393
},
94+
locale: {
95+
label: "言語",
96+
help: "通知に使用する優先言語(BCP-47 タグ、例: zh-CN、ja-JP)。配信時に受信者ごとに読み取られ、未設定の場合はデプロイのデフォルト言語が適用されます。objectql が所有します(better-auth はこの列を認識しません)。"
97+
},
9498
manager_id: {
9599
label: "マネージャー",
96100
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."

‎packages/platform-objects/src/apps/translations/ja-JP.source-hashes.generated.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,8 @@ export const jaJPGeneratedSourceHashes: Readonly<Record<string, string>> = {
580580
"objects.sys_two_factor.fields.locked_until.label": "c7f013ad9b7208d2",
581581
"objects.sys_two_factor.fields.verified.help": "0d351ba4c31608c2",
582582
"objects.sys_two_factor.fields.verified.label": "866e3a245ef04734",
583+
"objects.sys_user.fields.locale.help": "51a0ce37d494be54",
584+
"objects.sys_user.fields.locale.label": "92b39430e88d3b51",
583585
"objects.sys_user.fields.manager_id.help": "a53dec9ccd9d131f",
584586
"objects.sys_user.fields.primary_business_unit_id.help": "e01690f4a9b83956",
585587
"objects.sys_view_definition.fields.id.label": "00b0385c9c152888",

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,10 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
9191
image: {
9292
label: "头像"
9393
},
94+
locale: {
95+
label: "语言",
96+
help: "通知使用的首选语言,BCP-47 标签(如 zh-CN、ja-JP)。投递时按收件人读取;未设置时采用部署默认语言。归 objectql 所有(better-auth 不感知此列)。"
97+
},
9498
manager_id: {
9599
label: "经理",
96100
help: "This user's direct manager. Forms the reporting chain the `own_and_reports` hierarchy scope walks (ADR-0057 / @objectstack/security-enterprise)."

‎packages/platform-objects/src/apps/translations/zh-CN.source-hashes.generated.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,8 @@ export const zhCNGeneratedSourceHashes: Readonly<Record<string, string>> = {
527527
"objects.sys_two_factor.fields.locked_until.label": "c7f013ad9b7208d2",
528528
"objects.sys_two_factor.fields.verified.help": "0d351ba4c31608c2",
529529
"objects.sys_two_factor.fields.verified.label": "866e3a245ef04734",
530+
"objects.sys_user.fields.locale.help": "51a0ce37d494be54",
531+
"objects.sys_user.fields.locale.label": "92b39430e88d3b51",
530532
"objects.sys_user.fields.manager_id.help": "a53dec9ccd9d131f",
531533
"objects.sys_user.fields.primary_business_unit_id.help": "e01690f4a9b83956",
532534
"objects.sys_view_definition.fields.id.label": "00b0385c9c152888",

‎packages/services/service-messaging/src/email-channel.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -396,14 +396,14 @@ describe('email channel', () => {
396396
const data = fakeData({ users: {} });
397397
await build(data, email, 'ja-JP').send(silentCtx(), templated('bob@example.com'));
398398
expect(email.templated[0]).toMatchObject({ to: 'bob@example.com', locale: 'ja-JP' });
399-
expect(data.findOnes.filter((q) => q.object === 'sys_user')).toHaveLength(0);
399+
expect(data.findOnes.filter((q: { object: string }) => q.object === 'sys_user')).toHaveLength(0);
400400
});
401401

402402
it('reads the locale off the SAME sys_user row as the address — one query per recipient, no second read point', async () => {
403403
const email = templateEmail();
404404
const data = fakeData({ users: { user_1: { email: 'ada@example.com', locale: 'zh-CN' } } });
405405
await build(data, email, 'ja-JP').send(silentCtx(), templated());
406-
const userReads = data.findOnes.filter((q) => q.object === 'sys_user');
406+
const userReads = data.findOnes.filter((q: { object: string }) => q.object === 'sys_user');
407407
expect(userReads).toHaveLength(1);
408408
expect(userReads[0].query).toEqual({ where: { id: 'user_1' }, fields: ['email', 'locale'] });
409409
});

‎packages/services/service-messaging/src/email-channel.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,16 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel
168168
}
169169
if (!data) return undefined;
170170
let user: Record<string, unknown> | null | undefined;
171+
// Set only when the row was read WITH the locale projection: after a
172+
// retry the column was never asked for, so whatever the row carries
173+
// under that key is not a read — rung 2 applies.
174+
let localeRead = false;
171175
try {
172176
user = await data.findOne(userObject, {
173177
where: { id: recipient },
174178
fields: ['email', RECIPIENT_LOCALE_FIELD],
175179
});
180+
localeRead = true;
176181
} catch (err) {
177182
// Ruling item 3: NO path may dead-letter because of the locale
178183
// read. A `userObject` override that lacks the column must still
@@ -193,7 +198,7 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel
193198
if (!(typeof email === 'string' && EMAIL_SHAPE(email))) return undefined;
194199
return {
195200
address: email,
196-
locale: resolveRecipientLocale(user?.[RECIPIENT_LOCALE_FIELD], deploymentLocale),
201+
locale: resolveRecipientLocale(localeRead ? user?.[RECIPIENT_LOCALE_FIELD] : undefined, deploymentLocale),
197202
};
198203
}
199204

0 commit comments

Comments
 (0)