import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Contact } from '@prisma/client'; import { AuthenticatedUser } from '../auth/auth.types'; import { normalizeText } from '../common/normalize'; import { PrismaService } from '../prisma/prisma.service'; type ConversationFilter = 'all' | 'active' | 'pending'; @Injectable() export class ConversationsService { constructor(private readonly prisma: PrismaService) {} async findAll(query: { filter?: string; search?: string }) { const contacts = await this.prisma.contact.findMany({ orderBy: { createdAt: 'desc' }, }); const items = await Promise.all(contacts.map((contact) => this.buildConversationSummary(contact))); const normalizedFilter = this.normalizeFilter(query.filter); const searchNeedle = query.search?.trim().toLowerCase(); return items .filter((item) => { const matchesFilter = normalizedFilter === 'all' ? true : normalizedFilter === 'pending' ? item.status === 'PENDING' : item.status === 'ACTIVE' || item.status === 'NEW'; const matchesSearch = !searchNeedle || [item.name, item.email, item.phone, item.topic, item.snippet, item.location] .filter(Boolean) .some((value) => String(value).toLowerCase().includes(searchNeedle)); return matchesFilter && matchesSearch; }) .sort((left, right) => new Date(right.lastActivityAt).getTime() - new Date(left.lastActivityAt).getTime()); } async findOne(contactId: string) { const contact = await this.prisma.contact.findUnique({ where: { id: contactId }, }); if (!contact) { throw new NotFoundException('Conversation not found'); } await this.prisma.conversationMessage.updateMany({ where: { contactId, direction: 'incoming', readAt: null, }, data: { readAt: new Date(), }, }); return this.buildConversationDetail(contact); } async sendMessage(contactId: string, dto: { body: string }, user: AuthenticatedUser, ipAddress?: string) { const contact = await this.prisma.contact.findUnique({ where: { id: contactId }, }); if (!contact) { throw new NotFoundException('Conversation not found'); } const actor = await this.prisma.user.findUnique({ where: { id: user.sub }, select: { id: true, name: true, email: true }, }); const body = normalizeText(dto.body); if (!body) { throw new BadRequestException('Message body is required'); } const providerSend = await this.sendViaConfiguredProvider(contact.phoneNumber, body); const message = await this.prisma.conversationMessage.create({ data: { contactId, direction: 'outgoing', source: providerSend.provider, status: providerSend.status, body, senderUserId: actor?.id || user.sub, senderName: actor?.name || user.email, externalMessageId: providerSend.externalMessageId, }, }); await this.prisma.contact.update({ where: { id: contactId }, data: { assignedUserId: actor?.id || user.sub, assignedUserName: actor?.name || user.email, assignedAt: new Date(), }, }); await this.prisma.auditLog.create({ data: { actorUserId: actor?.id || user.sub, actorName: actor?.name || user.email, actorEmail: actor?.email || user.email, actionType: 'Conversation Reply Sent', module: 'Conversations', ipAddress: ipAddress || null, severity: 'default', details: `Sent reply to ${contact.name} (${contact.phoneNumber}) via ${providerSend.provider}.`, }, }); return { success: true, message: this.mapThreadMessage(message), }; } async assignToCurrentUser(contactId: string, user: AuthenticatedUser, ipAddress?: string) { const actor = await this.prisma.user.findUnique({ where: { id: user.sub }, select: { id: true, name: true, email: true }, }); const contact = await this.prisma.contact.findUnique({ where: { id: contactId }, }); if (!contact) { throw new NotFoundException('Conversation not found'); } await this.prisma.contact.update({ where: { id: contactId }, data: { assignedUserId: actor?.id || user.sub, assignedUserName: actor?.name || user.email, assignedAt: new Date(), }, }); await this.prisma.auditLog.create({ data: { actorUserId: actor?.id || user.sub, actorName: actor?.name || user.email, actorEmail: actor?.email || user.email, actionType: 'Conversation Assigned', module: 'Conversations', ipAddress: ipAddress || null, severity: 'default', details: `Assigned conversation ${contact.name} (${contact.phoneNumber}) to ${actor?.name || user.email}.`, }, }); return { success: true }; } async syncInboundFromWebhookEvent(input: { webhookEventId: string; contactId: string; externalMessageId?: string | null; body: string; occurredAt: Date; }) { const normalizedBody = normalizeText(input.body) || 'Inbound message received.'; await this.prisma.conversationMessage.upsert({ where: { webhookEventId: input.webhookEventId }, update: { body: normalizedBody, externalMessageId: input.externalMessageId || undefined, occurredAt: input.occurredAt, status: 'received', }, create: { contactId: input.contactId, direction: 'incoming', messageType: 'text', source: 'webhook', status: 'received', body: normalizedBody, externalMessageId: input.externalMessageId || undefined, webhookEventId: input.webhookEventId, occurredAt: input.occurredAt, }, }); } private async buildConversationSummary(contact: Contact) { const [messages, webhooks] = await Promise.all([ this.prisma.conversationMessage.findMany({ where: { contactId: contact.id }, orderBy: { occurredAt: 'desc' }, take: 20, }), this.prisma.webhookEvent.findMany({ where: { OR: [{ senderPhone: contact.phoneNumber }, { recipientPhone: contact.phoneNumber }], }, orderBy: { createdAt: 'desc' }, take: 10, }), ]); const latestMessage = messages[0] || null; const latestWebhook = webhooks[0] || null; const latestMessageAt = latestMessage?.occurredAt?.getTime() || 0; const latestWebhookAt = latestWebhook?.createdAt?.getTime() || 0; const lastActivityAt = new Date(Math.max(latestMessageAt, latestWebhookAt, contact.updatedAt.getTime())); const latestSnippet = latestMessageAt >= latestWebhookAt ? latestMessage?.body : this.extractWebhookSnippet(latestWebhook?.payloadJson) || latestWebhook?.eventType || 'No activity yet'; const tags = this.deriveTags(contact); const unreadCount = messages.filter((message) => message.direction === 'incoming' && !message.readAt).length; const fallbackInboundUnread = unreadCount === 0 && latestWebhook?.eventType === 'message.inbound' && latestWebhookAt > latestMessageAt; const status = fallbackInboundUnread || unreadCount > 0 ? 'NEW' : lastActivityAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 ? 'ACTIVE' : 'PENDING'; return { id: contact.id, name: contact.name, initials: this.getInitials(contact.name), time: this.relativeTime(lastActivityAt), status, tone: status === 'PENDING' ? 'warning' : status === 'ACTIVE' ? 'success' : 'info', topic: tags[0]?.toUpperCase() || 'GENERAL', snippet: latestSnippet || 'No activity yet', online: lastActivityAt.getTime() >= Date.now() - 1000 * 60 * 10, location: this.deriveLocation(contact.phoneNumber, contact.email), email: contact.email || 'N/A', phone: contact.phoneNumber, customerSince: `Customer since ${contact.createdAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric', timeZone: 'UTC', })}`, tags, lastActivityAt: lastActivityAt.toISOString(), unreadCount, assignedAgentName: contact.assignedUserName || null, }; } private async buildConversationDetail(contact: Contact) { const [messages, webhookEvents, auditLogs] = await Promise.all([ this.prisma.conversationMessage.findMany({ where: { contactId: contact.id }, orderBy: { occurredAt: 'asc' }, take: 200, }), this.prisma.webhookEvent.findMany({ where: { OR: [{ senderPhone: contact.phoneNumber }, { recipientPhone: contact.phoneNumber }], }, orderBy: { createdAt: 'desc' }, take: 10, }), this.prisma.auditLog.findMany({ where: { OR: [ { details: { contains: contact.phoneNumber, mode: 'insensitive' } }, { details: { contains: contact.name, mode: 'insensitive' } }, ], }, orderBy: { createdAt: 'desc' }, take: 6, }), ]); const mirroredWebhookIds = new Set(messages.map((message) => message.webhookEventId).filter(Boolean)); const fallbackInboundMessages = webhookEvents .filter((event) => event.eventType === 'message.inbound' && !mirroredWebhookIds.has(event.eventId)) .map((event) => ({ id: `webhook-${event.eventId}`, direction: 'incoming' as const, body: this.extractWebhookSnippet(event.payloadJson) || 'Inbound message received.', time: this.formatThreadTime(event.createdAt), occurredAt: event.createdAt.toISOString(), })); const threadMessages = [ ...messages.map((message) => this.mapThreadMessage(message)), ...fallbackInboundMessages, ].sort((left, right) => new Date(left.occurredAt).getTime() - new Date(right.occurredAt).getTime()); const summary = await this.buildConversationSummary(contact); return { ...summary, messages: threadMessages.map(({ occurredAt: _occurredAt, ...message }) => message), activity: this.buildRecentActivity(webhookEvents, auditLogs), assignedAgentName: contact.assignedUserName || summary.assignedAgentName, }; } private mapThreadMessage(message: { id: string; direction: string; body: string; occurredAt: Date; status?: string; }) { return { id: message.id, direction: message.direction === 'outgoing' ? 'outgoing' : 'incoming', body: message.body, time: this.formatThreadTime(message.occurredAt), status: message.status || 'sent', occurredAt: message.occurredAt.toISOString(), }; } private buildRecentActivity( webhookEvents: Array<{ id: string; eventType: string; createdAt: Date; processingStatus: string }>, auditLogs: Array<{ id: string; actionType: string; createdAt: Date; details: string; severity: string }>, ) { return [ ...webhookEvents.map((event) => ({ id: `webhook-${event.id}`, title: event.eventType, meta: `${event.processingStatus} • ${this.relativeTime(event.createdAt)}`, tone: event.eventType === 'message.inbound' ? 'primary' : 'muted', createdAt: event.createdAt.toISOString(), })), ...auditLogs.map((log) => ({ id: `audit-${log.id}`, title: log.actionType, meta: `${log.severity} • ${this.relativeTime(log.createdAt)}`, tone: log.severity === 'alert' ? 'primary' : 'muted', createdAt: log.createdAt.toISOString(), })), ] .sort((left, right) => new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime()) .slice(0, 5) .map(({ createdAt: _createdAt, ...item }) => item); } private normalizeFilter(value?: string): ConversationFilter { const normalized = value?.trim().toLowerCase(); if (normalized === 'active' || normalized === 'pending') { return normalized; } return 'all'; } private extractWebhookSnippet(payload: unknown) { if (!payload || typeof payload !== 'object') { return ''; } const record = payload as Record; const textRecord = record.text && typeof record.text === 'object' && !Array.isArray(record.text) ? (record.text as Record) : null; const interactiveRecord = record.interactive && typeof record.interactive === 'object' && !Array.isArray(record.interactive) ? (record.interactive as Record) : null; return [ typeof textRecord?.body === 'string' ? textRecord.body : null, typeof record.body === 'string' ? record.body : null, typeof record.caption === 'string' ? record.caption : null, typeof interactiveRecord?.title === 'string' ? interactiveRecord.title : null, typeof record.type === 'string' ? `[${record.type}]` : null, ].find((value) => typeof value === 'string' && value.trim()) || ''; } private deriveTags(contact: { company: string | null; notes: string | null; createdAt: Date; email: string | null }) { const haystack = `${contact.company || ''} ${contact.notes || ''} ${contact.email || ''}`.toLowerCase(); const tags = new Set(); if (haystack.includes('vip') || haystack.includes('premium')) tags.add('Premium'); if (haystack.includes('support') || haystack.includes('help') || haystack.includes('issue')) tags.add('Support'); if (haystack.includes('lead') || haystack.includes('prospect')) tags.add('Lead'); if (haystack.includes('retail') || haystack.includes('shop') || haystack.includes('store')) tags.add('Inquiry'); if (contact.createdAt.getTime() >= Date.now() - 1000 * 60 * 60 * 24 * 30) tags.add('New'); if (tags.size === 0) tags.add('General'); return Array.from(tags); } private deriveLocation(phoneNumber: string, email?: string | null) { const value = phoneNumber.replace(/\s+/g, ''); if (value.startsWith('+62') || value.startsWith('62')) return 'Jakarta, ID'; if (value.startsWith('+65') || value.startsWith('65')) return 'Singapore, SG'; if (value.startsWith('+1') || value.startsWith('1')) return 'San Francisco, CA'; if (email?.endsWith('.es')) return 'Madrid, ES'; if (email?.endsWith('.net')) return 'London, UK'; return 'Regional Contact'; } private relativeTime(date: Date) { const diffMs = Date.now() - date.getTime(); const diffMinutes = Math.max(1, Math.floor(diffMs / (1000 * 60))); if (diffMinutes < 60) return `${diffMinutes}m ago`; const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) return `${diffHours}h ago`; const diffDays = Math.floor(diffHours / 24); if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } private formatThreadTime(date: Date) { return new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, }).format(date); } private getInitials(name: string) { return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((part) => part[0]?.toUpperCase() || '') .join(''); } private async sendViaConfiguredProvider(phoneNumber: string, body: string) { const stored = await this.prisma.integrationConfig.findUnique({ where: { configKey: 'whatsapp' }, }); const configJson = (stored?.configJson as Record | null) ?? {}; const provider = String(configJson.provider || stored?.provider || 'internal').toLowerCase(); const accessToken = typeof configJson.accessToken === 'string' ? configJson.accessToken : ''; const phoneNumberId = typeof configJson.phoneNumberId === 'string' ? configJson.phoneNumberId : ''; if (stored?.isEnabled && provider === 'meta' && accessToken && phoneNumberId) { const response = await fetch(`https://graph.facebook.com/v20.0/${phoneNumberId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to: phoneNumber.replace(/\D+/g, ''), type: 'text', text: { preview_url: false, body, }, }), }); const payload = (await response.json().catch(() => ({}))) as { messages?: Array<{ id?: string }>; }; if (!response.ok) { return { provider: 'meta', status: 'failed', externalMessageId: null, }; } return { provider: 'meta', status: 'queued', externalMessageId: payload.messages?.[0]?.id || null, }; } return { provider: 'internal', status: 'sent', externalMessageId: null, }; } }