diff --git a/.changeset/25194-delivery-title-names-recipient.md b/.changeset/25194-delivery-title-names-recipient.md new file mode 100644 index 00000000000..5ab0f15b31e --- /dev/null +++ b/.changeset/25194-delivery-title-names-recipient.md @@ -0,0 +1,14 @@ +--- +"@objectstack/service-messaging": patch +--- + +fix(service-messaging): a notification delivery's title names the person it is for, not their raw user id (#25194) + +Clause-②: yes (widening) +Metadata-protocol: none + +The record title of a `sys_notification_delivery` row (Setup → Notification Deliveries) read `channel → recipient_id`, so a delivery to a user showed their raw user id. It now reads `channel → Person`: the user's name, or their email where the name is blank. A delivery to an address no user holds — an email kept as given, a phone number — is titled by that address, as before. + +The delivery object gains one optional field, `recipient_user_id` (Recipient User), a `sys_user` reference the title reads through. `emit()` sets it when the recipient is a user's id, from one `sys_user` read per emit, and leaves it empty for an address. Deleting the user empties it; the delivery stays, titled by the raw recipient. `recipient_id` keeps its type and meaning (the address: a user id, an email or a phone number), and is now labelled **Recipient**. + +Rows written before this change carry no `recipient_user_id` and keep their old title until the 90-day window ages them out. Nothing is migrated, and no delivery is routed by the new field. diff --git a/packages/services/service-messaging/src/delivery-recipient-user.integration.test.ts b/packages/services/service-messaging/src/delivery-recipient-user.integration.test.ts new file mode 100644 index 00000000000..5ef328d4b46 --- /dev/null +++ b/packages/services/service-messaging/src/delivery-recipient-user.integration.test.ts @@ -0,0 +1,228 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#25194] A notification delivery's record title names the PERSON it is for, + * and the address itself where the recipient is not a user. + * + * `recipient_id` stays a text column: a recipient is a `sys_user` id, or an + * address kept verbatim — an email no user holds (`RecipientResolver`), a + * phone number the SMS channel texts directly — and the outbox writes past the + * engine's reference check, so a lookup there would store each address as a + * dangling reference. The person rides in `recipient_user_id`, a `sys_user` + * reference the enqueue stamps from one `sys_user` read per emit; the title + * reads it one hop, the shape `sys_notification_preference` takes (PR #25178). + * + * Pinned on a real `ObjectQL` over `SqlDriver` (better-sqlite3) with the real + * `sys_user`, through `MessagingService.emit` and `SqlNotificationOutbox`: + * + * 1. the declaration: `recipient_id` text, `recipient_user_id` an optional + * `sys_user` reference that nulls when the user goes; + * 2. the enqueue stamps `recipient_user_id` for a recipient a user holds, and + * only for one: an unmatched email, a phone number and an id nobody holds + * stay empty; + * 3. the title: the name; the email where the name is blank; the address for + * an address recipient; the raw recipient once the user is deleted; + * 4. a failed `sys_user` read stamps nothing and fails no emit — the rows + * land titled by the raw recipient, and the failure is said once; + * 5. the title still never has a NULL part, and adds no stored column. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { SysNotification, SysUser } from '@objectstack/platform-objects'; +import { MessagingService } from './messaging-service.js'; +import { SqlNotificationOutbox, DELIVERY_OBJECT } from './sql-outbox.js'; +import type { MessagingChannel } from './channel.js'; +import { NotificationDelivery, NotificationPreference } from './objects/index.js'; +import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js'; + +const SYS = { context: { isSystem: true, tenantId: DEFAULT_ORGANIZATION_ID } } as any; + +const silent: any = { debug() {}, info() {}, warn() {}, error() {}, fatal() {}, child() { return silent; } }; + +let engine: ObjectQL | undefined; + +afterEach(async () => { + try { await engine?.destroy(); } catch { /* noop */ } + engine = undefined; +}); + +async function boot(): Promise<{ ql: ObjectQL; driver: SqlDriver }> { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const ql = new ObjectQL({ logger: silent }); + ql.registerDriver(driver, true); + await ql.init(); + for (const def of [SysUser, SysNotification, NotificationDelivery, NotificationPreference]) { + ql.registry.registerObject(def as any, '@objectstack/service-messaging'); + } + await ql.syncSchemas(); + await installDefaultOrganization(ql as any); + engine = ql; + return { ql, driver }; +} + +async function user(ql: ObjectQL, name: string, email: string): Promise { + return String((await ql.insert('sys_user', { name, email }, SYS)).id); +} + +/** A mounted channel that is never reached: the outbox path only enqueues. */ +function channel(id: string): MessagingChannel { + return { id, send: async () => { throw new Error(`${id}: the enqueue path never sends`); } } as MessagingChannel; +} + +function messaging(data: unknown, logger: any = silent): MessagingService { + const outbox = new SqlNotificationOutbox(data as any, { partitionCount: 1 }); + const service = new MessagingService({ logger, getData: () => data as any, outbox }); + service.registerChannel(channel('email')); + service.registerChannel(channel('sms')); + return service; +} + +interface Delivery { recipient_id: string; recipient_user_id: string | null; channel: string; display_title: string | null } + +async function deliveries(ql: ObjectQL): Promise { + const rows = (await ql.find(DELIVERY_OBJECT, { fields: ['recipient_id', 'recipient_user_id', 'channel', 'display_title'] }, SYS)) ?? []; + return rows + .map((r: any) => ({ + recipient_id: r.recipient_id, + recipient_user_id: r.recipient_user_id ?? null, + channel: r.channel, + display_title: r.display_title ?? null, + })) + .sort((a: Delivery, b: Delivery) => `${a.channel}|${a.recipient_id}`.localeCompare(`${b.channel}|${b.recipient_id}`)); +} + +describe('the declaration', () => { + it('recipient_id stays text; recipient_user_id is an optional sys_user reference that nulls when the user goes', () => { + const fields = (NotificationDelivery as any).fields; + expect(fields.recipient_id.type).toBe('text'); + expect(fields.recipient_id.required).toBe(true); + const ref = fields.recipient_user_id; + expect(ref.type).toBe('lookup'); + expect(ref.reference).toBe('sys_user'); + expect(ref.required).toBe(false); + // The lookup default: the delivery is history and outlives the user. + expect(ref.deleteBehavior ?? 'set_null').toBe('set_null'); + expect(ref.maxLength).toBeUndefined(); + }); +}); + +describe('the enqueue stamps recipient_user_id for a recipient a user holds, and only for one', () => { + it('a user (bare id and email-resolved) is stamped; an unmatched email, a phone number and an unheld id are not', async () => { + const { ql } = await boot(); + const bea = await user(ql, 'Bea Person', 'bea@example.com'); + const cal = await user(ql, 'Cal Person', 'cal@example.com'); + + const result = await messaging(ql).emit({ + topic: 'deal.won', + audience: [bea, 'cal@example.com', 'outside@example.org', '+15550100', 'usr_nobody'], + channels: ['email'], + payload: { title: 'Won', body: 'A deal was won.' }, + }); + expect(result.enqueued).toBe(5); + + expect((await deliveries(ql)).map((d) => [d.recipient_id, d.recipient_user_id])).toEqual([ + ['+15550100', null], + [bea, bea], + [cal, cal], + ['outside@example.org', null], + ['usr_nobody', null], + ].sort((a, b) => `email|${a[0]}`.localeCompare(`email|${b[0]}`))); + }); +}); + +describe('the record title names the person, and the address where there is none', () => { + it('name; email where the name is blank; the address for an address recipient', async () => { + const { ql, driver } = await boot(); + const bea = await user(ql, 'Bea Person', 'bea@example.com'); + const blank = await user(ql, 'Placeholder', 'no-name@example.com'); + await driver.getKnex()('sys_user').where({ id: blank }).update({ name: ' ' }); + + await messaging(ql).emit({ topic: 'deal.won', audience: [bea, blank, 'outside@example.org'], channels: ['email'], title: 't', body: 'b' } as any); + await messaging(ql).emit({ topic: 'deal.won', audience: ['+15550100'], channels: ['sms'], title: 't', body: 'b' } as any); + + const titles = Object.fromEntries((await deliveries(ql)).map((d) => [d.recipient_id, d.display_title])); + expect(titles).toEqual({ + [bea]: 'email → Bea Person', + [blank]: 'email → no-name@example.com', + 'outside@example.org': 'email → outside@example.org', + '+15550100': 'sms → +15550100', + }); + // Reversed: the raw user id was the title before. + expect(titles[bea]).not.toContain(bea); + }); + + it('deleting the user empties the reference, and the row is titled by the raw recipient again', async () => { + const { ql } = await boot(); + const eve = await user(ql, 'Eve Person', 'eve@example.com'); + await messaging(ql).emit({ topic: 'deal.won', audience: [eve], channels: ['email'], title: 't', body: 'b' } as any); + expect((await deliveries(ql))[0]).toMatchObject({ recipient_user_id: eve, display_title: 'email → Eve Person' }); + + await ql.delete('sys_user', { where: { id: eve }, context: { isSystem: true } }); + + // The delivery history stays; only the person leaves the title. + expect(await deliveries(ql)).toEqual([ + { recipient_id: eve, recipient_user_id: null, channel: 'email', display_title: `email → ${eve}` }, + ]); + }); +}); + +describe('a failed sys_user read stamps nothing and fails no emit', () => { + it('the rows land titled by the raw recipient, and the failure is said once at warn', async () => { + const { ql } = await boot(); + const bea = await user(ql, 'Bea Person', 'bea@example.com'); + // The real engine, with its sys_user list read failing. + const failing = new Proxy(ql, { + get(target, prop, receiver) { + if (prop === 'find') { + return (object: string, ...rest: unknown[]) => object === 'sys_user' + ? Promise.reject(new Error('directory unavailable')) + : (target.find as any)(object, ...rest); + } + const value = Reflect.get(target, prop, receiver); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + const logger = { ...silent, warn: vi.fn() }; + + const result = await messaging(failing, logger).emit({ topic: 'deal.won', audience: [bea, 'outside@example.org'], channels: ['email'], title: 't', body: 'b' } as any); + + expect(result.enqueued).toBe(2); + expect((await deliveries(ql)).map((d) => [d.recipient_user_id, d.display_title])).toEqual([ + [null, `email → ${bea}`], + [null, 'email → outside@example.org'], + ].sort((a, b) => String(a[1]).localeCompare(String(b[1])))); + const said = logger.warn.mock.calls.map((c: unknown[]) => String(c[0])).filter((m: string) => m.includes("'sys_user' read")); + expect(said).toHaveLength(1); + expect(said[0]).toContain('directory unavailable'); + }); +}); + +describe('the title keeps the guarantees it had', () => { + it('a row missing channel or recipient_id is refused, so the title never has a NULL part', async () => { + const { ql } = await boot(); + const NOW = new Date('2026-10-22T00:00:00Z'); + for (const omit of ['channel', 'recipient_id']) { + const row: Record = { + notification_id: `ntf_${omit}`, recipient_id: 'usr_alice', channel: 'email', + status: 'pending', created_at: NOW, updated_at: NOW, + }; + delete row[omit]; + await expect(ql.insert(DELIVERY_OBJECT, row, SYS)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: expect.arrayContaining([expect.objectContaining({ field: omit, code: 'required' })]), + }); + } + }); + + it('the registered body points at display_title, which adds no stored column', async () => { + const { ql, driver } = await boot(); + const registered = ql.registry.getObject(DELIVERY_OBJECT) as any; + expect(registered.nameField).toBe('display_title'); + expect(registered.fields.display_title?.type).toBe('formula'); + const columns = Object.keys(await driver.getKnex()(DELIVERY_OBJECT).columnInfo()); + expect(columns).toContain('recipient_user_id'); + expect(columns).not.toContain('display_title'); + }); +}); diff --git a/packages/services/service-messaging/src/fan-out-system-context.ts b/packages/services/service-messaging/src/fan-out-system-context.ts index 93f75ac2490..5b42d030982 100644 --- a/packages/services/service-messaging/src/fan-out-system-context.ts +++ b/packages/services/service-messaging/src/fan-out-system-context.ts @@ -22,6 +22,10 @@ * locale alone), the read that picks the language a keyed notification text is * rendered in, at delivery, for that recipient. * + * [#25194] And by `MessagingService.userRecipients` (`sys_user`, the id alone), + * the one read per emit that tells which recipients are a user, for the + * delivery rows' `recipient_user_id`. + * * `emit()` takes no caller context. The door in front of an emitting caller * has already decided whether it may notify, and every row these calls read or * write belongs to a RECIPIENT, not to the emitter — so no caller's grants diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index 6cc7d3ac42e..40f99429280 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -25,6 +25,7 @@ import { type InboxCaller, resolveInboxRecipient } from './inbox-caller.js'; import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js'; import { INBOX_SYSTEM_CONTEXT } from './inbox-system-context.js'; import { assertActorReferenceResolves } from './actor-reference.js'; +import { USER_OBJECT } from './recipient-locale.js'; import { assertNotificationTextKeys, type NotificationTextKeys, @@ -1368,6 +1369,9 @@ export class MessagingService { // report a channel as both refused and enqueued — which is exactly the // ambiguity the once-per-channel refusal count below exists to remove. const mounted = new Map(); + // [#25194] Which recipients are a user's id, read once per emit: the + // row's `recipient_user_id`, which titles it by the person. + const users = await this.userRecipients(targets.map((t) => t.recipient)); const isMounted = (id: string): boolean => { let answer = mounted.get(id); if (answer === undefined) { @@ -1392,6 +1396,7 @@ export class MessagingService { const id = await outbox.enqueue({ notificationId, recipientId: recipient, + recipientUserId: users.has(recipient) ? recipient : undefined, channel, topic: input.topic, payload: deliveryPayload, @@ -1419,6 +1424,43 @@ export class MessagingService { return deliveries; } + /** + * [#25194] The recipients that are a `sys_user` id, in ONE read per emit. + * + * A resolved recipient is a user id or an address kept verbatim (an email + * no user holds, a phone number), and only the store can tell which: a + * bare spec passes through `RecipientResolver` unchecked. The answer stamps + * the delivery row's `recipient_user_id`, which the outbox writes past the + * engine's reference check (system context), so it is stamped only for an + * id this read found. + * + * The read is display-only: the row's title falls back to `recipient_id` + * without it, and no delivery is routed by it. So a failed read is a + * `warn` and an empty answer, never a failed emit. + */ + private async userRecipients(recipients: readonly string[]): Promise> { + const data = this.ctx.getData?.(); + const ids = [...new Set(recipients)]; + if (!data || ids.length === 0) return new Set(); + try { + // The explicit system opt-in — see FAN_OUT_SYSTEM_CONTEXT: a + // directory read on the recipients' behalf whose only use is the id. + const rows = await data.find( + USER_OBJECT, + { where: { id: { $in: ids } }, fields: ['id'], limit: ids.length }, + { context: FAN_OUT_SYSTEM_CONTEXT }, + ); + return new Set((rows ?? []).map((r: any) => String(r?.id ?? '')).filter(Boolean)); + } catch (err) { + this.ctx.logger.warn( + `[messaging] emit: the '${USER_OBJECT}' read for ${ids.length} recipient(s) failed ` + + `(${(err as Error)?.message ?? String(err)}); their delivery rows name no recipient user ` + + `and are titled by the raw recipient`, + ); + return new Set(); + } + } + /** Find an existing event id by its dedup key, tolerating lookup failure. */ private async findEventByDedupKey(data: IDataEngine, dedupKey: string): Promise { try { diff --git a/packages/services/service-messaging/src/objects/notification-delivery.object.ts b/packages/services/service-messaging/src/objects/notification-delivery.object.ts index 46eca396b27..b5f8a8ab8c1 100644 --- a/packages/services/service-messaging/src/objects/notification-delivery.object.ts +++ b/packages/services/service-messaging/src/objects/notification-delivery.object.ts @@ -71,13 +71,15 @@ export const NotificationDelivery = ObjectSchema.create({ }, // ADR-0030 Layer 4 — the reference lives in the docblock above, not in the copy. description: 'Durable per-recipient × channel delivery outbox.', - // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the - // registry's designate-only pass stamped `nameField: 'id'` (the first - // title-eligible field), so a renderer honouring ADR-0079's order (an - // explicit `nameField` wins over `titleFormat`) drew the raw id as the - // record page's H1. `titleFormat` stays for renderers that still read it - // first; `notification-display-title.test.ts` holds the two to the same text. + // [ADR-0079] The record title is `display_title`, a text formula. With no + // pointer declared, the registry's designate-only pass stamped + // `nameField: 'id'` (the first title-eligible field), so a renderer + // honouring ADR-0079's order (an explicit `nameField` wins over + // `titleFormat`) drew the raw id as the record page's H1. `titleFormat` + // stays for renderers that still read it first; the formula names the + // recipient as a PERSON where `titleFormat` names the raw recipient, so the + // two no longer read the same text + // (`../delivery-recipient-user.integration.test.ts`). displayNameField: 'display_title', nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) titleFormat: '{channel} → {recipient_id}', @@ -87,13 +89,24 @@ export const NotificationDelivery = ObjectSchema.create({ id: Field.text({ label: 'Delivery ID', required: true, readonly: true }), // [ADR-0079] The record title (`nameField` above). A formula is computed - // on read and has no stored column. `channel` and `recipient_id` are - // both required, so the expression needs no null guard. + // on read and has no stored column. It names the recipient as a PERSON + // where the recipient is a user, one hop through `recipient_user_id`: + // the user's `name`, or their `email` where the name is blank. An + // address recipient (an empty `recipient_user_id`) reads as the address + // itself, `recipient_id`. `channel` and `recipient_id` are both + // required, so the address leg needs no null guard. + // + // `recipient_user_id` is read only through the hop and `recipient_id` + // only bare: one formula may not read a field both ways + // (`traversal-bare-and-traversed`). A reference the read cannot resolve + // (a user this reader may not read) leaves the whole title null, as on + // `sys_notification_preference`; it never falls back to the raw id, + // which would read as an address. display_title: Field.formula({ label: 'Title', returnType: 'text', - expression: F`record.channel + ' → ' + record.recipient_id`, - description: 'Record title: the delivery channel and its recipient (computed on read)', + expression: F`record.channel + ' → ' + (isBlank(trim(record.recipient_user_id.?name.orValue(''))) ? record.recipient_user_id.?email.orValue(record.recipient_id) : record.recipient_user_id.?name.orValue(''))`, + description: 'Record title: the delivery channel and the person it is for, or the address when the recipient is not a user (computed on read)', }), notification_id: Field.text({ @@ -109,10 +122,19 @@ export const NotificationDelivery = ObjectSchema.create({ maxLength: 255, description: 'FK → sys_notification (L2 event)', }), + // The address the delivery is sent to — a `sys_user` id, or an email + // address or phone number kept verbatim — and part of the dedup key. + // NOT a `sys_user` reference: `RecipientResolver` keeps an email no user + // holds verbatim, and the email and SMS channels send to an email-shaped + // or phone-shaped recipient directly. The outbox writes under the system + // context, which skips the engine's reference check, so a lookup here + // would store every such address as a dangling reference. The person, + // where there is one, is `recipient_user_id` below. recipient_id: Field.text({ - label: 'Recipient User', + label: 'Recipient', required: true, searchable: true, + description: 'The address the delivery is sent to: a user id, or an email address or phone number kept as given.', // [commit e4902d2b9] Referenced-column bound (route A, ruling 2026-08-24): a resolved // recipient is a `sys_user.id` (physical varchar(255), as above) // or an email-shaped value `RecipientResolver.resolveOne()` keeps @@ -121,6 +143,22 @@ export const NotificationDelivery = ObjectSchema.create({ // column. 255 admits both producers. maxLength: 255, }), + // [#25194] The person `recipient_id` addresses, when it is a user's + // id: stamped at enqueue by `MessagingService.enqueueDeliveries` from + // one `sys_user` read per emit, and left empty for an address no user + // holds. It exists for the record title (`display_title` above) and + // is never read to route a delivery — the channels resolve + // `recipient_id` themselves. + // + // `set_null` (the lookup default) on purpose: the delivery is history + // and outlives the user, and an emptied reference titles the row by + // `recipient_id` again. No `maxLength`: a reference holds the + // referenced row's id, whose width is the parent's id column. + recipient_user_id: Field.lookup('sys_user', { + label: 'Recipient User', + required: false, + description: 'The user the delivery is for. Empty when the recipient is an email address or phone number no user holds.', + }), channel: Field.text({ label: 'Channel', required: true, diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index fa71c886dc7..25256288c5f 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -2,8 +2,8 @@ /** * The record title of the notification objects that declared a - * `titleFormat` and no title pointer (#20044): `sys_notification_delivery` - * and `sys_notification_receipt` (`sys_notification_subscription` is retired). + * `titleFormat` and no title pointer (#20044): `sys_notification_receipt` + * (`sys_notification_subscription` is retired). * * `sys_notification_preference` left this table when its `user_id` became a * `sys_user` reference (ADR-0118 D1): its title now names the PERSON one hop @@ -12,6 +12,11 @@ * others to. Its case — the raw-id title 'usr_alice · billing.invoice · email' * — is reversed in `../preference-user-reference.integration.test.ts`. * + * `sys_notification_delivery` left it the same way (#25194): its title names + * the person one hop through `recipient_user_id`, and the address itself where + * the recipient is not a user. Its case — the raw-id title 'email → usr_alice' + * — is reversed in `../delivery-recipient-user.integration.test.ts`. + * * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? * derivation`, and an explicit `nameField` takes precedence over the * render-only `titleFormat`. With no pointer declared, the registry's @@ -21,8 +26,9 @@ * A renderer honouring the order therefore drew the raw id as the record * page's H1. * - * The composite title points at `display_title`, a text formula over the - * columns `titleFormat` names. `sys_notification_receipt`'s title is one + * A composite title points at `display_title`, a text formula over the + * columns `titleFormat` names; the `display_title` legs below hold any such + * object to that. `sys_notification_receipt`'s title is one * column (`{state}`), so its `nameField` names that column directly — the * describe's own migration for a single-field title. An explicit pointer is * honoured whatever the field's type (ADR-0079 D4); `select` is only kept out @@ -52,7 +58,6 @@ import { } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; -import { NotificationDelivery } from './notification-delivery.object.js'; import { NotificationReceipt } from './notification-receipt.object.js'; import { installDefaultOrganization } from '../default-organization.testkit.js'; @@ -78,17 +83,6 @@ let seq = 0; const unique = (prefix: string) => `${prefix}_${++seq}`; const CASES: Case[] = [ - { - schema: NotificationDelivery, - pointer: 'display_title', - row: () => ({ - notification_id: unique('ntf'), recipient_id: 'usr_alice', channel: 'email', - status: 'pending', created_at: NOW, updated_at: NOW, - }), - title: 'email → usr_alice', - refused: ['channel', 'recipient_id'], - defaulted: {}, - }, { schema: NotificationReceipt, pointer: 'state', diff --git a/packages/services/service-messaging/src/outbox.ts b/packages/services/service-messaging/src/outbox.ts index 4dc73ae7e9b..e4d718fc7a6 100644 --- a/packages/services/service-messaging/src/outbox.ts +++ b/packages/services/service-messaging/src/outbox.ts @@ -91,6 +91,15 @@ export interface ClaimedDeliveryRecord extends NotificationDeliveryRecord { export interface EnqueueDeliveryInput { notificationId: string; recipientId: string; + /** + * [#25194] `recipientId` again, when it is a `sys_user` id the caller has + * READ — never a guess. The SQL store writes it to `recipient_user_id`, the + * reference the row's title names the person through, past the engine's + * reference check (system context), so an unread id here would be stored + * as a dangling reference. Absent for an address recipient. Display-only: + * no claim, ack or send reads it. + */ + recipientUserId?: string; channel: string; topic?: string; payload: DeliveryPayload; diff --git a/packages/services/service-messaging/src/sql-outbox.ts b/packages/services/service-messaging/src/sql-outbox.ts index 8821ed0806e..083f2a6870f 100644 --- a/packages/services/service-messaging/src/sql-outbox.ts +++ b/packages/services/service-messaging/src/sql-outbox.ts @@ -40,6 +40,7 @@ interface DeliveryRow { id: string; notification_id: string; recipient_id: string; + recipient_user_id?: string | null; channel: string; topic?: string | null; payload?: unknown; // json column — engine returns object or string per driver @@ -114,6 +115,7 @@ export class SqlNotificationOutbox implements INotificationOutbox { id, notification_id: input.notificationId, recipient_id: input.recipientId, + recipient_user_id: input.recipientUserId ?? null, channel: input.channel, topic: input.topic ?? null, payload: input.payload ?? {}, 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 d91c4f0eb92..a75e6fa4a48 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -123,14 +123,19 @@ export const enObjects: NonNullable = { }, display_title: { label: "Title", - help: "Record title: the delivery channel and its recipient (computed on read)" + help: "Record title: the delivery channel and the person it is for, or the address when the recipient is not a user (computed on read)" }, notification_id: { label: "Notification Event", help: "FK → sys_notification (L2 event)" }, recipient_id: { - label: "Recipient User" + label: "Recipient", + help: "The address the delivery is sent to: a user id, or an email address or phone number kept as given." + }, + recipient_user_id: { + label: "Recipient User", + help: "The user the delivery is for. Empty when the recipient is an email address or phone number no user holds." }, channel: { label: "Channel" 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 b865504343f..fc909325bab 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 @@ -123,14 +123,19 @@ export const esESObjects: NonNullable = { }, display_title: { label: "Title", - help: "Record title: the delivery channel and its recipient (computed on read)" + help: "Record title: the delivery channel and the person it is for, or the address when the recipient is not a user (computed on read)" }, notification_id: { label: "Notification Event", help: "FK → sys_notification (L2 event)" }, recipient_id: { - label: "Recipient User" + label: "Recipient", + help: "The address the delivery is sent to: a user id, or an email address or phone number kept as given." + }, + recipient_user_id: { + label: "Recipient User", + help: "The user the delivery is for. Empty when the recipient is an email address or phone number no user holds." }, channel: { label: "Channel" diff --git a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts index 1704012a5ad..9f7ec205b7a 100644 --- a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts @@ -68,7 +68,7 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_delivery.fields.digest_key.help": "6277161d780a5a7f", "objects.sys_notification_delivery.fields.digest_key.label": "c67774ec9d8352cd", - "objects.sys_notification_delivery.fields.display_title.help": "0803d3f90a14907c", + "objects.sys_notification_delivery.fields.display_title.help": "fc03cedc101fb793", "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_delivery.fields.error.label": "786fed84bd8d5a32", "objects.sys_notification_delivery.fields.id.label": "05179727cb19255c", @@ -79,7 +79,10 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.partition_key.label": "c3d981b1d6885418", "objects.sys_notification_delivery.fields.payload.help": "310d32eb414a7fbe", "objects.sys_notification_delivery.fields.payload.label": "4488e1328e37dcb2", - "objects.sys_notification_delivery.fields.recipient_id.label": "0fd8db52eea0eb8f", + "objects.sys_notification_delivery.fields.recipient_id.help": "eb97fcbff26a62af", + "objects.sys_notification_delivery.fields.recipient_id.label": "be60ed3ad27b1a2a", + "objects.sys_notification_delivery.fields.recipient_user_id.help": "679f9028720a31b4", + "objects.sys_notification_delivery.fields.recipient_user_id.label": "0fd8db52eea0eb8f", "objects.sys_notification_delivery.fields.status.label": "64a420165247654b", "objects.sys_notification_delivery.fields.status.options.dead": "4bc6e183e741bccc", "objects.sys_notification_delivery.fields.status.options.failed": "83eaae562c4a1821", 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 d9a3aad68d6..3b21fdd33c6 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 @@ -123,14 +123,19 @@ export const jaJPObjects: NonNullable = { }, display_title: { label: "Title", - help: "Record title: the delivery channel and its recipient (computed on read)" + help: "Record title: the delivery channel and the person it is for, or the address when the recipient is not a user (computed on read)" }, notification_id: { label: "Notification Event", help: "FK → sys_notification (L2 event)" }, recipient_id: { - label: "Recipient User" + label: "Recipient", + help: "The address the delivery is sent to: a user id, or an email address or phone number kept as given." + }, + recipient_user_id: { + label: "Recipient User", + help: "The user the delivery is for. Empty when the recipient is an email address or phone number no user holds." }, channel: { label: "Channel" diff --git a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts index 9a7fe7b6a56..9addd38cb8f 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts @@ -67,7 +67,7 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.created_at.label": "1f02d416befb595b", "objects.sys_notification_delivery.fields.digest_key.help": "6277161d780a5a7f", "objects.sys_notification_delivery.fields.digest_key.label": "c67774ec9d8352cd", - "objects.sys_notification_delivery.fields.display_title.help": "0803d3f90a14907c", + "objects.sys_notification_delivery.fields.display_title.help": "fc03cedc101fb793", "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_delivery.fields.error.label": "786fed84bd8d5a32", "objects.sys_notification_delivery.fields.id.label": "05179727cb19255c", @@ -78,7 +78,10 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.partition_key.label": "c3d981b1d6885418", "objects.sys_notification_delivery.fields.payload.help": "310d32eb414a7fbe", "objects.sys_notification_delivery.fields.payload.label": "4488e1328e37dcb2", - "objects.sys_notification_delivery.fields.recipient_id.label": "0fd8db52eea0eb8f", + "objects.sys_notification_delivery.fields.recipient_id.help": "eb97fcbff26a62af", + "objects.sys_notification_delivery.fields.recipient_id.label": "be60ed3ad27b1a2a", + "objects.sys_notification_delivery.fields.recipient_user_id.help": "679f9028720a31b4", + "objects.sys_notification_delivery.fields.recipient_user_id.label": "0fd8db52eea0eb8f", "objects.sys_notification_delivery.fields.status.label": "64a420165247654b", "objects.sys_notification_delivery.fields.status.options.dead": "4bc6e183e741bccc", "objects.sys_notification_delivery.fields.status.options.failed": "83eaae562c4a1821", 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 05f01b96d1b..0c275394d03 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 @@ -123,14 +123,19 @@ export const zhCNObjects: NonNullable = { }, display_title: { label: "Title", - help: "Record title: the delivery channel and its recipient (computed on read)" + help: "记录标题:投递通道及其所针对的人员;接收方不是用户时为该地址(读取时计算)" }, notification_id: { label: "通知事件", help: "外键 → sys_notification(L2 事件)" }, recipient_id: { - label: "接收用户" + label: "接收方", + help: "投递发送到的地址:用户 ID,或按原样保留的电子邮件地址或电话号码。" + }, + recipient_user_id: { + label: "接收用户", + help: "此投递所针对的用户。接收方是没有对应用户的电子邮件地址或电话号码时为空。" }, channel: { label: "通道" diff --git a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts index 74a44ab7254..23b7b41ada6 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts @@ -21,7 +21,6 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_http_delivery.fields.error.help": "195b3fdb4a940b54", "objects.sys_http_delivery.fields.headers_json.help": "a120981f1b5b6978", "objects.sys_http_delivery.fields.status.help": "e437ecf81dfb4715", - "objects.sys_notification_delivery.fields.display_title.help": "0803d3f90a14907c", "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_preference.fields.display_title.label": "70f7aadecce647a5", };