change nademitex2
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS "StockNotifyRequest";
|
||||||
@@ -204,7 +204,6 @@ model Product {
|
|||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
favorites ProductFavorite[]
|
favorites ProductFavorite[]
|
||||||
catalogRequestItems CatalogRequestItem[]
|
catalogRequestItems CatalogRequestItem[]
|
||||||
stockNotifyRequests StockNotifyRequest[]
|
|
||||||
productSuggestions ProductSuggestion[]
|
productSuggestions ProductSuggestion[]
|
||||||
|
|
||||||
@@unique([companyId, code])
|
@@unique([companyId, code])
|
||||||
@@ -254,7 +253,6 @@ model Customer {
|
|||||||
remindersAbout Reminder[] @relation("ReminderCustomer")
|
remindersAbout Reminder[] @relation("ReminderCustomer")
|
||||||
favorites ProductFavorite[]
|
favorites ProductFavorite[]
|
||||||
catalogRequests CatalogRequest[]
|
catalogRequests CatalogRequest[]
|
||||||
stockNotifyRequests StockNotifyRequest[]
|
|
||||||
productSuggestions ProductSuggestion[]
|
productSuggestions ProductSuggestion[]
|
||||||
customerBrands CustomerBrand[]
|
customerBrands CustomerBrand[]
|
||||||
addresses CustomerAddress[]
|
addresses CustomerAddress[]
|
||||||
@@ -724,21 +722,6 @@ model CatalogItemMovement {
|
|||||||
@@index([itemId])
|
@@index([itemId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model StockNotifyRequest {
|
|
||||||
id String @id @default(cuid())
|
|
||||||
customerId String
|
|
||||||
productId String
|
|
||||||
active Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
notifiedAt DateTime?
|
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
|
|
||||||
@@unique([customerId, productId])
|
|
||||||
@@index([productId, active])
|
|
||||||
}
|
|
||||||
|
|
||||||
model ProductSuggestion {
|
model ProductSuggestion {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
customerId String
|
customerId String
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { CatalogRequestsModule } from './catalog-requests/catalog-requests.modul
|
|||||||
import { BrandsModule } from './brands/brands.module';
|
import { BrandsModule } from './brands/brands.module';
|
||||||
import { LevelsModule } from './levels/levels.module';
|
import { LevelsModule } from './levels/levels.module';
|
||||||
import { SuggestionsModule } from './suggestions/suggestions.module';
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
import { StockNotifyModule } from './stock-notify/stock-notify.module';
|
|
||||||
import { BannersModule } from './banners/banners.module';
|
import { BannersModule } from './banners/banners.module';
|
||||||
import { AddressesModule } from './addresses/addresses.module';
|
import { AddressesModule } from './addresses/addresses.module';
|
||||||
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
|
import { InternalMessagesModule } from './internal-messages/internal-messages.module';
|
||||||
@@ -50,7 +49,6 @@ import { InternalMessagesModule } from './internal-messages/internal-messages.mo
|
|||||||
BrandsModule,
|
BrandsModule,
|
||||||
LevelsModule,
|
LevelsModule,
|
||||||
SuggestionsModule,
|
SuggestionsModule,
|
||||||
StockNotifyModule,
|
|
||||||
BannersModule,
|
BannersModule,
|
||||||
AddressesModule,
|
AddressesModule,
|
||||||
InternalMessagesModule,
|
InternalMessagesModule,
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
|
|
||||||
|
/** قبل از ValidationPipe — نگهداشتن colorsJson کامل (شامل imageUrl/base64) */
|
||||||
|
export function preserveProductColorsJsonMiddleware(
|
||||||
|
req: Request,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction,
|
||||||
|
) {
|
||||||
|
const body = req.body;
|
||||||
|
if (body && typeof body === 'object' && 'colorsJson' in body) {
|
||||||
|
const raw = (body as { colorsJson?: unknown }).colorsJson;
|
||||||
|
if (raw !== undefined) {
|
||||||
|
try {
|
||||||
|
(req as Request & { preservedColorsJson?: unknown }).preservedColorsJson =
|
||||||
|
JSON.parse(JSON.stringify(raw));
|
||||||
|
} catch {
|
||||||
|
(req as Request & { preservedColorsJson?: unknown }).preservedColorsJson =
|
||||||
|
raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { json, urlencoded } from 'express';
|
|||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { applyPendingMigrations } from './apply-migrations';
|
import { applyPendingMigrations } from './apply-migrations';
|
||||||
import { runBundledSeed } from './run-seed';
|
import { runBundledSeed } from './run-seed';
|
||||||
|
import { preserveProductColorsJsonMiddleware } from './common/middleware/preserve-product-colors-json.middleware';
|
||||||
|
|
||||||
function swaggerUiAssetsPath() {
|
function swaggerUiAssetsPath() {
|
||||||
const bundled = path.join(process.cwd(), 'runtime', 'swagger-ui');
|
const bundled = path.join(process.cwd(), 'runtime', 'swagger-ui');
|
||||||
@@ -30,6 +31,7 @@ async function bootstrap() {
|
|||||||
const bodyLimit = process.env.BODY_LIMIT || '25mb';
|
const bodyLimit = process.env.BODY_LIMIT || '25mb';
|
||||||
app.use(json({ limit: bodyLimit }));
|
app.use(json({ limit: bodyLimit }));
|
||||||
app.use(urlencoded({ extended: true, limit: bodyLimit }));
|
app.use(urlencoded({ extended: true, limit: bodyLimit }));
|
||||||
|
app.use(preserveProductColorsJsonMiddleware);
|
||||||
|
|
||||||
// پشت LiteSpeed روی /server → GLOBAL_PREFIX=server/api
|
// پشت LiteSpeed روی /server → GLOBAL_PREFIX=server/api
|
||||||
const globalPrefix = process.env.GLOBAL_PREFIX || 'server/api';
|
const globalPrefix = process.env.GLOBAL_PREFIX || 'server/api';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
parseColorEntries,
|
parseColorEntries,
|
||||||
parseGeneralImageUrls,
|
parseGeneralImageUrls,
|
||||||
} from './color-entries';
|
} from './color-entries';
|
||||||
|
import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
|
||||||
|
|
||||||
describe('product image separation', () => {
|
describe('product image separation', () => {
|
||||||
it('keeps general images separate from color variant urls', () => {
|
it('keeps general images separate from color variant urls', () => {
|
||||||
@@ -24,6 +25,19 @@ describe('product image separation', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes color imageUrl for database', () => {
|
||||||
|
const db = serializeColorsJsonForDb([
|
||||||
|
{ name: 'قرمز', imageUrl: 'data:image/jpeg;base64,abc' },
|
||||||
|
]);
|
||||||
|
expect(db).toEqual([
|
||||||
|
{
|
||||||
|
name: 'قرمز',
|
||||||
|
imageUrl: 'data:image/jpeg;base64,abc',
|
||||||
|
imageUrls: ['data:image/jpeg;base64,abc'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not merge color images into general list when only imageUrlsJson is set', () => {
|
it('does not merge color images into general list when only imageUrlsJson is set', () => {
|
||||||
const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]);
|
const entries = parseColorEntries([{ name: 'Red', imageUrl: 'https://cdn/r.jpg' }]);
|
||||||
expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([
|
expect(parseGeneralImageUrls(['https://cdn/g.jpg'], entries)).toEqual([
|
||||||
|
|||||||
@@ -49,3 +49,23 @@ export function transformColorsJson(value: unknown): ProductColorJsonItemDto[] |
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ذخیره در Prisma — imageUrl + imageUrls برای هر رنگ */
|
||||||
|
export function serializeColorsJsonForDb(raw: unknown): unknown[] | undefined {
|
||||||
|
const entries = transformColorsJson(raw);
|
||||||
|
if (entries === undefined) return undefined;
|
||||||
|
return entries.map((entry) => {
|
||||||
|
const merged: string[] = [];
|
||||||
|
const push = (u?: string) => {
|
||||||
|
const t = u?.trim();
|
||||||
|
if (t && !merged.includes(t)) merged.push(t);
|
||||||
|
};
|
||||||
|
for (const u of entry.imageUrls ?? []) push(u);
|
||||||
|
push(entry.imageUrl);
|
||||||
|
const first = merged[0];
|
||||||
|
return {
|
||||||
|
name: entry.name,
|
||||||
|
...(first ? { imageUrl: first, imageUrls: merged } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { OmitType, PartialType } from '@nestjs/mapped-types';
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@@ -151,135 +152,16 @@ export class CreateProductDto {
|
|||||||
tagsJson?: string[];
|
tagsJson?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateProductDto {
|
/** همهٔ فیلدهای Create بهجز companyId — شامل brandId برای PATCH ادمین */
|
||||||
@IsOptional()
|
export class UpdateProductDto extends PartialType(
|
||||||
@IsString()
|
OmitType(CreateProductDto, ['companyId'] as const),
|
||||||
brandId?: string | null;
|
) {
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
code?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
productCode?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
color?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
description?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
minMeterage?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.001)
|
|
||||||
orderMultiplier?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0.001)
|
|
||||||
orderStep?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
basePrice?: number | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
price?: number | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
stock?: number | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
internalStock?: number | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isActive?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
usageCategory?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
season?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
widthCm?: number | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
composition?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
weightGrams?: number | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
weaveType?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
subtitle?: string | null;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsNumber()
|
|
||||||
@Min(0)
|
|
||||||
rollMeters?: number;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isNew?: boolean;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => transformColorsJson(value))
|
@Transform(({ value }) => transformColorsJson(value))
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => ProductColorJsonItemDto)
|
@Type(() => ProductColorJsonItemDto)
|
||||||
colorsJson?: ProductColorJsonItemDto[];
|
colorsJson?: ProductColorJsonItemDto[];
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@IsString({ each: true })
|
|
||||||
imageUrlsJson?: string[];
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
tagsJson?: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SetProductActiveDto {
|
export class SetProductActiveDto {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import { AuthType } from '../common/decorators/auth.decorators';
|
import { AuthType } from '../common/decorators/auth.decorators';
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
||||||
@@ -85,14 +87,21 @@ export class ProductsController {
|
|||||||
|
|
||||||
@AuthType('admin')
|
@AuthType('admin')
|
||||||
@Post()
|
@Post()
|
||||||
create(@Body() dto: CreateProductDto) {
|
create(
|
||||||
return this.productsService.create(dto);
|
@Body() dto: CreateProductDto,
|
||||||
|
@Req() req: Request & { preservedColorsJson?: unknown },
|
||||||
|
) {
|
||||||
|
return this.productsService.create(dto, req.preservedColorsJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AuthType('admin')
|
@AuthType('admin')
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(@Param('id') id: string, @Body() dto: UpdateProductDto) {
|
update(
|
||||||
return this.productsService.update(id, dto);
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateProductDto,
|
||||||
|
@Req() req: Request & { preservedColorsJson?: unknown },
|
||||||
|
) {
|
||||||
|
return this.productsService.update(id, dto, req.preservedColorsJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
@AuthType('admin')
|
@AuthType('admin')
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
|
import { CreateProductDto, UpdateProductDto } from './dto/product.dto';
|
||||||
|
import { serializeColorsJsonForDb } from './dto/product-color-json.dto';
|
||||||
import { loadCatalogCategories } from '../catalog/category-defs';
|
import { loadCatalogCategories } from '../catalog/category-defs';
|
||||||
import {
|
import {
|
||||||
colorVariantImageLists,
|
colorVariantImageLists,
|
||||||
@@ -75,7 +76,18 @@ export class ProductsService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateProductDto) {
|
private colorsJsonForWrite(
|
||||||
|
preserved: unknown | undefined,
|
||||||
|
fromDto: unknown | undefined,
|
||||||
|
): Prisma.InputJsonValue | undefined {
|
||||||
|
const source = preserved !== undefined ? preserved : fromDto;
|
||||||
|
if (source === undefined) return undefined;
|
||||||
|
const serialized = serializeColorsJsonForDb(source);
|
||||||
|
if (serialized === undefined) return undefined;
|
||||||
|
return serialized as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateProductDto, preservedColorsJson?: unknown) {
|
||||||
const code = (dto.code || dto.productCode || '').trim();
|
const code = (dto.code || dto.productCode || '').trim();
|
||||||
if (!code) throw new ConflictException('کد محصول الزامی است');
|
if (!code) throw new ConflictException('کد محصول الزامی است');
|
||||||
const brandId = await this.resolveBrandId(dto.brandId ?? null);
|
const brandId = await this.resolveBrandId(dto.brandId ?? null);
|
||||||
@@ -103,9 +115,10 @@ export class ProductsService {
|
|||||||
subtitle: dto.subtitle?.trim() || null,
|
subtitle: dto.subtitle?.trim() || null,
|
||||||
rollMeters: dto.rollMeters,
|
rollMeters: dto.rollMeters,
|
||||||
isNew: dto.isNew,
|
isNew: dto.isNew,
|
||||||
colorsJson: (dto.colorsJson ?? undefined) as unknown as
|
colorsJson: this.colorsJsonForWrite(
|
||||||
| Prisma.InputJsonValue
|
preservedColorsJson,
|
||||||
| undefined,
|
dto.colorsJson,
|
||||||
|
),
|
||||||
imageUrlsJson: (dto.imageUrlsJson ?? undefined) as Prisma.InputJsonValue | undefined,
|
imageUrlsJson: (dto.imageUrlsJson ?? undefined) as Prisma.InputJsonValue | undefined,
|
||||||
tagsJson: dto.tagsJson ?? undefined,
|
tagsJson: dto.tagsJson ?? undefined,
|
||||||
},
|
},
|
||||||
@@ -123,7 +136,11 @@ export class ProductsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateProductDto) {
|
async update(
|
||||||
|
id: string,
|
||||||
|
dto: UpdateProductDto,
|
||||||
|
preservedColorsJson?: unknown,
|
||||||
|
) {
|
||||||
await this.ensure(id);
|
await this.ensure(id);
|
||||||
const code =
|
const code =
|
||||||
dto.code !== undefined
|
dto.code !== undefined
|
||||||
@@ -185,9 +202,9 @@ export class ProductsService {
|
|||||||
rollMeters: dto.rollMeters,
|
rollMeters: dto.rollMeters,
|
||||||
isNew: dto.isNew,
|
isNew: dto.isNew,
|
||||||
colorsJson:
|
colorsJson:
|
||||||
dto.colorsJson === undefined
|
dto.colorsJson === undefined && preservedColorsJson === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: (dto.colorsJson as unknown as Prisma.InputJsonValue),
|
: this.colorsJsonForWrite(preservedColorsJson, dto.colorsJson),
|
||||||
imageUrlsJson:
|
imageUrlsJson:
|
||||||
dto.imageUrlsJson === undefined
|
dto.imageUrlsJson === undefined
|
||||||
? undefined
|
? undefined
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { Body, Controller, Get, Param, Patch, Post, Query } from '@nestjs/common';
|
|
||||||
import { IsString, MinLength } from 'class-validator';
|
|
||||||
import { StockNotifyService } from './stock-notify.service';
|
|
||||||
import { AuthType } from '../common/decorators/auth.decorators';
|
|
||||||
import { CurrentUser } from '../common/decorators/current-user.decorator';
|
|
||||||
import type { AuthPrincipal } from '../common/decorators/current-user.decorator';
|
|
||||||
|
|
||||||
class SubscribeDto {
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
productId!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class StockNotifyController {
|
|
||||||
constructor(private readonly stockNotifyService: StockNotifyService) {}
|
|
||||||
|
|
||||||
@AuthType('admin')
|
|
||||||
@Get('admin/stock-alerts')
|
|
||||||
list(@Query('activeOnly') activeOnly?: string) {
|
|
||||||
return this.stockNotifyService.listAdmin(activeOnly !== 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
@AuthType('admin')
|
|
||||||
@Patch('admin/stock-alerts/:id/notify')
|
|
||||||
notify(@Param('id') id: string) {
|
|
||||||
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) {
|
|
||||||
return this.stockNotifyService.subscribe(user.id, dto.productId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@AuthType('customer')
|
|
||||||
@Post('stock-alerts/:productId/cancel')
|
|
||||||
unsubscribe(
|
|
||||||
@CurrentUser() user: AuthPrincipal,
|
|
||||||
@Param('productId') productId: string,
|
|
||||||
) {
|
|
||||||
return this.stockNotifyService.unsubscribe(user.id, productId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { StockNotifyController } from './stock-notify.controller';
|
|
||||||
import { StockNotifyService } from './stock-notify.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
controllers: [StockNotifyController],
|
|
||||||
providers: [StockNotifyService],
|
|
||||||
exports: [StockNotifyService],
|
|
||||||
})
|
|
||||||
export class StockNotifyModule {}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return rows.map((r) => this.mapCustomer(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
async listAdmin(activeOnly = true) {
|
|
||||||
const rows = await this.prisma.stockNotifyRequest.findMany({
|
|
||||||
where: activeOnly ? { active: true } : undefined,
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: {
|
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true, stock: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return rows.map((r) => this.map(r));
|
|
||||||
}
|
|
||||||
|
|
||||||
async subscribe(customerId: string, productId: string) {
|
|
||||||
const product = await this.prisma.product.findUnique({ where: { id: productId } });
|
|
||||||
if (!product) throw new NotFoundException('محصول یافت نشد');
|
|
||||||
const row = await this.prisma.stockNotifyRequest.upsert({
|
|
||||||
where: { customerId_productId: { customerId, productId } },
|
|
||||||
create: { customerId, productId, active: true },
|
|
||||||
update: { active: true, notifiedAt: null },
|
|
||||||
include: {
|
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true, stock: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return this.map(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
async unsubscribe(customerId: string, productId: string) {
|
|
||||||
await this.prisma.stockNotifyRequest.updateMany({
|
|
||||||
where: { customerId, productId },
|
|
||||||
data: { active: false },
|
|
||||||
});
|
|
||||||
return { productId, active: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
async markNotified(id: string) {
|
|
||||||
const existing = await this.prisma.stockNotifyRequest.findUnique({
|
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
if (!existing) throw new NotFoundException('درخواست یافت نشد');
|
|
||||||
const row = await this.prisma.stockNotifyRequest.update({
|
|
||||||
where: { id },
|
|
||||||
data: { notifiedAt: new Date(), active: false },
|
|
||||||
include: {
|
|
||||||
customer: { select: { firstName: true, lastName: true, phone: true } },
|
|
||||||
product: { select: { name: true, code: true, color: true, stock: true } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.prisma.customerNotification.create({
|
|
||||||
data: {
|
|
||||||
customerId: row.customerId,
|
|
||||||
event: 'stock_available',
|
|
||||||
title: 'موجودی محصول',
|
|
||||||
body: `محصول ${row.product.name} دوباره موجود شد.`,
|
|
||||||
tone: 'success',
|
|
||||||
link: `/product?id=${row.productId}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
id: r.id,
|
|
||||||
productId: r.productId,
|
|
||||||
productName: r.product.name,
|
|
||||||
productCode: r.product.code,
|
|
||||||
catalogCode: r.product.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;
|
|
||||||
productId: string;
|
|
||||||
active: boolean;
|
|
||||||
createdAt: Date;
|
|
||||||
notifiedAt: Date | null;
|
|
||||||
customer: { firstName: string; lastName: string; phone: string };
|
|
||||||
product: { name: string; code: string; color: string; stock: number | null };
|
|
||||||
}) {
|
|
||||||
return {
|
|
||||||
id: r.id,
|
|
||||||
customerId: r.customerId,
|
|
||||||
customerName: `${r.customer.firstName} ${r.customer.lastName}`.trim(),
|
|
||||||
customerPhone: r.customer.phone,
|
|
||||||
productId: r.productId,
|
|
||||||
productName: r.product.name,
|
|
||||||
productCode: r.product.code,
|
|
||||||
color: r.product.color,
|
|
||||||
stock: r.product.stock,
|
|
||||||
active: r.active,
|
|
||||||
createdAt: r.createdAt.toISOString(),
|
|
||||||
notifiedAt: r.notifiedAt?.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user