diff --git a/.changeset/29818-push-device-locale-descriptions.md b/.changeset/29818-push-device-locale-descriptions.md new file mode 100644 index 00000000000..94efa2e59c6 --- /dev/null +++ b/.changeset/29818-push-device-locale-descriptions.md @@ -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. diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 3c8ca94a3eb..0c893257270 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -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 | diff --git a/content/docs/references/system/push-device.mdx b/content/docs/references/system/push-device.mdx index 9db8f6def14..d88bd1ced0b 100644 --- a/content/docs/references/system/push-device.mdx +++ b/content/docs/references/system/push-device.mdx @@ -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 | diff --git a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts index 3ea8891f964..7c9d77cd69e 100644 --- a/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts +++ b/packages/services/service-messaging/src/keyed-notification-text.integration.test.ts @@ -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'; @@ -60,6 +61,7 @@ import { NotificationPreference, NotificationReceipt, NotificationTemplate, + UserDevice, } from './objects/index.js'; const SYSTEM = { context: { isSystem: true } as ExecutionContext }; @@ -138,7 +140,7 @@ async function boot(): Promise { 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'); } @@ -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[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 { + 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]); + }); +}); diff --git a/packages/services/service-messaging/src/objects/user-device.object.ts b/packages/services/service-messaging/src/objects/user-device.object.ts index 22275146cd5..7120c49f7a8 100644 --- a/packages/services/service-messaging/src/objects/user-device.object.ts +++ b/packages/services/service-messaging/src/objects/user-device.object.ts @@ -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({ diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index 0380395b780..d8eaaf580be 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -460,7 +460,7 @@ export const enObjects: NonNullable = { }, 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" diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index 25499f4a26d..4f270f3cf71 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -460,7 +460,7 @@ export const esESObjects: NonNullable = { }, 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" diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index b58d056f490..658bdd1716c 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -460,7 +460,7 @@ export const jaJPObjects: NonNullable = { }, locale: { label: "ロケール", - help: "このデバイスへのプッシュ通知の表示に使うデバイスのロケール。" + help: "登録時に指定されたデバイスのロケール。デバイスに保存されますが、プッシュ通知の表示には使われません。プッシュ通知は受信者の言語(ユーザーのロケール、次にデプロイメントの既定)で届きます。" }, name: { label: "デバイス名" diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 19806bf48a5..9686ee20225 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -460,7 +460,7 @@ export const zhCNObjects: NonNullable = { }, locale: { label: "语言区域", - help: "发送到此设备的推送所使用的设备语言区域。" + help: "注册时提交的设备语言区域,随设备保存;推送不按它渲染:推送以收件人的语言送达(其用户语言,其次部署默认语言)。" }, name: { label: "设备名称" diff --git a/packages/spec/src/api/protocol.zod.ts b/packages/spec/src/api/protocol.zod.ts index 65aab6040cc..81528c9231e 100644 --- a/packages/spec/src/api/protocol.zod.ts +++ b/packages/spec/src/api/protocol.zod.ts @@ -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 ' diff --git a/packages/spec/src/system/push-device.zod.ts b/packages/spec/src/system/push-device.zod.ts index 136e6f7ddcf..9fcde1a3b58 100644 --- a/packages/spec/src/system/push-device.zod.ts +++ b/packages/spec/src/system/push-device.zod.ts @@ -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 '