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 { canonicalIranMobile, phoneIdentityKeys } from '../contacts/normalize'; import { SmsGatewayService } from '../sms-billing/sms-gateway.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: SmsGatewayService, ) {} async getOrgInfo(slug: string): Promise { const org = await this.findOrg(slug); return { name: org.name, slug: org.slug }; } async listFaq(slug: string): Promise { 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 { const org = await this.findOrg(slug); const mobile = canonicalIranMobile(mobileRaw) ?? 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('PORTAL_SHOW_OTP') === 'true' || this.config.get('AUTH_RETURN_OTP') === 'true' || (this.config.get('NODE_ENV') !== 'production' && this.config.get('PORTAL_SHOW_OTP') !== 'false'); const sent = await this.sms.sendOtp(org.id, mobile, code); if (!sent && !returnOtp) { throw new BadRequestException( 'ارسال پیامک ناموفق بود؛ کمی بعد دوباره تلاش کنید', ); } if (!sent) { // 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 { const org = await this.findOrg(slug); const mobile = canonicalIranMobile(mobileRaw) ?? normalizeMobile(mobileRaw); const mobileKeys = phoneIdentityKeys(mobile); 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 = mobileKeys.length ? await this.prisma.person.findFirst({ where: { organizationId: org.id, OR: [{ mobile: { in: mobileKeys } }, { phone: { in: mobileKeys } }], }, orderBy: { createdAt: 'asc' }, }) : null; 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('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 { 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 { const row = await this.findTicket(actor, id); return this.toPortalTicket(row, actor.personId); } async createTicket( actor: PortalActor, dto: PortalCreateTicketRequest, ): Promise { 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: { memberships: { some: { organizationId: actor.organizationId, role: { in: ['owner', 'admin', 'support'] }, isActive: true, }, }, }, 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 { 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 { 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; }