diff --git a/app/(dashboard)/business/invoices/invoices-client.tsx b/app/(dashboard)/business/invoices/invoices-client.tsx
index d78f284..062a9cd 100644
--- a/app/(dashboard)/business/invoices/invoices-client.tsx
+++ b/app/(dashboard)/business/invoices/invoices-client.tsx
@@ -1,8 +1,9 @@
"use client";
+import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useState } from "react";
import { format } from "date-fns";
-import { tr } from "date-fns/locale";
+import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import {
@@ -29,7 +30,7 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const formatCurrency = (amount: number, currency: string) => {
- return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
+ return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount);
};
const getStatusBadge = (status: string) => {
@@ -101,7 +102,7 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) {
{getStatusBadge(invoice.status)}
- {invoice.issue_date ? format(new Date(invoice.issue_date), "dd MMM yyyy", { locale: tr }) : "-"}
+ {invoice.issue_date ? format(new Date(invoice.issue_date), "dd MMM yyyy", { locale: getDocumentDateFnsLocale() }) : "-"}
|
diff --git a/app/(dashboard)/business/proposals/proposals-client.tsx b/app/(dashboard)/business/proposals/proposals-client.tsx
index b620c7e..ea75279 100644
--- a/app/(dashboard)/business/proposals/proposals-client.tsx
+++ b/app/(dashboard)/business/proposals/proposals-client.tsx
@@ -1,8 +1,9 @@
"use client";
+import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useState } from "react";
import { format } from "date-fns";
-import { tr } from "date-fns/locale";
+import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import {
@@ -28,7 +29,7 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const formatCurrency = (amount: number, currency: string) => {
- return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
+ return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount);
};
const getStatusBadge = (status: string) => {
@@ -100,7 +101,7 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) {
{getStatusBadge(proposal.status)}
|
- {proposal.valid_until ? format(new Date(proposal.valid_until), "dd MMM yyyy", { locale: tr }) : "-"}
+ {proposal.valid_until ? format(new Date(proposal.valid_until), "dd MMM yyyy", { locale: getDocumentDateFnsLocale() }) : "-"}
|
diff --git a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx
index 2753e90..1564f3e 100644
--- a/app/(dashboard)/business/subscriptions/subscriptions-client.tsx
+++ b/app/(dashboard)/business/subscriptions/subscriptions-client.tsx
@@ -1,8 +1,9 @@
"use client";
+import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useState } from "react";
import { format } from "date-fns";
-import { tr } from "date-fns/locale";
+import { getDocumentDateFnsLocale } from "@/lib/i18n/date-fns";
import { CreditCard, Plus, MoreHorizontal, FileEdit, Trash2, StopCircle, RefreshCw } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import {
@@ -28,7 +29,7 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const formatCurrency = (amount: number, currency: string) => {
- return new Intl.NumberFormat("tr-TR", { style: "currency", currency }).format(amount);
+ return new Intl.NumberFormat(getDocumentIntlLocale(), { style: "currency", currency }).format(amount);
};
const getCycleBadge = (cycle: string) => {
@@ -125,7 +126,7 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip
)}
|
- {sub.next_billing_date ? format(new Date(sub.next_billing_date), "dd MMM yyyy", { locale: tr }) : "-"}
+ {sub.next_billing_date ? format(new Date(sub.next_billing_date), "dd MMM yyyy", { locale: getDocumentDateFnsLocale() }) : "-"}
|
diff --git a/docs/self-hosted-redesign/i18n-phase-5.md b/docs/self-hosted-redesign/i18n-phase-5.md
new file mode 100644
index 0000000..df62360
--- /dev/null
+++ b/docs/self-hosted-redesign/i18n-phase-5.md
@@ -0,0 +1,62 @@
+---
+title: Faz 5 Çok Dilli Domain İçerikleri
+description: Proje, görev, planlama alanı ve portal metinleri için content translation altyapısı.
+phase: 5
+status: completed
+last_updated: 2026-07-19
+---
+
+# Faz 5 Çok Dilli Domain İçerikleri
+
+Faz-5, UI katalog çevirilerinden farklı olarak kullanıcı/veri içeriklerini
+locale bazlı saklayan domain katmanını devreye aldı.
+
+## Tamamlananlar
+
+- `content_translations` tablosu için ortak registry ve field sözleşmesi
+ oluşturuldu.
+- `ContentTranslationService` eklendi.
+- FormData içindeki çok dilli alanları okuyup default locale zorunluluğunu
+ kontrol eden parser eklendi.
+- Proje, görev ve planlama alanı create/update/delete akışları content
+ translations ile senkronlandı.
+- Default locale değerleri eski temel kolonlara projection olarak yazılmaya
+ devam ediyor.
+- Liste/detay okuma akışlarında proje, görev ve planlama alanı içerikleri
+ seçili locale'e göre resolve ediliyor.
+- Liste ekranlarında çeviriler batch okunuyor; N+1 pattern'i oluşmuyor.
+- Proje oluşturma/düzenleme formu tab'lı çok dilli alanlara taşındı.
+- Görev oluşturma/düzenleme formu tab'lı çok dilli alanlara taşındı.
+- Proje detayındaki planlama alanı ve proje görev ekleme formları tab'lı çok
+ dilli alanlara taşındı.
+- Genel ayarlar içinde portal karşılama/footer metinleri `branding` entity'si
+ olarak çok dilli hale getirildi.
+- 0008 migration'a eski Türkçe içerikleri `tr` content translation kayıtlarına
+ taşıyan idempotent backfill eklendi.
+- Faz-5 smoke testi eklendi.
+
+## Domain sözleşmesi
+
+İlk kapsamda aktif entity/field seti:
+
+- `project`: `name`, `description`, `coverImageAlt`
+- `planning_section`: `title`, `content`
+- `task`: `title`, `description`
+- `branding`: `portalWelcome`, `portalFooter`
+
+Default locale'de required olan alanlar boş bırakılamaz. Diğer locale'ler
+opsiyoneldir ve eksik kaldığında okuma tarafı default locale'e düşer.
+
+## Verification
+
+Çalıştırılan komutlar:
+
+```bash
+pnpm i18n:phase5-smoke
+pnpm typecheck
+pnpm build
+git diff --check
+```
+
+Sonuçlar başarılı. `pnpm build` sırasında mevcut Next edge runtime static
+generation uyarısı tekrar görüldü; Faz-5 kaynaklı yeni bir hata değil.
diff --git a/docs/self-hosted-redesign/i18n-phase-6.md b/docs/self-hosted-redesign/i18n-phase-6.md
new file mode 100644
index 0000000..39f5c59
--- /dev/null
+++ b/docs/self-hosted-redesign/i18n-phase-6.md
@@ -0,0 +1,59 @@
+---
+title: Faz 6 Müşteri Portal Dili ve Davet Akışı
+description: Portal daveti, müşteri locale persistence, portal shell ve client-facing içerik çözümleme çıktıları.
+phase: 6
+status: completed
+last_updated: 2026-07-19
+---
+
+# Faz 6 Müşteri Portal Dili ve Davet Akışı
+
+Faz-6, müşteri portalının davet anından itibaren seçilen dilde açılmasını ve
+client-facing domain içeriklerinin müşteri locale'inde çözülmesini sağlar.
+
+## Tamamlananlar
+
+- Portal daveti oluşturma akışına `locale` eklendi.
+- Compatibility `/api/create-client-user` adapter'ı locale kabul eder hale geldi.
+- `/api/portal-invitations` sözleşmesi locale kabul eder hale geldi.
+- Davet locale'i yalnız aktif dillerden seçilebiliyor.
+- Davet oluştururken invitation audit metadata'sına locale eklendi.
+- Davet kabulünde:
+ - `clients.portal_locale` set ediliyor.
+ - client `user_preferences.language` kaydı oluşturuluyor.
+ - accepted audit metadata'sına locale ekleniyor.
+- Davet preview response'u locale döndürüyor.
+- Davet sayfası invitation locale'i ile render ediliyor.
+- Portal shell `I18nProvider` ile sarıldı.
+- Portal sidebar seçili locale'e göre lokalize ediliyor.
+- Portal dashboard, projeler, görevler, revizyonlar ve proje detay sayfasında
+ temel UI metinleri portal namespace'ine bağlandı.
+- Portal tarih formatları locale-aware formatter'a taşındı.
+- Portal proje, planlama ve public görev içerikleri client locale'inde resolve
+ ediliyor.
+- Content resolver artık client-facing fallback zincirinde locale fallback +
+ default locale kullanıyor.
+- Müşteri detayında portal dili gösteriliyor/değiştirilebiliyor.
+- Portal dili değişince bağlı client session'ları düşürülüyor; sonraki request
+ yeni locale ile açılıyor.
+
+## Eklenen route
+
+- `PATCH /api/portal-clients/:clientId/locale`
+
+Bu route sadece freelancer session ile çalışır, aktif locale zorunludur ve
+client başka müşterinin locale bilgisini değiştiremez.
+
+## Verification
+
+Çalıştırılan komutlar:
+
+```bash
+pnpm typecheck
+pnpm build
+pnpm phase9:smoke
+git diff --check
+```
+
+Sonuçlar başarılı. `pnpm build` sırasında mevcut Next edge runtime static
+generation uyarısı tekrar görüldü; Faz-6 kaynaklı yeni bir hata değil.
diff --git a/docs/self-hosted-redesign/i18n-phase-7.md b/docs/self-hosted-redesign/i18n-phase-7.md
new file mode 100644
index 0000000..a7f9f64
--- /dev/null
+++ b/docs/self-hosted-redesign/i18n-phase-7.md
@@ -0,0 +1,82 @@
+---
+title: Faz 7 Auth, Hata ve Erişilebilirlik Bütünlüğü
+description: Auth öncesi locale seçimi, stabil hata kodları, edge ekranlar ve a11y metinleri çıktıları.
+phase: 7
+status: completed
+last_updated: 2026-07-19
+---
+
+# Faz 7 Auth, Hata ve Erişilebilirlik Bütünlüğü
+
+Faz-7, kullanıcı oturumu oluşmadan önce ve sistem edge ekranlarında görünen
+metinlerin locale modeliyle tutarlı çalışmasını tamamlar.
+
+## Tamamlananlar
+
+- Login ekranı aktif instance dillerini listeleyen locale seçici ile açılır.
+- Register ekranı aynı locale seçiciyi kullanır ve ilk admin kurulumu instance
+ default locale/cookie davranışına bağlanır.
+- Auth shell sol pazarlama alanı, feature etiketleri ve footer kopyası `auth`
+ namespace'inden beslenir.
+- Forgot/reset password ekranları eklendi ve katalog metinleriyle render edilir.
+- 404 ekranı server catalog üzerinden `common.notFound.*` anahtarlarını kullanır.
+- Error boundary için built-in TR/EN fallback eklendi.
+- Maintenance copy sözleşmesi `common.maintenance.*` anahtarlarıyla kataloglandı.
+- Login/signup server action redirect'leri raw mesaj yerine stabil `code`
+ parametresine taşındı.
+- Portal invite error/success redirect'leri stabil auth code kullanır.
+- Invite kabulünden sonra invitation locale'i cookie'ye yazılır; login ekranı aynı
+ dilde açılır.
+- `/api/i18n/locale` response'u kullanıcı metni yerine `messageKey` döndürür.
+- Locale select kontrolünde label + `aria-label` korunur.
+- Auth formlarındaki label, link ve pending metinleri katalogdan gelir.
+- Interpolation ve plural davranışı built-in TR/EN katalogları için smoke test ile
+ doğrulandı.
+- RTL temel direction helper smoke testi eklendi.
+
+## Hata kodu sözleşmesi
+
+Auth sayfaları query üzerinden aşağıdaki modeli kabul eder:
+
+```text
+/login?error=true&code=auth.messages.invalidCredentials
+/register?error=true&code=auth.messages.signupFailed
+/login?code=auth.invite.success
+```
+
+Geriye dönük uyumluluk için eski `message` parametresi okunur; yeni server
+action'lar raw mesaj üretmez.
+
+## Bildirim/e-posta locale sözleşmesi
+
+İleride e-posta veya notification sistemi eklendiğinde locale önceliği şu
+sırayı izlemelidir:
+
+1. Client portal hesabı için `clients.portal_locale`
+2. Auth user preference için `user_preferences.language`
+3. Invite veya event üzerinde snapshot locale
+4. Instance default locale
+5. Built-in fallback `tr`
+
+Template key'leri UI kataloglarıyla aynı namespace mantığını kullanmalı, fakat
+uzun mail içerikleri ayrı `notification` veya `email` namespace'ine taşınmalıdır.
+
+## RTL notu
+
+`directionForLocale("ar" | "fa" | "he" | "ur")` temel RTL direction üretir.
+Faz-7 smoke testi auth shell ve form katmanının direction bilgisini bozmadığını
+doğrular. Tam görsel RTL polish için sonraki hardening fazında sayfa bazlı görsel
+kontrol önerilir.
+
+## Verification
+
+Çalıştırılan komutlar:
+
+```bash
+pnpm i18n:phase7-smoke
+pnpm typecheck
+pnpm build
+git diff --check
+```
+
+Sonuçlar başarılı olduğunda Faz-7 tamamlanmış kabul edilir.
diff --git a/docs/self-hosted-redesign/i18n-phase-9-release-readiness.md b/docs/self-hosted-redesign/i18n-phase-9-release-readiness.md
new file mode 100644
index 0000000..493e4d8
--- /dev/null
+++ b/docs/self-hosted-redesign/i18n-phase-9-release-readiness.md
@@ -0,0 +1,229 @@
+---
+title: I18n Faz 9 — Hardening, Migrasyon ve Release Readiness
+description: Çok dilli sistemin self-host production yayınına hazır olduğunu gösteren migration, backup, API, Dokploy ve operasyon raporu.
+phase: 9
+status: completed
+last_updated: 2026-07-19
+---
+
+# I18n Faz 9 — Hardening, migrasyon ve release readiness
+
+Faz-9, çok dilli sistemin production self-host kurulumunda güvenli şekilde
+yayınlanabilmesi için migration, backup/restore, import, mobil API ve operasyon
+kapılarını kapatır.
+
+## Sonuç
+
+Çok dilli sistem release adayıdır.
+
+- Yeni kurulumda `tr` ve `en` aktif gelir, default locale `tr` olur.
+- Eski Türkçe kolonlar `content_translations` içine `tr` locale'iyle backfill
+ edilir.
+- Backfill tekrar çalışan migration'da duplicate üretmez.
+- `fr` custom locale, content translation, backup ve restore zincirinde korunur.
+- API v1 localization sözleşmesi additive ve eski mobil client'larla uyumludur.
+- Runtime Supabase bağımlılığı veya Supabase env kullanımı geri gelmemiştir.
+- Built-in kataloglar standalone server bundle içinde bulunur.
+
+## Release kapıları
+
+### Migration
+
+`pnpm i18n:phase9-hardening` şu migration senaryolarını gerçek temp SQLite DB
+üzerinde çalıştırır:
+
+1. Boş DB migration.
+2. Boş DB üzerinde ikinci migration çalıştırması.
+3. 0000–0007 arası eski schema üzerinde production-benzeri Türkçe veri.
+4. 0008 i18n migration'ı sonrası Türkçe backfill doğrulaması.
+5. Aynı DB üzerinde tekrar migration ve idempotent count kontrolü.
+
+Backfill doğrulanan alanlar:
+
+- `project.name`
+- `project.description`
+- `task.title`
+- `planning_section.title`
+- `branding.portalWelcome`
+
+### Backup ve restore
+
+Hardening smoke, production-benzeri DB'ye `fr` custom locale ve Fransızca proje
+çevirisi ekler, backup alır ve ayrı bir data directory içine restore eder.
+Restore sonrası:
+
+- `instance_locales.fr` aktif kalır.
+- `content_translations` içindeki Fransızca çeviri korunur.
+- `foreign_key_check` temiz döner.
+
+Restore failure prosedürü de test edilir: checksum'u bozulan backup restore
+edilmez ve mevcut hedef DB korunur.
+
+### Supabase import uyumu
+
+Faz-9, Supabase import aracının Faz-8 smoke kapsamını release blocker kabul eder.
+`phase8:import-smoke` şu davranışları doğrular:
+
+- dry-run DB'yi değiştirmez;
+- apply import kayıtları taşır;
+- `--allow-existing` idempotent upsert yapar;
+- invalid enum, eksik foreign key ve unsafe storage path reddedilir;
+- pre-import backup restore ile rollback provası yapılır.
+
+Import aracı Supabase SDK kullanmaz; offline `neta-supabase-export` bundle'ını
+okur.
+
+## Dokploy / Docker operasyon notu
+
+Dokploy'da `/app/data` kalıcı volume olmalıdır. Bu volume şunları taşır:
+
+- `neta.db`
+- `uploads/`
+- `backups/`
+- `tmp/`
+
+Production deploy öncesi:
+
+```bash
+pnpm db:backup -- --retention-count 14
+```
+
+Restore provası production volume üstüne değil, ayrı geçici hedefe yapılmalıdır:
+
+```bash
+pnpm db:restore -- \
+ --from /app/data/backups/neta-TIMESTAMP \
+ --target /tmp/neta-restore-rehearsal \
+ --force
+```
+
+Translation tabloları `neta.db` içinde olduğu için backup/restore kapsamında
+otomatik korunur:
+
+- `instance_locales`
+- `instance_i18n_settings`
+- `instance_ui_translations`
+- `content_translations`
+- `clients.portal_locale`
+- `portal_invitations.locale`
+- `user_preferences.language`
+
+## Self-host default locale
+
+Yeni kurulumda default locale `tr` olur. Owner Ayarlar üzerinden varsayılan dili
+aktif başka bir locale'e alabilir. Default locale yalnız `active` locale olabilir;
+draft veya archived locale default yapılamaz.
+
+Mobil istemciler instance dil bilgisini şu endpoint'lerden keşfeder:
+
+- `GET /.well-known/neta`
+- `GET /api/v1/meta`
+- `GET /api/v1/me`
+
+## Dil ekleme ve import/export rehberi
+
+Owner akışı:
+
+1. Ayarlar → Diller ve çeviriler bölümünden yeni locale ekle.
+2. Locale'i önce `draft` olarak tut.
+3. UI çeviri eksiklerini tamamla veya JSON import ile yükle.
+4. İçerik formlarındaki locale tab'larında proje/görev/portal metinlerini gir.
+5. Portal'a hazır olduğunda locale'i `active` yap.
+6. Gerekirse varsayılan locale'i değiştir.
+
+Import/export:
+
+- UI çeviri export paketi `neta-i18n` formatındadır.
+- Import yalnız owner scope ile çalışır.
+- Built-in locale'ler silinmez veya archived yapılamaz.
+- Bozuk namespace/key/value import'u validation ile reddedilir.
+
+## Portal müşterisine dil atama
+
+Müşteri portal dili iki noktada belirlenir:
+
+1. Portal daveti oluştururken seçilen locale.
+2. Müşteri detayındaki portal dili ayarı.
+
+Davet kabul edildiğinde:
+
+- `clients.portal_locale` set edilir;
+- client user preference dili aynı locale'e alınır;
+- davet kabulü sonrası login ekranı davet locale'iyle açılır.
+
+## Mobil API localization sözleşmesi
+
+Mobil taraf locale'i şu sırayla çözmelidir:
+
+1. Explicit query: `/api/v1/me?locale=en`
+2. Better Auth session preference
+3. Client portal locale
+4. `Accept-Language`
+5. Instance default locale
+
+Unsupported explicit locale için server:
+
+```json
+{
+ "ok": false,
+ "error": {
+ "code": "UNSUPPORTED_LOCALE",
+ "message": "Unsupported locale.",
+ "details": {
+ "messageKey": "validation.unsupportedLocale"
+ }
+ }
+}
+```
+
+Client kullanıcıya gösterilecek metni kendi catalog'undan `messageKey` ile
+çözmelidir; program akışı stabil `code` alanına bağlanmalıdır.
+
+## Gözlem ve performans
+
+Faz-9 smoke seviyesi performans kapıları:
+
+- Dashboard ve portal listeleri batch content resolver kullanır; N+1 translation
+ sorgusu release riski kabul edilir.
+- Missing translation durumunda raw key production kullanıcı arayüzüne düşmemeli;
+ fallback chain kullanılmalıdır.
+- Catalog version, UI translation mutation'larında artar ve mobil meta cache
+ invalidation için kullanılabilir.
+- Hassas içerik loglanmamalıdır; missing translation log'u key/locale seviyesinde
+ kalmalıdır.
+
+## Verification
+
+Çalıştırılan komutlar:
+
+```bash
+pnpm phase-i18n:boundary
+pnpm i18n:phase9-hardening
+pnpm i18n:phase8-smoke
+pnpm i18n:phase7-smoke
+pnpm i18n:phase5-smoke
+pnpm i18n:phase3-smoke
+pnpm i18n:phase2-smoke
+pnpm i18n:phase1-smoke
+pnpm phase8:import-smoke
+pnpm typecheck
+pnpm build
+pnpm phase9:smoke
+git diff --check
+```
+
+`phase9:smoke` local listener açtığı için sandbox içinde `EPERM` alabilir; bu
+durumda izinli çalıştırılmalıdır.
+
+## Release uyarısı
+
+Bu rapor teknik release readiness sağlar. Gerçek production cutover için ortam
+sahibi yine şu dış adımları yürütmelidir:
+
+- production backup;
+- maintenance/read-only penceresi;
+- final Supabase export/import;
+- gerçek satır ve dosya checksum karşılaştırması;
+- owner/client kabul smoke'u;
+- DNS/reverse proxy geçişi;
+- rollback retention penceresi.
diff --git a/package.json b/package.json
index bffad19..7251b7a 100644
--- a/package.json
+++ b/package.json
@@ -32,7 +32,16 @@
"phase8:import-smoke": "node scripts/phase8-import-smoke.mjs",
"phase8:release-boundary": "node scripts/phase8-release-boundary.mjs",
"phase9:api-boundary": "node scripts/phase9-api-boundary.mjs",
- "phase9:smoke": "node scripts/phase9-smoke.mjs"
+ "phase9:smoke": "node scripts/phase9-smoke.mjs",
+ "i18n:phase1-smoke": "node scripts/i18n-phase1-smoke.mjs",
+ "i18n:phase2-smoke": "node scripts/i18n-phase2-smoke.mjs",
+ "i18n:phase3-smoke": "node scripts/i18n-phase3-smoke.mjs",
+ "i18n:phase4-boundary": "node scripts/i18n-phase4-boundary.mjs",
+ "i18n:phase5-smoke": "node scripts/i18n-phase5-smoke.mjs",
+ "i18n:phase7-smoke": "node scripts/i18n-phase7-smoke.mjs",
+ "i18n:phase8-smoke": "node scripts/i18n-phase8-smoke.mjs",
+ "phase-i18n:boundary": "node scripts/i18n-phase9-boundary.mjs",
+ "i18n:phase9-hardening": "node scripts/i18n-phase9-hardening.mjs"
},
"dependencies": {
"@ai-sdk/google": "^3.0.80",
diff --git a/scripts/i18n-phase7-smoke.mjs b/scripts/i18n-phase7-smoke.mjs
new file mode 100644
index 0000000..b4b0108
--- /dev/null
+++ b/scripts/i18n-phase7-smoke.mjs
@@ -0,0 +1,19 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+const distDir = path.join(process.cwd(), ".next", "i18n-phase7-smoke-dist");
+
+execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase7-smoke.json"], {
+ cwd: process.cwd(),
+ stdio: "inherit",
+});
+
+const serverOnlyStubDir = path.join(distDir, "node_modules", "server-only");
+fs.mkdirSync(serverOnlyStubDir, { recursive: true });
+fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
+
+execFileSync(process.execPath, [path.join(distDir, "scripts", "i18n-phase7-smoke.js")], {
+ cwd: process.cwd(),
+ stdio: "inherit",
+});
diff --git a/scripts/i18n-phase7-smoke.ts b/scripts/i18n-phase7-smoke.ts
new file mode 100644
index 0000000..2c65da3
--- /dev/null
+++ b/scripts/i18n-phase7-smoke.ts
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+import {
+ compareCatalogKeys,
+ createTranslatorFromMessages,
+ flattenCatalog,
+ I18N_NAMESPACES,
+} from "../lib/i18n";
+import { enCatalog } from "../locales/en";
+import { trCatalog } from "../locales/tr";
+import { directionForLocale } from "../server/i18n/locale";
+
+const parity = compareCatalogKeys(trCatalog, enCatalog, I18N_NAMESPACES);
+assert.deepEqual(parity, { missingInLeft: [], missingInRight: [] }, "TR/EN catalog keys must match");
+
+const requiredKeys = [
+ "auth.login.title",
+ "auth.login.description",
+ "auth.language",
+ "auth.forgot.title",
+ "auth.reset.title",
+ "auth.messages.invalidCredentials",
+ "auth.messages.setupUnavailable",
+ "common.notFound.title",
+ "common.error.title",
+ "common.maintenance.title",
+ "validation.unsupportedLocale",
+];
+
+for (const catalog of [trCatalog, enCatalog]) {
+ const messages = flattenCatalog(catalog, I18N_NAMESPACES);
+ for (const key of requiredKeys) {
+ assert.ok(messages[key], `${key} must exist in built-in catalogs`);
+ }
+}
+
+const tr = createTranslatorFromMessages("tr", flattenCatalog(trCatalog, I18N_NAMESPACES));
+const en = createTranslatorFromMessages("en", flattenCatalog(enCatalog, I18N_NAMESPACES));
+
+assert.equal(
+ tr.t("auth.marketing.description", { app: "Neta" }).includes("Neta"),
+ true,
+ "auth marketing interpolation must keep app name",
+);
+assert.equal(en.t("common.itemsCount", { count: 1 }), "1 item");
+assert.equal(en.t("common.itemsCount", { count: 2 }), "2 items");
+assert.equal(directionForLocale("ar"), "rtl");
+assert.equal(directionForLocale("he-IL"), "rtl");
+assert.equal(directionForLocale("tr"), "ltr");
+
+const loginActions = fs.readFileSync(path.join(process.cwd(), "app", "login", "actions.ts"), "utf8");
+assert.equal(loginActions.includes("message=${encodeURIComponent"), false, "login action redirects must use stable codes");
+assert.equal(loginActions.includes("E-posta veya"), false, "login action must not embed Turkish user-facing errors");
+
+const inviteActions = fs.readFileSync(path.join(process.cwd(), "app", "invite", "[token]", "actions.ts"), "utf8");
+assert.equal(inviteActions.includes("message=${encodeURIComponent"), false, "invite action redirects must use stable codes");
+
+const localeApi = fs.readFileSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts"), "utf8");
+assert.equal(localeApi.includes("Dil kodu"), false, "locale API must not embed Turkish validation messages");
+assert.equal(localeApi.includes("messageKey"), true, "locale API must expose messageKey for localized clients");
+
+console.log("I18n phase 7 auth/error/a11y smoke passed.");
diff --git a/scripts/i18n-phase9-boundary.mjs b/scripts/i18n-phase9-boundary.mjs
new file mode 100644
index 0000000..3dfa391
--- /dev/null
+++ b/scripts/i18n-phase9-boundary.mjs
@@ -0,0 +1,94 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import path from "node:path";
+
+const root = process.cwd();
+const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
+
+for (const scriptName of [
+ "phase-i18n:boundary",
+ "i18n:phase1-smoke",
+ "i18n:phase2-smoke",
+ "i18n:phase3-smoke",
+ "i18n:phase4-boundary",
+ "i18n:phase5-smoke",
+ "i18n:phase7-smoke",
+ "i18n:phase8-smoke",
+ "i18n:phase9-hardening",
+]) {
+ assert.ok(pkg.scripts?.[scriptName], `Missing package script: ${scriptName}`);
+}
+
+const runtimeFiles = listFiles(root, [
+ "app",
+ "components",
+ "config",
+ "lib",
+ "server",
+]).filter((file) => /\.(ts|tsx|mjs|js)$/.test(file));
+const boundaryAllowlist = new Set([
+ path.join(root, "lib", "i18n", "date-fns.ts"),
+ path.join(root, "lib", "i18n", "format.ts"),
+]);
+
+const forbiddenRuntimePatterns = [
+ /@supabase\//i,
+ /createClient\([^)]*supabase/i,
+ /supabase\.co/i,
+ /NEXT_PUBLIC_SUPABASE/i,
+ /SUPABASE_SERVICE_ROLE/i,
+ /from ["']date-fns\/locale\/tr["']/,
+ /from ["']date-fns\/locale["']/,
+ /["']tr-TR["']/,
+];
+
+const violations = [];
+for (const file of runtimeFiles) {
+ if (boundaryAllowlist.has(file)) continue;
+ const text = fs.readFileSync(file, "utf8");
+ for (const pattern of forbiddenRuntimePatterns) {
+ if (pattern.test(text)) violations.push(`${path.relative(root, file)} :: ${pattern}`);
+ }
+}
+assert.deepEqual(violations, [], `I18n release boundary violations:\n${violations.join("\n")}`);
+
+const phase9ApiBoundary = fs.readFileSync(path.join(root, "scripts", "phase9-api-boundary.mjs"), "utf8");
+for (const marker of [
+ "instance.localization",
+ "UNSUPPORTED_LOCALE",
+ "Accept-Language",
+ "resolvedLocale",
+ "portalLocale",
+]) {
+ assert.ok(phase9ApiBoundary.includes(marker), `Phase 9 API boundary must include ${marker}`);
+}
+
+const hardcodedReport = path.join(root, "docs", "self-hosted-redesign", "i18n-phase-4-hardcoded-text-report.md");
+assert.ok(fs.existsSync(hardcodedReport), "Hardcoded text report must exist for release review");
+assert.ok(
+ fs.readFileSync(hardcodedReport, "utf8").includes("release blocker"),
+ "Hardcoded text report must document release blocker policy",
+);
+
+console.log("I18n phase 9 boundary passed: scripts, Supabase/runtime locale and release report gates verified.");
+
+function listFiles(baseDir, roots) {
+ const files = [];
+ for (const rootName of roots) {
+ const start = path.join(baseDir, rootName);
+ if (!fs.existsSync(start)) continue;
+ walk(start, files);
+ }
+ return files;
+}
+
+function walk(current, files) {
+ const stat = fs.lstatSync(current);
+ if (stat.isSymbolicLink()) return;
+ if (stat.isDirectory()) {
+ if (["node_modules", ".next", ".git"].includes(path.basename(current))) return;
+ for (const entry of fs.readdirSync(current)) walk(path.join(current, entry), files);
+ return;
+ }
+ if (stat.isFile()) files.push(current);
+}
diff --git a/scripts/i18n-phase9-hardening.mjs b/scripts/i18n-phase9-hardening.mjs
new file mode 100644
index 0000000..9049117
--- /dev/null
+++ b/scripts/i18n-phase9-hardening.mjs
@@ -0,0 +1,295 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+import Database from "better-sqlite3";
+import { drizzle } from "drizzle-orm/better-sqlite3";
+import { migrate } from "drizzle-orm/better-sqlite3/migrator";
+
+const root = process.cwd();
+const workRoot = path.join(root, ".data", `i18n-phase9-hardening-${Date.now()}`);
+const emptyDataDir = path.join(workRoot, "empty");
+const productionLikeDataDir = path.join(workRoot, "production-like");
+const restoreDir = path.join(workRoot, "restore");
+const failedRestoreDir = path.join(workRoot, "failed-restore");
+
+fs.mkdirSync(workRoot, { recursive: true });
+
+execFileSync(process.execPath, ["scripts/i18n-phase9-boundary.mjs"], {
+ cwd: root,
+ stdio: "inherit",
+});
+
+runEmptyMigrationRehearsal(emptyDataDir);
+runProductionLikeMigrationRehearsal(productionLikeDataDir);
+runBackupRestoreAndFailureRehearsal(productionLikeDataDir, restoreDir, failedRestoreDir);
+assertSupabaseImportCoverage();
+assertStandaloneCatalogPresence();
+
+console.log("I18n phase 9 hardening passed: migration, backup/restore, import coverage and release boundary verified.");
+
+function runEmptyMigrationRehearsal(dataDir) {
+ const env = envFor(dataDir);
+ execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
+ execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
+
+ const db = new Database(path.join(dataDir, "neta.db"));
+ try {
+ assert.equal(countRows(db, "instance_locales", "code in ('tr', 'en') and status = 'active'"), 2);
+ assert.equal(single(db, "select default_locale from instance_i18n_settings where key = 'default'"), "tr");
+ assert.equal(countRows(db, "content_translations"), 0);
+ assert.deepEqual(db.pragma("foreign_key_check"), []);
+ } finally {
+ db.close();
+ }
+}
+
+function runProductionLikeMigrationRehearsal(dataDir) {
+ fs.mkdirSync(dataDir, { recursive: true });
+ const oldMigrationsDir = createPartialMigrationsDir(7);
+ const dbPath = path.join(dataDir, "neta.db");
+ const db = new Database(dbPath);
+ try {
+ applyPragmas(db);
+ migrate(drizzle({ client: db }), { migrationsFolder: oldMigrationsDir });
+ seedProductionLikeTurkishData(db);
+ } finally {
+ db.close();
+ }
+
+ const env = envFor(dataDir);
+ execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
+ assertBackfilledTurkishData(dbPath);
+ const before = snapshotBackfillCounts(dbPath);
+ execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: root, env, stdio: "inherit" });
+ assert.deepEqual(snapshotBackfillCounts(dbPath), before, "Backfill must be idempotent on repeated migrate");
+}
+
+function runBackupRestoreAndFailureRehearsal(sourceDataDir, targetRestoreDir, failedTargetDir) {
+ const sourceDbPath = path.join(sourceDataDir, "neta.db");
+ const db = new Database(sourceDbPath);
+ try {
+ db.prepare(`
+ insert into instance_locales (code, name, native_name, status, fallback_locale, text_direction, built_in, sort_order)
+ values ('fr', 'French', 'Français', 'active', 'en', 'ltr', 0, 30)
+ on conflict(code) do update set status = excluded.status
+ `).run();
+ db.prepare(`
+ insert into content_translations (entity_type, entity_id, field, locale, value)
+ values ('project', 'prod-project', 'name', 'fr', 'Site de marque')
+ on conflict(entity_type, entity_id, field, locale) do update set value = excluded.value
+ `).run();
+ } finally {
+ db.close();
+ }
+
+ execFileSync(process.execPath, ["scripts/backup.mjs", "--retention-count", "2"], {
+ cwd: root,
+ env: envFor(sourceDataDir),
+ stdio: "inherit",
+ });
+ const backupDir = latestBackupDir(path.join(sourceDataDir, "backups"));
+ execFileSync(process.execPath, ["scripts/restore.mjs", "--from", backupDir, "--target", targetRestoreDir], {
+ cwd: root,
+ env: process.env,
+ stdio: "inherit",
+ });
+
+ const restored = new Database(path.join(targetRestoreDir, "neta.db"), { readonly: true });
+ try {
+ assert.equal(single(restored, "select status from instance_locales where code = 'fr'"), "active");
+ assert.equal(
+ single(restored, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'name' and locale = 'fr'"),
+ "Site de marque",
+ );
+ assert.deepEqual(restored.pragma("foreign_key_check"), []);
+ } finally {
+ restored.close();
+ }
+
+ fs.mkdirSync(failedTargetDir, { recursive: true });
+ fs.copyFileSync(path.join(targetRestoreDir, "neta.db"), path.join(failedTargetDir, "neta.db"));
+ const tamperedBackup = path.join(workRoot, "tampered-backup");
+ fs.cpSync(backupDir, tamperedBackup, { recursive: true });
+ fs.appendFileSync(path.join(tamperedBackup, "neta.db"), "tamper");
+ assert.throws(
+ () => execFileSync(
+ process.execPath,
+ ["scripts/restore.mjs", "--from", tamperedBackup, "--target", failedTargetDir, "--force"],
+ { cwd: root, env: process.env, stdio: "pipe" },
+ ),
+ /Command failed/,
+ );
+ const failedTarget = new Database(path.join(failedTargetDir, "neta.db"), { readonly: true });
+ try {
+ assert.equal(single(failedTarget, "select status from instance_locales where code = 'fr'"), "active");
+ } finally {
+ failedTarget.close();
+ }
+}
+
+function assertSupabaseImportCoverage() {
+ const importSmoke = fs.readFileSync(path.join(root, "scripts", "phase8-import-smoke.mjs"), "utf8");
+ for (const marker of [
+ "runImport([\"--dry-run\"])",
+ "runImport([\"--allow-existing\"])",
+ "invalid-enum",
+ "invalid-foreign-key",
+ "unsafe-storage",
+ "rollback",
+ ]) {
+ assert.ok(importSmoke.includes(marker), `Supabase import smoke must cover ${marker}`);
+ }
+}
+
+function assertStandaloneCatalogPresence() {
+ const standaloneDir = path.join(root, ".next", "standalone");
+ if (!fs.existsSync(standaloneDir)) return;
+ const serverDir = path.join(standaloneDir, ".next", "server");
+ const chunkText = listFiles(serverDir, ["."])
+ .filter((file) => file.endsWith(".js"))
+ .map((file) => fs.readFileSync(file, "utf8"))
+ .join("\n");
+ for (const marker of [
+ "auth.marketing.headline",
+ "Türkçe",
+ "English",
+ "instance.localization",
+ ]) {
+ assert.ok(chunkText.includes(marker), `Standalone server bundle must include marker: ${marker}`);
+ }
+}
+
+function createPartialMigrationsDir(maxIndex) {
+ const source = path.join(root, "server", "db", "migrations");
+ const destination = path.join(workRoot, `migrations-0-${maxIndex}`);
+ fs.mkdirSync(path.join(destination, "meta"), { recursive: true });
+
+ for (const entry of fs.readdirSync(source)) {
+ const match = /^(\d{4})_.*\.sql$/.exec(entry);
+ if (match && Number(match[1]) <= maxIndex) {
+ fs.copyFileSync(path.join(source, entry), path.join(destination, entry));
+ }
+ }
+ for (const entry of fs.readdirSync(path.join(source, "meta"))) {
+ const match = /^(\d{4})_snapshot\.json$/.exec(entry);
+ if (match && Number(match[1]) <= maxIndex) {
+ fs.copyFileSync(path.join(source, "meta", entry), path.join(destination, "meta", entry));
+ }
+ }
+
+ const journal = JSON.parse(fs.readFileSync(path.join(source, "meta", "_journal.json"), "utf8"));
+ journal.entries = journal.entries.filter((entry) => entry.idx <= maxIndex);
+ fs.writeFileSync(path.join(destination, "meta", "_journal.json"), `${JSON.stringify(journal, null, 2)}\n`);
+ return destination;
+}
+
+function seedProductionLikeTurkishData(db) {
+ const now = Date.now();
+ db.prepare("insert into user (id, name, email, email_verified, created_at, updated_at) values (?, ?, ?, 1, ?, ?)")
+ .run("prod-owner", "Prod Owner", "prod-owner@example.com", now, now);
+ db.prepare("insert into app_profiles (auth_user_id, email, display_name, role, disabled, created_at, updated_at) values (?, ?, ?, 'freelancer', 0, ?, ?)")
+ .run("prod-owner", "prod-owner@example.com", "Prod Owner", now, now);
+ db.prepare("insert into user_preferences (owner_user_id, language, created_at, updated_at) values (?, 'tr', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)")
+ .run("prod-owner");
+ db.prepare("insert into clients (id, owner_user_id, name, email, status, pipeline_stage, created_at, updated_at) values (?, ?, ?, ?, 'active', 'won', ?, ?)")
+ .run("prod-client", "prod-owner", "Türkçe Müşteri", "client@example.com", now, now);
+ db.prepare(`
+ insert into projects (id, owner_user_id, client_id, name, description, status, currency, progress, progress_type, revision_quota, cover_image_alt, created_at, updated_at)
+ values (?, ?, ?, ?, ?, 'active', 'TRY', 25, 'manual', 2, ?, ?, ?)
+ `).run("prod-project", "prod-owner", "prod-client", "Marka Sitesi", "Türkçe proje açıklaması", "Kapak görseli", now, now);
+ db.prepare(`
+ insert into tasks (id, owner_user_id, client_id, project_id, title, description, status, priority, created_at, updated_at)
+ values (?, ?, ?, ?, ?, ?, 'todo', 'medium', ?, ?)
+ `).run("prod-task", "prod-owner", "prod-client", "prod-project", "Hero alanını hazırla", "Başlık ve CTA metnini düzenle", now, now);
+ db.prepare(`
+ insert into project_planning_sections (id, owner_user_id, project_id, category, title, content, metadata, sort_order, created_at, updated_at)
+ values (?, ?, ?, 'overview', ?, ?, '{}', 1, ?, ?)
+ `).run("prod-section", "prod-owner", "prod-project", "Genel Bakış", "Türkçe plan içeriği", now, now);
+ db.prepare(`
+ insert into instance_branding (id, owner_user_id, application_name, short_name, primary_color, accent_color, default_color_mode, radius_scale, organization_name, portal_welcome_text, portal_footer_text, updated_by_user_id, created_at, updated_at)
+ values ('default', ?, 'Neta', 'Neta', '#C81E1E', '#E6EDF5', 'system', 'default', 'Prod Workspace', ?, ?, ?, ?, ?)
+ `).run("prod-owner", "Portala hoş geldiniz", "Tüm hakları saklıdır", "prod-owner", now, now);
+}
+
+function assertBackfilledTurkishData(dbPath) {
+ const db = new Database(dbPath);
+ try {
+ assert.equal(single(db, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'name' and locale = 'tr'"), "Marka Sitesi");
+ assert.equal(single(db, "select value from content_translations where entity_type = 'project' and entity_id = 'prod-project' and field = 'description' and locale = 'tr'"), "Türkçe proje açıklaması");
+ assert.equal(single(db, "select value from content_translations where entity_type = 'task' and entity_id = 'prod-task' and field = 'title' and locale = 'tr'"), "Hero alanını hazırla");
+ assert.equal(single(db, "select value from content_translations where entity_type = 'planning_section' and entity_id = 'prod-section' and field = 'title' and locale = 'tr'"), "Genel Bakış");
+ assert.equal(single(db, "select value from content_translations where entity_type = 'branding' and entity_id = 'default' and field = 'portalWelcome' and locale = 'tr'"), "Portala hoş geldiniz");
+ assert.equal(single(db, "select default_locale from instance_i18n_settings where key = 'default'"), "tr");
+ assert.equal(countRows(db, "instance_locales", "code in ('tr', 'en') and status = 'active'"), 2);
+ assert.deepEqual(db.pragma("foreign_key_check"), []);
+ } finally {
+ db.close();
+ }
+}
+
+function snapshotBackfillCounts(dbPath) {
+ const db = new Database(dbPath);
+ try {
+ return {
+ translations: countRows(db, "content_translations"),
+ locales: countRows(db, "instance_locales"),
+ settings: countRows(db, "instance_i18n_settings"),
+ };
+ } finally {
+ db.close();
+ }
+}
+
+function latestBackupDir(backupsDir) {
+ return fs
+ .readdirSync(backupsDir, { withFileTypes: true })
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => path.join(backupsDir, entry.name))
+ .sort((left, right) => path.basename(right).localeCompare(path.basename(left)))[0];
+}
+
+function envFor(dataDir) {
+ return {
+ ...process.env,
+ DATA_DIR: dataDir,
+ DATABASE_PATH: path.join(dataDir, "neta.db"),
+ };
+}
+
+function countRows(db, table, where = "1 = 1") {
+ return db.prepare(`select count(*) as value from ${table} where ${where}`).get().value;
+}
+
+function single(db, sql) {
+ const row = db.prepare(sql).get();
+ assert.ok(row, `Expected row for query: ${sql}`);
+ return Object.values(row)[0];
+}
+
+function applyPragmas(db) {
+ db.pragma("foreign_keys = ON");
+ db.pragma("journal_mode = WAL");
+ db.pragma("synchronous = NORMAL");
+ db.pragma("busy_timeout = 5000");
+}
+
+function listFiles(baseDir, roots) {
+ const files = [];
+ for (const rootName of roots) {
+ const start = path.join(baseDir, rootName);
+ if (!fs.existsSync(start)) continue;
+ walk(start, files);
+ }
+ return files;
+}
+
+function walk(current, files) {
+ const stat = fs.lstatSync(current);
+ if (stat.isSymbolicLink()) return;
+ if (stat.isDirectory()) {
+ for (const entry of fs.readdirSync(current)) walk(path.join(current, entry), files);
+ return;
+ }
+ if (stat.isFile()) files.push(current);
+}
diff --git a/tsconfig.i18n-phase7-smoke.json b/tsconfig.i18n-phase7-smoke.json
new file mode 100644
index 0000000..e988a22
--- /dev/null
+++ b/tsconfig.i18n-phase7-smoke.json
@@ -0,0 +1,18 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "outDir": ".next/i18n-phase7-smoke-dist",
+ "rootDir": ".",
+ "noEmit": false,
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "target": "ES2022",
+ "types": ["node"]
+ },
+ "include": [
+ "lib/i18n/**/*.ts",
+ "locales/**/*.ts",
+ "server/i18n/locale.ts",
+ "scripts/i18n-phase7-smoke.ts"
+ ]
+}
|