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) |
|
| `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` |
|
| `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) |
|
| `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.
|
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 |
|
| Enum | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
|
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
|
||||||
|
| `TranslationEntityType` | `product`, `category` |
|
||||||
|
| `TranslationLocale` | `en`, `ar` |
|
||||||
|
| `TranslationStatus` | `pending`, `ready`, `stale`, `failed` |
|
||||||
| `ContentStatus` | `draft`, `published`, `archived` |
|
| `ContentStatus` | `draft`, `published`, `archived` |
|
||||||
| `VariationType` | `color`, `size`, `custom` |
|
| `VariationType` | `color`, `size`, `custom` |
|
||||||
| `CityLevel` | `country`, `province`, `city`, `district` |
|
| `CityLevel` | `country`, `province`, `city`, `district` |
|
||||||
@@ -325,6 +329,10 @@ Pattern: `/businesses/:businessId/<resource>`
|
|||||||
| GET | `/categories/color-presets` | Predefined color palette |
|
| GET | `/categories/color-presets` | Predefined color palette |
|
||||||
| GET/PUT | `/categories/:id/variations` | Category variation options |
|
| GET/PUT | `/categories/:id/variations` | Category variation options |
|
||||||
| GET/PUT | `/categories/:id/technical-form` | Technical form definition |
|
| 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
|
#### 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/PUT | `/products/:id/variations` | Which category variation options apply to this product |
|
||||||
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
|
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
|
||||||
| GET/PUT | `/products/:id/technical-info` | Product technical data |
|
| 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)
|
#### 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`)
|
- One category per product (via `CategoryAssignment`)
|
||||||
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
|
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
|
||||||
- i18n: Persian name in `content.nameFa`, summary in `description`
|
- 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)
|
### Category variations (product categories)
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ model Business {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
contactSubmissions ContactSubmission[]
|
contactSubmissions ContactSubmission[]
|
||||||
domains Domain[]
|
domains Domain[]
|
||||||
|
entityTranslations EntityTranslation[]
|
||||||
expertReviews ExpertReview[]
|
expertReviews ExpertReview[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
invoiceItemTemplates InvoiceItemTemplate[]
|
invoiceItemTemplates InvoiceItemTemplate[]
|
||||||
@@ -1738,3 +1739,46 @@ model WebsitePageViewCounter {
|
|||||||
@@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind")
|
@@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind")
|
||||||
@@map("website_page_view_counters")
|
@@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',
|
'instructions',
|
||||||
'workshops',
|
'workshops',
|
||||||
'finance',
|
'finance',
|
||||||
|
'multilanguage_data',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
/** 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.
|
* enabled except opt-in modules. Customer modules stay opt-in.
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] =
|
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] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
'orders_30d',
|
'orders_30d',
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import {
|
|||||||
SuggestCategoryTechnicalFormDto,
|
SuggestCategoryTechnicalFormDto,
|
||||||
} from './dto/category-technical-form.dto';
|
} from './dto/category-technical-form.dto';
|
||||||
import { ReplaceCategoryVariationsDto } from './dto/category-variation.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';
|
import { GenerateCategoriesDto } from './dto/category-ai.dto';
|
||||||
|
|
||||||
@Controller('businesses/:businessId/categories')
|
@Controller('businesses/:businessId/categories')
|
||||||
@@ -37,6 +40,7 @@ export class CategoriesController {
|
|||||||
private readonly technicalFormService: CategoryTechnicalFormService,
|
private readonly technicalFormService: CategoryTechnicalFormService,
|
||||||
private readonly technicalFormAiService: CategoryTechnicalFormAiService,
|
private readonly technicalFormAiService: CategoryTechnicalFormAiService,
|
||||||
private readonly categoryAiService: CategoryAiService,
|
private readonly categoryAiService: CategoryAiService,
|
||||||
|
private readonly translations: TranslationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('color-presets')
|
@Get('color-presets')
|
||||||
@@ -65,6 +69,16 @@ export class CategoriesController {
|
|||||||
return this.categoryAiService.generateProductCategories(businessId, dto, user);
|
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()
|
@Post()
|
||||||
@RequireBusinessPermission('categories.create')
|
@RequireBusinessPermission('categories.create')
|
||||||
create(
|
create(
|
||||||
@@ -86,6 +100,57 @@ export class CategoriesController {
|
|||||||
return this.service.update(businessId, categoryId, dto, user);
|
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')
|
@Get(':categoryId/variations')
|
||||||
@RequireBusinessPermission('categories.read')
|
@RequireBusinessPermission('categories.read')
|
||||||
listVariations(
|
listVariations(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { TranslationsModule } from '../translations/translations.module';
|
||||||
import { CategoriesController, PublicCategoriesController } from './categories.controller';
|
import { CategoriesController, PublicCategoriesController } from './categories.controller';
|
||||||
import { CategoriesService } from './categories.service';
|
import { CategoriesService } from './categories.service';
|
||||||
import { CategoryTechnicalFormAiService } from './category-technical-form-ai.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';
|
import { CategoryVariationsService } from './category-variations.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, SitemapModule, TenantModule],
|
imports: [AuthModule, SitemapModule, TenantModule, TranslationsModule],
|
||||||
controllers: [CategoriesController, PublicCategoriesController],
|
controllers: [CategoriesController, PublicCategoriesController],
|
||||||
providers: [
|
providers: [
|
||||||
CategoriesService,
|
CategoriesService,
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { MediaEntityType } from '@prisma/client';
|
import {
|
||||||
|
MediaEntityType,
|
||||||
|
TranslationEntityType,
|
||||||
|
TranslationStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 {
|
return {
|
||||||
items: items.map((item) =>
|
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);
|
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||||
|
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
|
this.prisma.entityTranslation.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: TranslationEntityType.category,
|
||||||
|
entityId: { in: descendantIds },
|
||||||
|
},
|
||||||
|
}),
|
||||||
this.prisma.category.deleteMany({
|
this.prisma.category.deleteMany({
|
||||||
where: { id: { in: descendantIds } },
|
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(
|
private serialize(
|
||||||
category: {
|
category: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -354,6 +401,7 @@ export class CategoriesService {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
},
|
},
|
||||||
variationCount = 0,
|
variationCount = 0,
|
||||||
|
translationCount?: number,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
id: category.id.toString(),
|
id: category.id.toString(),
|
||||||
@@ -367,6 +415,7 @@ export class CategoriesService {
|
|||||||
sortOrder: category.sortOrder,
|
sortOrder: category.sortOrder,
|
||||||
isActive: category.isActive,
|
isActive: category.isActive,
|
||||||
variationCount,
|
variationCount,
|
||||||
|
...(translationCount !== undefined ? { translationCount } : {}),
|
||||||
createdAt: category.createdAt,
|
createdAt: category.createdAt,
|
||||||
updatedAt: category.updatedAt,
|
updatedAt: category.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { BusinessPermissionGuard } from '../auth/guards/business-permission.guar
|
|||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import { CreateProductByAiDto, FillProductFieldByAiDto } from './dto/product-ai.dto';
|
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 { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||||
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
import { ReplaceProductVariationValuesDto } from './dto/product-variation-values.dto';
|
||||||
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
import { ReplaceProductTechnicalInfoDto } from './dto/product-technical-info.dto';
|
||||||
@@ -33,6 +36,7 @@ export class ProductsController {
|
|||||||
private readonly aiService: ProductAiService,
|
private readonly aiService: ProductAiService,
|
||||||
private readonly variationValuesService: ProductVariationValuesService,
|
private readonly variationValuesService: ProductVariationValuesService,
|
||||||
private readonly technicalInfoService: ProductTechnicalInfoService,
|
private readonly technicalInfoService: ProductTechnicalInfoService,
|
||||||
|
private readonly translations: TranslationsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -65,6 +69,67 @@ export class ProductsController {
|
|||||||
return this.aiService.fillField(businessId, dto, user);
|
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')
|
@Get(':productId')
|
||||||
@RequireBusinessPermission('products.read')
|
@RequireBusinessPermission('products.read')
|
||||||
getOne(
|
getOne(
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { CategoriesModule } from '../categories/categories.module';
|
|||||||
import { MediaModule } from '../media/media.module';
|
import { MediaModule } from '../media/media.module';
|
||||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { TranslationsModule } from '../translations/translations.module';
|
||||||
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import { ProductAiService } from './product-ai.service';
|
import { ProductAiService } from './product-ai.service';
|
||||||
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
import { ProductTechnicalInfoService } from './product-technical-info.service';
|
||||||
@@ -20,6 +21,7 @@ import { ProductsService } from './products.service';
|
|||||||
MediaModule,
|
MediaModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
|
TranslationsModule,
|
||||||
WebsiteAnalyticsModule,
|
WebsiteAnalyticsModule,
|
||||||
],
|
],
|
||||||
controllers: [ProductsController, PublicProductsController],
|
controllers: [ProductsController, PublicProductsController],
|
||||||
|
|||||||
@@ -4,7 +4,13 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} 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 { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BrandsService } from '../brands/brands.service';
|
import { BrandsService } from '../brands/brands.service';
|
||||||
@@ -98,14 +104,20 @@ export class ProductsService {
|
|||||||
this.prisma.product.count({ where }),
|
this.prisma.product.count({ where }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const variantCounts = await this.productVariationValues.countForProducts(
|
const productIds = items.map((item) => item.id);
|
||||||
businessId,
|
const [variantCounts, translationCounts] = await Promise.all([
|
||||||
items.map((item) => item.id),
|
this.productVariationValues.countForProducts(businessId, productIds),
|
||||||
);
|
this.translationCounts(businessId, productIds),
|
||||||
|
]);
|
||||||
|
|
||||||
const serialized = await Promise.all(
|
const serialized = await Promise.all(
|
||||||
items.map((item) =>
|
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,
|
entityId: productId,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
this.prisma.entityTranslation.deleteMany({
|
||||||
|
where: {
|
||||||
|
businessId,
|
||||||
|
entityType: TranslationEntityType.product,
|
||||||
|
entityId: productId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
this.prisma.product.delete({ where: { id: productId } }),
|
this.prisma.product.delete({ where: { id: productId } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -528,10 +547,38 @@ export class ProductsService {
|
|||||||
return { message: 'Product deleted successfully' };
|
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(
|
private async serializeProduct(
|
||||||
product: ProductWithRelations,
|
product: ProductWithRelations,
|
||||||
variantCount?: number,
|
variantCount?: number,
|
||||||
options: { approvedCommentsOnly?: boolean } = {},
|
options: { approvedCommentsOnly?: boolean } = {},
|
||||||
|
translationCount?: number,
|
||||||
) {
|
) {
|
||||||
const content = this.asRecord(product.content);
|
const content = this.asRecord(product.content);
|
||||||
const metadata = this.asRecord(product.metadata);
|
const metadata = this.asRecord(product.metadata);
|
||||||
@@ -604,6 +651,7 @@ export class ProductsService {
|
|||||||
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
|
galleryMediaIds: galleryAttachments.map((item) => item.mediaId.toString()),
|
||||||
commentCount,
|
commentCount,
|
||||||
variantCount: resolvedVariantCount,
|
variantCount: resolvedVariantCount,
|
||||||
|
...(translationCount !== undefined ? { translationCount } : {}),
|
||||||
visitCount: product.visitCount.toString(),
|
visitCount: product.visitCount.toString(),
|
||||||
cartAddCount: product.cartAddCount.toString(),
|
cartAddCount: product.cartAddCount.toString(),
|
||||||
saleCount: product.saleCount.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