-
-
-
-
-
-
-
-
-
+
@@ -825,7 +842,7 @@ function isOverdue(task: TaskListItem) {
}
function formatDateTime(value: string) {
- return new Intl.DateTimeFormat("tr-TR", {
+ return new Intl.DateTimeFormat(getDocumentIntlLocale(), {
day: "2-digit",
month: "short",
hour: "2-digit",
diff --git a/scripts/i18n-phase5-smoke.mjs b/scripts/i18n-phase5-smoke.mjs
new file mode 100644
index 0000000..9c070a3
--- /dev/null
+++ b/scripts/i18n-phase5-smoke.mjs
@@ -0,0 +1,28 @@
+import { execFileSync } from "node:child_process";
+import fs from "node:fs";
+import path from "node:path";
+
+const dataDir = path.join(process.cwd(), ".data", `i18n-phase5-smoke-${Date.now()}`);
+const databasePath = path.join(dataDir, "neta.db");
+const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
+
+fs.mkdirSync(dataDir, { recursive: true });
+
+execFileSync(process.execPath, ["scripts/migrate.mjs"], {
+ cwd: process.cwd(),
+ env,
+ stdio: "inherit",
+});
+execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase5-smoke.json"], {
+ cwd: process.cwd(),
+ stdio: "inherit",
+});
+
+const serverOnlyStubDir = path.join(process.cwd(), ".next", "i18n-phase5-smoke-dist", "node_modules", "server-only");
+fs.mkdirSync(serverOnlyStubDir, { recursive: true });
+fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
+execFileSync(
+ process.execPath,
+ [path.join(".next", "i18n-phase5-smoke-dist", "scripts", "i18n-phase5-smoke.js")],
+ { cwd: process.cwd(), env, stdio: "inherit" },
+);
diff --git a/scripts/i18n-phase5-smoke.ts b/scripts/i18n-phase5-smoke.ts
new file mode 100644
index 0000000..c0866b5
--- /dev/null
+++ b/scripts/i18n-phase5-smoke.ts
@@ -0,0 +1,118 @@
+import assert from "node:assert/strict";
+import { user } from "../server/db/schema";
+import { getSqliteConnection } from "../server/db/client";
+import type { DomainActor } from "../server/domain/actor";
+import { DomainService } from "../server/services/domain";
+import { ContentTranslationService } from "../server/i18n/content";
+import { I18nService } from "../server/i18n/service";
+
+const { db } = getSqliteConnection();
+const owner: DomainActor = {
+ authUserId: "phase5-owner",
+ role: "freelancer",
+ clientId: null,
+ disabled: false,
+};
+
+db.insert(user)
+ .values({
+ id: owner.authUserId,
+ name: "Phase 5 Owner",
+ email: "phase5-owner@example.com",
+ emailVerified: true,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .onConflictDoNothing()
+ .run();
+
+const i18n = new I18nService(db);
+i18n.listLocales(owner);
+if (!i18n.listLocales(owner).some((locale) => locale.code === "fr")) {
+ i18n.createLocale(owner, {
+ code: "fr",
+ name: "French",
+ nativeName: "Français",
+ fallbackLocale: "en",
+ status: "active",
+ });
+} else {
+ i18n.updateLocale(owner, "fr", { status: "active" });
+}
+
+const domain = new DomainService(db, (() => {
+ let next = 0;
+ return () => `phase5-${++next}`;
+})());
+const content = new ContentTranslationService(db);
+
+const project = domain.createProject(owner, {
+ id: "phase5-project",
+ type: "side_project",
+ name: "Legacy fallback",
+ translations: {
+ tr: {
+ name: "Çok dilli proje",
+ description: "Türkçe açıklama",
+ coverImageAlt: "Türkçe kapak",
+ },
+ en: {
+ name: "Multilingual project",
+ description: "English description",
+ coverImageAlt: "English cover",
+ },
+ fr: {
+ name: "Projet multilingue",
+ description: "Description française",
+ coverImageAlt: "Couverture française",
+ },
+ },
+});
+
+assert.equal(project.name, "Çok dilli proje", "Default locale must be projected to legacy project.name.");
+assert.equal(project.description, "Türkçe açıklama");
+
+const projectTranslations = content.listEntityTranslations("project", project.id);
+assert.equal(projectTranslations.filter((row) => row.field === "name").length, 3);
+assert.equal(
+ content.resolveEntity("project", project, {
+ locale: "fr",
+ defaultLocale: "tr",
+ translations: projectTranslations,
+ }).name,
+ "Projet multilingue",
+ "Project must resolve according to selected locale.",
+);
+
+const section = domain.addPlanningSection(owner, {
+ id: "phase5-section",
+ projectId: project.id,
+ category: "overview",
+ title: "Legacy section",
+ translations: {
+ tr: { title: "Planlama", content: "Türkçe içerik" },
+ en: { title: "Planning", content: "English content" },
+ fr: { title: "Planification", content: "Contenu français" },
+ },
+});
+assert.equal(section.title, "Planlama");
+
+const task = domain.createTask(owner, {
+ id: "phase5-task",
+ projectId: project.id,
+ title: "Legacy task",
+ translations: {
+ tr: { title: "Görev başlığı", description: "Türkçe görev" },
+ en: { title: "Task title", description: "English task" },
+ fr: { title: "Titre de tâche", description: "Tâche française" },
+ },
+});
+assert.equal(task.title, "Görev başlığı");
+
+const batch = content.listBatch("task", [task.id]);
+assert.equal(batch.get(task.id)?.some((row) => row.locale === "fr" && row.value === "Titre de tâche"), true);
+
+domain.deleteTask(owner, task.id);
+assert.equal(content.listEntityTranslations("task", task.id).length, 0, "Task delete must remove content translations.");
+
+console.log("I18n phase 5 content translation smoke passed.");
diff --git a/server/services/domain.ts b/server/services/domain.ts
index 85f39a4..2fc48b9 100644
--- a/server/services/domain.ts
+++ b/server/services/domain.ts
@@ -39,15 +39,19 @@ import {
calendarEventUpdateSchema,
} from "../domain/validation";
import { createDomainRepositories, type DomainRepositories } from "../repositories/domain";
+import { ContentTranslationService, projectBaseFromTranslations } from "../i18n/content";
+import type { ContentTranslationInput } from "../../lib/i18n/content";
export class DomainService {
readonly repositories: DomainRepositories;
+ private readonly contentTranslations: ContentTranslationService;
constructor(
private readonly db: DomainDatabase,
private readonly id: IdGenerator = generateId,
) {
this.repositories = createDomainRepositories(db);
+ this.contentTranslations = new ContentTranslationService(db);
}
listClients(actor: DomainActor) {
@@ -112,17 +116,31 @@ export class DomainService {
createProject(actor: DomainActor, input: unknown) {
const scope = requireOwnerScope(actor);
- const value = parseDomainInput(projectCreateSchema, input);
+ const translations = this.getContentTranslations(input);
+ const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
+ const value = parseDomainInput(
+ projectCreateSchema,
+ translations ? projectBaseFromTranslations("project", input as Record, translations, defaultLocale) : input,
+ );
this.assertProjectClient(scope, value.type, value.clientId);
- return this.repositories.projects.create(scope, { ...value, id: value.id ?? this.id() });
+ const id = value.id ?? this.id();
+ const created = this.repositories.projects.create(scope, { ...value, id });
+ this.contentTranslations.upsertEntityTranslations("project", created.id, translations);
+ return created;
}
updateProject(actor: DomainActor, projectId: string, input: unknown) {
const scope = requireOwnerScope(actor);
const current = this.repositories.projects.get(scope, projectId) ?? this.throwNotFound("Proje");
- const value = parseDomainInput(projectUpdateSchema, input);
+ const translations = this.getContentTranslations(input);
+ const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
+ const value = parseDomainInput(
+ projectUpdateSchema,
+ translations ? projectBaseFromTranslations("project", input as Record, translations, defaultLocale) : input,
+ );
this.assertProjectClient(scope, value.type ?? current.type, value.clientId === undefined ? current.clientId : value.clientId);
const updated = this.repositories.projects.update(scope, projectId, value) ?? this.throwNotFound("Proje");
+ this.contentTranslations.upsertEntityTranslations("project", updated.id, translations);
if (
updated.progressType === "auto"
&& (value.progressType === "auto" || value.progress !== undefined)
@@ -134,7 +152,14 @@ export class DomainService {
}
deleteProject(actor: DomainActor, id: string) {
- return this.repositories.projects.remove(requireOwnerScope(actor), id) ?? this.throwNotFound("Proje");
+ const scope = requireOwnerScope(actor);
+ const sectionIds = this.repositories.planning.list(scope, id).map((section) => section.id);
+ const deleted = this.repositories.projects.remove(scope, id) ?? this.throwNotFound("Proje");
+ this.contentTranslations.deleteEntityTranslations("project", id);
+ for (const sectionId of sectionIds) {
+ this.contentTranslations.deleteEntityTranslations("planning_section", sectionId);
+ }
+ return deleted;
}
listTasks(actor: DomainActor, projectId?: string) {
@@ -151,9 +176,15 @@ export class DomainService {
createTask(actor: DomainActor, input: unknown) {
const scope = requireOwnerScope(actor);
- const value = parseDomainInput(taskCreateSchema, input);
+ const translations = this.getContentTranslations(input);
+ const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
+ const value = parseDomainInput(
+ taskCreateSchema,
+ translations ? projectBaseFromTranslations("task", input as Record, translations, defaultLocale) : input,
+ );
this.assertTaskRelations(scope, value);
const task = this.repositories.tasks.create(scope, { ...value, id: value.id ?? this.id() });
+ this.contentTranslations.upsertEntityTranslations("task", task.id, translations);
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
return task;
}
@@ -161,10 +192,16 @@ export class DomainService {
updateTask(actor: DomainActor, taskId: string, input: unknown) {
const scope = requireOwnerScope(actor);
const current = this.repositories.tasks.get(scope, taskId) ?? this.throwNotFound("Görev");
- const value = parseDomainInput(taskUpdateSchema, input);
+ const translations = this.getContentTranslations(input);
+ const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
+ const value = parseDomainInput(
+ taskUpdateSchema,
+ translations ? projectBaseFromTranslations("task", input as Record, translations, defaultLocale) : input,
+ );
const merged = { ...current, ...value };
this.assertTaskRelations(scope, merged);
const task = this.repositories.tasks.update(scope, taskId, value) ?? this.throwNotFound("Görev");
+ this.contentTranslations.upsertEntityTranslations("task", task.id, translations);
if (current.projectId) this.recalculateProjectProgress(scope, current.projectId);
if (task.projectId && task.projectId !== current.projectId) this.recalculateProjectProgress(scope, task.projectId);
return task;
@@ -173,6 +210,7 @@ export class DomainService {
deleteTask(actor: DomainActor, taskId: string) {
const scope = requireOwnerScope(actor);
const task = this.repositories.tasks.remove(scope, taskId) ?? this.throwNotFound("Görev");
+ this.contentTranslations.deleteEntityTranslations("task", taskId);
if (task.projectId) this.recalculateProjectProgress(scope, task.projectId);
return task;
}
@@ -255,20 +293,36 @@ export class DomainService {
addPlanningSection(actor: DomainActor, input: unknown) {
const scope = requireOwnerScope(actor);
- const value = parseDomainInput(planningSectionCreateSchema, input);
+ const translations = this.getContentTranslations(input);
+ const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
+ const value = parseDomainInput(
+ planningSectionCreateSchema,
+ translations ? projectBaseFromTranslations("planning_section", input as Record, translations, defaultLocale) : input,
+ );
this.requireOwnedProject(scope, value.projectId);
- return this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
+ const section = this.repositories.planning.create(scope, { ...value, id: value.id ?? this.id() });
+ this.contentTranslations.upsertEntityTranslations("planning_section", section.id, translations);
+ return section;
}
updatePlanningSection(actor: DomainActor, sectionId: string, input: unknown) {
const scope = requireOwnerScope(actor);
if (!this.repositories.planning.get(scope, sectionId)) throw notFound("Planlama bölümü");
- const value = parseDomainInput(planningSectionUpdateSchema, input);
- return this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
+ const translations = this.getContentTranslations(input);
+ const defaultLocale = this.contentTranslations.getLocalizationContext(actor).defaultLocale;
+ const value = parseDomainInput(
+ planningSectionUpdateSchema,
+ translations ? projectBaseFromTranslations("planning_section", input as Record, translations, defaultLocale) : input,
+ );
+ const section = this.repositories.planning.update(scope, sectionId, value) ?? this.throwNotFound("Planlama bölümü");
+ this.contentTranslations.upsertEntityTranslations("planning_section", section.id, translations);
+ return section;
}
deletePlanningSection(actor: DomainActor, sectionId: string) {
- return this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
+ const section = this.repositories.planning.remove(requireOwnerScope(actor), sectionId) ?? this.throwNotFound("Planlama bölümü");
+ this.contentTranslations.deleteEntityTranslations("planning_section", sectionId);
+ return section;
}
listPlanningSections(actor: DomainActor, projectId: string) {
@@ -680,4 +734,11 @@ export class DomainService {
private throwNotFound(resource: string): never {
throw notFound(resource);
}
+
+ private getContentTranslations(input: unknown): ContentTranslationInput | undefined {
+ if (!input || typeof input !== "object") return undefined;
+ const translations = (input as { translations?: unknown }).translations;
+ if (!translations || typeof translations !== "object") return undefined;
+ return translations as ContentTranslationInput;
+ }
}
diff --git a/tsconfig.i18n-phase5-smoke.json b/tsconfig.i18n-phase5-smoke.json
new file mode 100644
index 0000000..8d6326f
--- /dev/null
+++ b/tsconfig.i18n-phase5-smoke.json
@@ -0,0 +1,23 @@
+{
+ "extends": "./tsconfig.i18n-phase3-smoke.json",
+ "compilerOptions": {
+ "outDir": ".next/i18n-phase5-smoke-dist"
+ },
+ "include": [
+ "scripts/i18n-phase5-smoke.ts",
+ "lib/i18n/**/*.ts",
+ "locales/**/*.ts",
+ "server/auth/types.ts",
+ "server/db/**/*.ts",
+ "server/domain/**/*.ts",
+ "server/i18n/catalog.ts",
+ "server/i18n/content.ts",
+ "server/i18n/locale.ts",
+ "server/i18n/service.ts",
+ "server/i18n/translator.ts",
+ "server/repositories/domain.ts",
+ "server/repositories/i18n.ts",
+ "server/services/domain.ts"
+ ],
+ "exclude": ["node_modules"]
+}