mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
43e3912662
commit
f233665d13
@@ -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);
|
||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user