This commit is contained in:
2026-09-30 09:53:05 +03:30
parent 5a2c4cabfd
commit 38cabb0957
19 changed files with 397 additions and 72 deletions
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "Product" ADD COLUMN "brandId" TEXT;
-- CreateIndex
CREATE INDEX "Product_brandId_idx" ON "Product"("brandId");
-- AddForeignKey
ALTER TABLE "Product" ADD CONSTRAINT "Product_brandId_fkey" FOREIGN KEY ("brandId") REFERENCES "Brand"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1 @@
ALTER TABLE "InternalMessage" ADD COLUMN "link" TEXT;
+5
View File
@@ -214,11 +214,13 @@ model Product {
subtitle String?
rollMeters Float @default(50)
isNew Boolean @default(false)
brandId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
company Company @relation(fields: [companyId], references: [id], onDelete: Cascade)
catalog Catalog @relation(fields: [catalogId], references: [id], onDelete: Cascade)
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
cartItems CartItem[]
orderItems OrderItem[]
favorites ProductFavorite[]
@@ -229,6 +231,7 @@ model Product {
@@unique([companyId, code])
@@index([catalogId])
@@index([companyId])
@@index([brandId])
@@index([usageCategory])
@@index([season])
}
@@ -585,6 +588,7 @@ model InternalMessage {
senderName String
subject String
preview String
link String?
isRead Boolean @default(false)
sentAt DateTime @default(now())
}
@@ -658,6 +662,7 @@ model Brand {
updatedAt DateTime @updatedAt
customers CustomerBrand[]
products Product[]
}
model CustomerBrand {
+2
View File
@@ -26,6 +26,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
import { StockNotifyModule } from './stock-notify/stock-notify.module';
import { BannersModule } from './banners/banners.module';
import { AddressesModule } from './addresses/addresses.module';
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
@Module({
imports: [
@@ -54,6 +55,7 @@ import { AddressesModule } from './addresses/addresses.module';
StockNotifyModule,
BannersModule,
AddressesModule,
InternalMessagesModule,
],
providers: [
{
+2 -2
View File
@@ -10,7 +10,7 @@ export class BrandsService {
const rows = await this.prisma.brand.findMany({
where: activeOnly ? { isActive: true } : undefined,
orderBy: [{ displayOrder: 'asc' }, { name: 'asc' }],
include: { _count: { select: { customers: true } } },
include: { _count: { select: { products: true } } },
});
return rows.map((b) => ({
id: b.id,
@@ -19,7 +19,7 @@ export class BrandsService {
description: b.description ?? undefined,
isActive: b.isActive,
displayOrder: b.displayOrder,
customersCount: b._count.customers,
productsCount: b._count.products,
createdAt: b.createdAt.toISOString(),
updatedAt: b.updatedAt.toISOString(),
}));
-30
View File
@@ -21,9 +21,6 @@ const customerInclude = {
assignedAgent: {
select: { id: true, firstName: true, lastName: true, phone: true, agentCode: true },
},
customerBrands: {
include: { brand: { select: { id: true, name: true } } },
},
} satisfies Prisma.CustomerInclude;
type CustomerWithRelations = Prisma.CustomerGetPayload<{
@@ -93,15 +90,6 @@ export class CustomersService {
agentCode: dto.agentCode?.trim() || null,
},
});
if (dto.brandIds?.length) {
await tx.customerBrand.createMany({
data: dto.brandIds.map((brandId) => ({
customerId: created.id,
brandId,
})),
skipDuplicates: true,
});
}
return tx.customer.findUniqueOrThrow({
where: { id: created.id },
include: customerInclude,
@@ -177,19 +165,6 @@ export class CustomersService {
},
});
if (dto.brandIds !== undefined) {
await tx.customerBrand.deleteMany({ where: { customerId: id } });
if (dto.brandIds.length) {
await tx.customerBrand.createMany({
data: dto.brandIds.map((brandId) => ({
customerId: id,
brandId,
})),
skipDuplicates: true,
});
}
}
return tx.customer.findUniqueOrThrow({
where: { id },
include: customerInclude,
@@ -301,11 +276,6 @@ export class CustomersService {
assignedAgentId: customer.assignedAgentId,
assignedAgentName: agentName,
assignedAgentPhone: customer.assignedAgent?.phone ?? '',
brandIds: customer.customerBrands.map((cb) => cb.brandId),
brands: customer.customerBrands.map((cb) => ({
id: cb.brand.id,
name: cb.brand.name,
})),
registeredAt: customer.createdAt.toISOString(),
createdAt: customer.createdAt.toISOString(),
updatedAt: customer.updatedAt.toISOString(),
-11
View File
@@ -1,5 +1,4 @@
import {
IsArray,
IsBoolean,
IsEnum,
IsOptional,
@@ -57,11 +56,6 @@ export class CreateCustomerDto {
@IsOptional()
@IsString()
agentCode?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
brandIds?: string[];
}
export class UpdateCustomerDto {
@@ -112,11 +106,6 @@ export class UpdateCustomerDto {
@IsOptional()
@IsString()
agentCode?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
brandIds?: string[];
}
export class SetCustomerActiveDto {
+1
View File
@@ -166,6 +166,7 @@ export class DashboardService {
senderName: m.senderName,
subject: m.subject,
preview: m.preview,
link: m.link ?? '',
isRead: m.isRead,
sentAt: m.sentAt.toISOString(),
})),
@@ -0,0 +1,40 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class CreateInternalMessageDto {
@IsString()
@MinLength(1)
senderName!: string;
@IsString()
@MinLength(1)
subject!: string;
@IsString()
@MinLength(1)
preview!: string;
@IsOptional()
@IsString()
link?: string | null;
}
export class UpdateInternalMessageDto {
@IsOptional()
@IsString()
@MinLength(1)
senderName?: string;
@IsOptional()
@IsString()
@MinLength(1)
subject?: string;
@IsOptional()
@IsString()
@MinLength(1)
preview?: string;
@IsOptional()
@IsString()
link?: string | null;
}
@@ -0,0 +1,54 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { AuthType, Roles } from '../common/decorators/auth.decorators';
import { InternalMessagesService } from './internal-messages.service';
import {
CreateInternalMessageDto,
UpdateInternalMessageDto,
} from './dto/internal-message.dto';
@Controller('admin/internal-messages')
@AuthType('admin')
@Roles('admin', 'sales', 'finance', 'warehouse')
export class InternalMessagesController {
constructor(private readonly messages: InternalMessagesService) {}
@Get()
list(@Query('limit') limit?: string) {
const n = limit ? Number.parseInt(limit, 10) : 100;
return this.messages.list(Number.isFinite(n) ? n : 100);
}
@Post()
create(@Body() dto: CreateInternalMessageDto) {
return this.messages.create(dto);
}
@Patch('read-all')
markAllRead() {
return this.messages.markAllRead();
}
@Patch(':id/read')
markRead(@Param('id') id: string) {
return this.messages.markRead(id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() dto: UpdateInternalMessageDto) {
return this.messages.update(id, dto);
}
@Delete(':id')
remove(@Param('id') id: string) {
return this.messages.remove(id);
}
}
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { InternalMessagesController } from './internal-messages.controller';
import { InternalMessagesService } from './internal-messages.service';
@Module({
controllers: [InternalMessagesController],
providers: [InternalMessagesService],
exports: [InternalMessagesService],
})
export class InternalMessagesModule {}
@@ -0,0 +1,96 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import {
CreateInternalMessageDto,
UpdateInternalMessageDto,
} from './dto/internal-message.dto';
@Injectable()
export class InternalMessagesService {
constructor(private readonly prisma: PrismaService) {}
async list(limit = 100) {
const rows = await this.prisma.internalMessage.findMany({
orderBy: { sentAt: 'desc' },
take: Math.min(Math.max(limit, 1), 200),
});
return rows.map((row) => this.map(row));
}
async create(dto: CreateInternalMessageDto) {
const row = await this.prisma.internalMessage.create({
data: {
senderName: dto.senderName.trim(),
subject: dto.subject.trim(),
preview: dto.preview.trim(),
link: dto.link?.trim() || null,
isRead: false,
},
});
return this.map(row);
}
async update(id: string, dto: UpdateInternalMessageDto) {
await this.ensure(id);
const row = await this.prisma.internalMessage.update({
where: { id },
data: {
senderName: dto.senderName?.trim(),
subject: dto.subject?.trim(),
preview: dto.preview?.trim(),
link:
dto.link === undefined ? undefined : dto.link?.trim() || null,
},
});
return this.map(row);
}
async remove(id: string) {
await this.ensure(id);
await this.prisma.internalMessage.delete({ where: { id } });
return { ok: true };
}
async markRead(id: string) {
await this.ensure(id);
const row = await this.prisma.internalMessage.update({
where: { id },
data: { isRead: true },
});
return this.map(row);
}
async markAllRead() {
await this.prisma.internalMessage.updateMany({
where: { isRead: false },
data: { isRead: true },
});
return { ok: true };
}
private async ensure(id: string) {
const row = await this.prisma.internalMessage.findUnique({ where: { id } });
if (!row) throw new NotFoundException('پیام یافت نشد');
return row;
}
private map(row: {
id: string;
senderName: string;
subject: string;
preview: string;
link: string | null;
isRead: boolean;
sentAt: Date;
}) {
return {
id: row.id,
senderName: row.senderName,
subject: row.subject,
preview: row.preview,
link: row.link ?? '',
isRead: row.isRead,
sentAt: row.sentAt.toISOString(),
};
}
}
@@ -3,10 +3,12 @@ import { NotificationTone } from '@prisma/client';
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsEnum,
IsOptional,
IsString,
MinLength,
ValidateIf,
} from 'class-validator';
import { NotificationsService } from './notifications.service';
import { AuthType } from '../common/decorators/auth.decorators';
@@ -49,8 +51,12 @@ class BroadcastDto {
body!: string;
@IsOptional()
@IsBoolean()
allActive?: boolean;
@ValidateIf((dto: BroadcastDto) => !dto.allActive)
@IsArray()
@ArrayNotEmpty()
@ArrayNotEmpty({ message: 'حداقل یک مشتری انتخاب کنید یا ارسال به همه را فعال کنید.' })
@IsString({ each: true })
customerIds?: string[];
+5 -3
View File
@@ -238,12 +238,14 @@ export class NotificationsService {
async broadcastToCustomers(input: {
title: string;
body: string;
allActive?: boolean;
customerIds?: string[];
tone?: NotificationTone;
}) {
const where = input.customerIds?.length
? { id: { in: input.customerIds }, isActive: true }
: { isActive: true, appRole: 'customer' as const };
const sendToAll = input.allActive === true;
const where = sendToAll
? { isActive: true, appRole: 'customer' as const }
: { id: { in: input.customerIds ?? [] }, isActive: true };
const customers = await this.prisma.customer.findMany({
where,
select: { id: true },
@@ -0,0 +1,37 @@
import { IsOptional, IsString, MinLength } from 'class-validator';
export class ProductColorJsonItemDto {
@IsString()
@MinLength(1)
name!: string;
@IsOptional()
@IsString()
imageUrl?: string;
}
/** نرمال‌سازی colorsJson (رشتهٔ legacy یا آبجکت ادمین) قبل از اعتبارسنجی */
export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] | undefined {
if (value === undefined || value === null) return undefined;
if (!Array.isArray(value)) return undefined;
const out: ProductColorJsonItemDto[] = [];
for (const item of value) {
if (typeof item === 'string') {
const name = item.trim();
if (name) out.push({ name });
continue;
}
if (item && typeof item === 'object') {
const raw = item as Record<string, unknown>;
const name = String(raw.name ?? '').trim();
if (!name) continue;
const imageUrl =
typeof raw.imageUrl === 'string' && raw.imageUrl.trim()
? raw.imageUrl.trim()
: undefined;
out.push({ name, imageUrl });
}
}
return out;
}
+29 -3
View File
@@ -1,12 +1,18 @@
import {
IsArray,
IsBoolean,
IsNumber,
IsOptional,
IsString,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { Transform, Type } from 'class-transformer';
import {
ProductColorJsonItemDto,
transformColorsJson,
} from './product-color-json.dto';
export class CreateProductDto {
@IsString()
@@ -15,6 +21,10 @@ export class CreateProductDto {
@IsString()
catalogId!: string;
@IsOptional()
@IsString()
brandId?: string | null;
@IsString()
@MinLength(1)
name!: string;
@@ -128,9 +138,15 @@ export class CreateProductDto {
isNew?: boolean;
@IsOptional()
colorsJson?: Array<string | { name: string; imageUrl?: string }>;
@Transform(({ value }) => transformColorsJson(value))
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductColorJsonItemDto)
colorsJson?: ProductColorJsonItemDto[];
@IsOptional()
@IsArray()
@IsString({ each: true })
imageUrlsJson?: string[];
@IsOptional()
@@ -142,6 +158,10 @@ export class UpdateProductDto {
@IsString()
catalogId?: string;
@IsOptional()
@IsString()
brandId?: string | null;
@IsOptional()
@IsString()
@MinLength(1)
@@ -253,9 +273,15 @@ export class UpdateProductDto {
isNew?: boolean;
@IsOptional()
colorsJson?: Array<string | { name: string; imageUrl?: string }>;
@Transform(({ value }) => transformColorsJson(value))
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProductColorJsonItemDto)
colorsJson?: ProductColorJsonItemDto[];
@IsOptional()
@IsArray()
@IsString({ each: true })
imageUrlsJson?: string[];
@IsOptional()
+34 -22
View File
@@ -1,4 +1,5 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
@@ -9,6 +10,12 @@ import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
import { loadCatalogCategories } from '../catalog/category-defs';
import { mergeProductImages, parseColorEntries } from './color-entries';
const productInclude = {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
brand: { select: { id: true, name: true } },
};
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
@@ -40,10 +47,7 @@ export class ProductsService {
...(filters.season ? { season: filters.season as never } : {}),
},
orderBy: [{ isNew: 'desc' }, { name: 'asc' }, { color: 'asc' }],
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
include: productInclude,
});
const hidePrice = await this.shouldHidePublicPrice(filters.hidePublicPrice);
return products.map((p) => this.map(p, hidePrice));
@@ -52,10 +56,7 @@ export class ProductsService {
async findOne(id: string, hidePublicPrice?: boolean) {
const product = await this.prisma.product.findUnique({
where: { id },
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
include: productInclude,
});
if (!product) throw new NotFoundException('محصول یافت نشد');
const hidePrice = await this.shouldHidePublicPrice(hidePublicPrice);
@@ -84,12 +85,14 @@ export class ProductsService {
const code = (dto.code || dto.productCode || '').trim();
if (!code) throw new ConflictException('کد محصول الزامی است');
const brandId = await this.resolveBrandId(dto.brandId ?? null);
try {
const product = await this.prisma.product.create({
data: {
companyId: dto.companyId,
catalogId: dto.catalogId,
brandId,
name: dto.name.trim(),
code,
color: dto.color.trim(),
@@ -108,14 +111,13 @@ export class ProductsService {
subtitle: dto.subtitle?.trim() || null,
rollMeters: dto.rollMeters,
isNew: dto.isNew,
colorsJson: (dto.colorsJson ?? undefined) as Prisma.InputJsonValue | undefined,
colorsJson: (dto.colorsJson ?? undefined) as unknown as
| Prisma.InputJsonValue
| undefined,
imageUrlsJson: (dto.imageUrlsJson ?? undefined) as Prisma.InputJsonValue | undefined,
tagsJson: dto.tagsJson ?? undefined,
},
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
include: productInclude,
});
return this.map(product);
} catch (e) {
@@ -137,12 +139,14 @@ export class ProductsService {
: dto.productCode !== undefined
? dto.productCode.trim()
: undefined;
const brandId = await this.resolveBrandId(dto.brandId);
try {
const product = await this.prisma.product.update({
where: { id },
data: {
catalogId: dto.catalogId,
...(brandId !== undefined ? { brandId } : {}),
name: dto.name?.trim(),
code,
color: dto.color?.trim(),
@@ -192,17 +196,14 @@ export class ProductsService {
colorsJson:
dto.colorsJson === undefined
? undefined
: (dto.colorsJson as Prisma.InputJsonValue),
: (dto.colorsJson as unknown as Prisma.InputJsonValue),
imageUrlsJson:
dto.imageUrlsJson === undefined
? undefined
: (dto.imageUrlsJson as Prisma.InputJsonValue),
tagsJson: dto.tagsJson,
},
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
include: productInclude,
});
return this.map(product);
} catch (e) {
@@ -221,10 +222,7 @@ export class ProductsService {
const product = await this.prisma.product.update({
where: { id },
data: { isActive },
include: {
company: { select: { name: true } },
catalog: { select: { code: true, name: true } },
},
include: productInclude,
});
return this.map(product);
}
@@ -277,6 +275,17 @@ export class ProductsService {
return product;
}
private async resolveBrandId(
brandId?: string | null,
): Promise<string | null | undefined> {
if (brandId === undefined) return undefined;
const id = brandId?.trim() || null;
if (!id) return null;
const brand = await this.prisma.brand.findUnique({ where: { id } });
if (!brand) throw new BadRequestException('برند یافت نشد');
return id;
}
private map(
product: {
id: string;
@@ -295,6 +304,7 @@ export class ProductsService {
updatedAt: Date;
company: { name: string };
catalog: { code: string; name: string };
brand?: { id: string; name: string } | null;
usageCategory?: string;
season?: string;
widthCm?: number | null;
@@ -325,6 +335,8 @@ export class ProductsService {
catalogId: product.catalogId,
catalogCode: product.catalog.code,
catalogName: product.catalog.name,
brandId: product.brand?.id ?? null,
brandName: product.brand?.name ?? null,
name: product.name,
code: product.code,
productCode: product.code,
@@ -27,6 +27,12 @@ export class StockNotifyController {
return this.stockNotifyService.markNotified(id);
}
@AuthType('customer')
@Get('stock-alerts')
listMine(@CurrentUser() user: AuthPrincipal) {
return this.stockNotifyService.listForCustomer(user.id);
}
@AuthType('customer')
@Post('stock-alerts')
subscribe(@CurrentUser() user: AuthPrincipal, @Body() dto: SubscribeDto) {
+60
View File
@@ -5,6 +5,27 @@ import { PrismaService } from '../prisma/prisma.service';
export class StockNotifyService {
constructor(private readonly prisma: PrismaService) {}
async listForCustomer(customerId: string) {
const rows = await this.prisma.stockNotifyRequest.findMany({
where: { customerId },
orderBy: { createdAt: 'desc' },
include: {
product: {
select: {
id: true,
name: true,
code: true,
color: true,
stock: true,
imageUrlsJson: true,
catalog: { select: { code: true } },
},
},
},
});
return rows.map((r) => this.mapCustomer(r));
}
async listAdmin(activeOnly = true) {
const rows = await this.prisma.stockNotifyRequest.findMany({
where: activeOnly ? { active: true } : undefined,
@@ -68,6 +89,45 @@ export class StockNotifyService {
return this.map(row);
}
private productThumb(imageUrlsJson: unknown): string {
if (!Array.isArray(imageUrlsJson)) return '';
const url = imageUrlsJson.find(
(item): item is string => typeof item === 'string' && item.trim().length > 0,
);
return url?.trim() ?? '';
}
private mapCustomer(r: {
id: string;
productId: string;
active: boolean;
createdAt: Date;
notifiedAt: Date | null;
product: {
id: string;
name: string;
code: string;
color: string;
stock: number | null;
imageUrlsJson: unknown;
catalog: { code: string };
};
}) {
return {
id: r.id,
productId: r.productId,
productName: r.product.name,
productCode: r.product.code,
catalogCode: r.product.catalog.code,
color: r.product.color,
stock: r.product.stock,
imageUrl: this.productThumb(r.product.imageUrlsJson),
active: r.active,
createdAt: r.createdAt.toISOString(),
notifiedAt: r.notifiedAt?.toISOString() ?? null,
};
}
private map(r: {
id: string;
customerId: string;