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
+65
View File
@@ -0,0 +1,65 @@
import {
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export class PortalRequestOtpDto {
@IsString()
@MinLength(10)
@MaxLength(20)
mobile!: string;
}
export class PortalVerifyOtpDto {
@IsString()
@MinLength(10)
@MaxLength(20)
mobile!: string;
@IsString()
@MinLength(4)
@MaxLength(8)
code!: string;
}
export class PortalCreateTicketDto {
@IsString()
@MinLength(2)
@MaxLength(200)
subject!: string;
@IsOptional()
@IsString()
@MaxLength(8000)
description?: string;
@IsOptional()
@IsString()
priority?: string;
}
export class PortalCreateMessageDto {
@IsString()
@MinLength(1)
@MaxLength(8000)
body!: string;
}
export class PortalCsatDto {
@Type(() => Number)
@IsInt()
@Min(1)
@Max(5)
csatScore!: number;
@IsOptional()
@IsString()
@MaxLength(2000)
csatComment?: string | null;
}
+66
View File
@@ -0,0 +1,66 @@
import {
CanActivate,
createParamDecorator,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
export type PortalActor = {
personId: string;
organizationId: string;
mobile: string;
};
@Injectable()
export class PortalAuthGuard implements CanActivate {
constructor(
private readonly jwt: JwtService,
private readonly config: ConfigService,
) {}
canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest<{
headers: { authorization?: string };
portalActor?: PortalActor;
}>();
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
throw new UnauthorizedException('ورود پرتال لازم است');
}
const token = header.slice(7);
try {
const payload = this.jwt.verify<{
sub: string;
organizationId: string;
typ?: string;
mobile?: string;
}>(token, {
secret: this.config.get<string>('JWT_SECRET') ?? 'dev-secret',
});
if (payload.typ !== 'portal' || !payload.sub || !payload.organizationId) {
throw new UnauthorizedException('توکن پرتال نامعتبر است');
}
req.portalActor = {
personId: payload.sub,
organizationId: payload.organizationId,
mobile: payload.mobile ?? '',
};
return true;
} catch {
throw new UnauthorizedException('نشست پرتال منقضی یا نامعتبر است');
}
}
}
export const CurrentPortal = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): PortalActor => {
const req = ctx.switchToHttp().getRequest<{ portalActor?: PortalActor }>();
if (!req.portalActor) {
throw new UnauthorizedException('ورود پرتال لازم است');
}
return req.portalActor;
},
);
+84
View File
@@ -0,0 +1,84 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import {
PortalCreateMessageDto,
PortalCreateTicketDto,
PortalCsatDto,
PortalRequestOtpDto,
PortalVerifyOtpDto,
} from './dto';
import { CurrentPortal, PortalAuthGuard, type PortalActor } from './portal-auth';
import { PortalService } from './portal.service';
@Controller('public/portal/:slug')
export class PortalController {
constructor(private readonly portalService: PortalService) {}
@Get()
info(@Param('slug') slug: string) {
return this.portalService.getOrgInfo(slug);
}
@Get('faq')
faq(@Param('slug') slug: string) {
return this.portalService.listFaq(slug);
}
@Post('otp/request')
requestOtp(@Param('slug') slug: string, @Body() dto: PortalRequestOtpDto) {
return this.portalService.requestOtp(slug, dto.mobile);
}
@Post('otp/verify')
verifyOtp(@Param('slug') slug: string, @Body() dto: PortalVerifyOtpDto) {
return this.portalService.verifyOtp(slug, dto.mobile, dto.code);
}
@Get('tickets')
@UseGuards(PortalAuthGuard)
listTickets(@CurrentPortal() actor: PortalActor) {
return this.portalService.listTickets(actor);
}
@Get('tickets/:id')
@UseGuards(PortalAuthGuard)
getTicket(@CurrentPortal() actor: PortalActor, @Param('id') id: string) {
return this.portalService.getTicket(actor, id);
}
@Post('tickets')
@UseGuards(PortalAuthGuard)
createTicket(
@CurrentPortal() actor: PortalActor,
@Body() dto: PortalCreateTicketDto,
) {
return this.portalService.createTicket(actor, dto);
}
@Post('tickets/:id/messages')
@UseGuards(PortalAuthGuard)
addMessage(
@CurrentPortal() actor: PortalActor,
@Param('id') id: string,
@Body() dto: PortalCreateMessageDto,
) {
return this.portalService.addMessage(actor, id, dto);
}
@Patch('tickets/:id/csat')
@UseGuards(PortalAuthGuard)
csat(
@CurrentPortal() actor: PortalActor,
@Param('id') id: string,
@Body() dto: PortalCsatDto,
) {
return this.portalService.submitCsat(actor, id, dto);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { PortalController } from './portal.controller';
import { PortalService } from './portal.service';
import { PortalAuthGuard } from './portal-auth';
@Module({
imports: [AuthModule],
controllers: [PortalController],
providers: [PortalService, PortalAuthGuard, SmsService],
})
export class PortalModule {}
+410
View File
@@ -0,0 +1,410 @@
import {
BadRequestException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import type {
FaqArticleDto,
PortalCreateMessageRequest,
PortalCreateTicketRequest,
PortalCsatRequest,
PortalLoginResponse,
PortalOrgInfoDto,
PortalRequestOtpResponse,
PortalTicketDto,
TicketPriority,
} from '@mento/shared';
import { TICKET_SLA_HOURS } from '@mento/shared';
import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../common/sms.service';
import type { PortalActor } from './portal-auth';
const OTP_TTL_SECONDS = 5 * 60;
const OTP_LENGTH = 6;
@Injectable()
export class PortalService {
constructor(
private readonly prisma: PrismaService,
private readonly jwt: JwtService,
private readonly config: ConfigService,
private readonly sms: SmsService,
) {}
async getOrgInfo(slug: string): Promise<PortalOrgInfoDto> {
const org = await this.findOrg(slug);
return { name: org.name, slug: org.slug };
}
async listFaq(slug: string): Promise<FaqArticleDto[]> {
const org = await this.findOrg(slug);
const rows = await this.prisma.faqArticle.findMany({
where: { organizationId: org.id, isPublished: true },
orderBy: [{ position: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => ({
id: row.id,
question: row.question,
answer: row.answer,
category: row.category,
isPublished: row.isPublished,
position: row.position,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
}));
}
async requestOtp(
slug: string,
mobileRaw: string,
): Promise<PortalRequestOtpResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
if (!mobile) {
throw new BadRequestException('شماره موبایل معتبر نیست');
}
const recent = await this.prisma.portalOtp.count({
where: {
organizationId: org.id,
mobile,
createdAt: { gte: new Date(Date.now() - 60 * 60 * 1000) },
},
});
if (recent >= 8) {
throw new BadRequestException(
'درخواست زیاد است؛ کمی بعد دوباره تلاش کنید',
);
}
const code = String(
Math.floor(10 ** (OTP_LENGTH - 1) + Math.random() * 9 * 10 ** (OTP_LENGTH - 1)),
);
const expiresAt = new Date(Date.now() + OTP_TTL_SECONDS * 1000);
await this.prisma.portalOtp.create({
data: {
organizationId: org.id,
mobile,
code,
expiresAt,
},
});
const returnOtp =
this.config.get<string>('PORTAL_SHOW_OTP') === 'true' ||
this.config.get<string>('AUTH_RETURN_OTP') === 'true' ||
(this.config.get<string>('NODE_ENV') !== 'production' &&
this.config.get<string>('PORTAL_SHOW_OTP') !== 'false');
if (this.sms.isConfigured()) {
const sent = await this.sms.sendOtpSms(mobile, code);
if (!sent && !returnOtp) {
throw new BadRequestException(
'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید',
);
}
} else if (!returnOtp) {
throw new BadRequestException('سامانه پیامک پیکربندی نشده است');
} else {
// eslint-disable-next-line no-console
console.log(`[portal-otp] ${org.slug} ${mobile} => ${code}`);
}
return {
ok: true,
expiresInSeconds: OTP_TTL_SECONDS,
...(returnOtp ? { devCode: code } : {}),
};
}
async verifyOtp(
slug: string,
mobileRaw: string,
codeRaw: string,
): Promise<PortalLoginResponse> {
const org = await this.findOrg(slug);
const mobile = normalizeMobile(mobileRaw);
const code = codeRaw.trim();
if (!mobile || !code) {
throw new BadRequestException('موبایل یا کد ناقص است');
}
const otp = await this.prisma.portalOtp.findFirst({
where: {
organizationId: org.id,
mobile,
code,
consumedAt: null,
expiresAt: { gt: new Date() },
},
orderBy: { createdAt: 'desc' },
});
if (!otp) {
throw new UnauthorizedException('کد تأیید نامعتبر یا منقضی است');
}
await this.prisma.portalOtp.update({
where: { id: otp.id },
data: { consumedAt: new Date() },
});
let person = await this.prisma.person.findFirst({
where: { organizationId: org.id, mobile },
orderBy: { createdAt: 'asc' },
});
if (!person) {
person = await this.prisma.person.create({
data: {
organizationId: org.id,
firstName: 'مشتری',
lastName: mobile.slice(-4),
mobile,
},
});
}
const accessToken = await this.jwt.signAsync(
{
sub: person.id,
organizationId: org.id,
typ: 'portal',
mobile,
},
{
secret: this.config.get<string>('JWT_SECRET') ?? 'dev-secret',
expiresIn: '7d',
},
);
return {
accessToken,
person: {
id: person.id,
fullName: `${person.firstName} ${person.lastName}`.trim(),
mobile: person.mobile,
},
organization: { name: org.name, slug: org.slug },
};
}
async listTickets(actor: PortalActor): Promise<PortalTicketDto[]> {
const rows = await this.prisma.ticket.findMany({
where: {
organizationId: actor.organizationId,
personId: actor.personId,
},
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true } } },
},
},
orderBy: { updatedAt: 'desc' },
take: 50,
});
return rows.map((row) => this.toPortalTicket(row, actor.personId));
}
async getTicket(actor: PortalActor, id: string): Promise<PortalTicketDto> {
const row = await this.findTicket(actor, id);
return this.toPortalTicket(row, actor.personId);
}
async createTicket(
actor: PortalActor,
dto: PortalCreateTicketRequest,
): Promise<PortalTicketDto> {
const person = await this.prisma.person.findFirst({
where: { id: actor.personId, organizationId: actor.organizationId },
});
if (!person) throw new NotFoundException('پرونده مشتری پیدا نشد');
const count = await this.prisma.ticket.count({
where: { organizationId: actor.organizationId },
});
const number = `TK-${String(count + 1).padStart(4, '0')}`;
const priority = (dto.priority as TicketPriority) || 'normal';
const createdAt = new Date();
const hours = TICKET_SLA_HOURS[priority] ?? TICKET_SLA_HOURS.normal;
const owner =
(await this.prisma.user.findFirst({
where: {
organizationId: actor.organizationId,
role: { in: ['owner', 'admin', 'support'] },
},
orderBy: { createdAt: 'asc' },
})) ?? null;
const row = await this.prisma.ticket.create({
data: {
organizationId: actor.organizationId,
number,
subject: dto.subject.trim(),
description: dto.description?.trim() || null,
status: 'open',
priority: priority as never,
personId: person.id,
companyId: person.companyId,
assigneeId: owner?.id ?? null,
reporterId: null,
createdAt,
slaDueAt: new Date(createdAt.getTime() + hours * 60 * 60 * 1000),
...(dto.description?.trim()
? {
messages: {
create: {
authorId: null,
body: dto.description.trim(),
isInternal: false,
},
},
}
: {}),
},
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true } } },
},
},
});
return this.toPortalTicket(row, actor.personId);
}
async addMessage(
actor: PortalActor,
ticketId: string,
dto: PortalCreateMessageRequest,
): Promise<PortalTicketDto> {
await this.findTicket(actor, ticketId);
await this.prisma.ticketMessage.create({
data: {
ticketId,
authorId: null,
body: dto.body.trim(),
isInternal: false,
},
});
await this.prisma.ticket.update({
where: { id: ticketId },
data: {
updatedAt: new Date(),
status: 'waiting',
},
});
return this.getTicket(actor, ticketId);
}
async submitCsat(
actor: PortalActor,
ticketId: string,
dto: PortalCsatRequest,
): Promise<PortalTicketDto> {
const existing = await this.findTicket(actor, ticketId);
if (existing.status !== 'resolved' && existing.status !== 'closed') {
throw new BadRequestException(
'ثبت رضایت فقط پس از حل یا بستن تیکت ممکن است',
);
}
if (dto.csatScore < 1 || dto.csatScore > 5) {
throw new BadRequestException('امتیاز باید بین ۱ تا ۵ باشد');
}
await this.prisma.ticket.update({
where: { id: ticketId },
data: {
csatScore: dto.csatScore,
csatComment: dto.csatComment?.trim() || null,
csatAt: new Date(),
},
});
return this.getTicket(actor, ticketId);
}
private async findOrg(slug: string) {
const org = await this.prisma.organization.findUnique({
where: { slug },
select: { id: true, name: true, slug: true },
});
if (!org) throw new NotFoundException('سازمان پیدا نشد');
return org;
}
private async findTicket(actor: PortalActor, id: string) {
const row = await this.prisma.ticket.findFirst({
where: {
id,
organizationId: actor.organizationId,
personId: actor.personId,
},
include: {
messages: {
where: { isInternal: false },
orderBy: { createdAt: 'asc' },
include: { author: { select: { id: true } } },
},
},
});
if (!row) throw new NotFoundException('تیکت پیدا نشد');
return row;
}
private toPortalTicket(
row: {
id: string;
number: string;
subject: string;
description: string | null;
status: string;
priority: string;
resolvedAt: Date | null;
csatScore: number | null;
csatComment: string | null;
createdAt: Date;
updatedAt: Date;
messages: Array<{
id: string;
body: string;
createdAt: Date;
author: { id: string } | null;
}>;
},
personId: string,
): PortalTicketDto {
void personId;
return {
id: row.id,
number: row.number,
subject: row.subject,
description: row.description,
status: row.status,
priority: row.priority,
resolvedAt: row.resolvedAt?.toISOString() ?? null,
csatScore: row.csatScore,
csatComment: row.csatComment,
messages: row.messages.map((m) => ({
id: m.id,
body: m.body,
createdAt: m.createdAt.toISOString(),
fromStaff: Boolean(m.author),
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
/** نرمال‌سازی موبایل ایران به 09xxxxxxxxx */
export function normalizeMobile(raw: string): string | null {
let s = raw.replace(/[\s\-()]/g, '');
if (s.startsWith('+98')) s = `0${s.slice(3)}`;
if (s.startsWith('98') && s.length === 12) s = `0${s.slice(2)}`;
if (/^09\d{9}$/.test(s)) return s;
return null;
}