Files
BizOne-portal/backend/src/conversations/conversations.service.ts

488 lines
17 KiB
TypeScript

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<string, unknown>;
const textRecord =
record.text && typeof record.text === 'object' && !Array.isArray(record.text)
? (record.text as Record<string, unknown>)
: null;
const interactiveRecord =
record.interactive && typeof record.interactive === 'object' && !Array.isArray(record.interactive)
? (record.interactive as Record<string, unknown>)
: 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<string>();
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<string, unknown> | 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,
};
}
}