Add catalog EN/AR translations with AI translate-all by locale.

Store product/category translations in entity_translations, expose CMS APIs, and gate the MultiLanguage Data module for businesses.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-04 09:51:11 +03:30
co-authored by Cursor
parent 42cf29bc4e
commit d14f91a606
13 changed files with 950 additions and 10 deletions
@@ -158,6 +158,7 @@ export const BUSINESS_DASHBOARD_MODULE_IDS = [
'instructions',
'workshops',
'finance',
'multilanguage_data',
] as const;
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
@@ -254,7 +255,9 @@ export const DEFAULT_NEW_BUSINESS_MODULES: BusinessModuleId[] = [
* enabled except opt-in modules. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
BUSINESS_DASHBOARD_MODULE_IDS.filter((id) => id !== 'workshops');
BUSINESS_DASHBOARD_MODULE_IDS.filter(
(id) => id !== 'workshops' && id !== 'multilanguage_data',
);
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
'orders_30d',
+65
View File
@@ -26,6 +26,9 @@ import {
SuggestCategoryTechnicalFormDto,
} from './dto/category-technical-form.dto';
import { ReplaceCategoryVariationsDto } from './dto/category-variation.dto';
import { TranslationEntityType } from '@prisma/client';
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
import { TranslationsService } from '../translations/translations.service';
import { GenerateCategoriesDto } from './dto/category-ai.dto';
@Controller('businesses/:businessId/categories')
@@ -37,6 +40,7 @@ export class CategoriesController {
private readonly technicalFormService: CategoryTechnicalFormService,
private readonly technicalFormAiService: CategoryTechnicalFormAiService,
private readonly categoryAiService: CategoryAiService,
private readonly translations: TranslationsService,
) {}
@Get('color-presets')
@@ -65,6 +69,16 @@ export class CategoriesController {
return this.categoryAiService.generateProductCategories(businessId, dto, user);
}
@Post('translations/ai-all')
@RequireBusinessPermission('categories.update')
translateAllWithAi(
@Param('businessId') businessId: string,
@Body() dto: TranslateAllDto,
@CurrentUser() user: AuthUser,
) {
return this.translations.translateAllCategories(businessId, user, dto.locale);
}
@Post()
@RequireBusinessPermission('categories.create')
create(
@@ -86,6 +100,57 @@ export class CategoriesController {
return this.service.update(businessId, categoryId, dto, user);
}
@Get(':categoryId/translations')
@RequireBusinessPermission('categories.read')
listTranslations(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@CurrentUser() user: AuthUser,
) {
return this.translations.list(
businessId,
TranslationEntityType.category,
categoryId,
user,
);
}
@Post(':categoryId/translations/:locale/ai')
@RequireBusinessPermission('categories.update')
translateWithAi(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@Param('locale') locale: string,
@CurrentUser() user: AuthUser,
) {
return this.translations.translateWithAi(
businessId,
TranslationEntityType.category,
categoryId,
locale,
user,
);
}
@Put(':categoryId/translations/:locale')
@RequireBusinessPermission('categories.update')
saveTranslation(
@Param('businessId') businessId: string,
@Param('categoryId') categoryId: string,
@Param('locale') locale: string,
@Body() dto: UpsertTranslationDto,
@CurrentUser() user: AuthUser,
) {
return this.translations.save(
businessId,
TranslationEntityType.category,
categoryId,
locale,
dto,
user,
);
}
@Get(':categoryId/variations')
@RequireBusinessPermission('categories.read')
listVariations(
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { TranslationsModule } from '../translations/translations.module';
import { CategoriesController, PublicCategoriesController } from './categories.controller';
import { CategoriesService } from './categories.service';
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.service';
@@ -10,7 +11,7 @@ import { CategoryTechnicalFormService } from './category-technical-form.service'
import { CategoryVariationsService } from './category-variations.service';
@Module({
imports: [AuthModule, SitemapModule, TenantModule],
imports: [AuthModule, SitemapModule, TenantModule, TranslationsModule],
controllers: [CategoriesController, PublicCategoriesController],
providers: [
CategoriesService,
+51 -2
View File
@@ -4,7 +4,11 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { MediaEntityType } from '@prisma/client';
import {
MediaEntityType,
TranslationEntityType,
TranslationStatus,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { PrismaService } from '../prisma/prisma.service';
@@ -45,9 +49,18 @@ export class CategoriesService {
},
});
const translationCounts = await this.translationCounts(
businessId,
items.map((item) => item.id),
);
return {
items: items.map((item) =>
this.serialize(item, item._count.variations),
this.serialize(
item,
item._count.variations,
translationCounts.get(item.id.toString()) ?? 0,
),
),
};
}
@@ -252,6 +265,13 @@ export class CategoriesService {
const descendantIds = await this.collectDescendantIds(categoryId);
await this.prisma.$transaction([
this.prisma.entityTranslation.deleteMany({
where: {
businessId,
entityType: TranslationEntityType.category,
entityId: { in: descendantIds },
},
}),
this.prisma.category.deleteMany({
where: { id: { in: descendantIds } },
}),
@@ -338,6 +358,33 @@ export class CategoriesService {
}
}
private async translationCounts(
businessId: bigint,
categoryIds: bigint[],
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (categoryIds.length === 0) {
return map;
}
const grouped = await this.prisma.entityTranslation.groupBy({
by: ['entityId'],
where: {
businessId,
entityType: TranslationEntityType.category,
entityId: { in: categoryIds },
status: { in: [TranslationStatus.ready, TranslationStatus.stale] },
},
_count: { entityId: true },
});
for (const row of grouped) {
map.set(row.entityId.toString(), row._count.entityId);
}
return map;
}
private serialize(
category: {
id: bigint;
@@ -354,6 +401,7 @@ export class CategoriesService {
updatedAt: Date;
},
variationCount = 0,
translationCount?: number,
) {
return {
id: category.id.toString(),
@@ -367,6 +415,7 @@ export class CategoriesService {
sortOrder: category.sortOrder,
isActive: category.isActive,
variationCount,
...(translationCount !== undefined ? { translationCount } : {}),
createdAt: category.createdAt,
updatedAt: category.updatedAt,
};
+65
View File
@@ -17,6 +17,9 @@ import { BusinessPermissionGuard } from '../auth/guards/business-permission.guar
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
import { TranslationEntityType } from '@prisma/client';
import { UpsertTranslationDto, TranslateAllDto } from '../translations/dto/translation.dto';
import { TranslationsService } from '../translations/translations.service';
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
@@ -33,6 +36,7 @@ export class ProductsController {
private readonly aiService: ProductAiService,
private readonly variationValuesService: ProductVariationValuesService,
private readonly technicalInfoService: ProductTechnicalInfoService,
private readonly translations: TranslationsService,
) {}
@Get()
@@ -65,6 +69,67 @@ export class ProductsController {
return this.aiService.fillField(businessId, dto, user);
}
@Post('translations/ai-all')
@RequireBusinessPermission('products.update')
translateAllWithAi(
@Param('businessId') businessId: string,
@Body() dto: TranslateAllDto,
@CurrentUser() user: AuthUser,
) {
return this.translations.translateAllProducts(businessId, user, dto.locale);
}
@Get(':productId/translations')
@RequireBusinessPermission('products.read')
listTranslations(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@CurrentUser() user: AuthUser,
) {
return this.translations.list(
businessId,
TranslationEntityType.product,
productId,
user,
);
}
@Post(':productId/translations/:locale/ai')
@RequireBusinessPermission('products.update')
translateWithAi(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Param('locale') locale: string,
@CurrentUser() user: AuthUser,
) {
return this.translations.translateWithAi(
businessId,
TranslationEntityType.product,
productId,
locale,
user,
);
}
@Put(':productId/translations/:locale')
@RequireBusinessPermission('products.update')
saveTranslation(
@Param('businessId') businessId: string,
@Param('productId') productId: string,
@Param('locale') locale: string,
@Body() dto: UpsertTranslationDto,
@CurrentUser() user: AuthUser,
) {
return this.translations.save(
businessId,
TranslationEntityType.product,
productId,
locale,
dto,
user,
);
}
@Get(':productId')
@RequireBusinessPermission('products.read')
getOne(
+2
View File
@@ -5,6 +5,7 @@ import { CategoriesModule } from '../categories/categories.module';
import { MediaModule } from '../media/media.module';
import { SitemapModule } from '../sitemap/sitemap.module';
import { TenantModule } from '../tenant/tenant.module';
import { TranslationsModule } from '../translations/translations.module';
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
import { ProductAiService } from './product-ai.service';
import { ProductTechnicalInfoService } from './product-technical-info.service';
@@ -20,6 +21,7 @@ import { ProductsService } from './products.service';
MediaModule,
SitemapModule,
TenantModule,
TranslationsModule,
WebsiteAnalyticsModule,
],
controllers: [ProductsController, PublicProductsController],
+54 -6
View File
@@ -4,7 +4,13 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ContentStatus, MediaEntityType, Prisma } from '@prisma/client';
import {
ContentStatus,
MediaEntityType,
Prisma,
TranslationEntityType,
TranslationStatus,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BrandsService } from '../brands/brands.service';
@@ -98,14 +104,20 @@ export class ProductsService {
this.prisma.product.count({ where }),
]);
const variantCounts = await this.productVariationValues.countForProducts(
businessId,
items.map((item) => item.id),
);
const productIds = items.map((item) => item.id);
const [variantCounts, translationCounts] = await Promise.all([
this.productVariationValues.countForProducts(businessId, productIds),
this.translationCounts(businessId, productIds),
]);
const serialized = await Promise.all(
items.map((item) =>
this.serializeProduct(item, variantCounts.get(item.id.toString()) ?? 0),
this.serializeProduct(
item,
variantCounts.get(item.id.toString()) ?? 0,
{},
translationCounts.get(item.id.toString()) ?? 0,
),
),
);
@@ -519,6 +531,13 @@ export class ProductsService {
entityId: productId,
},
}),
this.prisma.entityTranslation.deleteMany({
where: {
businessId,
entityType: TranslationEntityType.product,
entityId: productId,
},
}),
this.prisma.product.delete({ where: { id: productId } }),
]);
@@ -528,10 +547,38 @@ export class ProductsService {
return { message: 'Product deleted successfully' };
}
private async translationCounts(
businessId: bigint,
productIds: bigint[],
): Promise<Map<string, number>> {
const map = new Map<string, number>();
if (productIds.length === 0) {
return map;
}
const grouped = await this.prisma.entityTranslation.groupBy({
by: ['entityId'],
where: {
businessId,
entityType: TranslationEntityType.product,
entityId: { in: productIds },
status: { in: [TranslationStatus.ready, TranslationStatus.stale] },
},
_count: { entityId: true },
});
for (const row of grouped) {
map.set(row.entityId.toString(), row._count.entityId);
}
return map;
}
private async serializeProduct(
product: ProductWithRelations,
variantCount?: number,
options: { approvedCommentsOnly?: boolean } = {},
translationCount?: number,
) {
const content = this.asRecord(product.content);
const metadata = this.asRecord(product.metadata);
@@ -604,6 +651,7 @@ export class ProductsService {
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
commentCount,
variantCount: resolvedVariantCount,
...(translationCount !== undefined ? { translationCount } : {}),
visitCount: product.visitCount.toString(),
cartAddCount: product.cartAddCount.toString(),
saleCount: product.saleCount.toString(),
+36
View File
@@ -0,0 +1,36 @@
import { Type } from 'class-transformer';
import { IsIn, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
export class TranslationFieldsDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
summary?: string;
@IsOptional()
@IsString()
descriptionHtml?: string;
}
export class UpsertTranslationDto {
@IsObject()
@ValidateNested()
@Type(() => TranslationFieldsDto)
fields!: TranslationFieldsDto;
}
export class TranslateAllDto {
@IsIn(['en', 'ar'])
locale!: 'en' | 'ar';
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { TranslationsService } from './translations.service';
@Module({
imports: [AuthModule],
providers: [TranslationsService],
exports: [TranslationsService],
})
export class TranslationsModule {}
+553
View File
@@ -0,0 +1,553 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
MediaEntityType,
Prisma,
TranslationEntityType,
TranslationLocale,
TranslationStatus,
} from '@prisma/client';
import { createHash } from 'crypto';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import {
requestAiJsonCompletion,
resolveAiProvider,
} from '../common/ai-provider.util';
import { PrismaService } from '../prisma/prisma.service';
import { TranslationFieldsDto } from './dto/translation.dto';
type CategorySource = {
name: string;
description: string;
};
type ProductSource = {
title: string;
summary: string;
descriptionHtml: string;
};
type SourceFields = CategorySource | ProductSource;
@Injectable()
export class TranslationsService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
private readonly config: ConfigService,
) {}
parseLocale(raw: string): TranslationLocale {
if (raw === TranslationLocale.en || raw === TranslationLocale.ar) {
return raw;
}
throw new BadRequestException('locale must be en or ar');
}
async list(
businessIdRaw: string,
entityType: TranslationEntityType,
entityIdRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const entityId = BigInt(entityIdRaw);
await this.assertPermission(businessId, actor.id, this.readPermission(entityType));
await this.loadSource(businessId, entityType, entityId);
const rows = await this.prisma.entityTranslation.findMany({
where: { businessId, entityType, entityId },
orderBy: { locale: 'asc' },
});
return {
entityType,
entityId: entityId.toString(),
items: rows.map((row) => this.serialize(row)),
};
}
async translateWithAi(
businessIdRaw: string,
entityType: TranslationEntityType,
entityIdRaw: string,
localeRaw: string,
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const entityId = BigInt(entityIdRaw);
const locale = this.parseLocale(localeRaw);
await this.assertPermission(businessId, actor.id, this.updatePermission(entityType));
const source = await this.loadSource(businessId, entityType, entityId);
if (!this.hasTranslatableText(source)) {
throw new BadRequestException(
'Add a name in the original language before translating',
);
}
const fields = await this.requestTranslation(entityType, locale, source);
const row = await this.upsertRow({
businessId,
entityType,
entityId,
locale,
fields,
sourceHash: hashSource(source),
generatedBy: 'ai',
});
return {
message: 'Translation generated',
translation: this.serialize(row),
};
}
async translateAllCategories(
businessIdRaw: string,
actor: AuthUser,
localeRaw: string,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'categories.update');
const locale = this.parseLocale(localeRaw);
const locales = [locale];
const categories = await this.prisma.category.findMany({
where: {
businessId,
entityType: MediaEntityType.product,
isActive: true,
},
select: { id: true },
});
const ids = categories.map((category) => category.id);
const existing =
ids.length === 0
? []
: await this.prisma.entityTranslation.findMany({
where: {
businessId,
entityType: TranslationEntityType.category,
entityId: { in: ids },
locale: { in: locales },
status: { in: [TranslationStatus.ready, TranslationStatus.stale] },
},
select: { entityId: true, locale: true },
});
const have = new Set(
existing.map((row) => `${row.entityId.toString()}:${row.locale}`),
);
let translated = 0;
let skipped = 0;
let failed = 0;
for (const category of categories) {
let source: SourceFields;
try {
source = await this.loadSource(
businessId,
TranslationEntityType.category,
category.id,
);
} catch {
failed += locales.length;
continue;
}
if (!this.hasTranslatableText(source)) {
skipped += locales.length;
continue;
}
const sourceHash = hashSource(source);
for (const targetLocale of locales) {
if (have.has(`${category.id.toString()}:${targetLocale}`)) {
skipped += 1;
continue;
}
try {
const fields = await this.requestTranslation(
TranslationEntityType.category,
targetLocale,
source,
);
await this.upsertRow({
businessId,
entityType: TranslationEntityType.category,
entityId: category.id,
locale: targetLocale,
fields,
sourceHash,
generatedBy: 'ai',
});
translated += 1;
} catch {
failed += 1;
}
}
}
return {
message: 'Category translations generated',
locale,
translated,
skipped,
failed,
};
}
async translateAllProducts(
businessIdRaw: string,
actor: AuthUser,
localeRaw: string,
) {
const businessId = BigInt(businessIdRaw);
await this.assertPermission(businessId, actor.id, 'products.update');
const locale = this.parseLocale(localeRaw);
const locales = [locale];
const products = await this.prisma.product.findMany({
where: { businessId },
select: { id: true },
});
const ids = products.map((product) => product.id);
const existing =
ids.length === 0
? []
: await this.prisma.entityTranslation.findMany({
where: {
businessId,
entityType: TranslationEntityType.product,
entityId: { in: ids },
locale: { in: locales },
status: { in: [TranslationStatus.ready, TranslationStatus.stale] },
},
select: { entityId: true, locale: true },
});
const have = new Set(
existing.map((row) => `${row.entityId.toString()}:${row.locale}`),
);
let translated = 0;
let skipped = 0;
let failed = 0;
for (const product of products) {
let source: SourceFields;
try {
source = await this.loadSource(
businessId,
TranslationEntityType.product,
product.id,
);
} catch {
failed += locales.length;
continue;
}
if (!this.hasTranslatableText(source)) {
skipped += locales.length;
continue;
}
const sourceHash = hashSource(source);
for (const targetLocale of locales) {
if (have.has(`${product.id.toString()}:${targetLocale}`)) {
skipped += 1;
continue;
}
try {
const fields = await this.requestTranslation(
TranslationEntityType.product,
targetLocale,
source,
);
await this.upsertRow({
businessId,
entityType: TranslationEntityType.product,
entityId: product.id,
locale: targetLocale,
fields,
sourceHash,
generatedBy: 'ai',
});
translated += 1;
} catch {
failed += 1;
}
}
}
return {
message: 'Product translations generated',
locale,
translated,
skipped,
failed,
};
}
async save(
businessIdRaw: string,
entityType: TranslationEntityType,
entityIdRaw: string,
localeRaw: string,
dto: { fields: TranslationFieldsDto },
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
const entityId = BigInt(entityIdRaw);
const locale = this.parseLocale(localeRaw);
await this.assertPermission(businessId, actor.id, this.updatePermission(entityType));
const source = await this.loadSource(businessId, entityType, entityId);
const fields = this.normalizeFields(entityType, dto.fields);
this.assertRequiredName(entityType, fields);
const row = await this.upsertRow({
businessId,
entityType,
entityId,
locale,
fields,
sourceHash: hashSource(source),
generatedBy: 'manual',
});
return {
message: 'Translation saved',
translation: this.serialize(row),
};
}
private async requestTranslation(
entityType: TranslationEntityType,
locale: TranslationLocale,
source: SourceFields,
): Promise<Record<string, string>> {
const provider = resolveAiProvider(this.config);
const targetLanguage =
locale === TranslationLocale.ar ? 'Arabic' : 'English';
const fieldKeys =
entityType === TranslationEntityType.category
? '"name", "description"'
: '"title", "summary", "descriptionHtml"';
const systemPrompt = `You translate Iranian e-commerce catalog content from Persian (Farsi) or mixed Persian/English into ${targetLanguage}.
Return ONLY valid JSON with exactly these keys: { ${fieldKeys} }
Keep brand names, model numbers, SKUs, and measurement units unchanged.
Do not add marketing fluff or extra sentences.
If a source field is empty, return an empty string for that key.
For descriptionHtml, keep the same HTML tags (p, ul, ol, li, strong, em, br) and translate text nodes only. Do not wrap in html/body/div.`;
const userPrompt = JSON.stringify({
targetLocale: locale,
targetLanguage,
entityType,
source,
});
let content: string;
try {
content = await requestAiJsonCompletion(provider, systemPrompt, userPrompt, 0.2);
} catch (err) {
const message = err instanceof Error ? err.message : 'AI request failed';
throw new BadRequestException(message);
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(content) as Record<string, unknown>;
} catch {
throw new BadRequestException('AI returned invalid JSON');
}
const fields = this.normalizeFields(entityType, parsed);
this.assertRequiredName(entityType, fields);
return fields;
}
private async loadSource(
businessId: bigint,
entityType: TranslationEntityType,
entityId: bigint,
): Promise<SourceFields> {
if (entityType === TranslationEntityType.category) {
const category = await this.prisma.category.findFirst({
where: { id: entityId, businessId },
select: { name: true, nameFa: true, description: true },
});
if (!category) {
throw new NotFoundException('Category not found');
}
return {
name: (category.nameFa?.trim() || category.name).trim(),
description: category.description?.trim() || '',
};
}
const product = await this.prisma.product.findFirst({
where: { id: entityId, businessId },
select: { title: true, description: true, content: true },
});
if (!product) {
throw new NotFoundException('Product not found');
}
const content = asRecord(product.content);
const nameFa =
typeof content.nameFa === 'string' ? content.nameFa.trim() : '';
const html = typeof content.html === 'string' ? content.html.trim() : '';
return {
title: nameFa || product.title.trim(),
summary: product.description?.trim() || '',
descriptionHtml: html,
};
}
private normalizeFields(
entityType: TranslationEntityType,
raw: Record<string, unknown> | TranslationFieldsDto,
): Record<string, string> {
if (entityType === TranslationEntityType.category) {
return {
name: String(raw.name ?? '').trim(),
description: String(raw.description ?? '').trim(),
};
}
return {
title: String(raw.title ?? '').trim(),
summary: String(raw.summary ?? '').trim(),
descriptionHtml: String(raw.descriptionHtml ?? '').trim(),
};
}
private assertRequiredName(
entityType: TranslationEntityType,
fields: Record<string, string>,
) {
const name =
entityType === TranslationEntityType.category ? fields.name : fields.title;
if (!name) {
throw new BadRequestException('Translated name is required');
}
}
private hasTranslatableText(source: SourceFields): boolean {
if ('name' in source) {
return Boolean(source.name);
}
return Boolean(source.title);
}
private async upsertRow(input: {
businessId: bigint;
entityType: TranslationEntityType;
entityId: bigint;
locale: TranslationLocale;
fields: Record<string, string>;
sourceHash: string;
generatedBy: string;
}) {
return this.prisma.entityTranslation.upsert({
where: {
entityType_entityId_locale: {
entityType: input.entityType,
entityId: input.entityId,
locale: input.locale,
},
},
create: {
businessId: input.businessId,
entityType: input.entityType,
entityId: input.entityId,
locale: input.locale,
fields: input.fields,
sourceHash: input.sourceHash,
status: TranslationStatus.ready,
generatedBy: input.generatedBy,
},
update: {
fields: input.fields,
sourceHash: input.sourceHash,
status: TranslationStatus.ready,
generatedBy: input.generatedBy,
},
});
}
private serialize(row: {
locale: TranslationLocale;
fields: Prisma.JsonValue;
status: TranslationStatus;
generatedBy: string;
updatedAt: Date;
}) {
const fields = asRecord(row.fields);
return {
locale: row.locale,
status: row.status,
fields,
generatedBy: row.generatedBy,
updatedAt: row.updatedAt,
};
}
private readPermission(entityType: TranslationEntityType) {
return entityType === TranslationEntityType.product
? 'products.read'
: 'categories.read';
}
private updatePermission(entityType: TranslationEntityType) {
return entityType === TranslationEntityType.product
? 'products.update'
: 'categories.update';
}
private async assertPermission(
businessId: bigint,
userId: bigint,
permission: string,
) {
const allowed = await this.permissions.hasBusinessPermission(
userId,
businessId,
permission,
);
if (!allowed) {
throw new ForbiddenException(
`Missing permission: ${permission} for this business`,
);
}
}
}
function hashSource(source: SourceFields): string {
return createHash('sha256').update(JSON.stringify(source)).digest('hex');
}
function asRecord(value: Prisma.JsonValue): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>;
}
return {};
}