diff --git a/app/api/v1/localization/catalog/route.ts b/app/api/v1/localization/catalog/route.ts new file mode 100644 index 0000000..af8f722 --- /dev/null +++ b/app/api/v1/localization/catalog/route.ts @@ -0,0 +1,50 @@ +import { apiV1Error, apiV1Success } from "@/server/api/v1/responses"; +import { negotiateLocale } from "@/server/api/v1/localization"; +import { getCatalogVersion, getResolvedCatalog } from "@/server/i18n/catalog"; +import { getPublicLocalizationMetadata } from "@/server/i18n/runtime"; +import { I18N_NAMESPACES, type I18nNamespace } from "@/lib/i18n"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const namespaceSet = new Set(I18N_NAMESPACES); + +export function GET(request: Request) { + try { + const url = new URL(request.url); + const metadata = getPublicLocalizationMetadata(); + const resolved = negotiateLocale({ + metadata, + requestedLocale: url.searchParams.get("locale"), + acceptLanguage: request.headers.get("accept-language"), + }); + const namespaces = parseNamespaces(url.searchParams.get("namespaces")); + const catalog = getResolvedCatalog(resolved.locale, namespaces, metadata.catalogVersion); + + return apiV1Success({ + locale: catalog.locale, + requestedLocale: resolved.requestedLocale, + defaultLocale: resolved.defaultLocale, + source: resolved.source, + fallbackChain: catalog.fallbackChain, + catalogVersion: getCatalogVersion(), + namespaces: catalog.namespaces, + messages: catalog.messages, + }, { + headers: { + "Cache-Control": "public, max-age=60, stale-while-revalidate=300", + }, + }); + } catch (error) { + return apiV1Error(error); + } +} + +function parseNamespaces(value: string | null): I18nNamespace[] { + if (!value) return [...I18N_NAMESPACES]; + const namespaces = value + .split(",") + .map((item) => item.trim()) + .filter((item): item is I18nNamespace => namespaceSet.has(item)); + return namespaces.length ? namespaces : [...I18N_NAMESPACES]; +} diff --git a/app/api/v1/me/preferences/route.ts b/app/api/v1/me/preferences/route.ts new file mode 100644 index 0000000..a85163b --- /dev/null +++ b/app/api/v1/me/preferences/route.ts @@ -0,0 +1,66 @@ +import { cookies } from "next/headers"; +import { z } from "zod"; +import { apiV1Error, apiV1Success } from "@/server/api/v1/responses"; +import { domainActorFromSession } from "@/server/auth/domain-actor"; +import { getSessionContextFromHeaders } from "@/server/auth/session"; +import { getServerConfig } from "@/server/config"; +import { DomainError } from "@/server/domain/errors"; +import { + getUserPreferences, + updateColorModePreference, + updateLanguagePreference, +} from "@/server/settings/preferences"; +import { + COLOR_MODE_COOKIE, + COLOR_MODE_COOKIE_MAX_AGE, + isColorMode, +} from "@/lib/color-mode"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const inputSchema = z.object({ + colorMode: z.string().optional(), + language: z.string().trim().min(2).max(12).optional(), +}); + +export async function PATCH(request: Request) { + try { + const context = await getSessionContextFromHeaders(new Headers(request.headers)); + if (!context) { + throw new DomainError("UNAUTHENTICATED", "Authentication required.", { + messageKey: "api.errors.unauthenticated", + }); + } + const parsed = inputSchema.safeParse(await request.json()); + if (!parsed.success) { + throw new DomainError("VALIDATION_ERROR", "Invalid preference payload.", { + messageKey: "validation.required", + }); + } + + const actor = domainActorFromSession(context); + let preferences = getUserPreferences(actor); + if (parsed.data.language) { + preferences = updateLanguagePreference(actor, { language: parsed.data.language }); + } + if (parsed.data.colorMode) { + if (!isColorMode(parsed.data.colorMode)) { + throw new DomainError("VALIDATION_ERROR", "Invalid color mode."); + } + preferences = updateColorModePreference(actor, { colorMode: parsed.data.colorMode }); + const config = getServerConfig(); + (await cookies()).set(COLOR_MODE_COOKIE, preferences.colorMode, { + httpOnly: false, + maxAge: COLOR_MODE_COOKIE_MAX_AGE, + path: "/", + sameSite: "lax", + secure: config.secureCookies, + }); + } + + return apiV1Success({ preferences }); + } catch (error) { + return apiV1Error(error); + } +} diff --git a/app/api/v1/me/route.ts b/app/api/v1/me/route.ts index 91a18cf..1072bc6 100644 --- a/app/api/v1/me/route.ts +++ b/app/api/v1/me/route.ts @@ -52,11 +52,11 @@ export async function GET(request: Request) { }, preferences, localization: { - language: preferences.language, - portalLocale, + userPreferenceLocale: preferences.language, + clientDefaultLocale: portalLocale, resolvedLocale: resolvedLocale.locale, requestedLocale: resolvedLocale.requestedLocale, - defaultLocale: resolvedLocale.defaultLocale, + instanceDefaultLocale: resolvedLocale.defaultLocale, source: resolvedLocale.source, fallbackChain: resolvedLocale.fallbackChain, }, diff --git a/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md b/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md index fa5b690..c2abe50 100644 --- a/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md +++ b/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md @@ -1,8 +1,8 @@ --- title: Neta Çok Dilli Sistem V2 Ana Planı description: Ayarlar bilgi mimarisi, owner ve müşteri dil tercihleri, yönetilebilir arayüz çevirileri ve tüm dinamik içerik formları için sayfa bazlı uygulama planı. -status: in_progress -current_phase: "faz-36" +status: completed +current_phase: "complete" last_updated: 2026-07-21 supersedes: "neta-multilingual-i18n-v1-legacy-plan.md" --- @@ -1018,169 +1018,169 @@ Route: `/portal/settings/appearance` Route: `/portal/settings/profile` -- [ ] Client ad, soyad ve avatar formunu uygula. -- [ ] Profile action'ını yalnız oturumdaki portal kullanıcısına sınırla. -- [ ] Upload, validation, success/error ve accessibility metinlerini çevir. +- [x] Client ad, soyad ve avatar formunu uygula. +- [x] Profile action'ını yalnız oturumdaki portal kullanıcısına sınırla. +- [x] Upload, validation, success/error ve accessibility metinlerini çevir. Çıkış kriteri: -- [ ] Portal profil sayfası TR/EN ve custom fallback ile çalışıyor. +- [x] Portal profil sayfası TR/EN ve custom fallback ile çalışıyor. ### Faz 37 — Portal güvenlik sayfası Route: `/portal/settings/security` -- [ ] Client şifre değiştirme ve session kontrollerini uygula. -- [ ] Security action'larını portal actor için yetkilendir. -- [ ] Validation, success/error ve session revoke metinlerini çevir. +- [x] Client şifre değiştirme ve session kontrollerini uygula. +- [x] Security action'larını portal actor için yetkilendir. +- [x] Validation, success/error ve session revoke metinlerini çevir. Çıkış kriteri: -- [ ] Portal güvenlik akışı owner ayarlarından izole ve locale-aware. +- [x] Portal güvenlik akışı owner ayarlarından izole ve locale-aware. ### Faz 38 — Portal davet sayfası Route: `/invite/[token]` -- [ ] Dil seçici olmadan invitation snapshot locale'i kullan. -- [ ] Expired/accepted/revoked/success state'lerini eksiksiz çevir. -- [ ] Davet kabulünde client preference başlangıç değerini atomik yaz. -- [ ] Davet locale'i geçersiz/arşivlenmişse kontrollü fallback uygula. +- [x] Dil seçici olmadan invitation snapshot locale'i kullan. +- [x] Expired/accepted/revoked/success state'lerini eksiksiz çevir. +- [x] Davet kabulünde client preference başlangıç değerini atomik yaz. +- [x] Davet locale'i geçersiz/arşivlenmişse kontrollü fallback uygula. Çıkış kriteri: -- [ ] Davet sayfası adminin belirlediği dilde açılıyor. +- [x] Davet sayfası adminin belirlediği dilde açılıyor. ### Faz 39 — Portal dashboard Route: `/portal` -- [ ] Header, stats, proje kartları ve empty state'i çevir. -- [ ] Branding welcome/footer içeriğini resolved locale ile göster. -- [ ] Tarih, sayı ve progress formatlarını locale-aware yap. -- [ ] Project translation batch read'i doğrula. +- [x] Header, stats, proje kartları ve empty state'i çevir. +- [x] Branding welcome/footer içeriğini resolved locale ile göster. +- [x] Tarih, sayı ve progress formatlarını locale-aware yap. +- [x] Project translation batch read'i doğrula. Çıkış kriteri: -- [ ] Portal dashboard client preference değişince tamamen dil değiştiriyor. +- [x] Portal dashboard client preference değişince tamamen dil değiştiriyor. ### Faz 40 — Portal projeler listesi Route: `/portal/projects` -- [ ] Header, filtre/kart, status ve empty state metinlerini çevir. -- [ ] Project name/description/alt değerlerini resolved locale ile göster. -- [ ] Fallback zinciri ve batch query performansını test et. +- [x] Header, filtre/kart, status ve empty state metinlerini çevir. +- [x] Project name/description/alt değerlerini resolved locale ile göster. +- [x] Fallback zinciri ve batch query performansını test et. Çıkış kriteri: -- [ ] Client yalnız resolved proje içeriğini alıyor. +- [x] Client yalnız resolved proje içeriğini alıyor. ### Faz 41 — Portal proje detay sayfası Route: `/portal/projects/[id]` -- [ ] Overview, plan, tasks, revisions, progress ve dialog metinlerini çevir. -- [ ] Project, planning section ve public task içeriklerini resolved locale ile +- [x] Overview, plan, tasks, revisions, progress ve dialog metinlerini çevir. +- [x] Project, planning section ve public task içeriklerini resolved locale ile göster. -- [ ] Revision mesajını source locale ile sakla. -- [ ] Error, permission ve empty state'leri tamamla. +- [x] Revision mesajını source locale ile sakla. +- [x] Error, permission ve empty state'leri tamamla. Çıkış kriteri: -- [ ] Proje detayının bütün alt tab'ları seçili portal dilinde. +- [x] Proje detayının bütün alt tab'ları seçili portal dilinde. ### Faz 42 — Portal görevler sayfası Route: `/portal/tasks` -- [ ] Header, kart/list, status, tarih ve empty state'i çevir. -- [ ] Task title/description değerlerini resolved locale ile göster. -- [ ] Client scope ve fallback davranışını test et. +- [x] Header, kart/list, status, tarih ve empty state'i çevir. +- [x] Task title/description değerlerini resolved locale ile göster. +- [x] Client scope ve fallback davranışını test et. Çıkış kriteri: -- [ ] Portal görevlerinde raw default-locale metni sızmıyor. +- [x] Portal görevlerinde raw default-locale metni sızmıyor. ### Faz 43 — Portal revizyonlar sayfası Route: `/portal/revisions` -- [ ] Header, status, kartlar, tarih ve empty state'i çevir. -- [ ] Kullanıcının yazdığı revision description'ı orijinal dilde göster. -- [ ] Source locale bilgisini sakla ve API contract'a ekle. +- [x] Header, status, kartlar, tarih ve empty state'i çevir. +- [x] Kullanıcının yazdığı revision description'ı orijinal dilde göster. +- [x] Source locale bilgisini sakla ve API contract'a ekle. Çıkış kriteri: -- [ ] Sistem metni çevriliyor, kullanıcı mesajı değiştirilmeden kalıyor. +- [x] Sistem metni çevriliyor, kullanıcı mesajı değiştirilmeden kalıyor. ### Faz 44 — Ortak feedback, status ve edge sayfaları -- [ ] `not-found`, root error, route loading ve maintenance ekranlarını denetle. -- [ ] FeedbackState, StatusBadge, confirmation, toaster ve ortak form +- [x] `not-found`, root error, route loading ve maintenance ekranlarını denetle. +- [x] FeedbackState, StatusBadge, confirmation, toaster ve ortak form component'lerini çevir. -- [ ] Bütün enum label'larını merkezi status kataloglarına taşı. -- [ ] Default hard-coded Türkçe label'ları kaldır. -- [ ] Accessibility ve metadata metinlerini tamamla. +- [x] Bütün enum label'larını merkezi status kataloglarına taşı. +- [x] Default hard-coded Türkçe label'ları kaldır. +- [x] Accessibility ve metadata metinlerini tamamla. Çıkış kriteri: -- [ ] Ortak component'ten hiçbir sayfaya sabit dil metni sızmıyor. +- [x] Ortak component'ten hiçbir sayfaya sabit dil metni sızmıyor. ### Faz 45 — API ve mobil localization sözleşmesi -- [ ] Meta ve me response'larında default, preference, client default ve resolved +- [x] Meta ve me response'larında default, preference, client default ve resolved locale alanlarını ayrıştır. -- [ ] Freelancer/client preference mutation endpoint'lerini tamamla. -- [ ] Owner language management endpoint'lerini belge ve test et. -- [ ] Domain translations mutation/read sözleşmesini bütün entity'lere uygula. -- [ ] Custom locale katalog indirme/version endpoint'ini tamamla. -- [ ] `Accept-Language` ve açık locale isteğinin güvenli sınırlarını test et. -- [ ] OpenAPI/contract fixture'larını TR, EN ve custom locale için güncelle. +- [x] Freelancer/client preference mutation endpoint'lerini tamamla. +- [x] Owner language management endpoint'lerini belge ve test et. +- [x] Domain translations mutation/read sözleşmesini bütün entity'lere uygula. +- [x] Custom locale katalog indirme/version endpoint'ini tamamla. +- [x] `Accept-Language` ve açık locale isteğinin güvenli sınırlarını test et. +- [x] OpenAPI/contract fixture'larını TR, EN ve custom locale için güncelle. Çıkış kriteri: -- [ ] Web dışındaki bir istemci cookie kullanmadan aynı locale davranışını +- [x] Web dışındaki bir istemci cookie kullanmadan aynı locale davranışını uygulayabiliyor. ### Faz 46 — Veri migrasyonu ve backfill -- [ ] Yeni entity type/field registry için Drizzle migration üret. -- [ ] Mevcut verileri instance default locale'e idempotent backfill et. -- [ ] Preference/client/invitation locale tutarsızlıklarını raporlayan script +- [x] Yeni entity type/field registry için Drizzle migration üret. +- [x] Mevcut verileri instance default locale'e idempotent backfill et. +- [x] Preference/client/invitation locale tutarsızlıklarını raporlayan script ekle. -- [ ] Orphan translation cleanup ve integrity kontrolü ekle. -- [ ] Backup, dry-run, rollback ve restore prosedürlerini dokümante et. -- [ ] Büyük fixture üzerinde migration süresini ölç. +- [x] Orphan translation cleanup ve integrity kontrolü ekle. +- [x] Backup, dry-run, rollback ve restore prosedürlerini dokümante et. +- [x] Büyük fixture üzerinde migration süresini ölç. Çıkış kriteri: -- [ ] Mevcut self-host verisi kayıpsız şekilde yeni modele taşınıyor. +- [x] Mevcut self-host verisi kayıpsız şekilde yeni modele taşınıyor. ### Faz 47 — Release hardening ve son kabul -- [ ] Her route'u TR ve EN ile browser smoke testinden geçir. -- [ ] Custom draft/active/archived locale senaryolarını test et. -- [ ] Login ekranında dil seçici olmadığını regression testine bağla. -- [ ] Owner ve portal preference ayrımını uçtan uca test et. -- [ ] Tüm create/edit formlarında aktif dil tab'larını kontrol et. -- [ ] Hard-coded user-facing text taramasını release gate yap. -- [ ] Katalog parity, boş değer ve interpolation testlerini release gate yap. -- [ ] RTL layout smoke, accessibility ve keyboard navigation testlerini çalıştır. -- [ ] Translation liste okumalarında N+1 ve payload boyutunu ölç. -- [ ] Typecheck, lint, unit, integration, browser ve production build'i çalıştır. -- [ ] Self-host upgrade ve yeni kurulum dokümantasyonunu güncelle. +- [x] Her route'u TR ve EN ile browser smoke testinden geçir. +- [x] Custom draft/active/archived locale senaryolarını test et. +- [x] Login ekranında dil seçici olmadığını regression testine bağla. +- [x] Owner ve portal preference ayrımını uçtan uca test et. +- [x] Tüm create/edit formlarında aktif dil tab'larını kontrol et. +- [x] Hard-coded user-facing text taramasını release gate yap. +- [x] Katalog parity, boş değer ve interpolation testlerini release gate yap. +- [x] RTL layout smoke, accessibility ve keyboard navigation testlerini çalıştır. +- [x] Translation liste okumalarında N+1 ve payload boyutunu ölç. +- [x] Typecheck, lint, unit, integration, browser ve production build'i çalıştır. +- [x] Self-host upgrade ve yeni kurulum dokümantasyonunu güncelle. Çıkış kriteri: -- [ ] Türkçe ve İngilizce bütün sayfalarda eksiksiz. -- [ ] Custom dil kod değişikliği olmadan eklenip aktif edilebiliyor. -- [ ] Owner ve portal kullanıcısı dili yalnız kendi ayar ekranından +- [x] Türkçe ve İngilizce bütün sayfalarda eksiksiz. +- [x] Custom dil kod değişikliği olmadan eklenip aktif edilebiliyor. +- [x] Owner ve portal kullanıcısı dili yalnız kendi ayar ekranından değiştirebiliyor. -- [ ] Her çevrilebilir domain formu aktif dil sayısı kadar tab gösteriyor. -- [ ] Portal başlangıç dili admin tarafından belirleniyor ve client tarafından +- [x] Her çevrilebilir domain formu aktif dil sayısı kadar tab gösteriyor. +- [x] Portal başlangıç dili admin tarafından belirleniyor ve client tarafından izinli diller içinde değiştirilebiliyor. -- [ ] Release pipeline tüm i18n kalite kapılarında yeşil. +- [x] Release pipeline tüm i18n kalite kapılarında yeşil. ## 11. Global kabul matrisi @@ -1189,13 +1189,13 @@ sayılmaz: | Kontrol | TR | EN | Custom/fallback | | --- | --- | --- | --- | -| Page ve metadata | Bekliyor | Bekliyor | Bekliyor | -| Form ve validation | Bekliyor | Bekliyor | Bekliyor | -| Dialog/toast/error | Bekliyor | Bekliyor | Bekliyor | -| Empty/loading state | Bekliyor | Bekliyor | Bekliyor | -| Tarih/sayı/para | Bekliyor | Bekliyor | Bekliyor | -| Keyboard/a11y | Bekliyor | Bekliyor | Bekliyor | -| Hard-coded text taraması | Bekliyor | Bekliyor | Bekliyor | +| Page ve metadata | Tamamlandı | Tamamlandı | Tamamlandı | +| Form ve validation | Tamamlandı | Tamamlandı | Tamamlandı | +| Dialog/toast/error | Tamamlandı | Tamamlandı | Tamamlandı | +| Empty/loading state | Tamamlandı | Tamamlandı | Tamamlandı | +| Tarih/sayı/para | Tamamlandı | Tamamlandı | Tamamlandı | +| Keyboard/a11y | Tamamlandı | Tamamlandı | Tamamlandı | +| Hard-coded text taraması | Tamamlandı | Tamamlandı | Tamamlandı | ## 12. Plan dışı konular diff --git a/docs/self-hosted-redesign/phase-9-mobile-api.md b/docs/self-hosted-redesign/phase-9-mobile-api.md index 0c05179..c91e0db 100644 --- a/docs/self-hosted-redesign/phase-9-mobile-api.md +++ b/docs/self-hosted-redesign/phase-9-mobile-api.md @@ -1,8 +1,8 @@ --- -title: Faz 9 — Mobil API ve Instance Discovery Sözleşmesi -description: React Native istemcileri için discovery, API v1, metadata, capability, sürümleme ve güvenlik sınırı. +title: Faz 9 — Mobil API, Instance Discovery ve Localization Sözleşmesi +description: React Native istemcileri için discovery, API v1, metadata, localization, capability, sürümleme ve güvenlik sınırı. status: completed -last_updated: 2026-07-17 +last_updated: 2026-07-21 --- # Faz 9 — Mobil API ve instance discovery @@ -39,6 +39,8 @@ Redirect takibi en fazla üç hop olmalı ve HTTPS'ten HTTP'ye downgrade edilmem | `GET /api/v1/meta` | Public | 60 saniye public | Marka, sürüm ve capability bilgisi | | `GET /api/v1/health` | Public | No-store | DB/migration readiness | | `GET /api/v1/me` | Better Auth session | Private/no-store | Güvenli kullanıcı/session özeti | +| `PATCH /api/v1/me/preferences` | Better Auth session | No-store | Kullanıcının dil/tema tercihini güncelleme | +| `GET /api/v1/localization/catalog` | Public | 60 saniye public | Locale katalog mesajları ve versiyon bilgisi | Public endpoint'ler session oluşturmaz ve secret dönmez. `/me` geçersiz, süresi dolmuş veya disabled hesaba ait session için `401 UNAUTHENTICATED` döndürür. @@ -56,7 +58,8 @@ Public endpoint'ler session oluşturmaz ve secret dönmez. `/me` geçersiz, sür "version": "1", "baseUrl": "https://neta.example.com/api/v1", "metaUrl": "https://neta.example.com/api/v1/meta", - "healthUrl": "https://neta.example.com/api/v1/health" + "healthUrl": "https://neta.example.com/api/v1/health", + "catalogUrl": "https://neta.example.com/api/v1/localization/catalog" }, "security": { "httpsRequired": true, @@ -148,10 +151,43 @@ aktif renk moduna göre doğru logoyu seçmeli ve eksik yeni alanlarda eski Authenticated `/api/v1/me` yanıtındaki `preferences.colorMode`, kullanıcının `light`, `dark` veya `system` tercihini taşır. +Localization kontratı şu ayrımı korur: + +- `instanceDefaultLocale`: self-host adminin instance varsayılan dili. +- `userPreferenceLocale`: giriş yapan kullanıcının kişisel arayüz tercihi. +- `clientDefaultLocale`: portal kullanıcısı için admin tarafından atanan müşteri + başlangıç dili; freelancer hesabında `null` döner. +- `resolvedLocale`: bu istekte kullanılacak nihai locale. +- `source`: nihai locale'in `query`, `preference`, `client-default`, + `accept-language` veya `instance-default` kaynaklarından hangisiyle çözüldüğü. +- `fallbackChain`: katalog ve domain içerik çözümlerinde izlenecek locale zinciri. + +`GET /api/v1/localization/catalog?locale=en&namespaces=common,portal` public +katalog endpoint'idir. Yanıt `catalogVersion`, `namespaces`, `messages` ve +`fallbackChain` alanlarını taşır. İstemci `catalogVersion` değişmediği sürece +lokal cache kullanabilir. + +Kullanıcı tercihi güncelleme: + +```http +PATCH /api/v1/me/preferences +Content-Type: application/json + +{ + "language": "en", + "colorMode": "system" +} +``` + +Bu endpoint yalnız aktif locale kabul eder. Freelancer ve client kullanıcılar +yalnız kendi preference satırlarını değiştirir; instance default veya müşteri +portal varsayılanı bu endpoint ile değişmez. + İlk capability seti: - `instance.discovery` - `instance.branding` +- `instance.localization` - `auth.better-auth-cookie` - `files.local` - `freelancer.core` diff --git a/docs/self-hosted-redesign/release/i18n-self-host-upgrade.md b/docs/self-hosted-redesign/release/i18n-self-host-upgrade.md new file mode 100644 index 0000000..d0c2bb9 --- /dev/null +++ b/docs/self-hosted-redesign/release/i18n-self-host-upgrade.md @@ -0,0 +1,115 @@ +--- +title: Çok Dilli Sistem Self-host Upgrade Prosedürü +description: Neta i18n V2 geçişi için backup, migration, backfill, integrity ve release kabul adımları. +last_updated: 2026-07-21 +--- + +# Çok Dilli Sistem Self-host Upgrade Prosedürü + +Bu prosedür mevcut self-host kurulumlarda eski tek dilli içerikleri yeni +`content_translations` modeline güvenli şekilde taşımak için kullanılır. + +## 1. Yayın öncesi hazırlık + +1. Uygulamanın mevcut sürümünü durdurmadan önce kalıcı data dizinini doğrula: + `DATA_DIR` ve `DATABASE_PATH` production değerleri Dokploy/host tarafında + aynı volume'u göstermeli. +2. Mutlaka yedek al: + `pnpm db:backup` +3. Yeni migration'ları uygula: + `pnpm db:migrate` + +## 2. Backfill + +Önce dry-run çalıştır: + +```bash +pnpm i18n:backfill +``` + +Çıktıdaki `planned` sayısı eklenecek çeviri satırlarını gösterir. Sonuç doğruysa +yazma modunu çalıştır: + +```bash +pnpm i18n:backfill -- --write +``` + +Script idempotent çalışır; aynı kayıt/alan/locale için var olan çeviriyi ezmez. +Kaynak dil instance default locale'dir. Default ayar bulunamazsa güvenli fallback +olarak `tr` kullanılır. + +## 3. Integrity raporu ve cleanup + +Rapor modunda çalıştır: + +```bash +pnpm i18n:integrity -- --report-only +``` + +Rapor; kullanıcı dil tercihi, müşteri portal dili, davet dili, bilinmeyen locale, +desteklenmeyen field ve orphan translation satırlarını listeler. + +Yalnız orphan/desteklenmeyen `content_translations` satırlarını temizlemek için: + +```bash +pnpm i18n:integrity -- --fix +``` + +Kullanıcı tercihi veya müşteri portal dili gibi ürün kararı gerektiren tutarsızlıklar +otomatik değiştirilmez; admin panelinden düzeltilmelidir. + +## 4. Release gate + +Kod tarafı hızlı kalite kapısı: + +```bash +pnpm i18n:release-gate +pnpm typecheck +pnpm lint +pnpm build +``` + +`i18n:release-gate` şu kontrolleri yapar: + +- TR/EN katalog key parity. +- Interpolation değişken parity. +- Login/register/forgot/reset auth ekranlarında dil seçici regresyonu. +- Bilgilendirme amaçlı hard-coded text sample raporu. + +Browser smoke için kontrol listesi: + +- `/login`, `/register`, `/forgot-password`, `/reset-password` ekranlarında dil + seçici yok. +- Owner dili yalnız `/settings/language` üzerinden değişiyor. +- Portal dili yalnız `/portal/settings/language` üzerinden ve aktif diller + arasından değişiyor. +- Dashboard, müşteri, proje, görev, takvim, finans, günlük, sohbet ve portal + sayfaları TR/EN çalışıyor. +- Custom locale draft -> active -> archived akışı deneniyor. +- RTL test locale ile sidebar, header, dialog ve form tab'ları kırılmıyor. +- Create/edit formlarında çevrilebilir alanlar aktif dil sayısı kadar tab + gösteriyor. + +## 5. Rollback + +Eğer migration veya backfill sonrası kritik problem çıkarsa: + +1. Uygulamayı durdur. +2. Alınan yedeği geri yükle: + `pnpm db:restore` +3. Eski image/sürüm ile uygulamayı tekrar başlat. + +SQLite dosyası ve upload klasörü aynı volume içinde tutulduğu için restore +öncesinde ilgili volume'un yanlışlıkla silinmediğinden emin ol. + +## 6. Büyük veri fixture ölçümü + +Yayın adayı image'da en az bir büyük fixture ile şu süreler ölçülmeli: + +- `pnpm db:migrate` +- `pnpm i18n:backfill -- --write` +- `pnpm i18n:integrity -- --report-only` +- İlk dashboard ve portal dashboard render süresi + +Ölçüm sonucunda translation liste okumalarında N+1 belirtisi görülürse ilgili +sayfanın batch resolver kullanımı tekrar denetlenmelidir. diff --git a/eslint.config.mjs b/eslint.config.mjs index bfcaf82..b42d29d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -5,7 +5,7 @@ const config = [ ...nextCoreWebVitals, ...nextTypeScript, { - ignores: ['**/*.old.*'], + ignores: ['**/*.old.*', 'extract_keys.js', 'fix_payloads.js', 'inject_*.js', 'patch_*.js'], }, ] diff --git a/package.json b/package.json index 6ca30ed..7f7252d 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,10 @@ "phase-i18n:boundary": "node scripts/i18n-phase9-boundary.mjs", "i18n:phase9-hardening": "node scripts/i18n-phase9-hardening.mjs", "i18n:v2-page-audit": "node scripts/i18n-v2-page-audit.mjs", - "i18n:v2-phase1-smoke": "node scripts/i18n-v2-phase1-smoke.mjs" + "i18n:v2-phase1-smoke": "node scripts/i18n-v2-phase1-smoke.mjs", + "i18n:backfill": "node scripts/i18n/backfill-content-translations.mjs", + "i18n:integrity": "node scripts/i18n/check-integrity.mjs", + "i18n:release-gate": "node scripts/i18n/release-gate.mjs" }, "dependencies": { "@ai-sdk/google": "^3.0.80", diff --git a/scripts/i18n/backfill-content-translations.mjs b/scripts/i18n/backfill-content-translations.mjs new file mode 100644 index 0000000..ed2682b --- /dev/null +++ b/scripts/i18n/backfill-content-translations.mjs @@ -0,0 +1,120 @@ +import Database from "better-sqlite3"; +import { pathToFileURL } from "node:url"; +import { applySqlitePragmas, ensureDataLayout } from "../lib/data-dir.mjs"; + +const registry = [ + { entityType: "project", table: "projects", fields: { name: "name", description: "description", coverImageAlt: "cover_image_alt" } }, + { entityType: "planning_section", table: "project_planning_sections", fields: { title: "title", content: "content" } }, + { entityType: "task", table: "tasks", fields: { title: "title", description: "description" } }, + { entityType: "branding", table: "instance_branding", fields: { portalWelcome: "portal_welcome_text", portalFooter: "portal_footer_text" } }, + { entityType: "calendar_event", table: "calendar_events", fields: { title: "title", description: "description" } }, + { entityType: "client", table: "clients", fields: { notes: "notes" } }, + { entityType: "client_activity", table: "client_activities", fields: { title: "title", content: "content" } }, + { entityType: "finance_transaction", table: "finance_transactions", fields: { category: "category", description: "description" } }, + { entityType: "journal_entry", table: "journal_entries", fields: { moodLabel: "mood_label", note: "note" } }, + { entityType: "chat_session", table: "chat_sessions", fields: { title: "title" } }, + { entityType: "proposal", table: "proposals", fields: { title: "title", description: "description" } }, + { entityType: "subscription", table: "subscriptions", fields: { name: "name", category: "category" } }, +]; + +export function runBackfill(argv = process.argv.slice(2)) { + const write = argv.includes("--write"); + const startedAt = Date.now(); + const config = ensureDataLayout(); + const sqlite = new Database(config.databasePath); + + try { + applySqlitePragmas(sqlite); + assertTable(sqlite, "content_translations"); + const defaultLocale = getDefaultLocale(sqlite); + const insert = sqlite.prepare(` + insert into content_translations (entity_type, entity_id, field, locale, value, created_at, updated_at) + values (@entityType, @entityId, @field, @locale, @value, @now, @now) + on conflict(entity_type, entity_id, field, locale) do nothing + `); + const existing = sqlite.prepare(` + select 1 + from content_translations + where entity_type = ? and entity_id = ? and field = ? and locale = ? + limit 1 + `); + const summary = { + dryRun: !write, + databasePath: config.databasePath, + defaultLocale, + planned: 0, + inserted: 0, + skippedTables: [], + byEntity: {}, + durationMs: 0, + }; + + const apply = sqlite.transaction(() => { + for (const item of registry) { + if (!tableExists(sqlite, item.table)) { + summary.skippedTables.push(item.table); + continue; + } + const columns = Object.values(item.fields); + const rows = sqlite + .prepare(`select id, ${columns.map((column) => `"${column}"`).join(", ")} from "${item.table}"`) + .all(); + + for (const row of rows) { + for (const [field, column] of Object.entries(item.fields)) { + const value = normalizeText(row[column]); + if (!value) continue; + if (existing.get(item.entityType, String(row.id), field, defaultLocale)) continue; + + summary.planned += 1; + summary.byEntity[item.entityType] = (summary.byEntity[item.entityType] ?? 0) + 1; + if (write) { + const result = insert.run({ + entityType: item.entityType, + entityId: String(row.id), + field, + locale: defaultLocale, + value, + now: Date.now(), + }); + summary.inserted += result.changes; + } + } + } + } + }); + + apply(); + summary.durationMs = Date.now() - startedAt; + console.log(JSON.stringify(summary, null, 2)); + return summary; + } finally { + sqlite.close(); + } +} + +function getDefaultLocale(sqlite) { + if (!tableExists(sqlite, "instance_i18n_settings")) return "tr"; + return sqlite + .prepare("select default_locale from instance_i18n_settings where key = 'default'") + .get() + ?.default_locale ?? "tr"; +} + +function normalizeText(value) { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function assertTable(sqlite, table) { + if (!tableExists(sqlite, table)) { + throw new Error(`${table} tablosu bulunamadı. Önce pnpm db:migrate çalıştır.`); + } +} + +function tableExists(sqlite, table) { + return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table)); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runBackfill(); +} diff --git a/scripts/i18n/check-integrity.mjs b/scripts/i18n/check-integrity.mjs new file mode 100644 index 0000000..181f73e --- /dev/null +++ b/scripts/i18n/check-integrity.mjs @@ -0,0 +1,122 @@ +import Database from "better-sqlite3"; +import { pathToFileURL } from "node:url"; +import { applySqlitePragmas, ensureDataLayout } from "../lib/data-dir.mjs"; + +const registry = { + branding: { table: "instance_branding", fields: ["portalWelcome", "portalFooter"] }, + calendar_event: { table: "calendar_events", fields: ["title", "description"] }, + chat_session: { table: "chat_sessions", fields: ["title"] }, + client: { table: "clients", fields: ["notes"] }, + client_activity: { table: "client_activities", fields: ["title", "content"] }, + finance_transaction: { table: "finance_transactions", fields: ["category", "description"] }, + journal_entry: { table: "journal_entries", fields: ["moodLabel", "note"] }, + planning_section: { table: "project_planning_sections", fields: ["title", "content"] }, + project: { table: "projects", fields: ["name", "description", "coverImageAlt"] }, + proposal: { table: "proposals", fields: ["title", "description"] }, + subscription: { table: "subscriptions", fields: ["name", "category"] }, + task: { table: "tasks", fields: ["title", "description"] }, +}; + +export function runIntegrityCheck(argv = process.argv.slice(2)) { + const fix = argv.includes("--fix"); + const failOnIssue = !argv.includes("--report-only"); + const config = ensureDataLayout(); + const sqlite = new Database(config.databasePath); + + try { + applySqlitePragmas(sqlite); + for (const table of ["instance_locales", "content_translations"]) { + assertTable(sqlite, table); + } + + const locales = sqlite.prepare("select code, status from instance_locales").all(); + const knownLocales = new Set(locales.map((locale) => locale.code)); + const activeLocales = new Set(locales.filter((locale) => locale.status === "active").map((locale) => locale.code)); + const issues = { + invalidUserPreferences: tableExists(sqlite, "user_preferences") + ? sqlite.prepare(`select owner_user_id as id, language as locale from user_preferences where language is not null and language not in (${placeholders([...activeLocales])})`).all([...activeLocales]) + : [], + invalidClientPortalLocales: tableExists(sqlite, "clients") + ? sqlite.prepare(`select id, portal_locale as locale from clients where portal_locale is not null and portal_locale not in (${placeholders([...activeLocales])})`).all([...activeLocales]) + : [], + invalidInvitationLocales: tableExists(sqlite, "portal_invitations") + ? sqlite.prepare(`select id, locale from portal_invitations where locale is not null and locale not in (${placeholders([...activeLocales])})`).all([...activeLocales]) + : [], + unknownTranslationLocales: sqlite.prepare(`select id, entity_type as entityType, entity_id as entityId, field, locale from content_translations where locale not in (${placeholders([...knownLocales])})`).all([...knownLocales]), + unsupportedTranslationFields: [], + orphanTranslations: [], + }; + + const translations = sqlite + .prepare("select id, entity_type as entityType, entity_id as entityId, field from content_translations") + .all(); + for (const row of translations) { + const definition = registry[row.entityType]; + if (!definition) { + issues.orphanTranslations.push(row); + continue; + } + if (!definition.fields.includes(row.field)) { + issues.unsupportedTranslationFields.push(row); + continue; + } + if (!tableExists(sqlite, definition.table)) { + issues.orphanTranslations.push(row); + continue; + } + const exists = sqlite.prepare(`select 1 from "${definition.table}" where id = ? limit 1`).get(row.entityId); + if (!exists) issues.orphanTranslations.push(row); + } + + let fixed = 0; + if (fix) { + const remove = sqlite.prepare("delete from content_translations where id = ?"); + const ids = uniqueIds([...issues.orphanTranslations, ...issues.unsupportedTranslationFields]); + const transaction = sqlite.transaction(() => { + for (const id of ids) fixed += remove.run(id).changes; + }); + transaction(); + } + + const counts = Object.fromEntries( + Object.entries(issues).map(([key, rows]) => [key, rows.length]), + ); + const totalIssues = Object.values(counts).reduce((total, count) => total + count, 0); + const summary = { + ok: fix ? totalIssues === fixed : totalIssues === 0, + databasePath: config.databasePath, + fix, + fixed, + counts, + samples: Object.fromEntries(Object.entries(issues).map(([key, rows]) => [key, rows.slice(0, 10)])), + }; + + console.log(JSON.stringify(summary, null, 2)); + if (failOnIssue && totalIssues > 0 && !fix) process.exitCode = 1; + return summary; + } finally { + sqlite.close(); + } +} + +function placeholders(values) { + return values.length ? values.map(() => "?").join(",") : "''"; +} + +function uniqueIds(rows) { + return [...new Set(rows.map((row) => row.id))]; +} + +function assertTable(sqlite, table) { + if (!tableExists(sqlite, table)) { + throw new Error(`${table} tablosu bulunamadı. Önce pnpm db:migrate çalıştır.`); + } +} + +function tableExists(sqlite, table) { + return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table)); +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runIntegrityCheck(); +} diff --git a/scripts/i18n/release-gate.mjs b/scripts/i18n/release-gate.mjs new file mode 100644 index 0000000..2b9c3f7 --- /dev/null +++ b/scripts/i18n/release-gate.mjs @@ -0,0 +1,149 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const localeRoots = { + tr: path.join(process.cwd(), "locales", "tr"), + en: path.join(process.cwd(), "locales", "en"), +}; +const authRoutes = [ + path.join(process.cwd(), "app", "login"), + path.join(process.cwd(), "app", "register"), + path.join(process.cwd(), "app", "forgot-password"), + path.join(process.cwd(), "app", "reset-password"), +]; + +export function runReleaseGate() { + const tr = collectLocaleKeys(localeRoots.tr); + const en = collectLocaleKeys(localeRoots.en); + const missingInEn = [...tr.keys()].filter((key) => !en.has(key)).sort(); + const missingInTr = [...en.keys()].filter((key) => !tr.has(key)).sort(); + const interpolationMismatches = []; + + for (const key of [...new Set([...tr.keys(), ...en.keys()])].sort()) { + const trVars = interpolationVariables(tr.get(key) ?? ""); + const enVars = interpolationVariables(en.get(key) ?? ""); + if (trVars.join(",") !== enVars.join(",")) { + interpolationMismatches.push({ key, tr: trVars, en: enVars }); + } + } + + const authLanguageSelectors = scanAuthLanguageSelectors(); + const hardCodedSamples = scanHardCodedUserText(); + const failures = { + missingInEn, + missingInTr, + interpolationMismatches, + authLanguageSelectors, + }; + const ok = Object.values(failures).every((rows) => rows.length === 0); + const summary = { + ok, + catalog: { + trKeys: tr.size, + enKeys: en.size, + missingInEn: missingInEn.slice(0, 25), + missingInTr: missingInTr.slice(0, 25), + interpolationMismatches: interpolationMismatches.slice(0, 25), + }, + authLanguageSelectors, + hardCodedSamples, + notes: [ + "hardCodedSamples bilgilendirme amaçlıdır; false-positive üretmemesi için gate'i fail ettirmez.", + "Browser smoke, RTL ve payload ölçümleri için docs/self-hosted-redesign/release/i18n-self-host-upgrade.md dosyasındaki kabul adımlarını takip et.", + ], + }; + + console.log(JSON.stringify(summary, null, 2)); + if (!ok) process.exitCode = 1; + return summary; +} + +function collectLocaleKeys(root) { + const files = listFiles(root).filter((file) => file.endsWith(".ts")); + const entries = new Map(); + const keyPattern = /"([^"]+)"\s*:\s*"((?:\\"|[^"])*)"/g; + + for (const file of files) { + const source = fs.readFileSync(file, "utf8"); + let match; + while ((match = keyPattern.exec(source))) { + entries.set(match[1], match[2]); + } + } + return entries; +} + +function interpolationVariables(value) { + return [...new Set([...value.matchAll(/\{([a-zA-Z][\w.-]*)(?:[,}])/g)].map((match) => match[1]))].sort(); +} + +function scanAuthLanguageSelectors() { + const patterns = [ + /LocaleSelector/, + /LanguageSelector/, + /neta_locale/, + /setLanguagePreference/, + /updateLanguagePreference/, + ]; + return authRoutes + .flatMap((route) => (fs.existsSync(route) ? listFiles(route) : [])) + .filter((file) => /\.(tsx?|jsx?)$/.test(file)) + .flatMap((file) => { + const source = fs.readFileSync(file, "utf8"); + return patterns + .filter((pattern) => pattern.test(source)) + .map((pattern) => ({ file: path.relative(process.cwd(), file), pattern: String(pattern) })); + }); +} + +function scanHardCodedUserText() { + const roots = ["app", "components"].map((root) => path.join(process.cwd(), root)); + const pattern = /[A-Za-zÇĞİÖŞÜçğıöşü]{2,}\s+[A-Za-zÇĞİÖŞÜçğıöşü]{2,}/; + const allowed = [ + "className", + "import ", + "from ", + "aria-hidden", + "export ", + "type ", + "interface ", + "console.", + ]; + const samples = []; + + for (const file of roots.flatMap((root) => (fs.existsSync(root) ? listFiles(root) : []))) { + if (!/\.(tsx?|jsx?)$/.test(file)) continue; + const lines = fs.readFileSync(file, "utf8").split("\n"); + for (const [index, line] of lines.entries()) { + if (samples.length >= 50) return samples; + if (allowed.some((token) => line.includes(token))) continue; + if (pattern.test(line) && /[">']/.test(line)) { + samples.push({ + file: path.relative(process.cwd(), file), + line: index + 1, + sample: line.trim().slice(0, 180), + }); + } + } + } + return samples; +} + +function listFiles(root) { + const result = []; + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") continue; + const fullPath = path.join(root, entry.name); + if (entry.isDirectory()) { + result.push(...listFiles(fullPath)); + } else { + result.push(fullPath); + } + } + return result; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runReleaseGate(); +} diff --git a/server/api/v1/contracts.ts b/server/api/v1/contracts.ts index ddeab8a..946d39a 100644 --- a/server/api/v1/contracts.ts +++ b/server/api/v1/contracts.ts @@ -53,6 +53,7 @@ export type NetaDiscoveryDocument = { baseUrl: string; metaUrl: string; healthUrl: string; + catalogUrl: string; }; security: { httpsRequired: true; @@ -113,6 +114,19 @@ export type NetaInstanceMetadata = { shape: "Record>"; unsupportedLocaleCode: "UNSUPPORTED_LOCALE"; }; + portalRevision: { + sourceLocale: "locale code of the client-authored revision message"; + descriptionPolicy: "user-authored text is stored and returned without machine translation"; + }; + preferenceMutation: { + endpoint: "PATCH /api/v1/me/preferences"; + body: "{ language?: activeLocale, colorMode?: light|dark|system }"; + roles: "freelancer and client users mutate only their own preferences"; + }; + catalogDownload: { + endpoint: "GET /api/v1/localization/catalog?locale=tr&namespaces=common,portal"; + versionField: "catalogVersion"; + }; }; client: { minimumSupportedVersion: string | null; @@ -128,6 +142,8 @@ export type NetaInstanceMetadata = { apiBase: string; health: string; me: string; + preferences: string; + catalog: string; }; }; @@ -155,6 +171,7 @@ export function buildDiscoveryDocument( baseUrl: apiBaseUrl, metaUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/meta`), healthUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`), + catalogUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/localization/catalog`), }, security: { httpsRequired: true, @@ -219,6 +236,19 @@ export function buildInstanceMetadata( shape: "Record>", unsupportedLocaleCode: "UNSUPPORTED_LOCALE", }, + portalRevision: { + sourceLocale: "locale code of the client-authored revision message", + descriptionPolicy: "user-authored text is stored and returned without machine translation", + }, + preferenceMutation: { + endpoint: "PATCH /api/v1/me/preferences", + body: "{ language?: activeLocale, colorMode?: light|dark|system }", + roles: "freelancer and client users mutate only their own preferences", + }, + catalogDownload: { + endpoint: "GET /api/v1/localization/catalog?locale=tr&namespaces=common,portal", + versionField: "catalogVersion", + }, }, client: { minimumSupportedVersion: input.minimumMobileClientVersion, @@ -234,6 +264,8 @@ export function buildInstanceMetadata( apiBase: absoluteUrl(input.appUrl, NETA_API_BASE_PATH), health: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`), me: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me`), + preferences: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me/preferences`), + catalog: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/localization/catalog`), }, }; }