Skip to content

Commit 8ed3c06

Browse files
committed
fix(service-messaging): resolve a notification template in the recipient's organization
sys_notification_template is organization-owned (ADR-0131). The template store now keys its read by the delivery's organization, the spelling the preference resolver scopes its rows by, and the email and SMS channels hand it the delivery's organization. A call that names no organization reads no row and renders the producer's own title and body. Pins: the store's read shape (template-renderer.test.ts) and, on a real ObjectQL over SqlDriver holding two organizations under isolated and group, the email and SMS channels each rendering the row of the delivery's own organization (template-organization.integration.test.ts), with the single posture's sole organization as the preservation control. Claude-Session: https://claude.ai/code/session_01UmqzD97e14iws6hmVhgZ5y Co-authored-by: Claude <noreply@anthropic.com>
1 parent a7199ef commit 8ed3c06

9 files changed

Lines changed: 317 additions & 27 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
---
4+
5+
fix(service-messaging): a notification template is resolved in the recipient's organization
6+
7+
Clause-②: no
8+
9+
`sys_notification_template` is organization-owned (ADR-0131: organization ownership is total). The email and SMS channels now render the template row of the delivery's own organization: `NotificationTemplateStore.load(topic, channel, locale, organizationId)` keys its read by the organization the delivery names (`Notification.organizationId`, the organization the notification event row carries), the same way the preference resolver scopes its rows.
10+
11+
- **An organization with no row of its own** for a `(topic, channel, locale)` renders the notification's own title and body, the documented no-template fallback.
12+
- **A call that names no organization** reads no row and answers `null`, so the channel renders that same fallback. Every delivery the messaging service fans out names its organization (the event row takes it from the producer or from the engine's system-write rule, and an emit with neither is refused), so this changes nothing a composed deployment renders. A direct caller of `NotificationTemplateStore.load` passes the recipient's organization as the fourth argument.
13+
- **Unchanged:** the locale ladder (the recipient's locale, its language, then `en`), the `is_active` filter, how a row renders, and the best-effort answer on a failed lookup.

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ type FakeUser = string | { email: string; locale?: unknown };
3636
/** Fake data engine: user id → email (+ locale), and template lookups. */
3737
function fakeData(opts: { users?: Record<string, FakeUser>; templates?: any[] } = {}) {
3838
const users = opts.users ?? { user_1: 'ada@example.com' };
39-
const templates = opts.templates ?? [];
39+
// A fixture row is the delivery organization's unless it names another.
40+
const templates = (opts.templates ?? []).map((t) => ({ organization_id: 'org_1', ...t }));
4041
const findOnes: Array<{ object: string; query: any }> = [];
4142
return {
4243
findOnes,
@@ -50,7 +51,7 @@ function fakeData(opts: { users?: Record<string, FakeUser>; templates?: any[] }
5051
return typeof user === 'string' ? { email: user } : { ...user };
5152
}
5253
if (object === 'sys_notification_template') {
53-
return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null;
54+
return templates.find((t) => t.organization_id === w.organization_id && t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null;
5455
}
5556
return null;
5657
},
@@ -206,8 +207,10 @@ describe('email channel', () => {
206207
templates: [{ topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: '<h1>{{ payload.title }}</h1>', format: 'html' }],
207208
});
208209
const ch = channel(() => email.service, data);
209-
await ch.send(silentCtx(), delivery());
210-
expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Won Deal closed', html: '<h1>Deal closed</h1>' });
210+
// The row is the delivery organization's (a template is resolved in
211+
// the recipient's organization), so the delivery names it.
212+
await ch.send(silentCtx(), delivery({ organizationId: 'org_1' }));
213+
expect(email.sent[0]).toEqual({ to: 'ada@example.com', subject: 'Won Deal closed', html: '<h1>Deal closed</h1>', organizationId: 'org_1' });
211214
});
212215

213216
it('accepts an email-shaped recipient verbatim (no user lookup)', async () => {
@@ -548,8 +551,8 @@ describe('email channel', () => {
548551
],
549552
});
550553
const ch = build(data, email, 'ja-JP');
551-
await ch.send(silentCtx(), delivery({}, 'ada'));
552-
await ch.send(silentCtx(), delivery({}, 'bob'));
554+
await ch.send(silentCtx(), delivery({ organizationId: 'org_1' }, 'ada'));
555+
await ch.send(silentCtx(), delivery({ organizationId: 'org_1' }, 'bob'));
553556
expect(email.sent.map((s) => [s.to, s.subject])).toEqual([
554557
['ada@example.com', '成交 Deal closed'], // her own locale
555558
['bob@example.com', '成約 Deal closed'], // no locale ⇒ deployment default
@@ -562,7 +565,7 @@ describe('email channel', () => {
562565
users: { user_1: { email: 'ada@example.com', locale: 'undefined' } },
563566
templates: [{ topic: 'deal.won', channel: 'email', locale: 'en', is_active: true, subject: 'Won {{ payload.title }}', body: 'en body', format: 'text' }],
564567
});
565-
await build(data, email).send(silentCtx(), delivery());
568+
await build(data, email).send(silentCtx(), delivery({ organizationId: 'org_1' }));
566569
expect(email.sent[0].subject).toBe('Won Deal closed');
567570
});
568571

@@ -575,7 +578,7 @@ describe('email channel', () => {
575578
{ topic: 'deal.won', channel: 'email', locale: 'es-ES', is_active: true, subject: 'Ganado', body: 'es', format: 'text' },
576579
],
577580
});
578-
await build(data, email).send(silentCtx(), delivery({ payload: { title: 'x', body: 'y', locale: 'es-ES' } }));
581+
await build(data, email).send(silentCtx(), delivery({ organizationId: 'org_1', payload: { title: 'x', body: 'y', locale: 'es-ES' } }));
579582
expect(email.sent[0].subject).toBe('成交');
580583
});
581584
});

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,9 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel
373373
// arm (#13881); the store walks its own ladder (`zh-CN` → `zh` →
374374
// DEFAULT_LOCALE) underneath, so a tag with no row still renders.
375375
const locale = recipientLocale ?? defaultLocale;
376-
const template = await opts.store.load(n.topic ?? '', 'email', locale);
376+
// The row is the recipient organization's: the delivery's own
377+
// organization keys the read (see NotificationTemplateStore).
378+
const template = await opts.store.load(n.topic ?? '', 'email', locale, n.organizationId);
377379
const rendered = renderNotification(template, {
378380
topic: n.topic ?? '',
379381
payload,

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

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ function delivery(over: Partial<Delivery['notification']> = {}, recipient = 'use
1919
recipient,
2020
notification: {
2121
notificationId: 'evt_1',
22+
// Every composed delivery names its organization (the event row's);
23+
// the template rows below are that organization's.
24+
organizationId: 'org_1',
2225
topic: 'deal.won',
2326
title: 'Deal closed',
2427
body: 'Acme signed',
@@ -36,7 +39,8 @@ type FakeUser = string | { phone_number: string; locale?: unknown };
3639
/** Fake data engine: user id → phone_number (+ locale), and template lookups. */
3740
function fakeData(opts: { users?: Record<string, FakeUser>; templates?: any[] } = {}) {
3841
const users = opts.users ?? { user_1: '+8613800000000' };
39-
const templates = opts.templates ?? [];
42+
// A fixture row is the delivery organization's unless it names another.
43+
const templates = (opts.templates ?? []).map((t) => ({ organization_id: 'org_1', ...t }));
4044
const findOnes: Array<{ object: string; query: any }> = [];
4145
return {
4246
findOnes,
@@ -50,7 +54,7 @@ function fakeData(opts: { users?: Record<string, FakeUser>; templates?: any[] }
5054
return typeof user === 'string' ? { phone_number: user } : { ...user };
5155
}
5256
if (object === 'sys_notification_template') {
53-
return templates.find((t) => t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null;
57+
return templates.find((t) => t.organization_id === w.organization_id && t.topic === w.topic && t.channel === w.channel && t.locale === w.locale && t.is_active) ?? null;
5458
}
5559
return null;
5660
},

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,9 @@ export function createSmsChannel(opts: SmsChannelOptions): MessagingChannel {
280280
// channel's static default; the store walks its own ladder
281281
// underneath. `payload.locale` is no longer consulted.
282282
const locale = resolved.locale ?? defaultLocale;
283-
const template = await opts.store.load(n.topic ?? '', 'sms', locale);
283+
// The row is the recipient organization's: the delivery's own
284+
// organization keys the read (see NotificationTemplateStore).
285+
const template = await opts.store.load(n.topic ?? '', 'sms', locale, n.organizationId);
284286
const rendered = renderNotification(template, {
285287
topic: n.topic ?? '',
286288
payload,

‎packages/services/service-messaging/src/system-context.pin.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,9 @@ describe('[#21908] rows 24 onward — the remaining fan-out and outbox calls car
409409
const store = new NotificationTemplateStore({ getData: () => engine });
410410
const email = createEmailChannel({ getEmail: () => ({ async send() { return { id: 'e1' }; } }) as any, getData: () => engine, store });
411411
const sms = createSmsChannel({ getSms: () => ({ async send() { return { id: 's1' }; } }) as any, getData: () => engine, store });
412-
const notification = { title: 't', body: 'b', recipients: ['u1'], topic: 'deal.won' };
412+
// The delivery names its organization, so the template read happens
413+
// (a call that names none reads no row — NotificationTemplateStore).
414+
const notification = { title: 't', body: 'b', recipients: ['u1'], topic: 'deal.won', organizationId: 'org_1' };
413415

414416
expect((await email.send({ logger: silentLogger() }, { notification, channel: 'email', recipient: 'u1' } as any)).ok).toBe(true);
415417
expect((await sms.send({ logger: silentLogger() }, { notification, channel: 'sms', recipient: 'u1' } as any)).ok).toBe(true);
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A notification template is resolved in the recipient's organization.
5+
*
6+
* `sys_notification_template` is organization-owned (ADR-0131: organization
7+
* ownership is total — the table carries `organization_id` and the
8+
* platform-object tenancy census lists it), so the row a delivery renders is
9+
* one the delivery's own organization authored. `NotificationTemplateStore`
10+
* keys its read by the delivery's organization, the spelling
11+
* `PreferenceResolver` scopes its rows by; a call that names no organization
12+
* reads no row.
13+
*
14+
* Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3) holding two
15+
* organizations, under each walled posture, through the real email and SMS
16+
* channels:
17+
*
18+
* - each organization's delivery renders its own organization's row;
19+
* - an organization that authored no row renders the producer's own title
20+
* and body (the renderer's documented no-template fallback);
21+
* - a delivery that names no organization renders that same fallback.
22+
*
23+
* And the preservation half, under `single`: the sole organization's row
24+
* still renders for its own deliveries.
25+
*/
26+
27+
import { describe, it, expect, afterEach } from 'vitest';
28+
import { ObjectQL } from '@objectstack/objectql';
29+
import type { ExecutionContext } from '@objectstack/spec/kernel';
30+
import { SqlDriver } from '@objectstack/driver-sql';
31+
import { NotificationTemplate } from './objects/index.js';
32+
import { NotificationTemplateStore, TEMPLATE_OBJECT } from './template-renderer.js';
33+
import { createEmailChannel } from './email-channel.js';
34+
import { createSmsChannel } from './sms-channel.js';
35+
import type { Delivery, MessagingChannel } from './channel.js';
36+
37+
// Declared as the platform declares them: better-auth owns both tables, so the
38+
// registration injects no organization column.
39+
const SYS_ORGANIZATION = { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } };
40+
const SYS_USER = {
41+
name: 'sys_user',
42+
label: 'User',
43+
managedBy: 'better-auth',
44+
fields: {
45+
name: { type: 'text', label: 'Name' },
46+
email: { type: 'text', label: 'Email' },
47+
phone_number: { type: 'text', label: 'Phone' },
48+
locale: { type: 'text', label: 'Locale' },
49+
},
50+
};
51+
52+
const SYSTEM = { context: { isSystem: true } as ExecutionContext };
53+
const TOPIC = 'deal.won';
54+
const silentLogger: any = { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
55+
silentLogger.child = () => silentLogger;
56+
const channelCtx = { logger: silentLogger };
57+
58+
let engine: ObjectQL | undefined;
59+
60+
afterEach(async () => {
61+
try { await engine?.destroy(); } catch { /* noop */ }
62+
engine = undefined;
63+
});
64+
65+
type Posture = 'isolated' | 'group' | 'single';
66+
67+
/** A real engine on `posture` holding `organizations`, and one recipient per organization. */
68+
async function boot(posture: Posture, organizations: readonly string[]): Promise<ObjectQL> {
69+
const ql = new ObjectQL();
70+
ql.registerDriver(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), true);
71+
await ql.init();
72+
for (const def of [SYS_ORGANIZATION, SYS_USER, NotificationTemplate]) {
73+
ql.registry.registerObject(def as any, '@objectstack/service-messaging');
74+
}
75+
await ql.syncSchemas();
76+
ql.setTenancyPostureProvider(() => posture);
77+
for (const [i, id] of organizations.entries()) {
78+
await ql.insert('sys_organization', { id, name: id }, SYSTEM);
79+
await ql.insert('sys_user', { id: `user_${id}`, name: id, email: `${id}@example.test`, phone_number: `+1555000000${i}`, locale: 'en' }, SYSTEM);
80+
}
81+
engine = ql;
82+
return ql;
83+
}
84+
85+
/** The row `organization` authors in Setup for (TOPIC, channel, 'en'), written as that organization's write lands. */
86+
async function author(ql: ObjectQL, organization: string, channel: 'email' | 'sms', text: string): Promise<void> {
87+
await ql.insert(
88+
TEMPLATE_OBJECT,
89+
{ topic: TOPIC, channel, locale: 'en', subject: `${text} subject`, body: `${text} body`, format: 'text', is_active: true },
90+
{ context: { isSystem: true, tenantId: organization } as ExecutionContext },
91+
);
92+
}
93+
94+
/** What each row is stored with — the fixture's own control, so no pin passes on a mis-stamped row. */
95+
async function storedOrganizations(ql: ObjectQL): Promise<string[]> {
96+
const rows = (await ql.find(TEMPLATE_OBJECT, { fields: ['channel', 'organization_id'] }, SYSTEM)) ?? [];
97+
return rows.map((r: Record<string, unknown>) => `${String(r.channel)}:${String(r.organization_id ?? 'NULL')}`).sort();
98+
}
99+
100+
/** The real email + SMS channels over `ql`, with transports that record what they were handed. */
101+
function channels(ql: ObjectQL) {
102+
const sent: Array<{ channel: string; subject?: string; text: string }> = [];
103+
const store = new NotificationTemplateStore({ getData: () => ql as any });
104+
const email = createEmailChannel({
105+
getEmail: () => ({ async send(i: any) { sent.push({ channel: 'email', subject: i.subject, text: i.text ?? i.html }); return { id: 'e' }; } }) as any,
106+
getData: () => ql as any,
107+
store,
108+
});
109+
const sms = createSmsChannel({
110+
getSms: () => ({ async send(i: any) { sent.push({ channel: 'sms', text: i.body }); return { messageId: 's' }; } }) as any,
111+
getData: () => ql as any,
112+
store,
113+
});
114+
return { sent, email, sms };
115+
}
116+
117+
function delivery(channel: 'email' | 'sms', organizationId: string | undefined, recipient: string): Delivery {
118+
return {
119+
channel,
120+
recipient,
121+
notification: {
122+
notificationId: 'evt_1',
123+
...(organizationId !== undefined ? { organizationId } : {}),
124+
topic: TOPIC,
125+
title: 'Producer title',
126+
body: 'Producer body',
127+
recipients: [recipient],
128+
payload: { title: 'Producer title', body: 'Producer body' },
129+
},
130+
};
131+
}
132+
133+
async function send(ch: MessagingChannel, d: Delivery): Promise<void> {
134+
const result = await ch.send(channelCtx as any, d);
135+
expect(result.ok, `${d.channel} delivery reported failure: ${result.error}`).toBe(true);
136+
}
137+
138+
describe.each(['isolated', 'group'] as const)("%s, two organizations: a template is resolved in the recipient's organization", (posture) => {
139+
it("each organization's delivery renders its own organization's row, on email and on SMS", async () => {
140+
const ql = await boot(posture, ['org_a', 'org_b']);
141+
for (const org of ['org_a', 'org_b']) {
142+
await author(ql, org, 'email', org);
143+
await author(ql, org, 'sms', org);
144+
}
145+
expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'email:org_b', 'sms:org_a', 'sms:org_b']);
146+
147+
const { sent, email, sms } = channels(ql);
148+
for (const org of ['org_a', 'org_b']) {
149+
await send(email, delivery('email', org, `${org}@example.test`));
150+
await send(sms, delivery('sms', org, `user_${org}`));
151+
}
152+
153+
expect(sent).toEqual([
154+
{ channel: 'email', subject: 'org_a subject', text: 'org_a body' },
155+
{ channel: 'sms', text: 'org_a body' },
156+
{ channel: 'email', subject: 'org_b subject', text: 'org_b body' },
157+
{ channel: 'sms', text: 'org_b body' },
158+
]);
159+
});
160+
161+
it("an organization that authored no row renders the producer's own title and body", async () => {
162+
const ql = await boot(posture, ['org_a', 'org_b']);
163+
await author(ql, 'org_a', 'email', 'org_a');
164+
await author(ql, 'org_a', 'sms', 'org_a');
165+
expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'sms:org_a']);
166+
167+
const { sent, email, sms } = channels(ql);
168+
await send(email, delivery('email', 'org_b', 'org_b@example.test'));
169+
await send(sms, delivery('sms', 'org_b', 'user_org_b'));
170+
171+
expect(sent).toEqual([
172+
{ channel: 'email', subject: 'Producer title', text: 'Producer body' },
173+
{ channel: 'sms', text: 'Producer body' },
174+
]);
175+
});
176+
177+
it("a delivery that names no organization renders the producer's own title and body", async () => {
178+
const ql = await boot(posture, ['org_a', 'org_b']);
179+
await author(ql, 'org_a', 'email', 'org_a');
180+
await author(ql, 'org_a', 'sms', 'org_a');
181+
expect(await storedOrganizations(ql)).toEqual(['email:org_a', 'sms:org_a']);
182+
183+
const { sent, email, sms } = channels(ql);
184+
await send(email, delivery('email', undefined, 'org_b@example.test'));
185+
await send(sms, delivery('sms', undefined, 'user_org_b'));
186+
187+
expect(sent).toEqual([
188+
{ channel: 'email', subject: 'Producer title', text: 'Producer body' },
189+
{ channel: 'sms', text: 'Producer body' },
190+
]);
191+
});
192+
});
193+
194+
describe("single, one organization: the organization's own row still renders", () => {
195+
it("a delivery of the sole organization renders that organization's row, on email and on SMS", async () => {
196+
const ql = await boot('single', ['org_sole']);
197+
await author(ql, 'org_sole', 'email', 'org_sole');
198+
await author(ql, 'org_sole', 'sms', 'org_sole');
199+
expect(await storedOrganizations(ql)).toEqual(['email:org_sole', 'sms:org_sole']);
200+
201+
const { sent, email, sms } = channels(ql);
202+
await send(email, delivery('email', 'org_sole', 'org_sole@example.test'));
203+
await send(sms, delivery('sms', 'org_sole', 'user_org_sole'));
204+
205+
expect(sent).toEqual([
206+
{ channel: 'email', subject: 'org_sole subject', text: 'org_sole body' },
207+
{ channel: 'sms', text: 'org_sole body' },
208+
]);
209+
});
210+
});

0 commit comments

Comments
 (0)