diff --git a/.changeset/localization-settings.md b/.changeset/localization-settings.md new file mode 100644 index 0000000000..002cf97fca --- /dev/null +++ b/.changeset/localization-settings.md @@ -0,0 +1,18 @@ +--- +"@objectstack/service-settings": minor +"@objectstack/runtime": minor +"@objectstack/spec": minor +"@objectstack/service-analytics": minor +--- + +feat(settings): `localization` settings — platform default timezone, language & formats (ADR-0053 Phase 2) + +Adds a `localization` SettingsManifest, the missing keystone that makes the Phase 2 reference-timezone actually configurable end-to-end. One declaration gives the full settings stack for free: platform built-in default → `global` → `tenant` cascade, a permission-gated settings page, and i18n. + +**Keys** (organization-level; per-user overrides intentionally out of scope for v1): `timezone` (UTC), `locale` (en-US), `default_country`, `date_format`, `time_format`, `number_format`, `first_day_of_week`, `currency` (USD), `fiscal_year_start`. Benchmarked against Salesforce/Workday "Company Information + Locale". + +**Resolver 收编** — `resolveExecutionContext` now resolves `timezone` **and** `locale` from the `localization` settings via the `settings` service (canonical 4-tier cascade), falling back to a direct tenant-scoped `sys_setting` read, then `UTC` / `en-US`. This replaces the hand-rolled `sys_user_preference` + tenant-only `sys_setting` path from #1978 (which bypassed the settings abstraction and is dropped along with the per-user tier). New `ExecutionContext.locale`. + +**Consumer wiring** — analytics date bucketing now picks up the resolved org timezone: `DatasetExecutor` threads `ExecutionContext.timezone` into the query (precedence: explicit selection tz → request tz → UTC), so #1982's tz-aware buckets fire for a configured org without callers passing a zone. Formula `today()`/`datetime` were already wired (#1979/#1980). + +Email `datetime` rendering (`SendTemplateInput.timezone`, shipped in #1981) is intentionally **not** wired here: the only current `sendTemplate` callers are pre-session auth emails with no org context; business-notification callers can pass the zone when they appear. diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index 1d0b303dce..330a8a100e 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -143,15 +143,31 @@ describe('resolveExecutionContext — API key verify path', () => { }); /** - * Reference-timezone resolution (ADR-0053 Phase 2, #1978): user preference → - * org default → UTC. Authenticate via API key so a userId is present, then - * seed `sys_user_preference` / `sys_setting` and assert `ctx.timezone`. + * Localization resolution (ADR-0053 Phase 2): reference `timezone` + `locale` + * resolved from the `localization` settings. Canonical path is the `settings` + * service (platform default → global → tenant); when it's absent the resolver + * falls back to a direct tenant-scoped `sys_setting` read, then UTC / en-US. + * Per-user overrides are intentionally out of scope (organization-level only), + * so `sys_user_preference` is no longer consulted. */ -describe('resolveExecutionContext — reference timezone (#1978)', () => { +describe('resolveExecutionContext — localization (timezone + locale)', () => { const RAW = 'osk_tz'; const apiKeyRows = [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', expires_at: FUTURE }]; - function makeTzOpts({ prefs = [], settings = [] }: { prefs?: any[]; settings?: any[] }) { + /** Fake `settings` service doing the 4-tier `get(namespace, key)` resolution. */ + function makeSettings(values: Record) { + return { + async get(namespace: string, key: string) { + return { value: values[`${namespace}.${key}`], source: 'tenant' }; + }, + }; + } + + function makeTzOpts({ + settings = [], + prefs = [], + settingsService, + }: { settings?: any[]; prefs?: any[]; settingsService?: any }) { const tables: Record = { sys_api_key: apiKeyRows, sys_user_preference: prefs, @@ -171,42 +187,57 @@ describe('resolveExecutionContext — reference timezone (#1978)', () => { }, }; return { - getService: async () => undefined, + getService: async (name: string) => (name === 'settings' ? settingsService : undefined), getQl: async () => ql, request: { headers: { 'x-api-key': RAW } }, }; } - it('prefers the user preference over the org default', async () => { + it('resolves timezone + locale via the settings service when present', async () => { const ctx = await resolveExecutionContext(makeTzOpts({ - prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }], - settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }], + settingsService: makeSettings({ + 'localization.timezone': 'Europe/Paris', + 'localization.locale': 'zh-CN', + }), })); expect(ctx.userId).toBe('u1'); - expect(ctx.timezone).toBe('America/New_York'); + expect(ctx.timezone).toBe('Europe/Paris'); + expect(ctx.locale).toBe('zh-CN'); }); - it('falls back to the tenant-scoped org default when no user preference', async () => { + it('falls back to a direct tenant-scoped sys_setting read when no settings service', async () => { const ctx = await resolveExecutionContext(makeTzOpts({ + settings: [ + { namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }, + { namespace: 'localization', key: 'locale', scope: 'tenant', value: 'ja-JP' }, + ], + })); + expect(ctx.timezone).toBe('Asia/Tokyo'); + expect(ctx.locale).toBe('ja-JP'); + }); + + it('ignores per-user sys_user_preference rows (organization-level only)', async () => { + const ctx = await resolveExecutionContext(makeTzOpts({ + prefs: [{ user_id: 'u1', key: 'timezone', value: 'America/New_York' }], settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Europe/Paris' }], })); - expect(ctx.timezone).toBe('Europe/Paris'); + expect(ctx.timezone).toBe('Europe/Paris'); // org default, NOT the user pref }); - it('defaults to UTC when neither is set', async () => { + it('defaults to UTC / en-US when nothing is configured', async () => { const ctx = await resolveExecutionContext(makeTzOpts({})); expect(ctx.timezone).toBe('UTC'); + expect(ctx.locale).toBe('en-US'); }); - it('ignores an invalid zone and continues down the chain', async () => { + it('ignores an invalid zone and falls back to the built-in', async () => { const ctx = await resolveExecutionContext(makeTzOpts({ - prefs: [{ user_id: 'u1', key: 'timezone', value: 'Not/AZone' }], - settings: [{ namespace: 'localization', key: 'timezone', scope: 'tenant', value: 'Asia/Tokyo' }], + settingsService: makeSettings({ 'localization.timezone': 'Not/AZone' }), })); - expect(ctx.timezone).toBe('Asia/Tokyo'); + expect(ctx.timezone).toBe('UTC'); }); - it('leaves timezone unset for anonymous requests', async () => { + it('leaves timezone/locale unset for anonymous requests', async () => { const ctx = await resolveExecutionContext({ getService: async () => undefined, getQl: async () => ({ async find() { return []; } }), @@ -214,5 +245,6 @@ describe('resolveExecutionContext — reference timezone (#1978)', () => { }); expect(ctx.userId).toBeUndefined(); expect(ctx.timezone).toBeUndefined(); + expect(ctx.locale).toBeUndefined(); }); }); diff --git a/packages/runtime/src/security/resolve-execution-context.ts b/packages/runtime/src/security/resolve-execution-context.ts index 5b0f679a03..e2d62bd76b 100644 --- a/packages/runtime/src/security/resolve-execution-context.ts +++ b/packages/runtime/src/security/resolve-execution-context.ts @@ -84,35 +84,57 @@ function coerceTimeZone(value: unknown): string | undefined { return s && isValidTimeZone(s) ? s : undefined; } +/** Coerce a stored locale value to a non-empty BCP-47-ish string, or undefined. */ +function coerceLocale(value: unknown): string | undefined { + const s = typeof value === 'string' ? value.trim() : value != null ? String(value).trim() : ''; + return s || undefined; +} + /** - * Resolve the active reference timezone for an authenticated context - * (ADR-0053 Phase 2): user preference → org default → `UTC`. + * Resolve the workspace localization defaults onto the ExecutionContext + * (ADR-0053 Phase 2): reference `timezone` and `locale`. * - * - User override: `sys_user_preference` row `(user_id, key='timezone')`. - * - Org default: the tenant-scoped `sys_setting` `(namespace='localization', - * key='timezone', scope='tenant')` — one org per physical tenant (ADR-0002), - * so the row needs no tenant_id filter. + * Canonical path is the `localization` SettingsManifest via the `settings` + * service, whose cascade is platform default → global → tenant (ADR-0002: one + * org per physical tenant; per-user overrides are intentionally out of scope + * for v1). When the settings service or its namespace is unavailable (e.g. a + * minimal deployment), fall back to a direct tenant-scoped `sys_setting` read, + * then to the built-ins `UTC` / `en-US`. * - * Pure plumbing: nothing downstream reads `ctx.timezone` yet, so an absent - * value resolves to `UTC` and preserves today's behavior. Every read is - * defensive (via `tryFind`) and an invalid zone falls through — timezone - * resolution never blocks auth. + * Every read is defensive — localization never blocks auth, and an invalid + * zone falls through to the built-in. */ -async function resolveTimezone(ql: any, userId: string): Promise { - const prefRows = await tryFind(ql, 'sys_user_preference', { user_id: userId, key: 'timezone' }, 1); - const userTz = coerceTimeZone(prefRows[0]?.value); - if (userTz) return userTz; - - const settingRows = await tryFind( - ql, - 'sys_setting', - { namespace: 'localization', key: 'timezone', scope: 'tenant' }, - 1, - ); - const orgTz = coerceTimeZone(settingRows[0]?.value); - if (orgTz) return orgTz; +async function resolveLocalization( + opts: ResolveOptions, + ql: any, + sctx: { tenantId?: string; userId?: string }, +): Promise<{ timezone: string; locale: string }> { + // 1. Canonical — the `localization` manifest via the settings service. + try { + const settings: any = await opts.getService('settings'); + if (settings && typeof settings.get === 'function') { + const [tzRes, localeRes] = await Promise.all([ + settings.get('localization', 'timezone', sctx).catch(() => undefined), + settings.get('localization', 'locale', sctx).catch(() => undefined), + ]); + const tz = coerceTimeZone(tzRes?.value); + const locale = coerceLocale(localeRes?.value); + // A resolved value (incl. the manifest default) means the namespace is + // live — trust it and skip the legacy direct read. + if (tz || locale) return { timezone: tz ?? 'UTC', locale: locale ?? 'en-US' }; + } + } catch { + // settings service unavailable → fall through to the direct read + } - return 'UTC'; + // 2. Fallback — direct tenant-scoped `sys_setting` rows (no settings service + // registered, or namespace not loaded). + const tzRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'timezone', scope: 'tenant' }, 1); + const localeRows = await tryFind(ql, 'sys_setting', { namespace: 'localization', key: 'locale', scope: 'tenant' }, 1); + return { + timezone: coerceTimeZone(tzRows[0]?.value) ?? 'UTC', + locale: coerceLocale(localeRows[0]?.value) ?? 'en-US', + }; } /** @@ -310,10 +332,13 @@ export async function resolveExecutionContext(opts: ResolveOptions): Promise; // Bucket selected date dimensions that declare an explicit `dateGranularity` @@ -314,7 +319,7 @@ export class DatasetExecutor { dimensions, where: baseFilter as Record | undefined, timeDimensions: shiftedTd, - timezone: selection.timezone ?? 'UTC', + timezone: selection.timezone ?? context?.timezone ?? 'UTC', }, context); // Rename measure columns to `__compare` so they merge alongside primary. return sub.rows.map((row) => { diff --git a/packages/services/service-settings/src/manifests/index.ts b/packages/services/service-settings/src/manifests/index.ts index ea0fee8d82..3b9d636a2b 100644 --- a/packages/services/service-settings/src/manifests/index.ts +++ b/packages/services/service-settings/src/manifests/index.ts @@ -12,6 +12,7 @@ export { aiTestEmbedderActionHandler, } from './ai.manifest.js'; export { knowledgeSettingsManifest, knowledgeTestActionHandler } from './knowledge.manifest.js'; +export { localizationSettingsManifest } from './localization.manifest.js'; import { authSettingsManifest } from './auth.manifest.js'; import { mailSettingsManifest } from './mail.manifest.js'; @@ -20,10 +21,12 @@ import { featureFlagsSettingsManifest } from './feature-flags.manifest.js'; import { storageSettingsManifest } from './storage.manifest.js'; import { aiSettingsManifest } from './ai.manifest.js'; import { knowledgeSettingsManifest } from './knowledge.manifest.js'; +import { localizationSettingsManifest } from './localization.manifest.js'; /** Convenience aggregate — pass to `SettingsServicePlugin({ manifests })`. */ export const builtinSettingsManifests = [ brandingSettingsManifest, + localizationSettingsManifest, authSettingsManifest, mailSettingsManifest, storageSettingsManifest, diff --git a/packages/services/service-settings/src/manifests/localization.manifest.test.ts b/packages/services/service-settings/src/manifests/localization.manifest.test.ts new file mode 100644 index 0000000000..72ee73d755 --- /dev/null +++ b/packages/services/service-settings/src/manifests/localization.manifest.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { SettingsManifestSchema } from '@objectstack/spec/system'; +import { localizationSettingsManifest } from './localization.manifest'; + +describe('localizationSettingsManifest', () => { + it('parses against SettingsManifestSchema', () => { + expect(() => SettingsManifestSchema.parse(localizationSettingsManifest)).not.toThrow(); + }); + + it('declares namespace=localization, scope=tenant, version=1', () => { + const parsed = SettingsManifestSchema.parse(localizationSettingsManifest); + expect(parsed.namespace).toBe('localization'); + expect(parsed.scope).toBe('tenant'); // 组织级 (per-user overrides out of scope for v1) + expect(parsed.version).toBe(1); + }); + + it('defaults to UTC / en-US — preserving pre-Phase-2 behavior when nothing is set', () => { + const specs = localizationSettingsManifest.specifiers as any[]; + const byKey = (k: string) => specs.find((s) => s.key === k); + expect(byKey('timezone').default).toBe('UTC'); + expect(byKey('locale').default).toBe('en-US'); + expect(byKey('currency').default).toBe('USD'); + expect(byKey('date_format').default).toBe('YYYY-MM-DD'); + expect(byKey('first_day_of_week').default).toBe('monday'); + expect(byKey('fiscal_year_start').default).toBe('january'); + }); + + it('every timezone option is a valid IANA zone', () => { + const tz = (localizationSettingsManifest.specifiers as any[]).find((s) => s.key === 'timezone'); + for (const opt of tz.options) { + expect( + () => new Intl.DateTimeFormat('en-US', { timeZone: String(opt.value) }), + ).not.toThrow(); + } + }); + + it('exposes the nine regional keys grouped into region/formats/finance', () => { + const specs = localizationSettingsManifest.specifiers as any[]; + const keys = specs.filter((s) => s.key).map((s) => s.key); + expect(keys).toEqual([ + 'timezone', 'locale', 'default_country', + 'date_format', 'time_format', 'number_format', 'first_day_of_week', + 'currency', 'fiscal_year_start', + ]); + const groups = specs.filter((s) => s.type === 'group').map((s) => s.id); + expect(groups).toEqual(['region', 'formats', 'finance']); + }); +}); diff --git a/packages/services/service-settings/src/manifests/localization.manifest.ts b/packages/services/service-settings/src/manifests/localization.manifest.ts new file mode 100644 index 0000000000..3d9b33d070 --- /dev/null +++ b/packages/services/service-settings/src/manifests/localization.manifest.ts @@ -0,0 +1,147 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SettingsManifest } from '@objectstack/spec/system'; + +/** + * Localization — workspace-wide regional defaults (ADR-0053 Phase 2 follow-up). + * + * The single source of truth for the platform's reference timezone, language, + * currency, and display formats. `resolveExecutionContext` reads `timezone` + * and `locale` from here (cascade: platform default → global → tenant) onto + * every `ExecutionContext`, so formulas (`today()`), analytics date bucketing, + * and rendered `datetime` instants all resolve against the org's region. + * + * Scope is `tenant`: one org per physical tenant (ADR-0002) sets its regional + * defaults; the manifest `default` of each key is the platform built-in, and a + * `global` row (or `OS_LOCALIZATION_*` env) can pin a deployment-wide value. + * Per-user overrides are intentionally out of scope for v1. + */ +export const localizationSettingsManifest: SettingsManifest = { + namespace: 'localization', + version: 1, + label: 'Localization', + icon: 'Globe', + description: 'Default timezone, language, currency, and date/number formats.', + scope: 'tenant', + readPermission: 'setup.access', + writePermission: 'setup.write', + category: 'Workspace', + order: 2, + specifiers: [ + // ── Region ──────────────────────────────────────────────────────────── + { type: 'group', id: 'region', label: 'Region', required: false }, + { + type: 'select', key: 'timezone', label: 'Default timezone', required: false, default: 'UTC', + description: 'IANA zone used to resolve today()/daysFromNow, analytics date buckets, and rendered datetimes.', + options: [ + { value: 'UTC', label: 'UTC' }, + { value: 'America/Los_Angeles', label: '(UTC−08/−07) Los Angeles' }, + { value: 'America/Denver', label: '(UTC−07/−06) Denver' }, + { value: 'America/Chicago', label: '(UTC−06/−05) Chicago' }, + { value: 'America/New_York', label: '(UTC−05/−04) New York' }, + { value: 'America/Sao_Paulo', label: '(UTC−03) São Paulo' }, + { value: 'Europe/London', label: '(UTC±00/+01) London' }, + { value: 'Europe/Paris', label: '(UTC+01/+02) Paris' }, + { value: 'Europe/Berlin', label: '(UTC+01/+02) Berlin' }, + { value: 'Europe/Moscow', label: '(UTC+03) Moscow' }, + { value: 'Asia/Dubai', label: '(UTC+04) Dubai' }, + { value: 'Asia/Kolkata', label: '(UTC+05:30) Kolkata' }, + { value: 'Asia/Singapore', label: '(UTC+08) Singapore' }, + { value: 'Asia/Shanghai', label: '(UTC+08) Shanghai' }, + { value: 'Asia/Tokyo', label: '(UTC+09) Tokyo' }, + { value: 'Australia/Sydney', label: '(UTC+10/+11) Sydney' }, + { value: 'Pacific/Auckland', label: '(UTC+12/+13) Auckland' }, + ], + }, + { + type: 'select', key: 'locale', label: 'Default language', required: false, default: 'en-US', + description: 'BCP-47 locale for message catalogs and number/date formatting.', + options: [ + { value: 'en-US', label: 'English (US)' }, + { value: 'zh-CN', label: '简体中文' }, + { value: 'ja-JP', label: '日本語' }, + { value: 'es-ES', label: 'Español (España)' }, + ], + }, + { + type: 'text', key: 'default_country', label: 'Default country', required: false, default: 'US', + description: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN). Used for address and phone defaults.', + pattern: '^[A-Za-z]{2}$', minLength: 2, maxLength: 2, + }, + + // ── Formats ─────────────────────────────────────────────────────────── + { type: 'group', id: 'formats', label: 'Formats', required: false }, + { + type: 'select', key: 'date_format', label: 'Date format', required: false, default: 'YYYY-MM-DD', + options: [ + { value: 'YYYY-MM-DD', label: '2026-06-17 (ISO)' }, + { value: 'MM/DD/YYYY', label: '06/17/2026 (US)' }, + { value: 'DD/MM/YYYY', label: '17/06/2026 (EU)' }, + { value: 'DD.MM.YYYY', label: '17.06.2026' }, + { value: 'DD-MMM-YYYY', label: '17-Jun-2026' }, + ], + }, + { + type: 'select', key: 'time_format', label: 'Time format', required: false, default: '24h', + options: [ + { value: '24h', label: '24-hour (14:30)' }, + { value: '12h', label: '12-hour (2:30 PM)' }, + ], + }, + { + type: 'select', key: 'number_format', label: 'Number format', required: false, default: '1,234.56', + description: 'Grouping and decimal separators for displayed numbers.', + options: [ + { value: '1,234.56', label: '1,234.56 (comma / dot)' }, + { value: '1.234,56', label: '1.234,56 (dot / comma)' }, + { value: '1 234,56', label: '1 234,56 (space / comma)' }, + { value: '1,23,456.78', label: '1,23,456.78 (Indian)' }, + ], + }, + { + type: 'select', key: 'first_day_of_week', label: 'First day of week', required: false, default: 'monday', + description: 'Anchors weekly analytics buckets and calendar grids.', + options: [ + { value: 'monday', label: 'Monday (ISO)' }, + { value: 'sunday', label: 'Sunday' }, + { value: 'saturday', label: 'Saturday' }, + ], + }, + + // ── Finance ─────────────────────────────────────────────────────────── + { type: 'group', id: 'finance', label: 'Finance', required: false }, + { + type: 'select', key: 'currency', label: 'Default currency', required: false, default: 'USD', + description: 'ISO 4217 code applied when a currency field omits its own.', + options: [ + { value: 'USD', label: 'USD — US Dollar' }, + { value: 'EUR', label: 'EUR — Euro' }, + { value: 'GBP', label: 'GBP — British Pound' }, + { value: 'JPY', label: 'JPY — Japanese Yen' }, + { value: 'CNY', label: 'CNY — Chinese Yuan' }, + { value: 'INR', label: 'INR — Indian Rupee' }, + { value: 'AUD', label: 'AUD — Australian Dollar' }, + { value: 'CAD', label: 'CAD — Canadian Dollar' }, + { value: 'BRL', label: 'BRL — Brazilian Real' }, + ], + }, + { + type: 'select', key: 'fiscal_year_start', label: 'Fiscal year start', required: false, default: 'january', + description: 'First month of the fiscal year — drives "this quarter / fiscal year" in reports.', + options: [ + { value: 'january', label: 'January' }, + { value: 'february', label: 'February' }, + { value: 'march', label: 'March' }, + { value: 'april', label: 'April' }, + { value: 'may', label: 'May' }, + { value: 'june', label: 'June' }, + { value: 'july', label: 'July' }, + { value: 'august', label: 'August' }, + { value: 'september', label: 'September' }, + { value: 'october', label: 'October' }, + { value: 'november', label: 'November' }, + { value: 'december', label: 'December' }, + ], + }, + ], +}; diff --git a/packages/services/service-settings/src/translations/en.ts b/packages/services/service-settings/src/translations/en.ts index f5e8aa7d93..c34834e6f3 100644 --- a/packages/services/service-settings/src/translations/en.ts +++ b/packages/services/service-settings/src/translations/en.ts @@ -72,6 +72,27 @@ export const en: TranslationData = { }, }, + localization: { + title: 'Localization', + description: 'Default timezone, language, currency, and date/number formats.', + groups: { + region: { title: 'Region' }, + formats: { title: 'Formats' }, + finance: { title: 'Finance' }, + }, + keys: { + timezone: { label: 'Default timezone', help: 'IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes.' }, + locale: { label: 'Default language', help: 'BCP-47 locale for message catalogs and number/date formatting.' }, + default_country: { label: 'Default country', help: 'ISO 3166-1 alpha-2 code (e.g. US, GB, CN).' }, + date_format: { label: 'Date format' }, + time_format: { label: 'Time format', options: { '24h': '24-hour (14:30)', '12h': '12-hour (2:30 PM)' } }, + number_format: { label: 'Number format' }, + first_day_of_week: { label: 'First day of week', options: { monday: 'Monday (ISO)', sunday: 'Sunday', saturday: 'Saturday' } }, + currency: { label: 'Default currency' }, + fiscal_year_start: { label: 'Fiscal year start' }, + }, + }, + auth: { title: 'Authentication', description: 'Sign-in, registration, and built-in auth feature controls.', diff --git a/packages/services/service-settings/src/translations/zh-CN.ts b/packages/services/service-settings/src/translations/zh-CN.ts index 60e8f657e5..53c36ed492 100644 --- a/packages/services/service-settings/src/translations/zh-CN.ts +++ b/packages/services/service-settings/src/translations/zh-CN.ts @@ -68,6 +68,27 @@ export const zhCN: TranslationData = { }, }, + localization: { + title: '本地化', + description: '默认时区、语言、货币及日期/数字格式。', + groups: { + region: { title: '区域' }, + formats: { title: '格式' }, + finance: { title: '财务' }, + }, + keys: { + timezone: { label: '默认时区', help: '用于 today()/daysFromNow、分析日期分桶和 datetime 渲染的 IANA 时区。' }, + locale: { label: '默认语言', help: '用于消息文案和数字/日期格式的 BCP-47 语言。' }, + default_country: { label: '默认国家/地区', help: 'ISO 3166-1 二位代码(如 US、GB、CN)。' }, + date_format: { label: '日期格式' }, + time_format: { label: '时间格式', options: { '24h': '24 小时制(14:30)', '12h': '12 小时制(2:30 PM)' } }, + number_format: { label: '数字格式' }, + first_day_of_week: { label: '每周起始日', options: { monday: '周一(ISO)', sunday: '周日', saturday: '周六' } }, + currency: { label: '默认货币' }, + fiscal_year_start: { label: '财年起始月' }, + }, + }, + auth: { title: '认证', description: '登录、注册以及内置认证功能的控制项。', diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 0c48407c45..18b0ec2c0b 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -27,11 +27,19 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ /** * Active reference timezone (IANA name, e.g. `America/New_York`), resolved - * once per request as user-preference → org default → `UTC` (ADR-0053 - * Phase 2). When unset, consumers treat it as `UTC` — today's behavior. + * once per request from the `localization` settings (platform default → + * global → tenant; ADR-0053 Phase 2). When unset, consumers treat it as + * `UTC` — today's behavior. */ timezone: z.string().optional(), + /** + * Active locale (BCP-47, e.g. `en-US`), resolved from the `localization` + * settings alongside `timezone`. Drives message catalogs and number/date + * formatting. When unset, consumers treat it as `en-US`. + */ + locale: z.string().optional(), + /** User role names (resolved from Member + Role) */ roles: z.array(z.string()).default([]),