feat(i18n): implement new locale resolution logic and service layer
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
+6
-20
@@ -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;
|
||||
}
|
||||
|
||||
+105
-69
@@ -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<ResolvedLocale> => {
|
||||
const requestHeaders = await headers();
|
||||
const cookieStore = await cookies();
|
||||
const context = await getSessionContextFromHeaders(requestHeaders);
|
||||
type LocaleContext = ReturnType<typeof readLocaleContext>;
|
||||
|
||||
export const resolvePublicLocale = cache(async (): Promise<ResolvedLocale> => {
|
||||
return resolveFromContext(readLocaleContext(), []);
|
||||
});
|
||||
|
||||
export async function resolveInvitationLocale(
|
||||
invitationLocale: string | null | undefined,
|
||||
): Promise<ResolvedLocale> {
|
||||
return resolveFromContext(readLocaleContext(), [
|
||||
{ locale: invitationLocale, source: "invitation" },
|
||||
]);
|
||||
}
|
||||
|
||||
export async function resolveFreelancerLocale(
|
||||
providedContext?: SessionContext,
|
||||
): Promise<ResolvedLocale> {
|
||||
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<ResolvedLocale> {
|
||||
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<ResolvedLocale> => {
|
||||
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<typeof resolveLocalePolicy>[0]["candidates"],
|
||||
) {
|
||||
return resolveLocalePolicy({
|
||||
activeLocales: context.locales,
|
||||
defaultLocale: context.defaultLocale,
|
||||
candidates,
|
||||
});
|
||||
}
|
||||
|
||||
+155
-5
@@ -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<string> {
|
||||
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") {
|
||||
|
||||
@@ -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<number>`count(*)` })
|
||||
.from(contentTranslations)
|
||||
.where(eq(contentTranslations.locale, code))
|
||||
.get()?.count ?? 0,
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
+11
-3
@@ -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): {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user