Initial Mento CRM: portal OTP, reports, SMS, and deploy for crm.montzar.ir.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateProductDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sku?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateProductDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(160)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sku?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
description?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
unitPrice?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
currency?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class ListProductsQueryDto {
|
||||
@IsOptional()
|
||||
@Type(() => Boolean)
|
||||
@IsBoolean()
|
||||
activeOnly?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { CreateProductDto, ListProductsQueryDto, UpdateProductDto } from './dto';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Controller('products')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class ProductsController {
|
||||
constructor(private readonly productsService: ProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListProductsQueryDto,
|
||||
) {
|
||||
return this.productsService.list(user.organizationId, {
|
||||
activeOnly: query.activeOnly,
|
||||
q: query.q,
|
||||
});
|
||||
}
|
||||
|
||||
@Post()
|
||||
@Roles('owner', 'admin')
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: CreateProductDto,
|
||||
) {
|
||||
return this.productsService.create(user.organizationId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@Roles('owner', 'admin')
|
||||
update(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateProductDto,
|
||||
) {
|
||||
return this.productsService.update(user.organizationId, id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
async remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
await this.productsService.remove(user.organizationId, id);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ProductsController } from './products.controller';
|
||||
import { ProductsService } from './products.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
controllers: [ProductsController],
|
||||
providers: [ProductsService],
|
||||
exports: [ProductsService],
|
||||
})
|
||||
export class ProductsModule {}
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateProductRequest,
|
||||
ProductDto,
|
||||
UpdateProductRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
type ProductRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
sku: string | null;
|
||||
description: string | null;
|
||||
unitPrice: Prisma.Decimal | null;
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts?: { activeOnly?: boolean; q?: string },
|
||||
): Promise<ProductDto[]> {
|
||||
const rows = await this.prisma.product.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(opts?.activeOnly ? { isActive: true } : {}),
|
||||
...(opts?.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ sku: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
dto: CreateProductRequest,
|
||||
): Promise<ProductDto> {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('نام محصول الزامی است');
|
||||
|
||||
const row = await this.prisma.product.create({
|
||||
data: {
|
||||
organizationId,
|
||||
name,
|
||||
sku: emptyToNull(dto.sku),
|
||||
description: emptyToNull(dto.description),
|
||||
unitPrice:
|
||||
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
|
||||
? null
|
||||
: new Prisma.Decimal(dto.unitPrice),
|
||||
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
dto: UpdateProductRequest,
|
||||
): Promise<ProductDto> {
|
||||
await this.ensureExists(organizationId, id);
|
||||
const data: Prisma.ProductUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('نام محصول الزامی است');
|
||||
data.name = name;
|
||||
}
|
||||
if ('sku' in dto) data.sku = emptyToNull(dto.sku);
|
||||
if ('description' in dto) data.description = emptyToNull(dto.description);
|
||||
if ('unitPrice' in dto) {
|
||||
data.unitPrice =
|
||||
dto.unitPrice == null || Number.isNaN(Number(dto.unitPrice))
|
||||
? null
|
||||
: new Prisma.Decimal(dto.unitPrice);
|
||||
}
|
||||
if (dto.currency !== undefined) {
|
||||
data.currency = dto.currency.trim() || 'IRR';
|
||||
}
|
||||
if (dto.isActive !== undefined) data.isActive = dto.isActive;
|
||||
|
||||
const row = await this.prisma.product.update({ where: { id }, data });
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string): Promise<void> {
|
||||
await this.ensureExists(organizationId, id);
|
||||
await this.prisma.product.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async ensureInOrg(organizationId: string, productId: string): Promise<void> {
|
||||
await this.ensureExists(organizationId, productId);
|
||||
}
|
||||
|
||||
private async ensureExists(organizationId: string, id: string) {
|
||||
const found = await this.prisma.product.findFirst({
|
||||
where: { id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!found) throw new NotFoundException('محصول پیدا نشد');
|
||||
}
|
||||
|
||||
private toDto(row: ProductRow): ProductDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
sku: row.sku,
|
||||
description: row.description,
|
||||
unitPrice: row.unitPrice == null ? null : Number(row.unitPrice),
|
||||
currency: row.currency,
|
||||
isActive: row.isActive,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function emptyToNull(value?: string | null): string | null {
|
||||
if (value == null) return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
}
|
||||
Reference in New Issue
Block a user