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
@@ -0,0 +1,51 @@
-- AI / manual catalog translations (EN, AR) for products and categories.
-- Source text stays on the entity; extra locales live here, one row per entity + locale.
DO $$ BEGIN
CREATE TYPE translation_entity_type AS ENUM ('product', 'category');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE translation_locale AS ENUM ('en', 'ar');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE translation_status AS ENUM ('pending', 'ready', 'stale', 'failed');
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
CREATE TABLE IF NOT EXISTS entity_translations (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
entity_type translation_entity_type NOT NULL,
entity_id BIGINT NOT NULL,
locale translation_locale NOT NULL,
fields JSONB NOT NULL DEFAULT '{}'::jsonb,
source_hash VARCHAR(64) NOT NULL,
status translation_status NOT NULL DEFAULT 'pending',
generated_by VARCHAR(16) NOT NULL DEFAULT 'ai',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT entity_translations_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT entity_translations_entity_locale_unique
UNIQUE (entity_type, entity_id, locale)
);
CREATE INDEX IF NOT EXISTS idx_entity_translations_business_id
ON entity_translations (business_id);
CREATE INDEX IF NOT EXISTS idx_entity_translations_entity
ON entity_translations (entity_type, entity_id);
DROP TRIGGER IF EXISTS entity_translations_set_updated_at ON entity_translations;
CREATE TRIGGER entity_translations_set_updated_at
BEFORE UPDATE ON entity_translations
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
+13
View File
@@ -167,6 +167,7 @@ Example super admin: `+989121111111` / `password`
| `054_seed_iran_provinces_cities.sql` | Seed Iran provinces + cities when missing (004 seed may never have run) |
| `055_user_products_listing_fields.sql` | User product listing fields: `price_currency`, `delivery_note`, `condition` (`user_product_condition`), `technical_notes` |
| `058_invoice_user_id.sql` | `invoices.user_id` billed user (backfill from business owner) |
| `080_entity_translations.sql` | `entity_translations` for product/category EN+AR (AI or manual) |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
@@ -179,6 +180,9 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
| Enum | Values |
|------|--------|
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
| `TranslationEntityType` | `product`, `category` |
| `TranslationLocale` | `en`, `ar` |
| `TranslationStatus` | `pending`, `ready`, `stale`, `failed` |
| `ContentStatus` | `draft`, `published`, `archived` |
| `VariationType` | `color`, `size`, `custom` |
| `CityLevel` | `country`, `province`, `city`, `district` |
@@ -325,6 +329,10 @@ Pattern: `/businesses/:businessId/<resource>`
| GET | `/categories/color-presets` | Predefined color palette |
| GET/PUT | `/categories/:id/variations` | Category variation options |
| GET/PUT | `/categories/:id/technical-form` | Technical form definition |
| POST | `/categories/translations/ai-all` | Generate missing EN+AR for all product categories (skips existing) |
| GET | `/categories/:id/translations` | EN/AR translations (AI or edited) |
| POST | `/categories/:id/translations/:locale/ai` | Generate EN or AR with AI |
| PUT | `/categories/:id/translations/:locale` | Save edited EN or AR text |
#### Products — notable sub-routes
@@ -333,6 +341,10 @@ Pattern: `/businesses/:businessId/<resource>`
| GET/PUT | `/products/:id/variations` | Which category variation options apply to this product |
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
| GET/PUT | `/products/:id/technical-info` | Product technical data |
| POST | `/products/translations/ai-all` | Generate missing EN+AR for all products (skips existing) |
| GET | `/products/:id/translations` | EN/AR translations (AI or edited) |
| POST | `/products/:id/translations/:locale/ai` | Generate EN or AR with AI |
| PUT | `/products/:id/translations/:locale` | Save edited EN or AR text |
#### My user products (customer — JWT, must be business customer)
@@ -453,6 +465,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
- One category per product (via `CategoryAssignment`)
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
- i18n: Persian name in `content.nameFa`, summary in `description`
- Extra locales (EN, AR) in `entity_translations` (globe button in business dashboard; AI first, then optional edit)
### Category variations (product categories)
+44
View File
@@ -106,6 +106,7 @@ model Business {
comments Comment[]
contactSubmissions ContactSubmission[]
domains Domain[]
entityTranslations EntityTranslation[]
expertReviews ExpertReview[]
favorites Favorite[]
invoiceItemTemplates InvoiceItemTemplate[]
@@ -1738,3 +1739,46 @@ model WebsitePageViewCounter {
@@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind")
@@map("website_page_view_counters")
}
model EntityTranslation {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
entityType TranslationEntityType @map("entity_type")
entityId BigInt @map("entity_id")
locale TranslationLocale
fields Json @default("{}")
sourceHash String @map("source_hash") @db.VarChar(64)
status TranslationStatus @default(pending)
generatedBy String @default("ai") @map("generated_by") @db.VarChar(16)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([entityType, entityId, locale], map: "entity_translations_entity_locale_unique")
@@index([businessId], map: "idx_entity_translations_business_id")
@@index([entityType, entityId], map: "idx_entity_translations_entity")
@@map("entity_translations")
}
enum TranslationEntityType {
product
category
@@map("translation_entity_type")
}
enum TranslationLocale {
en
ar
@@map("translation_locale")
}
enum TranslationStatus {
pending
ready
stale
failed
@@map("translation_status")
}
@@ -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 {};
}