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