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
+4
View File
@@ -73,6 +73,10 @@ CENTRAL_API_HOST=api.meshkee.com
WEBSITE_DEPLOY_AGENT_URL=http://89.44.241.119:9050/deploy WEBSITE_DEPLOY_AGENT_URL=http://89.44.241.119:9050/deploy
WEBSITE_DEPLOY_TOKEN= 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}) # Public domain for platform invoice links (https://{domain}/invoices/{id})
INVOICE_PUBLIC_DOMAIN=meshkee.com INVOICE_PUBLIC_DOMAIN=meshkee.com
# Optional full origin for local (overrides domain), e.g. https://meshkee.app:5174 # Optional full origin for local (overrides domain), e.g. https://meshkee.app:5174
@@ -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);
+3 -3
View File
@@ -115,7 +115,7 @@
"tags": ["Homepage"], "tags": ["Homepage"],
"summary": "Homepage category groups", "summary": "Homepage category groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }], "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": { "/tenants/{domain}/website/brand-groups": {
@@ -123,7 +123,7 @@
"tags": ["Homepage"], "tags": ["Homepage"],
"summary": "Homepage brand groups", "summary": "Homepage brand groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }], "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": { "/tenants/{domain}/store-specials": {
@@ -131,7 +131,7 @@
"tags": ["Homepage", "Store"], "tags": ["Homepage", "Store"],
"summary": "Active store specials", "summary": "Active store specials",
"parameters": [{ "$ref": "#/components/parameters/domain" }], "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": { "/tenants/{domain}/categories": {
+6
View File
@@ -957,6 +957,7 @@ model StoreSpecial {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id") businessId BigInt @map("business_id")
title String @db.VarChar(255) title String @db.VarChar(255)
key String @db.VarChar(100)
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active") isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
@@ -964,6 +965,7 @@ model StoreSpecial {
items StoreSpecialItem[] items StoreSpecialItem[]
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction) 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") @@index([businessId], map: "idx_store_specials_business_id")
@@map("store_specials") @@map("store_specials")
} }
@@ -985,6 +987,7 @@ model website_brand_groups {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
business_id BigInt business_id BigInt
title String @db.VarChar(255) title String @db.VarChar(255)
key String @db.VarChar(100)
sort_order Int @default(0) sort_order Int @default(0)
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
@@ -992,6 +995,7 @@ model website_brand_groups {
website_brand_group_items website_brand_group_items[] website_brand_group_items website_brand_group_items[]
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) 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") @@index([business_id], map: "idx_website_brand_groups_business_id")
} }
@@ -1012,6 +1016,7 @@ model website_category_groups {
id BigInt @id @default(autoincrement()) id BigInt @id @default(autoincrement())
business_id BigInt business_id BigInt
title String @db.VarChar(255) title String @db.VarChar(255)
key String @db.VarChar(100)
sort_order Int @default(0) sort_order Int @default(0)
is_active Boolean @default(true) is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
@@ -1019,6 +1024,7 @@ model website_category_groups {
website_category_group_items website_category_group_items[] website_category_group_items website_category_group_items[]
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction) 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") @@index([business_id], map: "idx_website_category_groups_business_id")
} }
+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); 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') @Post(':domainId/deploy')
@HttpCode(202) @HttpCode(202)
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
+40
View File
@@ -100,6 +100,46 @@ export class DomainAdminService {
return { items, total: totalRow[0]?.total ?? 0, page, pageSize }; 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) { async deploy(domainIdRaw: string, actor: AuthUser) {
await this.assertSuperAdmin(actor); await this.assertSuperAdmin(actor);
+1
View File
@@ -528,6 +528,7 @@ export class ProductsService {
status: product.status, status: product.status,
categoryId: categoryAssignment?.categoryId.toString() ?? null, categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '', categoryName: categoryAssignment?.category.name ?? '',
categoryNameFa: categoryAssignment?.category.nameFa ?? '',
brandId: product.brandId?.toString() ?? null, brandId: product.brandId?.toString() ?? null,
brand: this.brands.serializeBrandSummary(product.brand), brand: this.brands.serializeBrandSummary(product.brand),
tags: Array.isArray(metadata.tags) tags: Array.isArray(metadata.tags)
+18
View File
@@ -5,12 +5,23 @@ import {
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
Matches,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import {
SPECIAL_GROUP_KEY_MESSAGE,
SPECIAL_GROUP_KEY_PATTERN,
} from '../../common/special-group-key';
export class CreateStoreSpecialDto { export class CreateStoreSpecialDto {
@IsString()
@MinLength(1)
@MaxLength(100)
@Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE })
key!: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(255) @MaxLength(255)
@@ -31,6 +42,13 @@ export class CreateStoreSpecialDto {
} }
export class UpdateStoreSpecialDto { export class UpdateStoreSpecialDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
@Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE })
key?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
+32
View File
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -7,6 +8,7 @@ import {
import { MediaEntityType, Prisma } from '@prisma/client'; import { MediaEntityType, Prisma } 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 { normalizeSpecialGroupKey } from '../common/special-group-key';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service'; import { TenantService } from '../tenant/tenant.service';
import { import {
@@ -150,10 +152,14 @@ export class StoreSpecialsService {
await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds);
} }
const key = normalizeSpecialGroupKey(dto.key);
await this.assertKeyAvailable(businessId, key);
const created = await this.prisma.$transaction(async (tx) => { const created = await this.prisma.$transaction(async (tx) => {
const special = await tx.storeSpecial.create({ const special = await tx.storeSpecial.create({
data: { data: {
businessId, businessId,
key,
title: dto.title.trim(), title: dto.title.trim(),
sortOrder: dto.sortOrder ?? 0, sortOrder: dto.sortOrder ?? 0,
isActive: dto.isActive ?? true, isActive: dto.isActive ?? true,
@@ -196,10 +202,17 @@ export class StoreSpecialsService {
await this.assertStoreItemsBelongToBusiness(businessId, storeItemIds); 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) => { const updated = await this.prisma.$transaction(async (tx) => {
await tx.storeSpecial.update({ await tx.storeSpecial.update({
where: { id: specialId }, where: { id: specialId },
data: { data: {
...(nextKey !== undefined ? { key: nextKey } : {}),
...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}), ...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}), ...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
@@ -341,6 +354,7 @@ export class StoreSpecialsService {
return { return {
id: special.id.toString(), id: special.id.toString(),
key: special.key,
title: special.title, title: special.title,
sortOrder: special.sortOrder, sortOrder: special.sortOrder,
isActive: special.isActive, 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( private serializeStoreItem(
storeItem: Prisma.StoreItemGetPayload<{ include: typeof storeItemInclude }>, storeItem: Prisma.StoreItemGetPayload<{ include: typeof storeItemInclude }>,
galleryByProduct: Map<string, string>, galleryByProduct: Map<string, string>,
+3 -3
View File
@@ -115,7 +115,7 @@
"tags": ["Homepage"], "tags": ["Homepage"],
"summary": "Homepage category groups", "summary": "Homepage category groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }], "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": { "/tenants/{domain}/website/brand-groups": {
@@ -123,7 +123,7 @@
"tags": ["Homepage"], "tags": ["Homepage"],
"summary": "Homepage brand groups", "summary": "Homepage brand groups",
"parameters": [{ "$ref": "#/components/parameters/domain" }], "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": { "/tenants/{domain}/store-specials": {
@@ -131,7 +131,7 @@
"tags": ["Homepage", "Store"], "tags": ["Homepage", "Store"],
"summary": "Active store specials", "summary": "Active store specials",
"parameters": [{ "$ref": "#/components/parameters/domain" }], "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": { "/tenants/{domain}/categories": {
@@ -5,12 +5,23 @@ import {
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
Matches,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import {
SPECIAL_GROUP_KEY_MESSAGE,
SPECIAL_GROUP_KEY_PATTERN,
} from '../../common/special-group-key';
export class CreateWebsiteBrandGroupDto { export class CreateWebsiteBrandGroupDto {
@IsString()
@MinLength(1)
@MaxLength(100)
@Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE })
key!: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(255) @MaxLength(255)
@@ -31,6 +42,13 @@ export class CreateWebsiteBrandGroupDto {
} }
export class UpdateWebsiteBrandGroupDto { export class UpdateWebsiteBrandGroupDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
@Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE })
key?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -5,12 +5,23 @@ import {
IsInt, IsInt,
IsOptional, IsOptional,
IsString, IsString,
Matches,
MaxLength, MaxLength,
Min, Min,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import {
SPECIAL_GROUP_KEY_MESSAGE,
SPECIAL_GROUP_KEY_PATTERN,
} from '../../common/special-group-key';
export class CreateWebsiteCategoryGroupDto { export class CreateWebsiteCategoryGroupDto {
@IsString()
@MinLength(1)
@MaxLength(100)
@Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE })
key!: string;
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@MaxLength(255) @MaxLength(255)
@@ -31,6 +42,13 @@ export class CreateWebsiteCategoryGroupDto {
} }
export class UpdateWebsiteCategoryGroupDto { export class UpdateWebsiteCategoryGroupDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(100)
@Matches(SPECIAL_GROUP_KEY_PATTERN, { message: SPECIAL_GROUP_KEY_MESSAGE })
key?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
@MinLength(1) @MinLength(1)
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -7,6 +8,7 @@ import {
import { Prisma } from '@prisma/client'; import { Prisma } 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 { normalizeSpecialGroupKey } from '../common/special-group-key';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service'; import { TenantService } from '../tenant/tenant.service';
import { import {
@@ -113,10 +115,14 @@ export class WebsiteBrandGroupsService {
await this.assertBrandsBelongToBusiness(businessId, brandIds); await this.assertBrandsBelongToBusiness(businessId, brandIds);
} }
const key = normalizeSpecialGroupKey(dto.key);
await this.assertKeyAvailable(businessId, key);
const created = await this.prisma.$transaction(async (tx) => { const created = await this.prisma.$transaction(async (tx) => {
const group = await tx.website_brand_groups.create({ const group = await tx.website_brand_groups.create({
data: { data: {
business_id: businessId, business_id: businessId,
key,
title: dto.title.trim(), title: dto.title.trim(),
sort_order: dto.sortOrder ?? 0, sort_order: dto.sortOrder ?? 0,
is_active: dto.isActive ?? true, is_active: dto.isActive ?? true,
@@ -154,10 +160,17 @@ export class WebsiteBrandGroupsService {
await this.assertBrandsBelongToBusiness(businessId, brandIds); 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) => { const updated = await this.prisma.$transaction(async (tx) => {
await tx.website_brand_groups.update({ await tx.website_brand_groups.update({
where: { id: groupId }, where: { id: groupId },
data: { data: {
...(nextKey !== undefined ? { key: nextKey } : {}),
...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}),
@@ -266,6 +279,7 @@ export class WebsiteBrandGroupsService {
return { return {
id: group.id.toString(), id: group.id.toString(),
key: group.key,
title: group.title, title: group.title,
sortOrder: group.sort_order, sortOrder: group.sort_order,
isActive: group.is_active, 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( private async assertPermission(
businessId: bigint, businessId: bigint,
userId: bigint, userId: bigint,
@@ -1,5 +1,6 @@
import { import {
BadRequestException, BadRequestException,
ConflictException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
NotFoundException, NotFoundException,
@@ -7,6 +8,7 @@ import {
import { MediaEntityType, Prisma } from '@prisma/client'; import { MediaEntityType, Prisma } 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 { normalizeSpecialGroupKey } from '../common/special-group-key';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service'; import { TenantService } from '../tenant/tenant.service';
import { import {
@@ -111,10 +113,14 @@ export class WebsiteCategoryGroupsService {
await this.assertCategoriesBelongToBusiness(businessId, categoryIds); await this.assertCategoriesBelongToBusiness(businessId, categoryIds);
} }
const key = normalizeSpecialGroupKey(dto.key);
await this.assertKeyAvailable(businessId, key);
const created = await this.prisma.$transaction(async (tx) => { const created = await this.prisma.$transaction(async (tx) => {
const group = await tx.website_category_groups.create({ const group = await tx.website_category_groups.create({
data: { data: {
business_id: businessId, business_id: businessId,
key,
title: dto.title.trim(), title: dto.title.trim(),
sort_order: dto.sortOrder ?? 0, sort_order: dto.sortOrder ?? 0,
is_active: dto.isActive ?? true, is_active: dto.isActive ?? true,
@@ -152,10 +158,17 @@ export class WebsiteCategoryGroupsService {
await this.assertCategoriesBelongToBusiness(businessId, categoryIds); 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) => { const updated = await this.prisma.$transaction(async (tx) => {
await tx.website_category_groups.update({ await tx.website_category_groups.update({
where: { id: groupId }, where: { id: groupId },
data: { data: {
...(nextKey !== undefined ? { key: nextKey } : {}),
...(dto.title !== undefined ? { title: dto.title.trim() } : {}), ...(dto.title !== undefined ? { title: dto.title.trim() } : {}),
...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}), ...(dto.sortOrder !== undefined ? { sort_order: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}), ...(dto.isActive !== undefined ? { is_active: dto.isActive } : {}),
@@ -265,6 +278,7 @@ export class WebsiteCategoryGroupsService {
return { return {
id: group.id.toString(), id: group.id.toString(),
key: group.key,
title: group.title, title: group.title,
sortOrder: group.sort_order, sortOrder: group.sort_order,
isActive: group.is_active, 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( private async assertPermission(
businessId: bigint, businessId: bigint,
userId: bigint, userId: bigint,