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,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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user