All documentation

Architecture

i18n Architecture — English / Uzbek / Russian

Module 15, task #348. This document describes the internationalization foundation added across the platform, and — just as importantly — what it deliberately does not cover yet.

Scope

This task builds the architecture: the mechanism by which any part of the platform can resolve a locale and translate a string, wired end-to-end with one real, working example per surface. It is not a full retrofit of every hardcoded string across apps/api, apps/web, apps/desktop, and apps/mobile. Doing that in one pass would mean rewriting the UI layer of every previously-shipped module — explicitly out of bounds under this project's standing rule against rewriting completed modules except where integration or bug-fixing requires it.

What exists after this task:

  • A single source of truth for supported locales (packages/shared/src/i18n.ts).
  • A working backend locale-resolution + translation pattern, demonstrated end-to-end in one real notification path.
  • A working, additive i18n provider in the web app, wired into the root layout and demonstrated in the Preferences settings page.
  • A working, additive i18n store in the desktop app, wired into the Appearance settings section (which already had a language selector persisting to localStorage but doing nothing with it).
  • Locale-aware UserPreferences.language validation (the field already existed; it's now restricted to en/uz/ru instead of any string).
  • Scaffolding for the mobile app's own string catalog (ARB files + l10n.yaml + flutter_localizations wiring for Flutter's built-in localizations), plus a working localeProvider that already drives MaterialApp.router's locale/supportedLocales.

What does not exist after this task, on purpose:

  • Every string in every screen of every app is still hardcoded English. The reference integrations above (one settings page per surface) exist to prove the mechanism works and to give the next contributor a template to copy — not to claim the platform is fully translated.
  • No UI language auto-switches based on this catalog outside the settings pages touched in this task.
  • Mobile's AppLocalizations (the codegenerated string catalog) is not yet generated — see "Mobile: one step remaining" below.

Supported locales

packages/shared/src/i18n.ts:

ts
export const SUPPORTED_LOCALES = ["en", "uz", "ru"] as const;
export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = "en";
export const LOCALE_LABELS: Record<Locale, string>; // each locale's name for itself
export function isSupportedLocale(value: string): value is Locale;

Every surface imports from here rather than redeclaring the locale list, so adding a fourth locale later is a one-line change plus new catalog entries — not a hunt across four codebases for every place "en" | "uz" | "ru" was typed out by hand.

Backend (apps/api)

Two small, focused utilities under apps/api/src/common/i18n/:

  • locale.util.ts — resolveLocale(preferredLanguage, acceptLanguageHeader). Resolution order: a valid stored UserPreferences.language wins first; otherwise the first supported tag in an Accept-Language header (region subtags stripped, e.g. en-US → en); otherwise DEFAULT_LOCALE.
  • message-catalog.ts — translate(key, locale, params), backed by a small hand-written Record<Locale, Record<MessageKey, (params) => string>> catalog. This is for backend-generated, persisted user-facing strings (notification titles/bodies, future email subjects) — not a general replacement for every AppException message in the API.

Reference integration: BugBountyDeadlineNotifierService (apps/api/src/modules/bugbounty/notifications/bugbounty-deadline-notifier.service.ts). Deadline notification titles/bodies used to be hardcoded English template literals (`Overdue: ${event.title}`); they're now resolved through resolveLocale() (using the recipient's UserPreferences.language, looked up via PrismaService directly — read-only and best-effort, so a failed lookup falls back to DEFAULT_LOCALE rather than blocking the notification) and rendered through translate(). Covered by bugbounty-deadline-notifier.service.spec.ts.

UserPreferences.language validation: previously any string up to 10 characters (update-preferences.dto.ts). Now @IsIn(SUPPORTED_LOCALES) — mirrors the existing theme field's @IsIn(THEMES) pattern. No schema migration needed; language was already a plain String column with a "en" default, so this tightens validation at the DTO boundary rather than the database.

Web (apps/web)

