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
+169
View File
@@ -0,0 +1,169 @@
import {
ArrayMinSize,
IsArray,
IsIn,
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
const QUOTE_STATUSES = [
'draft',
'sent',
'accepted',
'rejected',
'expired',
] as const;
export class QuoteLineItemInputDto {
@IsOptional()
@IsString()
productId?: string | null;
@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
quantity!: number;
@Type(() => Number)
@IsNumber()
@Min(0)
unitPrice!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
}
export class CreateQuoteDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsOptional()
@IsIn(QUOTE_STATUSES)
status?: (typeof QUOTE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string;
@IsOptional()
@IsString()
validUntil?: string | null;
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsString()
fromDealId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => QuoteLineItemInputDto)
@ArrayMinSize(0)
lineItems?: QuoteLineItemInputDto[];
}
export class UpdateQuoteDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsIn(QUOTE_STATUSES)
status?: (typeof QUOTE_STATUSES)[number];
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
notes?: string | null;
@IsOptional()
@IsString()
validUntil?: string | null;
@IsOptional()
@IsString()
personId?: string | null;
@IsOptional()
@IsString()
companyId?: string | null;
@IsOptional()
@IsString()
dealId?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => QuoteLineItemInputDto)
lineItems?: QuoteLineItemInputDto[];
}
export class ListQuotesQueryDto {
@IsOptional()
@IsString()
q?: string;
@IsOptional()
@IsIn(QUOTE_STATUSES)
status?: (typeof QUOTE_STATUSES)[number];
@IsOptional()
@IsString()
dealId?: string;
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
page?: number;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(1)
limit?: number;
}
+67
View File
@@ -0,0 +1,67 @@
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 {
CreateQuoteDto,
ListQuotesQueryDto,
UpdateQuoteDto,
} from './dto';
import { QuotesService } from './quotes.service';
type AuthUser = { organizationId: string; userId: string; role: string };
@Controller('quotes')
@UseGuards(JwtAuthGuard)
export class QuotesController {
constructor(private readonly quotesService: QuotesService) {}
@Get()
list(
@CurrentUser() user: AuthUser,
@Query() query: ListQuotesQueryDto,
) {
return this.quotesService.list(user.organizationId, user, {
q: query.q,
status: query.status,
dealId: query.dealId,
personId: query.personId,
page: query.page ?? 1,
limit: query.limit ?? 50,
});
}
@Get(':id')
get(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.quotesService.get(user.organizationId, id, user);
}
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreateQuoteDto) {
return this.quotesService.create(user.organizationId, user.userId, dto);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@Body() dto: UpdateQuoteDto,
) {
return this.quotesService.update(user.organizationId, id, user, dto);
}
@Delete(':id')
async remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
await this.quotesService.remove(user.organizationId, id, user);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { QuotesController } from './quotes.controller';
import { QuotesService } from './quotes.service';
@Module({
controllers: [QuotesController],
providers: [QuotesService],
exports: [QuotesService],
})
export class QuotesModule {}
+367
View File
@@ -0,0 +1,367 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateQuoteRequest,
DealLineItemInput,
PaginatedResponse,
QuoteDto,
UpdateQuoteRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
type QuoteActor = Pick<Actor, 'userId' | 'role'>;
const include = {
person: { select: { id: true, firstName: true, lastName: true } },
company: { select: { id: true, name: true } },
deal: { select: { id: true, title: true } },
owner: { select: { id: true, fullName: true } },
lineItems: { orderBy: { position: 'asc' as const } },
} satisfies Prisma.QuoteInclude;
type QuoteRow = Prisma.QuoteGetPayload<{ include: typeof include }>;
@Injectable()
export class QuotesService {
constructor(private readonly prisma: PrismaService) {}
async list(
organizationId: string,
actor: QuoteActor,
opts: {
q?: string;
status?: string;
dealId?: string;
personId?: string;
page?: number;
limit?: number;
},
): Promise<PaginatedResponse<QuoteDto>> {
const page = opts.page ?? 1;
const limit = opts.limit ?? 50;
const where: Prisma.QuoteWhereInput = {
organizationId,
...ownerWhere(actor),
...(opts.status ? { status: opts.status as never } : {}),
...(opts.dealId ? { dealId: opts.dealId } : {}),
...(opts.personId ? { personId: opts.personId } : {}),
...(opts.q?.trim()
? {
OR: [
{ title: { contains: opts.q.trim(), mode: 'insensitive' } },
{ number: { contains: opts.q.trim(), mode: 'insensitive' } },
{ notes: { contains: opts.q.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
this.prisma.quote.findMany({
where,
include,
orderBy: { updatedAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
this.prisma.quote.count({ where }),
]);
return {
items: rows.map((row) => this.toDto(row)),
total,
page,
limit,
};
}
async get(
organizationId: string,
id: string,
actor: QuoteActor,
): Promise<QuoteDto> {
const row = await this.prisma.quote.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
include,
});
if (!row) throw new NotFoundException('پیش‌فاکتور پیدا نشد');
return this.toDto(row);
}
async create(
organizationId: string,
userId: string,
dto: CreateQuoteRequest,
): Promise<QuoteDto> {
let title = dto.title.trim();
let personId = emptyToNull(dto.personId ?? null);
let companyId = emptyToNull(dto.companyId ?? null);
let dealId = emptyToNull(dto.dealId ?? null);
let lineItems = dto.lineItems ?? [];
let currency = (dto.currency ?? 'IRR').trim() || 'IRR';
let notes = emptyToNull(dto.notes ?? null);
const fromDealId = emptyToNull(dto.fromDealId ?? null);
if (fromDealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: fromDealId, organizationId },
include: { lineItems: { orderBy: { position: 'asc' } } },
});
if (!deal) throw new BadRequestException('معامله مبدأ معتبر نیست');
dealId = deal.id;
personId = personId ?? deal.personId;
companyId = companyId ?? deal.companyId;
if (!title || title === 'پیش‌فاکتور') {
title = `پیش‌فاکتور — ${deal.title}`;
}
currency = deal.currency || currency;
notes = notes ?? deal.notes;
if (!lineItems.length && deal.lineItems.length) {
lineItems = deal.lineItems.map((item) => ({
productId: item.productId,
name: item.name,
quantity: Number(item.quantity),
unitPrice: Number(item.unitPrice),
currency: item.currency,
}));
}
}
await this.assertLinks(organizationId, personId, companyId, dealId);
if (lineItems.length) {
await this.assertProducts(organizationId, lineItems);
}
const number = await this.nextNumber(organizationId);
const row = await this.prisma.quote.create({
data: {
organizationId,
number,
title,
status: (dto.status as never) || 'draft',
currency,
notes,
validUntil: dto.validUntil ? new Date(dto.validUntil) : null,
personId,
companyId,
dealId,
ownerId: userId,
...(lineItems.length
? {
lineItems: {
create: lineItems.map((item, index) => ({
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
},
}
: {}),
},
include,
});
return this.toDto(row);
}
async update(
organizationId: string,
id: string,
actor: QuoteActor,
dto: UpdateQuoteRequest,
): Promise<QuoteDto> {
await this.ensureExists(organizationId, id, actor);
if (
'personId' in dto ||
'companyId' in dto ||
'dealId' in dto
) {
await this.assertLinks(
organizationId,
emptyToNull(dto.personId ?? null),
emptyToNull(dto.companyId ?? null),
emptyToNull(dto.dealId ?? null),
);
}
if (dto.lineItems?.length) {
await this.assertProducts(organizationId, dto.lineItems);
}
const data: Prisma.QuoteUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.status !== undefined) data.status = dto.status as never;
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('validUntil' in dto) {
data.validUntil = dto.validUntil ? new Date(dto.validUntil) : null;
}
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
if (dto.lineItems) {
const currency =
(typeof data.currency === 'string' && data.currency) ||
(
await this.prisma.quote.findFirst({
where: { id },
select: { currency: true },
})
)?.currency ||
'IRR';
await this.prisma.$transaction([
this.prisma.quoteLineItem.deleteMany({ where: { quoteId: id } }),
this.prisma.quote.update({
where: { id },
data: {
...data,
lineItems: {
create: dto.lineItems.map((item, index) => ({
productId: emptyToNull(item.productId ?? null),
name: item.name.trim(),
quantity: new Prisma.Decimal(item.quantity),
unitPrice: new Prisma.Decimal(item.unitPrice),
currency: (item.currency ?? currency).trim() || 'IRR',
position: index,
})),
},
},
}),
]);
} else if (Object.keys(data).length) {
await this.prisma.quote.update({ where: { id }, data });
}
return this.get(organizationId, id, actor);
}
async remove(
organizationId: string,
id: string,
actor: QuoteActor,
): Promise<void> {
await this.ensureExists(organizationId, id, actor);
await this.prisma.quote.delete({ where: { id } });
}
private async nextNumber(organizationId: string): Promise<string> {
const count = await this.prisma.quote.count({ where: { organizationId } });
return `PF-${String(count + 1).padStart(4, '0')}`;
}
private async ensureExists(
organizationId: string,
id: string,
actor: QuoteActor,
) {
const found = await this.prisma.quote.findFirst({
where: { id, organizationId, ...ownerWhere(actor) },
select: { id: true },
});
if (!found) throw new NotFoundException('پیش‌فاکتور پیدا نشد');
}
private async assertLinks(
organizationId: string,
personId: string | null,
companyId: string | null,
dealId: string | null,
) {
if (personId) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { id: true },
});
if (!person) throw new BadRequestException('شخص معتبر نیست');
}
if (companyId) {
const company = await this.prisma.company.findFirst({
where: { id: companyId, organizationId },
select: { id: true },
});
if (!company) throw new BadRequestException('شرکت معتبر نیست');
}
if (dealId) {
const deal = await this.prisma.deal.findFirst({
where: { id: dealId, organizationId },
select: { id: true },
});
if (!deal) throw new BadRequestException('معامله معتبر نیست');
}
}
private async assertProducts(
organizationId: string,
lines: DealLineItemInput[],
) {
const ids = [
...new Set(
lines
.map((l) => emptyToNull(l.productId ?? null))
.filter((id): id is string => Boolean(id)),
),
];
if (!ids.length) return;
const count = await this.prisma.product.count({
where: { organizationId, id: { in: ids } },
});
if (count !== ids.length) {
throw new BadRequestException('یکی از محصولات معتبر نیست');
}
}
private toDto(row: QuoteRow): QuoteDto {
const lineItems = row.lineItems.map((item) => {
const quantity = Number(item.quantity);
const unitPrice = Number(item.unitPrice);
return {
id: item.id,
productId: item.productId,
name: item.name,
quantity,
unitPrice,
currency: item.currency,
position: item.position,
lineTotal: quantity * unitPrice,
};
});
const totalAmount = lineItems.reduce((sum, item) => sum + item.lineTotal, 0);
return {
id: row.id,
number: row.number,
title: row.title,
status: row.status,
currency: row.currency,
notes: row.notes,
validUntil: row.validUntil?.toISOString() ?? null,
totalAmount,
person: row.person
? {
id: row.person.id,
fullName: `${row.person.firstName} ${row.person.lastName}`.trim(),
}
: null,
company: row.company,
deal: row.deal,
owner: row.owner,
lineItems,
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;
}