Initial BizOne portal setup
This commit is contained in:
487
backend/src/conversations/conversations.service.ts
Normal file
487
backend/src/conversations/conversations.service.ts
Normal file
@ -0,0 +1,487 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user