apps/web/features/i18n/:

  • messages/{en,uz,ru}.json — a starter catalog (~15 keys: common actions, the Preferences settings page's own copy).
  • i18n-context.tsx — I18nProvider + useI18n(). Plain React context, not a new npm dependency (no next-intl/react-intl) — this pass is about the architecture, and the extra library surface isn't earning its keep yet for ~15 keys; adding a dependency here would also have needed a pnpm install this environment couldn't verify completes within its tool-call time ceiling.

Locale state lives in localStorage (key phai.language, shared with the desktop app's convention — see below) with a navigator.language fallback, not in the authenticated usePreferences() query — the provider mounts at the app root (app/providers.tsx), which wraps logged-out routes (/login, /register) too, so it can't depend on data that only exists for signed-in users.

Reference integration: app/(dashboard)/settings/preferences/page.tsx. Its own copy (title, description, field labels, toast messages) now reads from t(), and a new Language <Select> sits next to the existing Theme control, calling setLocale() (instant UI update) and updatePreferences.mutateAsync({ language }) (persistence) together — the same two-step pattern the Theme control already used for setTheme()/mutateAsync({ theme }).

Desktop (apps/desktop)

apps/desktop/src/hooks/useI18n.ts — a Zustand store (useI18nStore) mirroring useTheme.ts's exact shape: module-level initial* read at import time, localStorage persistence under the same phai.language key the desktop Appearance settings' language <select> was already writing to (that control's own doc comment called this out explicitly as "the integration point once [i18n] lands, not a no-op" — this task is that landing).

apps/desktop/src/i18n/messages/{en,uz,ru}.json — a small starter catalog for the Appearance settings section's own labels.

Reference integration: AppearanceSettings.tsx. Both the Theme and Language section headers, and the three theme option labels, now read from t(); the language <select> now drives useI18nStore instead of raw useState + manual localStorage calls.

Verification note: apps/desktop/node_modules does not exist in this sandbox (confirmed — the directory is entirely absent, unlike apps/api/apps/web/packages/shared, which all have working installs). tsc/eslint could not be run against the desktop app's changes here; they were verified by careful manual review and by mirroring useTheme.ts's already-working pattern field-for-field. Run pnpm --filter @pentesthub/desktop check-types and pnpm --filter @pentesthub/desktop lint on a normal machine before shipping.

Mobile (apps/mobile)

Two layers, one working now and one requiring a single codegen step:

  1. Working now: flutter_localizations added as a dependency (ships with the Flutter SDK, no pub.dev resolution needed — unlike every other dependency in pubspec.yaml, which already carries an "unverified against a live resolution" disclaimer from Module 12, since this sandbox has no network path to the Flutter/Dart artifact host). A new localeProvider (features/settings/application/locale_notifier.dart) mirrors ThemeModeNotifier exactly — NotifierProvider + the same Hive settings_box persistence — and now drives MaterialApp.router's locale/supportedLocales/localizationsDelegates in app.dart, giving real, immediately-working localization for Flutter's own built-in Material/Widgets/Cupertino strings (date pickers, "OK"/"Cancel", text direction). The Settings screen's "Language" row — previously a static Text('English (device default)') — is now a working DropdownButton<Locale> wired to this provider, the same pattern the Theme row next to it already used.

  2. One step remaining: lib/l10n/app_{en,uz,ru}.arb (a starter catalog) and l10n.yaml are in place, and pubspec.yaml has generate: true set. This is the standard Flutter mechanism for a codegenerated AppLocalizations class — but generating it requires running flutter gen-l10n (or any flutter build/flutter run, which trigger it automatically), which needs the Flutter SDK. This sandbox cannot run it (no network path to the Flutter/Dart artifact host — the same constraint pubspec.yaml's own top-of-file comment already discloses for flutter pub get). No Dart file in this repo imports AppLocalizations — doing so before the class exists would break the analyzer outright, not just leave something unverified. Run flutter gen-l10n once on a machine with the Flutter SDK, then add AppLocalizations.delegate to app.dart's localizationsDelegates list and begin converting screens' hardcoded strings.

Adding a string to an existing catalog

  • Backend (apps/api): add the key to MessageKey and all three locale entries in message-catalog.ts.
  • Web: add the key to all three files under apps/web/features/i18n/messages/. TypeScript enforces the three files stay in sync (Messages = typeof en, and CATALOGS: Record<Locale, Messages> — a missing key in uz.json/ru.json is a compile error).
  • Desktop: same pattern, under apps/desktop/src/i18n/messages/.
  • Mobile: add the key to all three .arb files, then run flutter gen-l10n to regenerate AppLocalizations.

Adding a fourth locale

  1. Add it to SUPPORTED_LOCALES/LOCALE_LABELS in packages/shared/src/i18n.ts, rebuild the package (pnpm --filter @pentesthub/shared build).
  2. Add a full catalog file for it in each of the four locations above.
  3. For mobile, add it to supportedLocales in locale_notifier.dart and re-run flutter gen-l10n.

Known gaps (tracked, not silently dropped)

  • Full string-extraction across every existing screen/component in all four apps — genuinely out of scope for this task, not an oversight. A natural place to pick this up incrementally: each settings page or feature area converts its own strings when it's next touched for an unrelated reason, rather than a single enormous mechanical PR.
  • Mobile's AppLocalizations generation (see above) — a five-minute step on a real machine, blocked purely by this sandbox's lack of network access to the Flutter toolchain.
  • No RTL testing was done (none of the three current locales are RTL); the Locale/ICU-based approach used here supports RTL locales without further architectural changes if one is added later.
  • No translator/reviewer pass on the Uzbek or Russian strings in this task's catalogs — they were written directly, not run through a professional translation review. Fine for a starter catalog demonstrating the mechanism; worth a real linguistic review before any of these strings are load-bearing for real users.