411 lines
12 KiB
TypeScript
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;
|
|
}
|