488 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|