diff --git a/.env.example b/.env.example index 11e3c76..b690430 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,10 @@ CENTRAL_API_HOST=api.meshkee.com WEBSITE_DEPLOY_AGENT_URL=http://89.44.241.119:9050/deploy WEBSITE_DEPLOY_TOKEN= +# Dashboards SSL sync agent (POST from Super Admin → dashboards VPS) +SSL_SYNC_AGENT_URL=http://45.149.76.52:9051/ssl-sync +SSL_SYNC_AGENT_TOKEN= + # Public domain for platform invoice links (https://{domain}/invoices/{id}) INVOICE_PUBLIC_DOMAIN=meshkee.com # Optional full origin for local (overrides domain), e.g. https://meshkee.app:5174 diff --git a/database/migrations/050_special_group_keys.sql b/database/migrations/050_special_group_keys.sql new file mode 100644 index 0000000..1c381a7 --- /dev/null +++ b/database/migrations/050_special_group_keys.sql @@ -0,0 +1,40 @@ +-- Stable English keys for website special groups (display title stays free-language) + +ALTER TABLE store_specials + ADD COLUMN IF NOT EXISTS key VARCHAR(100); + +UPDATE store_specials +SET key = 'special-' || id::text +WHERE key IS NULL OR BTRIM(key) = ''; + +ALTER TABLE store_specials + ALTER COLUMN key SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS store_specials_business_id_key_unique + ON store_specials (business_id, key); + +ALTER TABLE website_category_groups + ADD COLUMN IF NOT EXISTS key VARCHAR(100); + +UPDATE website_category_groups +SET key = 'category-group-' || id::text +WHERE key IS NULL OR BTRIM(key) = ''; + +ALTER TABLE website_category_groups + ALTER COLUMN key SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS website_category_groups_business_id_key_unique + ON website_category_groups (business_id, key); + +ALTER TABLE website_brand_groups + ADD COLUMN IF NOT EXISTS key VARCHAR(100); + +UPDATE website_brand_groups +SET key = 'brand-group-' || id::text +WHERE key IS NULL OR BTRIM(key) = ''; + +ALTER TABLE website_brand_groups + ALTER COLUMN key SET NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS website_brand_groups_business_id_key_unique + ON website_brand_groups (business_id, key); diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 1a2db0c..b473517 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -115,7 +115,7 @@ "tags": ["Homepage"], "summary": "Homepage category groups", "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: CategoryGroup[] }" } } + "responses": { "200": { "description": "{ items: CategoryGroup[] } — each group has id, key, title, items[]" } } } }, "/tenants/{domain}/website/brand-groups": { @@ -123,7 +123,7 @@ "tags": ["Homepage"], "summary": "Homepage brand groups", "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: BrandGroup[] }" } } + "responses": { "200": { "description": "{ items: BrandGroup[] } — each group has id, key, title, items[]" } } } }, "/tenants/{domain}/store-specials": { @@ -131,7 +131,7 @@ "tags": ["Homepage", "Store"], "summary": "Active store specials", "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: StoreSpecial[] }" } } + "responses": { "200": { "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" } } } }, "/tenants/{domain}/categories": { diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4cd33c0..1f24664 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -957,6 +957,7 @@ model StoreSpecial { id BigInt @id @default(autoincrement()) businessId BigInt @map("business_id") title String @db.VarChar(255) + key String @db.VarChar(100) sortOrder Int @default(0) @map("sort_order") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) @@ -964,6 +965,7 @@ model StoreSpecial { items StoreSpecialItem[] business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@unique([businessId, key], map: "store_specials_business_id_key_unique") @@index([businessId], map: "idx_store_specials_business_id") @@map("store_specials") } @@ -985,6 +987,7 @@ model website_brand_groups { id BigInt @id @default(autoincrement()) business_id BigInt title String @db.VarChar(255) + key String @db.VarChar(100) sort_order Int @default(0) is_active Boolean @default(true) created_at DateTime @default(now()) @db.Timestamptz(6) @@ -992,6 +995,7 @@ model website_brand_groups { website_brand_group_items website_brand_group_items[] businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@unique([business_id, key], map: "website_brand_groups_business_id_key_unique") @@index([business_id], map: "idx_website_brand_groups_business_id") } @@ -1012,6 +1016,7 @@ model website_category_groups { id BigInt @id @default(autoincrement()) business_id BigInt title String @db.VarChar(255) + key String @db.VarChar(100) sort_order Int @default(0) is_active Boolean @default(true) created_at DateTime @default(now()) @db.Timestamptz(6) @@ -1019,6 +1024,7 @@ model website_category_groups { website_category_group_items website_category_group_items[] businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) + @@unique([business_id, key], map: "website_category_groups_business_id_key_unique") @@index([business_id], map: "idx_website_category_groups_business_id") } diff --git a/src/common/special-group-key.ts b/src/common/special-group-key.ts new file mode 100644 index 0000000..b74b5a6 --- /dev/null +++ b/src/common/special-group-key.ts @@ -0,0 +1,9 @@ +/** English stable key for website special group lookups (e.g. top-selling). */ +export const SPECIAL_GROUP_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/; + +export const SPECIAL_GROUP_KEY_MESSAGE = + 'key must be English letters, numbers, hyphens, or underscores'; + +export function normalizeSpecialGroupKey(raw: string): string { + return raw.trim(); +} diff --git a/src/domain-admin/domain-admin.controller.ts b/src/domain-admin/domain-admin.controller.ts index e97c928..ccd422f 100644 --- a/src/domain-admin/domain-admin.controller.ts +++ b/src/domain-admin/domain-admin.controller.ts @@ -29,6 +29,13 @@ export class DomainAdminController { return this.service.list(query, user); } + @Post('ssl-sync') + @HttpCode(202) + @UseGuards(JwtAuthGuard) + syncSsl(@CurrentUser() user: AuthUser) { + return this.service.syncSsl(user); + } + @Post(':domainId/deploy') @HttpCode(202) @UseGuards(JwtAuthGuard) diff --git a/src/domain-admin/domain-admin.service.ts b/src/domain-admin/domain-admin.service.ts index a4adf13..db47b7b 100644 --- a/src/domain-admin/domain-admin.service.ts +++ b/src/domain-admin/domain-admin.service.ts @@ -100,6 +100,46 @@ export class DomainAdminService { return { items, total: totalRow[0]?.total ?? 0, page, pageSize }; } + async syncSsl(actor: AuthUser) { + await this.assertSuperAdmin(actor); + + const agentUrl = this.config.get('SSL_SYNC_AGENT_URL')?.trim(); + const token = this.config.get('SSL_SYNC_AGENT_TOKEN')?.trim(); + if (!agentUrl || !token) { + throw new ServiceUnavailableException('SSL sync agent is not configured'); + } + + let response: Response; + try { + response = await fetch(agentUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-SSL-Sync-Agent-Token': token, + }, + body: '{}', + }); + } catch { + throw new ServiceUnavailableException('Could not reach SSL sync agent'); + } + + if (response.status === 409) { + throw new ConflictException('SSL sync is already running'); + } + + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new ServiceUnavailableException( + `SSL sync agent rejected request (${response.status})${text ? `: ${text}` : ''}`, + ); + } + + return { + status: 'accepted' as const, + message: 'SSL sync started on dashboards server', + }; + } + async deploy(domainIdRaw: string, actor: AuthUser) { await this.assertSuperAdmin(actor); diff --git a/src/products/products.service.ts b/src/products/products.service.ts index 85b29ee..9e9b648 100644 --- a/src/products/products.service.ts +++ b/src/products/products.service.ts @@ -528,6 +528,7 @@ export class ProductsService { status: product.status, categoryId: categoryAssignment?.categoryId.toString() ?? null, categoryName: categoryAssignment?.category.name ?? '', + categoryNameFa: categoryAssignment?.category.nameFa ?? '', brandId: product.brandId?.toString() ?? null, brand: this.brands.serializeBrandSummary(product.brand), tags: Array.isArray(metadata.tags) diff --git a/src/store/dto/store-specials.dto.ts b/src/store/dto/store-specials.dto.ts index f350b9d..a60ab61 100644 --- a/src/store/dto/store-specials.dto.ts +++ b/src/store/dto/store-specials.dto.ts @@ -5,12 +5,23 @@ import { IsInt, IsOptional, IsString, + Matches, MaxLength, Min, MinLength, } from 'class-validator'; +import { + SPECIAL_GROUP_KEY_MESSAGE, + SPECIAL_GROUP_KEY_PATTERN, +} from '../../common/special-group-key'; export class CreateStoreSpecialDto { + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key!: string; + @IsString() @MinLength(1) @MaxLength(255) @@ -31,6 +42,13 @@ export class CreateStoreSpecialDto { } export class UpdateStoreSpecialDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key?: string; + @IsOptional() @IsString() @MinLength(1) diff --git a/src/store/store-specials.service.ts b/src/store/store-specials.service.ts index d053f24..a1edeb2 100644 --- a/src/store/store-specials.service.ts +++ b/src/store/store-specials.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -7,6 +8,7 @@ import { import { MediaEntityType, Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; +import { normalizeSpecialGroupKey } from '../common/special-group-key'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { @@ -150,10 +152,14 @@ export class StoreSpecialsService { await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); } + const key = normalizeSpecialGroupKey(dto.key); + await this.assertKeyAvailable(businessId, key); + const created = await this.prisma.$transaction(async (tx) => { const special = await tx.storeSpecial.create({ data: { businessId, + key, title: dto.title.trim(), sortOrder: dto.sortOrder ?? 0, isActive: dto.isActive ?? true, @@ -196,10 +202,17 @@ export class StoreSpecialsService { await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); } + const nextKey = + dto.key !== undefined ? normalizeSpecialGroupKey(dto.key) : undefined; + if (nextKey !== undefined) { + await this.assertKeyAvailable(businessId, nextKey, specialId); + } + const updated = await this.prisma.$transaction(async (tx) => { await tx.storeSpecial.update({ where: { id: specialId }, data: { + ...(nextKey !== undefined ? { key: nextKey } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), @@ -341,6 +354,7 @@ export class StoreSpecialsService { return { id: special.id.toString(), + key: special.key, title: special.title, sortOrder: special.sortOrder, isActive: special.isActive, @@ -350,6 +364,24 @@ export class StoreSpecialsService { }; } + private async assertKeyAvailable( + businessId: bigint, + key: string, + excludeId?: bigint, + ) { + const existing = await this.prisma.storeSpecial.findFirst({ + where: { + businessId, + key, + ...(excludeId !== undefined ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (existing) { + throw new ConflictException('A special with this key already exists'); + } + } + private serializeStoreItem( storeItem: Prisma.StoreItemGetPayload<{ include: typeof storeItemInclude }>, galleryByProduct: Map, diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 1a2db0c..b473517 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -115,7 +115,7 @@ "tags": ["Homepage"], "summary": "Homepage category groups", "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: CategoryGroup[] }" } } + "responses": { "200": { "description": "{ items: CategoryGroup[] } — each group has id, key, title, items[]" } } } }, "/tenants/{domain}/website/brand-groups": { @@ -123,7 +123,7 @@ "tags": ["Homepage"], "summary": "Homepage brand groups", "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: BrandGroup[] }" } } + "responses": { "200": { "description": "{ items: BrandGroup[] } — each group has id, key, title, items[]" } } } }, "/tenants/{domain}/store-specials": { @@ -131,7 +131,7 @@ "tags": ["Homepage", "Store"], "summary": "Active store specials", "parameters": [{ "$ref": "#/components/parameters/domain" }], - "responses": { "200": { "description": "{ items: StoreSpecial[] }" } } + "responses": { "200": { "description": "{ items: StoreSpecial[] } — each special has id, key, title, items[]" } } } }, "/tenants/{domain}/categories": { diff --git a/src/website/dto/website-brand-groups.dto.ts b/src/website/dto/website-brand-groups.dto.ts index 6d4d335..b3c21ac 100644 --- a/src/website/dto/website-brand-groups.dto.ts +++ b/src/website/dto/website-brand-groups.dto.ts @@ -5,12 +5,23 @@ import { IsInt, IsOptional, IsString, + Matches, MaxLength, Min, MinLength, } from 'class-validator'; +import { + SPECIAL_GROUP_KEY_MESSAGE, + SPECIAL_GROUP_KEY_PATTERN, +} from '../../common/special-group-key'; export class CreateWebsiteBrandGroupDto { + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key!: string; + @IsString() @MinLength(1) @MaxLength(255) @@ -31,6 +42,13 @@ export class CreateWebsiteBrandGroupDto { } export class UpdateWebsiteBrandGroupDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key?: string; + @IsOptional() @IsString() @MinLength(1) diff --git a/src/website/dto/website-category-groups.dto.ts b/src/website/dto/website-category-groups.dto.ts index 341e099..f555b20 100644 --- a/src/website/dto/website-category-groups.dto.ts +++ b/src/website/dto/website-category-groups.dto.ts @@ -5,12 +5,23 @@ import { IsInt, IsOptional, IsString, + Matches, MaxLength, Min, MinLength, } from 'class-validator'; +import { + SPECIAL_GROUP_KEY_MESSAGE, + SPECIAL_GROUP_KEY_PATTERN, +} from '../../common/special-group-key'; export class CreateWebsiteCategoryGroupDto { + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key!: string; + @IsString() @MinLength(1) @MaxLength(255) @@ -31,6 +42,13 @@ export class CreateWebsiteCategoryGroupDto { } export class UpdateWebsiteCategoryGroupDto { + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(100) + @Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE }) + key?: string; + @IsOptional() @IsString() @MinLength(1) diff --git a/src/website/website-brand-groups.service.ts b/src/website/website-brand-groups.service.ts index 167bde4..c28bff6 100644 --- a/src/website/website-brand-groups.service.ts +++ b/src/website/website-brand-groups.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -7,6 +8,7 @@ import { import { Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; +import { normalizeSpecialGroupKey } from '../common/special-group-key'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { @@ -113,10 +115,14 @@ export class WebsiteBrandGroupsService { await this.assertBrandsBelongToBusiness(businessId, brandIds); } + const key = normalizeSpecialGroupKey(dto.key); + await this.assertKeyAvailable(businessId, key); + const created = await this.prisma.$transaction(async (tx) => { const group = await tx.website_brand_groups.create({ data: { business_id: businessId, + key, title: dto.title.trim(), sort_order: dto.sortOrder ?? 0, is_active: dto.isActive ?? true, @@ -154,10 +160,17 @@ export class WebsiteBrandGroupsService { await this.assertBrandsBelongToBusiness(businessId, brandIds); } + const nextKey = + dto.key !== undefined ? normalizeSpecialGroupKey(dto.key) : undefined; + if (nextKey !== undefined) { + await this.assertKeyAvailable(businessId, nextKey, groupId); + } + const updated = await this.prisma.$transaction(async (tx) => { await tx.website_brand_groups.update({ where: { id: groupId }, data: { + ...(nextKey !== undefined ? { key: nextKey } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), @@ -266,6 +279,7 @@ export class WebsiteBrandGroupsService { return { id: group.id.toString(), + key: group.key, title: group.title, sortOrder: group.sort_order, isActive: group.is_active, @@ -275,6 +289,24 @@ export class WebsiteBrandGroupsService { }; } + private async assertKeyAvailable( + businessId: bigint, + key: string, + excludeId?: bigint, + ) { + const existing = await this.prisma.website_brand_groups.findFirst({ + where: { + business_id: businessId, + key, + ...(excludeId !== undefined ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (existing) { + throw new ConflictException('A brand group with this key already exists'); + } + } + private async assertPermission( businessId: bigint, userId: bigint, diff --git a/src/website/website-category-groups.service.ts b/src/website/website-category-groups.service.ts index 076ab7f..5f02296 100644 --- a/src/website/website-category-groups.service.ts +++ b/src/website/website-category-groups.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -7,6 +8,7 @@ import { import { MediaEntityType, Prisma } from '@prisma/client'; import { AuthUser } from '../auth/auth.types'; import { PermissionsService } from '../auth/permissions.service'; +import { normalizeSpecialGroupKey } from '../common/special-group-key'; import { PrismaService } from '../prisma/prisma.service'; import { TenantService } from '../tenant/tenant.service'; import { @@ -111,10 +113,14 @@ export class WebsiteCategoryGroupsService { await this.assertCategoriesBelongToBusiness(businessId, categoryIds); } + const key = normalizeSpecialGroupKey(dto.key); + await this.assertKeyAvailable(businessId, key); + const created = await this.prisma.$transaction(async (tx) => { const group = await tx.website_category_groups.create({ data: { business_id: businessId, + key, title: dto.title.trim(), sort_order: dto.sortOrder ?? 0, is_active: dto.isActive ?? true, @@ -152,10 +158,17 @@ export class WebsiteCategoryGroupsService { await this.assertCategoriesBelongToBusiness(businessId, categoryIds); } + const nextKey = + dto.key !== undefined ? normalizeSpecialGroupKey(dto.key) : undefined; + if (nextKey !== undefined) { + await this.assertKeyAvailable(businessId, nextKey, groupId); + } + const updated = await this.prisma.$transaction(async (tx) => { await tx.website_category_groups.update({ where: { id: groupId }, data: { + ...(nextKey !== undefined ? { key: nextKey } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), @@ -265,6 +278,7 @@ export class WebsiteCategoryGroupsService { return { id: group.id.toString(), + key: group.key, title: group.title, sortOrder: group.sort_order, isActive: group.is_active, @@ -274,6 +288,24 @@ export class WebsiteCategoryGroupsService { }; } + private async assertKeyAvailable( + businessId: bigint, + key: string, + excludeId?: bigint, + ) { + const existing = await this.prisma.website_category_groups.findFirst({ + where: { + business_id: businessId, + key, + ...(excludeId !== undefined ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + if (existing) { + throw new ConflictException('A category group with this key already exists'); + } + } + private async assertPermission( businessId: bigint, userId: bigint,