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:
co-authored by
Cursor
parent
42cf29bc4e
commit
d14f91a606
@@ -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();
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 {};
|
||||
}
|
||||
Reference in New Issue
Block a user