Migrate TREK 3 to NestJS + React 19 with a shared Zod contract layer

Brownfield strangler migration of the backend onto NestJS modules
(auth, trips, days, places, assignments, packing, todo, budget,
reservations, collab, files, photos, journey, share, settings, backup,
oidc, oauth, admin, atlas, vacay, weather, airports, maps, categories,
tags, notifications, system-notices) served through a per-prefix
dispatcher, keeping the existing SQLite/better-sqlite3 DB and JWT
httpOnly cookie auth, with behavioural parity for every route.

Client: React 19 upgrade, "page = wiring container + data hook"
pattern across all pages, per-domain Zustand stores bound to
@trek/shared contracts, and decomposition of the large components
(DayPlanSidebar, PackingListPanel, CollabNotes, FileManager,
MemoriesPanel, PlacesSidebar, CollabChat, SystemNoticeModal,
BudgetPanel, PlaceFormModal, ...) into focused render units backed by
in-file hooks.

Apply the shared global request pipeline (helmet/CSP, CORS, HSTS,
forced HTTPS, the global MFA policy and request logging) to the NestJS
instance as well, so a migrated route is protected identically to the
legacy fallback rather than bypassing it.
This commit is contained in:
Maurice
2026-05-30 02:39:26 +02:00
parent 6d2dd37414
commit fc7d8b5d12
347 changed files with 31278 additions and 10381 deletions
+145
View File
@@ -0,0 +1,145 @@
#!/usr/bin/env node
// i18n parity check — keeps Julien's "every locale = same files + keys as en"
// DoD honest over time.
//
// What it checks per non-en locale:
// 1. File set parity: every domain file that exists in en/ must exist in this
// locale's dir; no extra domain files allowed.
// 2. Key set parity: for each shared domain file, the top-level translation
// keys must match exactly (no missing, no extra).
//
// Output: structured report grouped by locale, plus a `--strict` flag that
// returns exit-code 1 when any drift is present (intended for CI). Without
// `--strict` the script exits 0 and prints, so it can also run as a non-blocking
// audit during translation work.
//
// Limitations: we only parse *top-level* string keys (those declared as the
// first column of the file, matching the regex below). Nested objects, function
// bodies, and inline comments are ignored. This matches how `t(key)` calls
// resolve at runtime in TranslationContext.
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const i18nRoot = join(here, '..', 'src', 'i18n');
// Match a top-level translation key declaration: leading whitespace, then a
// quoted key (must start with a lowercase letter), then a colon. This is the
// exact pattern every domain file uses.
const TOP_LEVEL_KEY_RE = /^\s*'([a-z][a-zA-Z0-9.\-_]*)'\s*:/gm;
function listLocales() {
return readdirSync(i18nRoot)
.filter((name) => statSync(join(i18nRoot, name)).isDirectory())
// externalNotifications is a barrel module, not a locale.
.filter((name) => name !== 'externalNotifications');
}
function listDomainFiles(locale) {
return readdirSync(join(i18nRoot, locale))
.filter((f) => f.endsWith('.ts') && f !== 'index.ts')
.sort();
}
function extractKeys(locale, file) {
const content = readFileSync(join(i18nRoot, locale, file), 'utf8');
const keys = new Set();
for (const match of content.matchAll(TOP_LEVEL_KEY_RE)) {
keys.add(match[1]);
}
return keys;
}
function diffSets(reference, candidate) {
const missing = [];
const extra = [];
for (const k of reference) if (!candidate.has(k)) missing.push(k);
for (const k of candidate) if (!reference.has(k)) extra.push(k);
return { missing, extra };
}
function checkParity() {
const locales = listLocales();
if (!locales.includes('en')) {
throw new Error('shared/src/i18n/en is required as the reference locale');
}
const enFiles = listDomainFiles('en');
const enKeysByDomain = new Map();
for (const f of enFiles) enKeysByDomain.set(f, extractKeys('en', f));
const report = { fileDrift: [], keyDrift: [] };
for (const locale of locales) {
if (locale === 'en') continue;
const localeFiles = listDomainFiles(locale);
const { missing: missingFiles, extra: extraFiles } = diffSets(
new Set(enFiles),
new Set(localeFiles),
);
if (missingFiles.length || extraFiles.length) {
report.fileDrift.push({ locale, missing: missingFiles, extra: extraFiles });
}
for (const file of enFiles) {
if (!localeFiles.includes(file)) continue;
const localeKeys = extractKeys(locale, file);
const { missing, extra } = diffSets(enKeysByDomain.get(file), localeKeys);
if (missing.length || extra.length) {
report.keyDrift.push({ locale, file, missing, extra });
}
}
}
return report;
}
function formatReport(report) {
const lines = [];
if (report.fileDrift.length === 0) {
lines.push('File parity: OK');
} else {
lines.push(`File parity: ${report.fileDrift.length} locale(s) with file drift`);
for (const { locale, missing, extra } of report.fileDrift) {
if (missing.length) lines.push(` ${locale}: missing ${missing.join(', ')}`);
if (extra.length) lines.push(` ${locale}: extra ${extra.join(', ')}`);
}
}
if (report.keyDrift.length === 0) {
lines.push('Key parity: OK');
} else {
lines.push(`Key parity: ${report.keyDrift.length} domain file(s) with key drift`);
for (const { locale, file, missing, extra } of report.keyDrift) {
const parts = [];
if (missing.length) parts.push(`missing ${missing.length} (e.g. ${missing.slice(0, 3).join(', ')})`);
if (extra.length) parts.push(`extra ${extra.length} (e.g. ${extra.slice(0, 3).join(', ')})`);
lines.push(` ${locale}/${file}: ${parts.join('; ')}`);
}
}
return lines.join('\n');
}
// Export a structured API for vitest. The CLI entry point only runs when
// executed directly (`node scripts/i18n-parity.mjs`), so importing this file
// from a spec does not produce side effects.
export { checkParity, formatReport };
const isCli = process.argv[1] && process.argv[1].endsWith('i18n-parity.mjs');
if (isCli) {
const strict = process.argv.includes('--strict');
const filesOnly = process.argv.includes('--files-only');
const report = checkParity();
process.stdout.write(formatReport(filesOnly ? { ...report, keyDrift: [] } : report) + '\n');
if (strict) {
const hasFileDrift = report.fileDrift.length > 0;
const hasKeyDrift = filesOnly ? false : report.keyDrift.length > 0;
if (hasFileDrift || hasKeyDrift) process.exit(1);
}
}