Add stable keys for special groups and SSL sync agent hooks.

English keys on store/website specials stay unique per business; domain-admin can trigger dashboard SSL sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-03 00:06:44 +03:30
co-authored by Cursor
parent 43e3912662
commit f233665d13
15 changed files with 263 additions and 6 deletions
+9
View File
@@ -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();
}
@@ -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)
+40
View File
@@ -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<string>('SSL_SYNC_AGENT_URL')?.trim();
const token = this.config.get<string>('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);
+1
View File
@@ -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)
+18
View File
@@ -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)
+32
View File
@@ -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<string, string>,
+3 -3
View File
@@ -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": {
@@ -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)
@@ -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)
@@ -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,
@@ -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,