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
+11
View File
@@ -0,0 +1,11 @@
import { IsOptional, IsString } from 'class-validator';
export class ReportsQueryDto {
@IsOptional()
@IsString()
from?: string;
@IsOptional()
@IsString()
to?: string;
}
@@ -0,0 +1,27 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { ReportsQueryDto } from './dto';
import { ReportsService } from './reports.service';
@Controller('reports')
@UseGuards(JwtAuthGuard)
export class ReportsController {
constructor(private readonly reportsService: ReportsService) {}
@Get('dashboard')
dashboard(
@CurrentUser()
user: { userId: string; organizationId: string; role: string },
@Query() query: ReportsQueryDto,
) {
return this.reportsService.dashboard(
{
userId: user.userId,
organizationId: user.organizationId,
role: user.role,
},
{ from: query.from, to: query.to },
);
}
}
+11
View File
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
imports: [AuthModule],
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}
+271
View File
@@ -0,0 +1,271 @@
import { Injectable } from '@nestjs/common';
import type { ReportsDashboardDto } from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
type Actor,
assigneeWhere,
elevated,
ownerWhere,
ticketAccessWhere,
} from '../common/record-scope';
import { decimalToBase, loadOrgRates } from '../common/currency';
@Injectable()
export class ReportsService {
constructor(private readonly prisma: PrismaService) {}
async dashboard(
actor: Actor,
opts: { from?: string; to?: string },
): Promise<ReportsDashboardDto> {
const { start, end } = resolveRange(opts.from, opts.to);
const orgId = actor.organizationId;
const dealScope = ownerWhere(actor);
const activityScope = assigneeWhere(actor);
const ticketScope = ticketAccessWhere(actor);
const rates = await loadOrgRates(this.prisma, orgId);
const [deals, stages, activities, tickets, peopleCreated, users] =
await Promise.all([
this.prisma.deal.findMany({
where: {
organizationId: orgId,
...dealScope,
OR: [
{ createdAt: { gte: start, lte: end } },
{
status: { in: ['won', 'lost'] },
updatedAt: { gte: start, lte: end },
},
{ status: 'open' },
],
},
select: {
id: true,
status: true,
amount: true,
currency: true,
stageId: true,
ownerId: true,
updatedAt: true,
createdAt: true,
},
}),
this.prisma.pipelineStage.findMany({
where: { pipeline: { organizationId: orgId } },
include: { pipeline: { select: { name: true } } },
orderBy: [{ pipelineId: 'asc' }, { position: 'asc' }],
}),
this.prisma.activity.findMany({
where: {
organizationId: orgId,
...activityScope,
createdAt: { gte: start, lte: end },
},
select: {
id: true,
type: true,
status: true,
dueAt: true,
assigneeId: true,
},
}),
this.prisma.ticket.findMany({
where: {
organizationId: orgId,
...ticketScope,
createdAt: { gte: start, lte: end },
},
select: {
status: true,
slaDueAt: true,
firstRespondedAt: true,
csatScore: true,
},
}),
this.prisma.person.count({
where: {
organizationId: orgId,
createdAt: { gte: start, lte: end },
...(elevated(actor.role) ? {} : { ownerId: actor.userId }),
},
}),
this.prisma.user.findMany({
where: { organizationId: orgId },
select: { id: true, fullName: true },
}),
]);
const openDeals = deals.filter((d) => d.status === 'open');
const wonInPeriod = deals.filter(
(d) =>
d.status === 'won' &&
d.updatedAt >= start &&
d.updatedAt <= end,
);
const lostInPeriod = deals.filter(
(d) =>
d.status === 'lost' &&
d.updatedAt >= start &&
d.updatedAt <= end,
);
const sumBase = (
rows: Array<{ amount: Prisma.Decimal | null; currency: string }>,
) =>
rows.reduce(
(acc, row) => acc + decimalToBase(row.amount, row.currency, rates),
0,
);
const closedCount = wonInPeriod.length + lostInPeriod.length;
const winRate = closedCount ? wonInPeriod.length / closedCount : 0;
const openByStage = new Map<string, { count: number; amount: number }>();
for (const d of openDeals) {
const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 };
cur.count += 1;
cur.amount += decimalToBase(d.amount, d.currency, rates);
openByStage.set(d.stageId, cur);
}
const funnel = stages.map((s) => {
const agg = openByStage.get(s.id) ?? { count: 0, amount: 0 };
return {
stageId: s.id,
stageName: s.name,
pipelineName: s.pipeline.name,
count: agg.count,
amount: Math.round(agg.amount),
};
});
const now = new Date();
const activityOpen = activities.filter((a) => a.status === 'open').length;
const activityDone = activities.filter((a) => a.status === 'done').length;
const activityOverdue = activities.filter(
(a) =>
a.status === 'open' &&
a.dueAt != null &&
a.dueAt.getTime() < now.getTime(),
).length;
const byTypeMap = new Map<string, number>();
for (const a of activities) {
byTypeMap.set(a.type, (byTypeMap.get(a.type) ?? 0) + 1);
}
const teamMap = new Map<
string,
{ wonAmount: number; wonCount: number; activitiesDone: number }
>();
for (const u of users) {
teamMap.set(u.id, { wonAmount: 0, wonCount: 0, activitiesDone: 0 });
}
for (const d of wonInPeriod) {
if (!d.ownerId) continue;
const t = teamMap.get(d.ownerId)!;
t.wonCount += 1;
t.wonAmount += decimalToBase(d.amount, d.currency, rates);
}
for (const a of activities) {
if (a.status !== 'done' || !a.assigneeId) continue;
const t = teamMap.get(a.assigneeId);
if (t) t.activitiesDone += 1;
}
const ticketOpen = tickets.filter((t) =>
['open', 'in_progress', 'waiting'].includes(t.status),
).length;
const ticketResolved = tickets.filter(
(t) => t.status === 'resolved',
).length;
const ticketClosed = tickets.filter((t) => t.status === 'closed').length;
const slaBreached = tickets.filter(
(t) =>
t.slaDueAt &&
!t.firstRespondedAt &&
['open', 'in_progress', 'waiting'].includes(t.status) &&
t.slaDueAt.getTime() < now.getTime(),
).length;
const csatScores = tickets
.map((t) => t.csatScore)
.filter((s): s is number => s != null);
const avgCsat = csatScores.length
? csatScores.reduce((a, b) => a + b, 0) / csatScores.length
: null;
return {
from: start.toISOString(),
to: end.toISOString(),
currency: rates.baseCurrency,
sales: {
openCount: openDeals.length,
wonCount: wonInPeriod.length,
lostCount: lostInPeriod.length,
openAmount: Math.round(sumBase(openDeals)),
wonAmount: Math.round(sumBase(wonInPeriod)),
lostAmount: Math.round(sumBase(lostInPeriod)),
winRate: Math.round(winRate * 1000) / 10,
},
funnel,
activities: {
open: activityOpen,
done: activityDone,
overdue: activityOverdue,
byType: [...byTypeMap.entries()].map(([type, count]) => ({
type,
count,
})),
},
team: users
.map((u) => {
const t = teamMap.get(u.id)!;
return {
userId: u.id,
fullName: u.fullName,
wonAmount: Math.round(t.wonAmount),
wonCount: t.wonCount,
activitiesDone: t.activitiesDone,
};
})
.filter(
(u) =>
elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0,
)
.sort((a, b) => b.wonAmount - a.wonAmount),
tickets: {
open: ticketOpen,
resolved: ticketResolved,
closed: ticketClosed,
slaBreached,
avgCsat: avgCsat == null ? null : Math.round(avgCsat * 10) / 10,
csatCount: csatScores.length,
},
peopleCreated,
};
}
}
function resolveRange(from?: string, to?: string): { start: Date; end: Date } {
const end = to ? endOfDay(parseDay(to)) : endOfDay(new Date());
const start = from
? startOfDay(parseDay(from))
: startOfDay(new Date(end.getFullYear(), end.getMonth(), 1));
return { start, end };
}
function parseDay(value: string): Date {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return new Date();
return d;
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
function endOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 23, 59, 59, 999);
}