diff --git a/database/migrations/080_entity_translations.sql b/database/migrations/080_entity_translations.sql new file mode 100644 index 0000000..e6df96c --- /dev/null +++ b/database/migrations/080_entity_translations.sql @@ -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(); diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 43eaa4c..8eae433 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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/` | 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/` | 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) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9d0b18d..58f1915 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 3a02fdb..ab9d55d 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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', diff --git a/src/categories/categories.controller.ts b/src/categories/categories.controller.ts index 3ae81f0..0e35e35 100644 --- a/src/categories/categories.controller.ts +++ b/src/categories/categories.controller.ts @@ -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( diff --git a/src/categories/categories.module.ts b/src/categories/categories.module.ts index 6baa737..47f20df 100644 --- a/src/categories/categories.module.ts +++ b/src/categories/categories.module.ts @@ -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, diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts index a8f9c18..b37d920 100644 --- a/src/categories/categories.service.ts +++ b/src/categories/categories.service.ts @@ -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> { + const map = new Map(); + 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, }; diff --git a/src/products/products.controller.ts b/src/products/products.controller.ts index 7413bda..aa8f62b 100644 --- a/src/products/products.controller.ts +++ b/src/products/products.controller.ts @@ -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( diff --git a/src/products/products.module.ts b/src/products/products.module.ts index 6cc67d1..8d9d5b3 100644 --- a/src/products/products.module.ts +++ b/src/products/products.module.ts @@ -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], diff --git a/src/products/products.service.ts b/src/products/products.service.ts index ba7672a..72903eb 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -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> { + const map = new Map(); + 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(), diff --git a/src/translations/dto/translation.dto.ts b/src/translations/dto/translation.dto.ts new file mode 100644 index 0000000..3a08ee4 --- /dev/null +++ b/src/translations/dto/translation.dto.ts @@ -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'; +} diff --git a/src/translations/translations.module.ts b/src/translations/translations.module.ts new file mode 100644 index 0000000..3e94f60 --- /dev/null +++ b/src/translations/translations.module.ts @@ -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 {} diff --git a/src/translations/translations.service.ts b/src/translations/translations.service.ts new file mode 100644 index 0000000..7ecd415 --- /dev/null +++ b/src/translations/translations.service.ts @@ -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> { + 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; + try { + parsed = JSON.parse(content) as Record; + } 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 { + 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 | TranslationFieldsDto, + ): Record { + 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, + ) { + 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; + 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 { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; +}