chore: add i18n release hardening gates
This commit is contained in:
@@ -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)}
|
||||
</td>
|
||||
<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 className="p-4 align-middle text-right">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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)}
|
||||
</td>
|
||||
<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 className="p-4 align-middle text-right">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
</td>
|
||||
<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 className="p-4 align-middle text-right">
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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. 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.
|
||||
+10
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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.");
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user