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
12 changes: 12 additions & 0 deletions .changeset/29818-push-device-locale-descriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/spec': patch
'@objectstack/service-messaging': patch
---

fix(spec,messaging): a push device's `locale` is described as what it is — stored for the device, and no push renders by it

Clause-②: no
Metadata-protocol: none

- **`@objectstack/spec`**: `UserDeviceSchema.locale` and `RegisterDeviceRequestSchema.locale` no longer say pushes to the device are rendered in its locale. They now say the value is stored for the device and that a push arrives in the recipient's language: `sys_user.locale`, then the deployment default. Description text only; nothing a caller sends is accepted or refused differently.
- **`@objectstack/service-messaging`**: the `sys_user_device.locale` field description says the same, in all four shipped languages. A test now pins it end to end: with the real push channel over the real device registry, a phone registered with a locale other than its user's receives the push in the user's language, on the inline fan-out and on the durable outbox path alike.
2 changes: 1 addition & 1 deletion content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2777,7 +2777,7 @@ Register or refresh a push device for the signed-in user
| **name** | `string` | optional | Device friendly name |
| **vendor** | `Enum<'apns' \| 'fcm'>` | optional | Push vendor the token belongs to: apns or fcm. Absent, the server derives it from platform: ios means apns, android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused |
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN). Stored on the device row; no push renders by it: a push arrives in the recipient's language (sys_user.locale, then the deployment default) |
| **origin** | `string` | optional | [PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example https://crm.example.com): the address the mobile shell saved for this server. The Console writes it; the server stores it on the device row and every push to the device carries it back, so a shell with several saved servers opens the push on the one that sent it. http or https, a lowercase host and an optional port; a path, another scheme or a bare host is refused |


Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/system/push-device.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ A push device of one user: one row per app install
| **token** | `string` | ✅ | Vendor push token. Held by one live device at a time: a second user's registration moves it |
| **platform** | `Enum<'ios' \| 'android' \| 'web'>` | ✅ | Device platform, as the registration named it |
| **appId** | `string` | optional | App the token was issued to: the iOS bundle ID or the Android application ID |
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in |
| **locale** | `string` | optional | Device locale (BCP 47, for example zh-CN), as the registration named it. Stored for the device; no push renders by it: a push arrives in the recipient's language (sys_user.locale, then the deployment default) |
| **name** | `string` | optional | Device friendly name, as the registration named it |
| **origin** | `string` | optional | [PLANNED — not yet stored] The origin the Console was loaded from, as the registration named it (for example https://crm.example.com). The device registry keeps it on the row and the push channel copies it into every push to this device, where the mobile shell picks the saved server by it. Absent on a device registered without one |
| **enabled** | `boolean` | ✅ | False once the vendor reported the token dead; nothing is delivered to a disabled device |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { MessagingService, NOTIFICATION_EVENT_OBJECT, type EmitInput } from './m
import { createInboxChannel, INBOX_OBJECT } from './inbox-channel.js';
import { createEmailChannel } from './email-channel.js';
import { createSmsChannel } from './sms-channel.js';
import { createPushChannel, type PushSenderSurface } from './push-channel.js';
import { NotificationTemplateStore } from './template-renderer.js';
import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js';
import { MemoryNotificationOutbox } from './memory-outbox.js';
Expand All @@ -60,6 +61,7 @@ import {
NotificationPreference,
NotificationReceipt,
NotificationTemplate,
UserDevice,
} from './objects/index.js';

const SYSTEM = { context: { isSystem: true } as ExecutionContext };
Expand Down Expand Up @@ -138,7 +140,7 @@ async function boot(): Promise<ObjectQL> {
await ql.init();
for (const def of [
SYS_ORGANIZATION, SYS_USER, SysNotification, InboxMessage, NotificationReceipt,
NotificationDelivery, NotificationPreference, NotificationTemplate,
NotificationDelivery, NotificationPreference, NotificationTemplate, UserDevice,
]) {
ql.registry.registerObject(def as any, '@objectstack/service-messaging');
}
Expand Down Expand Up @@ -543,3 +545,63 @@ describe('#24978 — the contract is closed', () => {
expect(await ql.find(NOTIFICATION_EVENT_OBJECT, { fields: ['id'] }, SYSTEM)).toEqual([]);
});
});

describe("a push reaches the recipient in the recipient's language, not in its device's locale", () => {
type Pushed = Parameters<PushSenderSurface['send']>[0];

/**
* The real push channel over the real device registry (`sys_user_device`),
* on a push-service double that records what each device token was sent.
* Each recipient's phone is registered with a locale that is not the
* recipient's own: the zh-CN user's phone in `en`, the `en` user's in
* `zh-CN`, the user with no locale's in `ja-JP`.
*/
async function withPush(c: Composition): Promise<Pushed[]> {
const pushed: Pushed[] = [];
c.messaging.registerChannel(createPushChannel({
getPush: () => ({
vendors: () => ['apns'],
async send(m) { pushed.push(m); return { status: 'sent', messageId: `push_${pushed.length}` }; },
}),
devices: c.messaging,
}));
await c.messaging.registerDevice('u_zh', { token: 'tok-zh', platform: 'ios', deviceId: 'phone-zh', locale: 'en' });
await c.messaging.registerDevice('u_en', { token: 'tok-en', platform: 'ios', deviceId: 'phone-en', locale: 'zh-CN' });
await c.messaging.registerDevice('u_none', { token: 'tok-none', platform: 'ios', deviceId: 'phone-none', locale: 'ja-JP' });
return pushed;
}

const toToken = (pushed: Pushed[], token: string) =>
pushed.filter((m) => m.token === token).map((m) => ({ title: m.title, body: m.body }));

it('the device row keeps the locale it was registered with — stored, and read by nothing on the way out', async () => {
const ql = await boot();
await withPush(compose(ql));
const rows = (await ql.find('sys_user_device', { fields: ['user_id', 'locale'] }, SYSTEM)) as any[];
expect(rows.map((r) => [r.user_id, r.locale]).sort()).toEqual([['u_en', 'zh-CN'], ['u_none', 'ja-JP'], ['u_zh', 'en']]);
});

it('inline fan-out: each phone gets its user\'s sentence, then the deployment default for a user with none', async () => {
const ql = await boot();
const c = compose(ql, { deploymentDefault: 'zh-CN' });
const pushed = await withPush(c);

await c.messaging.emit(emitTo(['u_zh', 'u_en', 'u_none'], { channels: ['push'] }));

expect(toToken(pushed, 'tok-zh')).toEqual([ZH]); // device says en
expect(toToken(pushed, 'tok-en')).toEqual([LITERAL]); // device says zh-CN
expect(toToken(pushed, 'tok-none')).toEqual([ZH]); // device says ja-JP; the deployment default wins
});

it('durable path (SQL outbox + dispatcher): the same, rendered at delivery', async () => {
const ql = await boot();
const c = compose(ql, { outbox: 'sql' });
const pushed = await withPush(c);

expect((await c.messaging.emit(emitTo(['u_zh', 'u_en'], { channels: ['push'] }))).enqueued).toBe(2);
await c.dispatcher!.tick();

expect(toToken(pushed, 'tok-zh')).toEqual([ZH]);
expect(toToken(pushed, 'tok-en')).toEqual([LITERAL]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ export const UserDevice = ObjectSchema.create({
required: false,
// BCP 47 tags are short; 35 is the RFC 5646 practical maximum.
maxLength: 35,
description: 'Device locale that pushes to this device are rendered in.',
description: 'Device locale, as the registration named it. Stored for the device; no push renders by it: a push '
+ 'arrives in the recipient\'s language (their user locale, then the deployment default).',
}),

name: Field.text({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "Locale",
help: "Device locale that pushes to this device are rendered in."
help: "Device locale, as the registration named it. Stored for the device; no push renders by it: a push arrives in the recipient's language (their user locale, then the deployment default)."
},
name: {
label: "Device Name"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "Configuración regional",
help: "Configuración regional del dispositivo en la que se muestran las notificaciones push enviadas a él."
help: "Configuración regional del dispositivo, tal como la indicó el registro. Se guarda para el dispositivo; ninguna notificación push se muestra según ella: llega en el idioma del destinatario (su configuración regional de usuario y, si no, la predeterminada de la implantación)."
},
name: {
label: "Nombre del dispositivo"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "ロケール",
help: "このデバイスへのプッシュ通知の表示に使うデバイスのロケール。"
help: "登録時に指定されたデバイスのロケール。デバイスに保存されますが、プッシュ通知の表示には使われません。プッシュ通知は受信者の言語(ユーザーのロケール、次にデプロイメントの既定)で届きます。"
},
name: {
label: "デバイス名"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,7 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
},
locale: {
label: "语言区域",
help: "发送到此设备的推送所使用的设备语言区域。"
help: "注册时提交的设备语言区域,随设备保存;推送不按它渲染:推送以收件人的语言送达(其用户语言,其次部署默认语言)。"
},
name: {
label: "设备名称"
Expand Down
5 changes: 4 additions & 1 deletion packages/spec/src/api/protocol.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3706,7 +3706,10 @@ export const RegisterDeviceRequestSchema = lazySchema(() => z.object({
+ 'android means fcm. web has no default vendor: a web registration names apns or fcm, and one without vendor is refused',
),
appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
locale: LocaleSchema.optional().describe(
'Device locale (BCP 47, for example zh-CN). Stored on the device row; no push renders by it: a push arrives in the '
+ 'recipient\'s language (sys_user.locale, then the deployment default)',
),
origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe(
'[PLANNED — not yet stored] The origin the Console was loaded from (window.location.origin, for example '
+ 'https://crm.example.com): the address the mobile shell saved for this server. The Console writes it; the server '
Expand Down
5 changes: 4 additions & 1 deletion packages/spec/src/system/push-device.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ export const UserDeviceSchema = lazySchema(() => z.object({
token: z.string().min(1).describe('Vendor push token. Held by one live device at a time: a second user\'s registration moves it'),
platform: z.enum(PUSH_DEVICE_PLATFORMS).describe('Device platform, as the registration named it'),
appId: z.string().optional().describe('App the token was issued to: the iOS bundle ID or the Android application ID'),
locale: LocaleSchema.optional().describe('Device locale (BCP 47, for example zh-CN) that pushes to this device are rendered in'),
locale: LocaleSchema.optional().describe(
'Device locale (BCP 47, for example zh-CN), as the registration named it. Stored for the device; no push renders by it: '
+ 'a push arrives in the recipient\'s language (sys_user.locale, then the deployment default)',
),
name: z.string().optional().describe('Device friendly name, as the registration named it'),
origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message: PUSH_SERVER_ORIGIN_MESSAGE }).optional().describe(
'[PLANNED — not yet stored] The origin the Console was loaded from, as the registration named it (for example '
Expand Down
Loading