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:
2026-08-29 21:06:30 +03:30
co-authored by Cursor
commit 29a040c21d
195 changed files with 39430 additions and 0 deletions
+89
View File
@@ -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 };
}
}
+12
View File
@@ -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 {}
+141
View File
@@ -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;
}