docs(spec): SPEC-072 recipient-locale rendering for system emails and notifications (built-in user_preferences + resolver seam) - #54
Conversation
…fication delivery Reframes the Tournee-side SPEC-063 discussion (fullstackhouse/tournee#942) as a platform-level open-mercato problem: core renders auth emails and notification delivery on the initiator's request locale, never the recipient's stored language. Documents an additive host-registered resolver seam across the three send paths, with sequencing against the notification-pipeline rewrite in open-mercato#4326. Tournee is documented only as an example consumer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
How would the end-user usage look like (by the OM app)?
Does it mean that I, as an app developer, I'd have to configure it anyhow? Isn't "user's locale" built in anyhow into the OM already? Maybe we should make that built in? So that notifications are delivered in the recipient's language out of the box, without each OM app having to configure that manually? |
|
cc @Frshy , did we meet this problem in Covo? |
|
@jtomaszewski kind of, from what I remember this was one of the reasons why we send emails using inline |
… notifications after open-mercato#4326) Reframes to built-in stored locale (new core user_preferences module) plus an optional recipient-locale resolver override, addressing PR #54 review: - built-in out-of-the-box default (Jacek), not per-app config - resolver signature (em, userId, tenantId) so it exposes em+tenantId (Copilot) - Covo added as a second example consumer (Frshy) - explicit phasing: Phase 1 auth emails implemented now; Phase 2 notification delivery deferred until after open-mercato#4326 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…peline (open-mercato#5366) The notification-pipeline rewrite this spec was waiting on landed upstream as open-mercato#5366 (supersedes the closed open-mercato#4326). The merged pipeline already localizes push per device and in-app per viewer, so Phase 2 narrows to the email base copy in the dispatch subscriber plus a copyLocale handoff to the push fan-out (its default-locale fast path assumes the base copy is default-locale). Added Phase 2 implementation notes, per-channel acceptance tests, and the email/push coupling risk. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@Frshy – Updated after merge your PR in OM. Could you take a look at those specs? |
|
|
||
| ## Data models | ||
|
|
||
| `user_preferences` (new table): |
There was a problem hiding this comment.
is this in notifications module? if yes please check if we don't already have the table which stores per user preferences
There was a problem hiding this comment.
No — it’s a new standalone user_preferences module, not part of notifications. I checked notification_preferences: that table stores per-(type, channel) opt-outs for notifications only, while preferred_locale is one row per (user, tenant) and is consumed outside notifications too (password reset and invite emails, where the recipient has no notification context yet).
jtomaszewski
left a comment
There was a problem hiding this comment.
Thanks for reframing this as a platform problem — that part is right, and the Phase 2 analysis of the merged pipeline (push already per-device, in-app viewer-side, email the remaining gap, plus the copyLocale handoff) is good work and I'd keep it as-is.
Requesting changes on the design, though. Two structural issues and one hole in the flagship example.
1. Split this into two specs
As written, the stored preference is invisible to the user: detectLocale() is untouched, so a user who PUTs preferredLocale: pl still gets the backend UI in whatever the cookie says. Only mail changes. A preference that can only be set via curl and only affects email isn't a shippable user-facing feature — and "OM knows my language" is the more valuable half of this.
- Spec A — user locale. Storage, the profile UI to set it (
/backend/auth/profile, or/backend/profile/*wherenotification-preferencesandcommunication-channelsalready live), the precedence chain, and a request-free resolver (resolveUserLocale(em, {userId, tenantId})+resolveTranslationsFor(locale)). - Spec B — recipient rendering. This spec minus the storage: the three send paths + the push
copyLocalehandoff, as a pure consumer of Spec A.
They can still ship as one PR later if that's more convenient, but I want the "how does OM know a user's locale" discussion to happen on its own, in detail, rather than as a substrate of the email fix.
2. The invite case doesn't work under this design
The Problem section leads with "an invitation renders in the inviting admin's language, not the invitee's" — but a brand-new invitee has no user_preferences row at send time. The resolver returns undefined, precedence falls to detectLocale(), and the invite renders in the inviting admin's request locale exactly as it does today. The acceptance test "invitee with stored en, admin on PL → EN invite" presupposes a row that cannot exist for a new user.
Needs an explicit answer: either the create-user/invite command accepts a preferredLocale (admin picks the invitee's language in the form, and it seeds the preference row), or the no-preference fallback for cross-user sends becomes a tenant default rather than the initiator's request locale. Right now the headline motivating case is unfixed.
3. Storage: no alternatives were considered
A new core module is the largest surface of the available options and the spec doesn't justify it against the others. Please cover at least:
users.localenullable column on the authUser— cheapest, resolver is one select.user_preferencestable owned byauth—authalready owns a per-user preference table (user_sidebar_preferences), and ownsUser; no new module id/ACL/API surface.- User-scoped rows in
configs— mirrormodule_configs(module_id,name,value_json,tenant_id,org_id) with auser_id; makes tenant default and user override the same story, which is what open-mercato#1964 needs. - New
user_preferencesmodule — what you have.
Also: the stated rationale "The frozen User entity is untouched" is not correct. BACKWARD_COMPATIBILITY.md §8 (Database Schema, ADDITIVE-ONLY) explicitly says "MAY add new columns with defaults (non-breaking)". A nullable locale column is legal. Fix the rationale, or keep the decision on grounds that actually hold (extensibility to timezone/date-format, for instance).
My prior: the generic per-user store only pays for itself once there's a second and third preference — which is exactly open-mercato#1964 (timezone, date format, first day of week). So the decision hinges on point 4.
4. Missing prior art: open-mercato#1964
open-mercato#1964 "FR: Implement system-wide date and locale settings" and its tracking spec .ai/specs/2026-05-18-date-locale-settings.md cover the same ground — the issue scope names "tenant/system settings, and optional user overrides", and the spec's Q2 is verbatim our question:
Q2. Should user profile locale/date preferences override tenant defaults in the first implementation, or be deferred?
Never answered, never implemented. Spec A must reference it and either claim Q2 or explicitly defer, otherwise we ship two competing precedence chains for "where does a user's locale come from".
5. Use DI for the override seam, not a global registry
registerRecipientLocaleResolver(fn) with last-wins semantics is a second override mechanism next to the one OM already has (DI + modules.ts overrides). BC §9 says new DI registrations are free, so a userLocaleResolver service costs nothing and gets scoping, testability and a documented override path for free. The load-order-dependent "last registration wins" and the em: unknown cast in shared are both symptoms of the seam sitting in the wrong layer.
6. Precedence chain is underspecified
Today the language switcher writes only a cookie (auth/api/locale/route.ts). The spec puts the stored preference above detectLocale() without saying what happens when they disagree. Please state it explicitly. My proposal: the switcher writes both, login rewrites the cookie from the stored value (so a second device inherits the choice), stored beats Accept-Language, anonymous/pre-login stays cookie-only. Plus: is there a tenant-default layer between stored preference and defaultLocale? Point 2 needs one.
7. Which recipients?
customer_accounts users have no locale either, and they can be notification recipients. The signature (em, userId, tenantId) bakes in "an auth user". Declare portal/customer recipients in or out of scope, and if out, how the contract extends later without a breaking signature change.
Non-blocking
- Cite
devices.locale(packages/core/src/modules/devices/data/entities.ts:56) as precedent — the platform already stores and honours a recipient-side locale, just per push device. That's a stronger argument than the Tournee/Covo anecdote, and it's the origin of the Phase 2 coupling you correctly identified. - Per
AGENTS.md, the spec must list integration coverage for every affected API path and key UI path — add TC ids for/api/user_preferences/me(or whatever storage wins) and the profile UI path. 422on an unsupported locale is inconsistent with the existingPOST /api/auth/locale, which returns400. Pick one.- Don't invest further in
feat/recipient-locale-auth-emailsuntil 3 and 5 are settled — the storage and seam decisions rewrite most of it. - Keep this PR design-only, as you already planned; implementation lands on its own PR.
Tournee isn't blocked on this: we're doing a temporary app-level patch there in the meantime, so take the time to get the platform contract right rather than the fastest path to unblocking one app.
Summary
Draft spec (SPEC-072,
.ai/specs/SPEC-072-2026-07-30-recipient-locale-system-email-seam.md) reframing the discussion from fullstackhouse/tournee#942 as a platform-level open-mercato problem, per maintainer direction — Tournee is only an example consumer (Covo independently hit the same gap and worked around it with inlinesendEmail).Problem: Core renders system messages (password reset, new-invitee email, notification email) in the initiator's request locale (or a single global default), never in the recipient's stored language. Any multi-locale OM deployment sends wrong-language mail on background/cross-user sends — most visibly, an invite renders in the inviting admin's language, not the invitee's.
Approach — works out of the box, override optional (shaped by review feedback that an app developer shouldn't have to configure anything):
user_preferencesmodule: one row per(user, tenant)withpreferred_locale, managed viaGET/PUT /api/user_preferences/me.registerRecipientLocaleResolver(fn)in@open-mercato/shared/lib/i18n;user_preferencesregisters a default reader, apps may re-register to override (last wins). The resolver receivesem+tenantId(recipient-locale resolution is a tenant-scoped DB lookup, not a pure function of user id).OM_FORCE_LOCALE→ registered resolver →detectLocale()(request paths only) →defaultLocale. No stored row ⇒ resolver returnsundefined⇒ byte-for-byte today's behavior. Additive across all contract surfaces; the frozenUserentity is untouched.Phasing
feat/recipient-locale-auth-emails, PR to follow): password reset + new-invitee render viaresolveTranslationsForRecipient.device.locale) and in-app per viewer (keys resolved client-side), so the remaining work is:defaultLocale; Phase 2 resolves the recipient's stored locale via the seam and passes it toresolveNotificationCopy, exposed ascopyLocaleonNotificationDeliveryContext;copyLocalehandoff to the push fan-out: its fast path reuses the base copy verbatim assuming it is default-locale — without the handoff, anendevice of apl-preference user would get a Polish push.Verification
Docs-only change (single spec file);
lintandaudit-scopeCI checks pass. Acceptance tests for both phases are enumerated in the spec and land with the implementation PRs.Follow-ups
feat/recipient-locale-auth-emails(rebase + acceptance tests), then Phase 2 on the merged pipeline — proposed as one combined upstream PR toopen-mercato/open-mercatosince the reason for phasing (rebase conflict with the rewrite) is gone.@open-mercato/coreand drop their app-level locale workarounds.🤖 Generated with Claude Code