chore: add i18n release hardening gates

This commit is contained in:
poyrazavsever
2026-07-19 03:08:07 +03:00
parent 178f285191
commit 7d82bcc4f1
13 changed files with 943 additions and 10 deletions
@@ -1,8 +1,9 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useState } from "react"; import { useState } from "react";
import { format } from "date-fns"; 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 { Plus, MoreHorizontal, FileEdit, Trash2, Send, Download, CheckCircle2 } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import { import {
@@ -29,7 +30,7 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const formatCurrency = (amount: number, currency: string) => { 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) => { const getStatusBadge = (status: string) => {
@@ -101,7 +102,7 @@ export function InvoicesClient({ invoices }: { invoices: InvoiceRow[] }) {
{getStatusBadge(invoice.status)} {getStatusBadge(invoice.status)}
</td> </td>
<td className="p-4 align-middle text-muted-foreground"> <td className="p-4 align-middle text-muted-foreground">
{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() }) : "-"}
</td> </td>
<td className="p-4 align-middle text-right"> <td className="p-4 align-middle text-right">
<DropdownMenu> <DropdownMenu>
@@ -1,8 +1,9 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useState } from "react"; import { useState } from "react";
import { format } from "date-fns"; 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 { Plus, MoreHorizontal, FileEdit, Trash2, Mail, CheckCircle2, XCircle } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import { import {
@@ -28,7 +29,7 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) {
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const formatCurrency = (amount: number, currency: string) => { 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) => { const getStatusBadge = (status: string) => {
@@ -100,7 +101,7 @@ export function ProposalsClient({ proposals }: { proposals: ProposalRow[] }) {
{getStatusBadge(proposal.status)} {getStatusBadge(proposal.status)}
</td> </td>
<td className="p-4 align-middle text-muted-foreground"> <td className="p-4 align-middle text-muted-foreground">
{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() }) : "-"}
</td> </td>
<td className="p-4 align-middle text-right"> <td className="p-4 align-middle text-right">
<DropdownMenu> <DropdownMenu>
@@ -1,8 +1,9 @@
"use client"; "use client";
import { getDocumentIntlLocale } from "@/lib/i18n/browser";
import { useState } from "react"; import { useState } from "react";
import { format } from "date-fns"; 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 { CreditCard, Plus, MoreHorizontal, FileEdit, Trash2, StopCircle, RefreshCw } from "lucide-react";
import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms"; import { Button, Card, CardContent, Badge } from "poyraz-ui/atoms";
import { import {
@@ -28,7 +29,7 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip
const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isAddModalOpen, setIsAddModalOpen] = useState(false);
const formatCurrency = (amount: number, currency: string) => { 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) => { const getCycleBadge = (cycle: string) => {
@@ -125,7 +126,7 @@ export function SubscriptionsClient({ subscriptions }: { subscriptions: Subscrip
)} )}
</td> </td>
<td className="p-4 align-middle text-muted-foreground"> <td className="p-4 align-middle text-muted-foreground">
{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() }) : "-"}
</td> </td>
<td className="p-4 align-middle text-right"> <td className="p-4 align-middle text-right">
<DropdownMenu> <DropdownMenu>
+62
View File
@@ -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.
+59
View File
@@ -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.
+82
View File
@@ -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.
@@ -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. 00000007 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.
+10 -1
View File
@@ -32,7 +32,16 @@
"phase8:import-smoke": "node scripts/phase8-import-smoke.mjs", "phase8:import-smoke": "node scripts/phase8-import-smoke.mjs",
"phase8:release-boundary": "node scripts/phase8-release-boundary.mjs", "phase8:release-boundary": "node scripts/phase8-release-boundary.mjs",
"phase9:api-boundary": "node scripts/phase9-api-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": { "dependencies": {
"@ai-sdk/google": "^3.0.80", "@ai-sdk/google": "^3.0.80",
+19
View File
@@ -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",
});
+63
View File
@@ -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.");
+94
View File
@@ -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);
}
+295
View File
@@ -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);
}
+18
View File
@@ -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"
]
}