diff --git a/lib/i18n/content.ts b/lib/i18n/content.ts index 5035eef..7bb211d 100644 --- a/lib/i18n/content.ts +++ b/lib/i18n/content.ts @@ -33,8 +33,8 @@ export const contentTranslationRegistry = { { name: "description", label: "Açıklama", kind: "textarea", maxLength: 20_000, placeholder: "Kapsam, not veya teslim kriterleri..." }, ], branding: [ - { name: "portalWelcome", label: "Portal karşılama metni", kind: "textarea", maxLength: 10_000 }, - { name: "portalFooter", label: "Portal footer metni", kind: "textarea", maxLength: 5_000 }, + { name: "portalWelcome", label: "Portal karşılama metni", kind: "textarea", maxLength: 2_000 }, + { name: "portalFooter", label: "Portal footer metni", kind: "textarea", maxLength: 1_000 }, ], calendar_event: [ { name: "title", label: "Başlık", required: true, maxLength: 300 }, diff --git a/lib/i18n/locale-resolution.ts b/lib/i18n/locale-resolution.ts new file mode 100644 index 0000000..cb8e871 --- /dev/null +++ b/lib/i18n/locale-resolution.ts @@ -0,0 +1,78 @@ +export type LocaleCandidate = { + code: string; + status: "draft" | "active" | "archived" | "test"; + textDirection: "ltr" | "rtl"; +}; + +export type LocaleResolutionSource = + | "client" + | "fallback" + | "instance" + | "invitation" + | "user"; + +export type LocaleResolution = { + locale: string; + requestedLocale: string | null; + defaultLocale: string; + direction: "ltr" | "rtl"; + source: LocaleResolutionSource; +}; + +type ResolveLocalePolicyInput = { + activeLocales: readonly LocaleCandidate[]; + defaultLocale: string | null | undefined; + candidates: ReadonlyArray<{ + locale: string | null | undefined; + source: Exclude; + }>; + fallbackLocale?: string; +}; + +export function resolveLocalePolicy(input: ResolveLocalePolicyInput): LocaleResolution { + const fallbackLocale = input.fallbackLocale ?? "tr"; + const activeLocales = new Map( + input.activeLocales + .filter((locale) => locale.status === "active") + .map((locale) => [locale.code, locale] as const), + ); + const safeDefault = activeLocales.has(input.defaultLocale ?? "") + ? input.defaultLocale! + : activeLocales.has(fallbackLocale) + ? fallbackLocale + : activeLocales.keys().next().value ?? fallbackLocale; + + for (const candidate of input.candidates) { + const requestedLocale = normalizeLocaleCode(candidate.locale); + if (!requestedLocale) continue; + const locale = activeLocales.get(requestedLocale); + if (!locale) continue; + + return { + locale: locale.code, + requestedLocale, + defaultLocale: safeDefault, + direction: locale.textDirection, + source: candidate.source, + }; + } + + const defaultRecord = activeLocales.get(safeDefault); + return { + locale: safeDefault, + requestedLocale: normalizeLocaleCode(input.defaultLocale), + defaultLocale: safeDefault, + direction: defaultRecord?.textDirection ?? directionForLocale(safeDefault), + source: activeLocales.has(input.defaultLocale ?? "") ? "instance" : "fallback", + }; +} + +export function normalizeLocaleCode(value: string | null | undefined): string | null { + if (!value) return null; + const normalized = value.trim(); + return /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/.test(normalized) ? normalized : null; +} + +export function directionForLocale(locale: string): "ltr" | "rtl" { + return /^(ar|fa|he|ur)(-|$)/.test(locale) ? "rtl" : "ltr"; +} diff --git a/server/auth/invitations.ts b/server/auth/invitations.ts index 5ad8b57..1d3884d 100644 --- a/server/auth/invitations.ts +++ b/server/auth/invitations.ts @@ -545,28 +545,16 @@ export function setClientPortalLocale( .where(eq(clients.id, clientId)) .run(); - if (ownedClient.authUserId) { - tx.insert(userPreferences) - .values({ - ownerUserId: ownedClient.authUserId, - language: locale, - }) - .onConflictDoUpdate({ - target: userPreferences.ownerUserId, - set: { - language: locale, - updatedAt: new Date().toISOString(), - }, - }) - .run(); - tx.delete(session).where(eq(session.userId, ownedClient.authUserId)).run(); - } - tx.insert(authAuditEvents) .values({ type: "client_locale_updated", authUserId: actor.user.id, - metadata: { clientId, locale, targetAuthUserId: ownedClient.authUserId }, + metadata: { + clientId, + locale, + targetAuthUserId: ownedClient.authUserId, + userPreferencePreserved: Boolean(ownedClient.authUserId), + }, }) .run(); diff --git a/server/i18n/locale.ts b/server/i18n/locale.ts index f9ba070..a70f84f 100644 --- a/server/i18n/locale.ts +++ b/server/i18n/locale.ts @@ -1,28 +1,14 @@ import type { TextDirection } from "../../lib/i18n"; +import { + directionForLocale as inferDirectionForLocale, + normalizeLocaleCode, +} from "../../lib/i18n/locale-resolution"; -export const LOCALE_COOKIE = "neta_locale"; -export const LOCALE_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; export const DEFAULT_TEXT_DIRECTION: TextDirection = "ltr"; -export function normalizeLocaleCode(value: string | null | undefined): string | null { - if (!value) return null; - const normalized = value.trim(); - return /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/.test(normalized) ? normalized : null; -} +export { normalizeLocaleCode }; export function directionForLocale(locale: string, explicit?: TextDirection | null): TextDirection { if (explicit) return explicit; - return /^(ar|fa|he|ur)(-|$)/.test(locale) ? "rtl" : DEFAULT_TEXT_DIRECTION; -} - -export function buildLocaleCookie(value: string) { - return { - name: LOCALE_COOKIE, - value, - maxAge: LOCALE_COOKIE_MAX_AGE, - sameSite: "lax" as const, - path: "/", - httpOnly: false, - secure: process.env.NODE_ENV === "production", - }; + return inferDirectionForLocale(locale) ?? DEFAULT_TEXT_DIRECTION; } diff --git a/server/i18n/resolver.ts b/server/i18n/resolver.ts index d435269..28d443c 100644 --- a/server/i18n/resolver.ts +++ b/server/i18n/resolver.ts @@ -1,82 +1,118 @@ -import { cookies, headers } from "next/headers"; -import { cache } from "react"; -import { DEFAULT_LOCALE } from "../../lib/i18n"; -import { getSessionContextFromHeaders } from "../auth/session"; -import { getSqliteConnection } from "../db/client"; -import { clients, instanceI18nSettings, instanceLocales, userPreferences } from "../db/schema"; +import "server-only"; + import { eq } from "drizzle-orm"; -import { directionForLocale, LOCALE_COOKIE, normalizeLocaleCode } from "./locale"; +import { cache } from "react"; +import { + resolveLocalePolicy, + type LocaleResolution, +} from "../../lib/i18n/locale-resolution"; +import type { SessionContext } from "../auth/session"; +import { getSessionContext } from "../auth/session"; +import { getSqliteConnection } from "../db/client"; +import { + clients, + instanceI18nSettings, + instanceLocales, + userPreferences, +} from "../db/schema"; -export type ResolvedLocale = { - locale: string; - requestedLocale: string | null; - defaultLocale: string; - direction: "ltr" | "rtl"; - source: "user" | "client" | "cookie" | "instance" | "fallback"; -}; +export type ResolvedLocale = LocaleResolution; -export const resolveRequestLocale = cache(async (): Promise => { - const requestHeaders = await headers(); - const cookieStore = await cookies(); - const context = await getSessionContextFromHeaders(requestHeaders); +type LocaleContext = ReturnType; + +export const resolvePublicLocale = cache(async (): Promise => { + return resolveFromContext(readLocaleContext(), []); +}); + +export async function resolveInvitationLocale( + invitationLocale: string | null | undefined, +): Promise { + return resolveFromContext(readLocaleContext(), [ + { locale: invitationLocale, source: "invitation" }, + ]); +} + +export async function resolveFreelancerLocale( + providedContext?: SessionContext, +): Promise { + const context = providedContext ?? await getSessionContext(); + const localeContext = readLocaleContext(); + const preference = context + ? readUserPreference(localeContext, context.profile.authUserId) + : null; + + return resolveFromContext(localeContext, [ + { locale: preference, source: "user" }, + ]); +} + +export async function resolvePortalLocale( + providedContext?: SessionContext, +): Promise { + const context = providedContext ?? await getSessionContext(); + const localeContext = readLocaleContext(); + const preference = context + ? readUserPreference(localeContext, context.profile.authUserId) + : null; + const clientDefault = context?.profile.clientId + ? localeContext.db + .select({ portalLocale: clients.portalLocale }) + .from(clients) + .where(eq(clients.id, context.profile.clientId)) + .get()?.portalLocale + : null; + + return resolveFromContext(localeContext, [ + { locale: preference, source: "user" }, + { locale: clientDefault, source: "client" }, + ]); +} + +export const resolveRootLocale = cache(async (): Promise => { + const context = await getSessionContext(); + if (!context) return resolvePublicLocale(); + if (context.profile.role === "client") return resolvePortalLocale(context); + return resolveFreelancerLocale(context); +}); + +function readLocaleContext() { const { db } = getSqliteConnection(); - - const settings = db.select().from(instanceI18nSettings).where(eq(instanceI18nSettings.key, "default")).get(); - const defaultLocale = settings?.defaultLocale ?? DEFAULT_LOCALE; - const cookieLocale = normalizeLocaleCode(cookieStore.get(LOCALE_COOKIE)?.value); - let requestedLocale: string | null = null; - let source: ResolvedLocale["source"] = "fallback"; - - if (context?.profile.role === "client" && context.profile.clientId) { - requestedLocale = normalizeLocaleCode( - db - .select({ portalLocale: clients.portalLocale }) - .from(clients) - .where(eq(clients.id, context.profile.clientId)) - .get()?.portalLocale, - ); - source = requestedLocale ? "client" : source; - } - - if (!requestedLocale && context) { - requestedLocale = normalizeLocaleCode( - db - .select({ language: userPreferences.language }) - .from(userPreferences) - .where(eq(userPreferences.ownerUserId, context.profile.authUserId)) - .get()?.language, - ); - source = requestedLocale ? "user" : source; - } - - if (!requestedLocale && cookieLocale) { - requestedLocale = cookieLocale; - source = "cookie"; - } - - if (!requestedLocale) { - requestedLocale = defaultLocale; - source = "instance"; - } - - const localeRow = db + const settings = db + .select({ defaultLocale: instanceI18nSettings.defaultLocale }) + .from(instanceI18nSettings) + .where(eq(instanceI18nSettings.key, "default")) + .get(); + const locales = db .select({ code: instanceLocales.code, status: instanceLocales.status, textDirection: instanceLocales.textDirection, }) .from(instanceLocales) - .where(eq(instanceLocales.code, requestedLocale)) - .get(); - const locale = localeRow && localeRow.status !== "archived" - ? localeRow.code - : defaultLocale || DEFAULT_LOCALE; + .all(); return { - locale, - requestedLocale, - defaultLocale, - direction: directionForLocale(locale, localeRow?.textDirection), - source, + db, + defaultLocale: settings?.defaultLocale ?? "tr", + locales, }; -}); +} + +function readUserPreference(context: LocaleContext, authUserId: string) { + return context.db + .select({ language: userPreferences.language }) + .from(userPreferences) + .where(eq(userPreferences.ownerUserId, authUserId)) + .get()?.language ?? null; +} + +function resolveFromContext( + context: LocaleContext, + candidates: Parameters[0]["candidates"], +) { + return resolveLocalePolicy({ + activeLocales: context.locales, + defaultLocale: context.defaultLocale, + candidates, + }); +} diff --git a/server/i18n/service.ts b/server/i18n/service.ts index 9d8caaf..1c7088e 100644 --- a/server/i18n/service.ts +++ b/server/i18n/service.ts @@ -72,8 +72,53 @@ export type TranslationCompletion = { missingKeys: string[]; }; +export type NamespaceCompletion = { + namespace: I18nNamespace; + translated: number; + total: number; + percent: number; + missingKeys: string[]; +}; + +export type LocaleUsage = { + defaultSettings: number; + fallbacks: number; + userPreferences: number; + clients: number; + portalInvitations: number; + contentTranslations: number; +}; + +export type LocaleReadiness = { + canActivate: boolean; + canArchive: boolean; + canSetDefault: boolean; + missingCriticalKeys: string[]; + archiveReferences: number; +}; + const LOCALE_CODE_PATTERN = /^[a-z]{2}(?:-[A-Z]{2}[0-9]?)?$/; const BUILT_IN_LOCALES = new Set(["tr", "en"]); +export const ACTIVATION_CRITICAL_KEYS = [ + "common.error.title", + "common.error.description", + "common.actions.save", + "common.actions.cancel", + "auth.login.title", + "auth.login.email", + "auth.login.password", + "auth.login.submit", + "auth.messages.invalidCredentials", + "navigation.items.dashboard", + "navigation.items.settings", + "navigation.account.signOut", + "portal.dashboard.title", + "portal.projects.title", + "portal.tasks.title", + "portal.revisions.title", + "validation.required", + "validation.invalidLocale", +] as const; export class I18nService { private readonly repository; @@ -115,7 +160,7 @@ export class I18nService { nativeName: normalizeRequiredText(input.nativeName ?? input.name, "Yerel dil adı zorunludur."), fallbackLocale, textDirection: input.textDirection ?? "ltr", - status: input.status ?? "draft", + status: "draft", builtIn: false, sortOrder: input.sortOrder ?? 100, }); @@ -138,6 +183,13 @@ export class I18nService { this.assertValidFallback(locale.code, nextFallback); const nextStatus = input.status ?? locale.status; + if ( + nextStatus === "active" + && locale.status !== "active" + && !locale.builtIn + ) { + this.assertLocaleCanBeActivated(locale.code); + } if (nextStatus === "archived") { this.assertLocaleCanBeArchived(locale.code); } @@ -252,6 +304,61 @@ export class I18nService { }); } + getNamespaceCompletion(actor: DomainActor, code: string): NamespaceCompletion[] { + requireOwnerScope(actor); + this.ensureBootstrap(); + const locale = this.getExistingLocale(code); + const translations = this.translationKeysForLocale(locale.code); + + return I18N_NAMESPACES.map((namespace) => { + const referenceKeys = Object.keys(flattenCatalog(trCatalog, [namespace])); + const missingKeys = referenceKeys.filter((key) => !translations.has(key)); + const translated = referenceKeys.length - missingKeys.length; + return { + namespace, + translated, + total: referenceKeys.length, + percent: referenceKeys.length + ? Math.round((translated / referenceKeys.length) * 100) + : 100, + missingKeys, + }; + }); + } + + getLocaleUsage(actor: DomainActor, code: string): LocaleUsage { + requireOwnerScope(actor); + this.ensureBootstrap(); + const locale = this.getExistingLocale(code); + return this.repository.countLocaleReferences(locale.code); + } + + getLocaleReadiness(actor: DomainActor, code: string): LocaleReadiness { + requireOwnerScope(actor); + this.ensureBootstrap(); + const locale = this.getExistingLocale(code); + const settings = this.getSettings(actor); + const usage = this.getLocaleUsage(actor, locale.code); + const archiveReferences = Object.entries(usage) + .filter(([key]) => key !== "contentTranslations") + .reduce((total, [, value]) => total + value, 0); + const missingCriticalKeys = locale.builtIn + ? [] + : ACTIVATION_CRITICAL_KEYS.filter( + (key) => !this.translationKeysForLocale(locale.code).has(key), + ); + + return { + canActivate: locale.status === "active" || missingCriticalKeys.length === 0, + canArchive: !locale.builtIn + && settings.defaultLocale !== locale.code + && archiveReferences === 0, + canSetDefault: locale.status === "active", + missingCriticalKeys, + archiveReferences, + }; + } + exportPackage(actor: DomainActor): I18nExportPackage { requireOwnerScope(actor); this.ensureBootstrap(); @@ -331,12 +438,20 @@ export class I18nService { private assertValidFallback(code: string, fallbackLocale: string | null): void { if (!fallbackLocale) return; if (fallbackLocale === code) { - throw new DomainError("VALIDATION_ERROR", "Dil kendi kendine fallback olamaz."); + throw new DomainError( + "VALIDATION_ERROR", + "Dil kendi kendine fallback olamaz.", + { reason: "self_fallback" }, + ); } const fallback = this.repository.getLocale(fallbackLocale); if (!fallback || fallback.status === "archived") { - throw new DomainError("VALIDATION_ERROR", "Fallback dili aktif veya taslak bir dil olmalıdır."); + throw new DomainError( + "VALIDATION_ERROR", + "Fallback dili aktif veya taslak bir dil olmalıdır.", + { reason: "invalid_fallback" }, + ); } const graph = new Map( @@ -350,7 +465,11 @@ export class I18nService { let cursor: string | null = fallbackLocale; while (cursor) { if (cursor === code || seen.has(cursor)) { - throw new DomainError("VALIDATION_ERROR", "Fallback zinciri döngü oluşturamaz."); + throw new DomainError( + "VALIDATION_ERROR", + "Fallback zinciri döngü oluşturamaz.", + { reason: "fallback_loop" }, + ); } seen.add(cursor); cursor = graph.get(cursor) ?? null; @@ -359,11 +478,42 @@ export class I18nService { private assertLocaleCanBeArchived(code: string): void { const references = this.repository.countLocaleReferences(code); - const referenceCount = Object.values(references).reduce((total, value) => total + value, 0); + const referenceCount = Object.entries(references) + .filter(([key]) => key !== "contentTranslations") + .reduce((total, [, value]) => total + value, 0); if (referenceCount > 0) { throw new DomainError("CONFLICT", "Kullanımda olan dil arşivlenemez.", references); } } + + private assertLocaleCanBeActivated(code: string): void { + const locale = this.getExistingLocale(code); + if (locale.builtIn) return; + const translatedKeys = this.translationKeysForLocale(locale.code); + const missingCriticalKeys = ACTIVATION_CRITICAL_KEYS.filter( + (key) => !translatedKeys.has(key), + ); + if (missingCriticalKeys.length > 0) { + throw new DomainError( + "VALIDATION_ERROR", + "Kritik arayüz çevirileri tamamlanmadan dil aktifleştirilemez.", + { missingCriticalKeys }, + ); + } + } + + private translationKeysForLocale(code: string): Set { + const builtIn = getBuiltInCatalog(code); + const keys = builtIn + ? Object.keys(flattenCatalog(builtIn, I18N_NAMESPACES)) + : []; + for (const row of this.repository.listAllUiTranslations()) { + if (row.locale === code && row.value.trim()) { + keys.push(`${row.namespace}.${row.translationKey}`); + } + } + return new Set(keys); + } } export function getReferenceTranslationKeys(namespace: I18nNamespace | "all" = "all") { diff --git a/server/repositories/i18n.ts b/server/repositories/i18n.ts index 3d4fe3e..066e6d5 100644 --- a/server/repositories/i18n.ts +++ b/server/repositories/i18n.ts @@ -6,6 +6,7 @@ import { instanceLocales, portalInvitations, userPreferences, + contentTranslations, } from "../db/schema"; import type { DomainDatabase } from "../domain/database"; @@ -148,6 +149,13 @@ export function createI18nRepository(db: DomainDatabase) { .where(eq(portalInvitations.locale, code)) .get()?.count ?? 0, ), + contentTranslations: Number( + db + .select({ count: sql`count(*)` }) + .from(contentTranslations) + .where(eq(contentTranslations.locale, code)) + .get()?.count ?? 0, + ), }), }; } diff --git a/server/settings/ai.ts b/server/settings/ai.ts index 7aeaf78..b6198da 100644 --- a/server/settings/ai.ts +++ b/server/settings/ai.ts @@ -11,11 +11,13 @@ import { DomainError } from "../domain/errors"; const inputSchema = z.object({ provider: z.enum(["gemini", "openai", "groq", "ollama"]), + model: z.string().trim().max(200).optional(), apiKey: z.string().trim().max(4_096).optional(), }); export type PublicAiSettings = { provider: AiProvider; + model: string | null; hasApiKey: boolean; }; @@ -29,6 +31,7 @@ export function getPublicAiSettings(actor: DomainActor): PublicAiSettings { return { provider: row?.provider ?? "gemini", + model: row?.model ?? null, hasApiKey: Boolean(row?.encryptedApiKey), }; } @@ -51,26 +54,31 @@ export function updateAiSettings(actor: DomainActor, input: unknown): PublicAiSe : parsed.data.apiKey ? encryptSecret(parsed.data.apiKey) : current?.encryptedApiKey ?? null; + const model = parsed.data.model || null; db.insert(userAiSettings) .values({ ownerUserId: scope.ownerUserId, provider: parsed.data.provider, - model: null, + model, encryptedApiKey, }) .onConflictDoUpdate({ target: userAiSettings.ownerUserId, set: { provider: parsed.data.provider, - model: null, + model, encryptedApiKey, updatedAt: sqlNow(), }, }) .run(); - return { provider: parsed.data.provider, hasApiKey: Boolean(encryptedApiKey) }; + return { + provider: parsed.data.provider, + model, + hasApiKey: Boolean(encryptedApiKey), + }; } export function getAiRuntimeSettings(actor: DomainActor): { diff --git a/server/settings/preferences.ts b/server/settings/preferences.ts index 9702458..164abd3 100644 --- a/server/settings/preferences.ts +++ b/server/settings/preferences.ts @@ -4,7 +4,7 @@ import { eq } from "drizzle-orm"; import { z } from "zod"; import type { ColorMode } from "@/lib/color-mode"; import { getSqliteConnection } from "@/server/db/client"; -import { userPreferences } from "@/server/db/schema"; +import { instanceLocales, userPreferences } from "@/server/db/schema"; import { assertEnabledActor, type DomainActor } from "@/server/domain/actor"; import { DomainError } from "@/server/domain/errors"; @@ -82,6 +82,18 @@ export function updateLanguagePreference( } const { db } = getSqliteConnection(); + const locale = db + .select({ + code: instanceLocales.code, + status: instanceLocales.status, + }) + .from(instanceLocales) + .where(eq(instanceLocales.code, parsed.data.language)) + .get(); + if (!locale || locale.status !== "active") { + throw new DomainError("VALIDATION_ERROR", "Dil tercihi aktif bir dil olmalıdır."); + } + db.insert(userPreferences) .values({ ownerUserId: actor.authUserId,