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" + ] +}