chore: add i18n release hardening gates
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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: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",
|
||||||
|
|||||||
@@ -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