Files
CRM_Montzar/apps/api/src/portal/portal.service.ts
T

411 lines
12 KiB
TypeScript

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;
}