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
+105
View File
@@ -0,0 +1,105 @@
import {
IsNumber,
IsOptional,
IsString,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class CreateSalesGoalDto {
@IsString()
@MinLength(2)
@MaxLength(200)
title!: string;
@IsString()
periodStart!: string;
@IsString()
periodEnd!: string;
@Type(() => Number)
@IsNumber()
@Min(0)
targetAmount!: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string;
@IsOptional()
@IsString()
userId?: string | null;
}
export class UpdateSalesGoalDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
periodStart?: string;
@IsOptional()
@IsString()
periodEnd?: string;
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0)
targetAmount?: number;
@IsOptional()
@IsString()
@MaxLength(8)
currency?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
notes?: string | null;
@IsOptional()
@IsString()
userId?: string | null;
}
export class ForecastQueryDto {
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@IsString()
userId?: string;
}
export class ListGoalsQueryDto {
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
@IsOptional()
@IsString()
userId?: string;
}
+125
View File
@@ -0,0 +1,125 @@
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 {
CreateSalesGoalDto,
ForecastQueryDto,
ListGoalsQueryDto,
UpdateSalesGoalDto,
} from './dto';
import { SalesService } from './sales.service';
@Controller('sales')
@UseGuards(JwtAuthGuard)
export class SalesController {
constructor(private readonly salesService: SalesService) {}
@Get('forecast')
forecast(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ForecastQueryDto,
) {
return this.salesService.forecast(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to, userId: query.userId },
);
}
@Get('goals')
listGoals(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ListGoalsQueryDto,
) {
return this.salesService.listGoals(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to, userId: query.userId },
);
}
@Get('goals/:id')
getGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
) {
return this.salesService.getGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
);
}
@Post('goals')
createGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Body() dto: CreateSalesGoalDto,
) {
return this.salesService.createGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
dto,
);
}
@Patch('goals/:id')
updateGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
@Body() dto: UpdateSalesGoalDto,
) {
return this.salesService.updateGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
dto,
);
}
@Delete('goals/:id')
async removeGoal(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Param('id') id: string,
) {
await this.salesService.removeGoal(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
id,
);
return { ok: true };
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SalesController } from './sales.controller';
import { SalesService } from './sales.service';
@Module({
controllers: [SalesController],
providers: [SalesService],
exports: [SalesService],
})
export class SalesModule {}
+338
View File
@@ -0,0 +1,338 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type {
CreateSalesGoalRequest,
PaginatedResponse,
SalesForecastDto,
SalesGoalDto,
UpdateSalesGoalRequest,
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
elevated,
ownerWhere,
} from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
const include = {
user: { select: { id: true, fullName: true } },
} satisfies Prisma.SalesGoalInclude;
type GoalRow = Prisma.SalesGoalGetPayload<{ include: typeof include }>;
const OPEN_WEIGHT = 0.4;
@Injectable()
export class SalesService {
constructor(private readonly prisma: PrismaService) {}
async listGoals(
actor: Actor,
opts: { from?: string; to?: string; userId?: string } = {},
): Promise<PaginatedResponse<SalesGoalDto>> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const where: Prisma.SalesGoalWhereInput = {
organizationId: actor.organizationId,
periodStart: { lte: end },
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
const rows = await this.prisma.salesGoal.findMany({
where,
include,
orderBy: [{ periodStart: 'desc' }, { createdAt: 'desc' }],
take: 100,
});
return {
items: rows.map((row) => this.toGoalDto(row)),
total: rows.length,
page: 1,
limit: 100,
};
}
async getGoal(actor: Actor, id: string): Promise<SalesGoalDto> {
const row = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
include,
});
if (!row) throw new NotFoundException('هدف پیدا نشد');
return this.toGoalDto(row);
}
async createGoal(
actor: Actor,
dto: CreateSalesGoalRequest,
): Promise<SalesGoalDto> {
const periodStart = parseDate(dto.periodStart, 'شروع دوره');
const periodEnd = parseDate(dto.periodEnd, 'پایان دوره');
if (periodEnd < periodStart) {
throw new BadRequestException('پایان دوره باید بعد از شروع باشد');
}
const userId = await this.resolveGoalUserId(actor, dto.userId);
const row = await this.prisma.salesGoal.create({
data: {
organizationId: actor.organizationId,
userId,
title: dto.title.trim(),
periodStart,
periodEnd,
targetAmount: new Prisma.Decimal(dto.targetAmount),
currency: (dto.currency ?? 'IRR').trim() || 'IRR',
notes: emptyToNull(dto.notes ?? null),
},
include,
});
return this.toGoalDto(row);
}
async updateGoal(
actor: Actor,
id: string,
dto: UpdateSalesGoalRequest,
): Promise<SalesGoalDto> {
const existing = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
});
if (!existing) throw new NotFoundException('هدف پیدا نشد');
if (!elevated(actor.role) && existing.userId !== actor.userId) {
throw new ForbiddenException('اجازه ویرایش این هدف را ندارید');
}
const data: Prisma.SalesGoalUncheckedUpdateInput = {};
if (dto.title !== undefined) data.title = dto.title.trim();
if (dto.periodStart !== undefined) {
data.periodStart = parseDate(dto.periodStart, 'شروع دوره');
}
if (dto.periodEnd !== undefined) {
data.periodEnd = parseDate(dto.periodEnd, 'پایان دوره');
}
if (dto.targetAmount !== undefined) {
data.targetAmount = new Prisma.Decimal(dto.targetAmount);
}
if (dto.currency !== undefined) {
data.currency = dto.currency.trim() || 'IRR';
}
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
if ('userId' in dto) {
data.userId = await this.resolveGoalUserId(actor, dto.userId);
}
const start =
data.periodStart instanceof Date
? data.periodStart
: existing.periodStart;
const end =
data.periodEnd instanceof Date ? data.periodEnd : existing.periodEnd;
if (end < start) {
throw new BadRequestException('پایان دوره باید بعد از شروع باشد');
}
const row = await this.prisma.salesGoal.update({
where: { id },
data,
include,
});
return this.toGoalDto(row);
}
async removeGoal(actor: Actor, id: string): Promise<void> {
const existing = await this.prisma.salesGoal.findFirst({
where: {
id,
organizationId: actor.organizationId,
...this.goalVisibility(actor),
},
});
if (!existing) throw new NotFoundException('هدف پیدا نشد');
if (!elevated(actor.role) && existing.userId !== actor.userId) {
throw new ForbiddenException('اجازه حذف این هدف را ندارید');
}
await this.prisma.salesGoal.delete({ where: { id } });
}
async forecast(
actor: Actor,
opts: { from?: string; to?: string; userId?: string } = {},
): Promise<SalesForecastDto> {
const { start, end } = resolvePeriod(opts.from, opts.to);
const dealOwner =
elevated(actor.role) && opts.userId
? { ownerId: opts.userId }
: ownerWhere(actor);
const goalsWhere: Prisma.SalesGoalWhereInput = {
organizationId: actor.organizationId,
periodStart: { lte: end },
periodEnd: { gte: start },
...this.goalVisibility(actor, opts.userId),
};
const [goals, wonDeals, openDeals, rateMap] = await Promise.all([
this.prisma.salesGoal.findMany({
where: goalsWhere,
include,
orderBy: { periodStart: 'asc' },
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
status: 'won',
updatedAt: { gte: start, lte: end },
...dealOwner,
},
select: { amount: true, currency: true },
}),
this.prisma.deal.findMany({
where: {
organizationId: actor.organizationId,
status: 'open',
...dealOwner,
},
select: { amount: true, currency: true },
}),
loadOrgRates(this.prisma, actor.organizationId),
]);
const targetAmount = goals.reduce(
(sum, g) =>
sum + decimalToBase(g.targetAmount, g.currency, rateMap),
0,
);
const wonAmount = wonDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const openPipelineAmount = openDeals.reduce(
(sum, d) => sum + decimalToBase(d.amount, d.currency, rateMap),
0,
);
const forecastAmount = wonAmount + openPipelineAmount * OPEN_WEIGHT;
const progressPercent =
targetAmount > 0
? Math.round((wonAmount / targetAmount) * 1000) / 10
: null;
return {
periodStart: start.toISOString(),
periodEnd: end.toISOString(),
currency: rateMap.baseCurrency,
targetAmount,
wonAmount,
openPipelineAmount,
forecastAmount,
progressPercent,
openDealsCount: openDeals.length,
wonDealsCount: wonDeals.length,
goals: goals.map((g) => this.toGoalDto(g)),
};
}
private goalVisibility(
actor: Actor,
filterUserId?: string,
): Prisma.SalesGoalWhereInput {
if (elevated(actor.role)) {
if (filterUserId === 'org') return { userId: null };
if (filterUserId) return { userId: filterUserId };
return {};
}
return {
OR: [{ userId: actor.userId }, { userId: null }],
};
}
private async resolveGoalUserId(
actor: Actor,
requested?: string | null,
): Promise<string | null> {
if (!elevated(actor.role)) {
return actor.userId;
}
if (requested === undefined) return null;
if (requested == null || requested === '' || requested === 'org') {
return null;
}
const user = await this.prisma.user.findFirst({
where: { id: requested, organizationId: actor.organizationId },
select: { id: true },
});
if (!user) throw new BadRequestException('کاربر هدف معتبر نیست');
return user.id;
}
private toGoalDto(row: GoalRow): SalesGoalDto {
return {
id: row.id,
title: row.title,
periodStart: row.periodStart.toISOString(),
periodEnd: row.periodEnd.toISOString(),
targetAmount: Number(row.targetAmount),
currency: row.currency,
notes: row.notes,
user: row.user,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
function resolvePeriod(from?: string, to?: string): { start: Date; end: Date } {
if (from || to) {
const start = from
? parseDate(from, 'از تاریخ')
: startOfMonth(new Date());
const end = to ? endOfDay(parseDate(to, 'تا تاریخ')) : endOfMonth(start);
return { start, end };
}
const now = new Date();
return { start: startOfMonth(now), end: endOfMonth(now) };
}
function parseDate(value: string, label: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) {
throw new BadRequestException(`${label} معتبر نیست`);
}
return d;
}
function startOfMonth(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1, 0, 0, 0, 0));
}
function endOfMonth(d: Date): Date {
return new Date(
Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 0, 23, 59, 59, 999),
);
}
function endOfDay(d: Date): Date {
const x = new Date(d);
x.setUTCHours(23, 59, 59, 999);
return x;
}
function emptyToNull(value?: string | null): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}