Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
1006 lines
28 KiB
TypeScript
1006 lines
28 KiB
TypeScript
import { revalidatePath } from "next/cache";
|
|
import { redirect } from "next/navigation";
|
|
|
|
import {
|
|
ConversationStatus,
|
|
DeliveryStatus,
|
|
MessageDirection,
|
|
RoleCode,
|
|
type Prisma
|
|
} from "@prisma/client";
|
|
|
|
import { getSession } from "@/lib/auth";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { assertPermission, type ActionPermission } from "@/lib/permissions";
|
|
import { getRequestAuditContext, writeAuditTrail } from "@/lib/audit";
|
|
import { sendOutboundTextMessage } from "@/lib/whatsapp-provider";
|
|
|
|
type AppRole = "admin" | "agent";
|
|
type InboxScope = "all" | "unassigned" | "resolved" | "open" | "pending";
|
|
|
|
export type ConversationSummary = {
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
phone: string;
|
|
snippet: string;
|
|
time: string;
|
|
status: "Open" | "Pending" | "Resolved";
|
|
assignee: string;
|
|
assigneeId: string | null;
|
|
channel: string;
|
|
tags: string[];
|
|
priority: "Normal" | "High";
|
|
contactId: string;
|
|
};
|
|
|
|
export type ConversationMessage = {
|
|
id: string;
|
|
body: string;
|
|
direction: "INBOUND" | "OUTBOUND";
|
|
from: string;
|
|
at: string;
|
|
};
|
|
|
|
export type ConversationNote = {
|
|
id: string;
|
|
content: string;
|
|
by: string;
|
|
at: string;
|
|
};
|
|
|
|
export type MentionedConversationRecord = {
|
|
id: string;
|
|
contactName: string;
|
|
mentionedBy: string;
|
|
time: string;
|
|
snippet: string;
|
|
};
|
|
|
|
export type ResolvedConversationRecord = {
|
|
id: string;
|
|
contactName: string;
|
|
resolvedAt: string;
|
|
lastAction: string;
|
|
};
|
|
|
|
export type InboxConversationDetail = ConversationSummary & {
|
|
tagJson: string;
|
|
messages: ConversationMessage[];
|
|
notes: ConversationNote[];
|
|
};
|
|
|
|
type ListOptions = {
|
|
scope: AppRole;
|
|
conversationId?: string | null;
|
|
filter?: InboxScope;
|
|
};
|
|
|
|
function formValue(formData: FormData, name: string, fallback = "") {
|
|
const value = formData.get(name);
|
|
return typeof value === "string" ? value.trim() : fallback;
|
|
}
|
|
|
|
function parseRole(sessionRole: string): AppRole | null {
|
|
if (sessionRole === "admin_client") {
|
|
return "admin";
|
|
}
|
|
|
|
if (sessionRole === "agent") {
|
|
return "agent";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function mapConversationStatus(status: ConversationStatus): "Open" | "Pending" | "Resolved" {
|
|
if (status === ConversationStatus.PENDING) {
|
|
return "Pending";
|
|
}
|
|
|
|
if (status === ConversationStatus.RESOLVED) {
|
|
return "Resolved";
|
|
}
|
|
|
|
return "Open";
|
|
}
|
|
|
|
function mapConversationPriority(value: "LOW" | "NORMAL" | "HIGH" | "URGENT" | string) {
|
|
return value === "HIGH" || value === "URGENT" ? "High" : "Normal";
|
|
}
|
|
|
|
function formatRelativeDate(date: Date | null | undefined) {
|
|
if (!date) {
|
|
return "-";
|
|
}
|
|
|
|
const now = new Date();
|
|
const diff = now.getTime() - date.getTime();
|
|
const day = 1000 * 60 * 60 * 24;
|
|
|
|
if (diff < day) {
|
|
return new Intl.DateTimeFormat("id-ID", {
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
}).format(date);
|
|
}
|
|
|
|
if (diff < day * 2) {
|
|
return "Yesterday";
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("id-ID", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric"
|
|
}).format(date);
|
|
}
|
|
|
|
function formatDateTime(date: Date | null | undefined) {
|
|
if (!date) {
|
|
return "-";
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("id-ID", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit"
|
|
}).format(date);
|
|
}
|
|
|
|
function mapStatusForSelect(status: ConversationStatus): string {
|
|
return status;
|
|
}
|
|
|
|
async function requireInboxScope(scope: AppRole, permission?: ActionPermission) {
|
|
const session = await getSession();
|
|
const role = session?.role ? parseRole(session.role) : null;
|
|
|
|
if (!session || !role) {
|
|
redirect("/login");
|
|
}
|
|
|
|
if (role !== scope && !(scope === "admin" && session.role === "super_admin")) {
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
if (permission && !assertPermission(session.role, permission, session.extraPermissions)) {
|
|
const requestContext = await getRequestAuditContext();
|
|
await writeAuditTrail({
|
|
tenantId: session.tenantId,
|
|
actorUserId: session.userId,
|
|
entityType: "user",
|
|
entityId: session.userId,
|
|
action: "permission_denied",
|
|
metadata: {
|
|
action: `inbox_scope_${scope}`,
|
|
requiredPermission: permission,
|
|
requestScope: scope
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
return { session, role };
|
|
}
|
|
|
|
async function requireInboxActor(permission?: ActionPermission) {
|
|
const session = await getSession();
|
|
if (!session) {
|
|
redirect("/login");
|
|
}
|
|
|
|
const role = parseRole(session.role);
|
|
if (!role) {
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
if (permission && !assertPermission(session.role, permission, session.extraPermissions)) {
|
|
const requestContext = await getRequestAuditContext();
|
|
await writeAuditTrail({
|
|
tenantId: session.tenantId,
|
|
actorUserId: session.userId,
|
|
entityType: "user",
|
|
entityId: session.userId,
|
|
action: "permission_denied",
|
|
metadata: {
|
|
action: "inbox_actor",
|
|
requiredPermission: permission,
|
|
requestScope: scopeLabel(role)
|
|
},
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
});
|
|
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
return { session, role };
|
|
}
|
|
|
|
function scopeLabel(role: AppRole) {
|
|
return role === "admin" ? "admin_scope" : "agent_scope";
|
|
}
|
|
|
|
type AuditActorSession = {
|
|
tenantId: string;
|
|
userId: string;
|
|
};
|
|
|
|
async function getAuditContext(session: AuditActorSession) {
|
|
const requestContext = await getRequestAuditContext();
|
|
return {
|
|
tenantId: session.tenantId,
|
|
actorUserId: session.userId,
|
|
ipAddress: requestContext.ipAddress,
|
|
userAgent: requestContext.userAgent
|
|
};
|
|
}
|
|
|
|
function buildWhere(role: AppRole, tenantId: string, userId: string, filter: InboxScope) {
|
|
const base = { tenantId };
|
|
if (role === "admin") {
|
|
if (filter === "unassigned") {
|
|
return { ...base, assignedUserId: null };
|
|
}
|
|
|
|
if (filter === "resolved") {
|
|
return { ...base, status: ConversationStatus.RESOLVED };
|
|
}
|
|
|
|
if (filter === "pending") {
|
|
return { ...base, status: ConversationStatus.PENDING };
|
|
}
|
|
|
|
if (filter === "open") {
|
|
return { ...base, status: ConversationStatus.OPEN };
|
|
}
|
|
|
|
return base;
|
|
}
|
|
|
|
if (filter === "resolved") {
|
|
return { ...base, status: ConversationStatus.RESOLVED, assignedUserId: userId };
|
|
}
|
|
|
|
if (filter === "unassigned") {
|
|
return { ...base, assignedUserId: null };
|
|
}
|
|
|
|
if (filter === "pending") {
|
|
return { ...base, status: ConversationStatus.PENDING, assignedUserId: userId };
|
|
}
|
|
|
|
if (filter === "open") {
|
|
return { ...base, status: ConversationStatus.OPEN, assignedUserId: userId };
|
|
}
|
|
|
|
return { ...base, assignedUserId: userId };
|
|
}
|
|
|
|
function mentionNeedles(fullName: string) {
|
|
const normalized = fullName
|
|
.toLowerCase()
|
|
.split(" ")
|
|
.filter(Boolean);
|
|
|
|
const unique = [fullName.toLowerCase(), ...normalized];
|
|
|
|
return [...new Set(unique)].map((name) => `@${name}`);
|
|
}
|
|
|
|
function textContainsAny(value: string | null, needles: string[]) {
|
|
if (!value) {
|
|
return false;
|
|
}
|
|
|
|
const target = value.toLowerCase();
|
|
return needles.some((needle) => target.includes(needle.toLowerCase()));
|
|
}
|
|
|
|
export async function getInboxWorkspace({ scope, conversationId, filter = "all" }: ListOptions) {
|
|
const { session, role } = await requireInboxScope(scope, "inbox:read");
|
|
const agentFilter = role === "agent";
|
|
const where = buildWhere(role, session.tenantId, session.userId, filter);
|
|
|
|
const conversations = await prisma.conversation.findMany({
|
|
where,
|
|
orderBy: { lastMessageAt: "desc" },
|
|
include: {
|
|
contact: true,
|
|
channel: true,
|
|
assignedUser: true,
|
|
conversationTags: {
|
|
include: { tag: true }
|
|
},
|
|
messages: {
|
|
take: 1,
|
|
orderBy: { createdAt: "desc" }
|
|
}
|
|
}
|
|
});
|
|
|
|
const mapped = conversations.map((conversation) => {
|
|
const latestMessage = conversation.messages[0];
|
|
return {
|
|
id: conversation.id,
|
|
tenantId: conversation.tenantId,
|
|
name: conversation.contact?.fullName ?? "Unknown Contact",
|
|
phone: conversation.contact?.phoneNumber ?? "-",
|
|
snippet: latestMessage?.contentText ?? conversation.subject ?? "No content",
|
|
time: formatRelativeDate(conversation.lastMessageAt),
|
|
status: mapConversationStatus(conversation.status),
|
|
assignee: conversation.assignedUser?.fullName ?? "Unassigned",
|
|
assigneeId: conversation.assignedUser?.id ?? null,
|
|
channel: conversation.channel?.channelName ?? "Unknown Channel",
|
|
tags: conversation.conversationTags
|
|
.map((item) => item.tag?.name)
|
|
.filter((tag): tag is string => Boolean(tag)),
|
|
priority: mapConversationPriority(conversation.priority),
|
|
contactId: conversation.contactId
|
|
} satisfies ConversationSummary;
|
|
});
|
|
|
|
const selected = conversationId
|
|
? mapped.find((item) => item.id === conversationId) || mapped[0]
|
|
: mapped[0];
|
|
|
|
if (!selected) {
|
|
return {
|
|
conversations: mapped,
|
|
selectedConversation: null,
|
|
canSelfAssign: role === "agent",
|
|
role,
|
|
agents: [] as Array<{ id: string; name: string }>,
|
|
filter,
|
|
defaultPath: scope === "admin" ? "/inbox" : "/agent/inbox"
|
|
};
|
|
}
|
|
|
|
const fullConversation = await prisma.conversation.findUnique({
|
|
where: {
|
|
id: selected.id
|
|
},
|
|
include: {
|
|
contact: true,
|
|
channel: true,
|
|
assignedUser: true,
|
|
messages: {
|
|
include: { sentByUser: true, contact: true },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 30
|
|
},
|
|
conversationTags: { include: { tag: true } },
|
|
notes: {
|
|
include: { user: true },
|
|
orderBy: { createdAt: "desc" }
|
|
}
|
|
}
|
|
});
|
|
|
|
const tenantScopedFilter = agentFilter
|
|
? { tenantId: session.tenantId, role: { code: RoleCode.AGENT } }
|
|
: { tenantId: session.tenantId, role: { code: { in: [RoleCode.ADMIN_CLIENT, RoleCode.AGENT] } } };
|
|
|
|
const agents = await prisma.user.findMany({
|
|
where: tenantScopedFilter,
|
|
orderBy: [{ fullName: "asc" }]
|
|
});
|
|
|
|
const detail: InboxConversationDetail | null = fullConversation
|
|
? {
|
|
id: fullConversation.id,
|
|
tenantId: fullConversation.tenantId,
|
|
name: fullConversation.contact?.fullName ?? "Unknown Contact",
|
|
phone: fullConversation.contact?.phoneNumber ?? "-",
|
|
snippet: fullConversation.subject ?? fullConversation.messages[0]?.contentText ?? "No content",
|
|
time: formatRelativeDate(fullConversation.lastMessageAt),
|
|
status: mapConversationStatus(fullConversation.status),
|
|
assignee: fullConversation.assignedUser?.fullName ?? "Unassigned",
|
|
assigneeId: fullConversation.assignedUser?.id ?? null,
|
|
channel: fullConversation.channel?.channelName ?? "Unknown Channel",
|
|
tags: fullConversation.conversationTags
|
|
.map((item) => item.tag?.name)
|
|
.filter((tag): tag is string => Boolean(tag)),
|
|
priority: mapConversationPriority(fullConversation.priority),
|
|
contactId: fullConversation.contact?.id ?? fullConversation.contactId,
|
|
tagJson: JSON.stringify(fullConversation.conversationTags.map((item) => item.tag.name)),
|
|
messages: fullConversation.messages
|
|
.slice()
|
|
.reverse()
|
|
.map((message) => {
|
|
const from =
|
|
message.direction === MessageDirection.OUTBOUND
|
|
? message.sentByUser?.fullName ?? "Agent"
|
|
: fullConversation.contact.fullName;
|
|
return {
|
|
id: message.id,
|
|
body: message.contentText ?? "",
|
|
direction: message.direction === MessageDirection.INBOUND ? "INBOUND" : "OUTBOUND",
|
|
from,
|
|
at: formatDateTime(message.createdAt)
|
|
};
|
|
}),
|
|
notes: fullConversation.notes.map((note) => ({
|
|
id: note.id,
|
|
content: note.content,
|
|
by: note.user?.fullName ?? "System",
|
|
at: formatDateTime(note.createdAt)
|
|
}))
|
|
}
|
|
: null;
|
|
|
|
if (!detail) {
|
|
return {
|
|
conversations: mapped,
|
|
selectedConversation: null,
|
|
canSelfAssign: role === "agent",
|
|
role,
|
|
agents: [],
|
|
filter,
|
|
defaultPath: scope === "admin" ? "/inbox" : "/agent/inbox"
|
|
};
|
|
}
|
|
|
|
return {
|
|
conversations: mapped,
|
|
selectedConversation: detail,
|
|
canSelfAssign: role === "agent",
|
|
role,
|
|
agents: agents.map((agent) => ({ id: agent.id, name: agent.fullName })),
|
|
filter,
|
|
defaultPath: scope === "admin" ? "/inbox" : "/agent/inbox"
|
|
};
|
|
}
|
|
|
|
export async function getAgentMentionedConversations() {
|
|
const { session, role } = await requireInboxActor("inbox:read");
|
|
if (role !== "agent") {
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
const needles = mentionNeedles(session.fullName);
|
|
const tenantConversations = await prisma.conversation.findMany({
|
|
where: {
|
|
tenantId: session.tenantId,
|
|
OR: [
|
|
{
|
|
messages: {
|
|
some: {
|
|
contentText: {
|
|
contains: needles[0]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
messages: {
|
|
some: {
|
|
contentText: {
|
|
contains: needles[1] ?? ""
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
notes: {
|
|
some: {
|
|
content: {
|
|
contains: needles[0]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
notes: {
|
|
some: {
|
|
content: {
|
|
contains: needles[1] ?? ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
},
|
|
include: {
|
|
contact: true,
|
|
messages: {
|
|
include: {
|
|
sentByUser: true,
|
|
contact: true
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: 15
|
|
},
|
|
notes: {
|
|
include: { user: true },
|
|
orderBy: { createdAt: "desc" },
|
|
take: 15
|
|
}
|
|
},
|
|
orderBy: { lastMessageAt: "desc" }
|
|
});
|
|
|
|
const mapped = tenantConversations.map((conversation) => {
|
|
const noteHit = conversation.notes.find((note) => textContainsAny(note.content, needles));
|
|
const messageHit = conversation.messages.find((message) => textContainsAny(message.contentText ?? "", needles));
|
|
|
|
let mentionedBy = "System";
|
|
let mentionTime: Date | null = conversation.lastMessageAt;
|
|
|
|
if (noteHit) {
|
|
mentionedBy = noteHit.user?.fullName ?? "System";
|
|
mentionTime = noteHit.createdAt;
|
|
} else if (messageHit) {
|
|
mentionedBy =
|
|
messageHit.direction === MessageDirection.INBOUND
|
|
? conversation.contact.fullName
|
|
: messageHit.sentByUser?.fullName ?? "Agent";
|
|
mentionTime = messageHit.createdAt;
|
|
}
|
|
|
|
return {
|
|
id: conversation.id,
|
|
contactName: conversation.contact.fullName,
|
|
mentionedBy,
|
|
time: formatDateTime(mentionTime),
|
|
snippet: conversation.messages[0]?.contentText ?? conversation.subject ?? "No content"
|
|
} satisfies MentionedConversationRecord;
|
|
});
|
|
|
|
return mapped;
|
|
}
|
|
|
|
export async function getAgentResolvedHistory() {
|
|
const { session, role } = await requireInboxActor("inbox:read");
|
|
if (role !== "agent") {
|
|
redirect("/unauthorized");
|
|
}
|
|
|
|
const conversations = await prisma.conversation.findMany({
|
|
where: {
|
|
tenantId: session.tenantId,
|
|
assignedUserId: session.userId,
|
|
status: ConversationStatus.RESOLVED
|
|
},
|
|
include: {
|
|
contact: true,
|
|
activities: {
|
|
orderBy: { createdAt: "desc" },
|
|
take: 1
|
|
}
|
|
},
|
|
orderBy: { resolvedAt: "desc" }
|
|
});
|
|
|
|
const mapLastAction = (activities: Array<{ activityType: string }>) => {
|
|
const lastActivity = activities[0]?.activityType;
|
|
if (lastActivity === "MESSAGE_SENT") {
|
|
return "Reply sent";
|
|
}
|
|
|
|
if (lastActivity === "STATUS_UPDATED") {
|
|
return "Status updated";
|
|
}
|
|
|
|
if (lastActivity === "ASSIGNED") {
|
|
return "Assigned";
|
|
}
|
|
|
|
return lastActivity ? `Last action: ${lastActivity}` : "No action";
|
|
};
|
|
|
|
return conversations.map((conversation) => ({
|
|
id: conversation.id,
|
|
contactName: conversation.contact.fullName,
|
|
resolvedAt: formatDateTime(conversation.resolvedAt),
|
|
lastAction: mapLastAction(conversation.activities)
|
|
}) satisfies ResolvedConversationRecord);
|
|
}
|
|
|
|
export async function assignConversation(formData: FormData) {
|
|
const { session, role } = await requireInboxActor("inbox:assign");
|
|
const conversationId = formValue(formData, "conversationId");
|
|
const assigneeId = formValue(formData, "assigneeId");
|
|
const nextPath = formValue(formData, "nextPath", role === "admin" ? "/inbox" : "/agent/inbox");
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
if (!conversationId) {
|
|
redirect(nextPath);
|
|
}
|
|
|
|
const conversation = await prisma.conversation.findFirst({
|
|
where: { id: conversationId, tenantId: session.tenantId }
|
|
});
|
|
if (!conversation) {
|
|
redirect(`${nextPath}?error=conversation_not_found`);
|
|
}
|
|
|
|
let normalizedAssignee: string | null = assigneeId || null;
|
|
if (role === "agent") {
|
|
normalizedAssignee = session.userId;
|
|
} else if (assigneeId) {
|
|
const assignee = await prisma.user.findFirst({
|
|
where: {
|
|
id: assigneeId,
|
|
tenantId: session.tenantId,
|
|
role: { code: RoleCode.AGENT }
|
|
}
|
|
});
|
|
|
|
if (!assignee) {
|
|
redirect(`${nextPath}?error=invalid_agent`);
|
|
}
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.conversation.update({
|
|
where: { id: conversationId },
|
|
data: { assignedUserId: normalizedAssignee }
|
|
}),
|
|
prisma.conversationActivity.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
actorUserId: session.userId,
|
|
activityType: "ASSIGNED",
|
|
metadataJson: JSON.stringify({
|
|
assigneeId: normalizedAssignee ?? null
|
|
})
|
|
}
|
|
})
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "conversation",
|
|
entityId: conversationId,
|
|
action: role === "agent" ? "self_assign_conversation" : "assign_conversation",
|
|
metadata: {
|
|
assigneeId: normalizedAssignee,
|
|
previousAssigneeId: conversation.assignedUserId
|
|
}
|
|
});
|
|
|
|
revalidatePath("/inbox");
|
|
revalidatePath("/agent/inbox");
|
|
redirect(`${nextPath}?conversationId=${conversationId}`);
|
|
}
|
|
|
|
export async function updateConversationStatus(formData: FormData) {
|
|
const { session, role } = await requireInboxActor("inbox:status");
|
|
const conversationId = formValue(formData, "conversationId");
|
|
const statusRaw = formValue(formData, "status");
|
|
const nextPath = formValue(formData, "nextPath", role === "admin" ? "/inbox" : "/agent/inbox");
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
const validStatuses = [
|
|
ConversationStatus.OPEN,
|
|
ConversationStatus.PENDING,
|
|
ConversationStatus.RESOLVED,
|
|
ConversationStatus.ARCHIVED,
|
|
ConversationStatus.SPAM
|
|
];
|
|
if (!conversationId || !validStatuses.includes(statusRaw as ConversationStatus)) {
|
|
redirect(`${nextPath}?error=invalid_status`);
|
|
}
|
|
|
|
const status = statusRaw as ConversationStatus;
|
|
const conversation = await prisma.conversation.findFirst({
|
|
where: { id: conversationId, tenantId: session.tenantId }
|
|
});
|
|
if (!conversation) {
|
|
redirect(`${nextPath}?error=conversation_not_found`);
|
|
}
|
|
|
|
await prisma.$transaction([
|
|
prisma.conversation.update({
|
|
where: { id: conversationId },
|
|
data: {
|
|
status,
|
|
resolvedAt: status === ConversationStatus.RESOLVED ? new Date() : status === ConversationStatus.OPEN ? null : conversation.resolvedAt
|
|
}
|
|
}),
|
|
prisma.conversationActivity.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
actorUserId: session.userId,
|
|
activityType: "STATUS_UPDATED",
|
|
metadataJson: JSON.stringify({
|
|
from: mapStatusForSelect(conversation.status),
|
|
to: mapStatusForSelect(status)
|
|
})
|
|
}
|
|
})
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "conversation",
|
|
entityId: conversationId,
|
|
action: "update_conversation_status",
|
|
metadata: {
|
|
from: mapStatusForSelect(conversation.status),
|
|
to: mapStatusForSelect(status)
|
|
}
|
|
});
|
|
|
|
revalidatePath("/inbox");
|
|
revalidatePath("/agent/inbox");
|
|
redirect(`${nextPath}?conversationId=${conversationId}`);
|
|
}
|
|
|
|
export async function saveConversationNote(formData: FormData) {
|
|
const { session, role } = await requireInboxActor("inbox:notes");
|
|
const conversationId = formValue(formData, "conversationId");
|
|
const note = formValue(formData, "note");
|
|
const nextPath = formValue(formData, "nextPath", role === "admin" ? "/inbox" : "/agent/inbox");
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
if (!conversationId || !note) {
|
|
redirect(`${nextPath}${conversationId ? `?conversationId=${conversationId}` : ""}`);
|
|
}
|
|
|
|
const conversation = await prisma.conversation.findFirst({
|
|
where: { id: conversationId, tenantId: session.tenantId }
|
|
});
|
|
if (!conversation) {
|
|
redirect(`${nextPath}?error=conversation_not_found`);
|
|
}
|
|
|
|
await prisma.conversationNote.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
userId: session.userId,
|
|
content: note
|
|
}
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "conversation",
|
|
entityId: conversationId,
|
|
action: "add_conversation_note",
|
|
metadata: {
|
|
note
|
|
}
|
|
});
|
|
|
|
revalidatePath("/inbox");
|
|
revalidatePath("/agent/inbox");
|
|
redirect(`${nextPath}?conversationId=${conversationId}`);
|
|
}
|
|
|
|
export async function sendConversationReply(formData: FormData) {
|
|
const { session, role } = await requireInboxActor("inbox:reply");
|
|
const conversationId = formValue(formData, "conversationId");
|
|
const content = formValue(formData, "content");
|
|
const nextPath = formValue(formData, "nextPath", role === "admin" ? "/inbox" : "/agent/inbox");
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
if (!conversationId || !content) {
|
|
redirect(`${nextPath}${conversationId ? `?conversationId=${conversationId}` : ""}`);
|
|
}
|
|
|
|
const conversation = await prisma.conversation.findUnique({
|
|
where: { id: conversationId },
|
|
include: { contact: true, channel: true }
|
|
});
|
|
if (!conversation || conversation.tenantId !== session.tenantId) {
|
|
redirect(`${nextPath}?error=conversation_not_found`);
|
|
}
|
|
|
|
if (!conversation.contactId || conversation.assignedUserId !== session.userId && role === "agent") {
|
|
if (role === "agent") {
|
|
redirect(`${nextPath}?conversationId=${conversationId}&error=not_assigned`);
|
|
}
|
|
}
|
|
|
|
const now = new Date();
|
|
|
|
const createdMessage = await prisma.message.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
channelId: conversation.channelId,
|
|
contactId: conversation.contactId,
|
|
direction: MessageDirection.OUTBOUND,
|
|
type: "TEXT" as Prisma.MessageCreateManyInput["type"],
|
|
contentText: content,
|
|
deliveryStatus: DeliveryStatus.QUEUED,
|
|
sentByUserId: session.userId,
|
|
sentAt: now
|
|
}
|
|
});
|
|
|
|
const outboundResult = await sendOutboundTextMessage({
|
|
tenantId: session.tenantId,
|
|
channelId: conversation.channelId,
|
|
channelProvider: conversation.channel.provider,
|
|
phoneNumberId: conversation.channel.phoneNumberId,
|
|
to: conversation.contact.phoneNumber,
|
|
content,
|
|
messageId: createdMessage.id
|
|
});
|
|
|
|
const metadata = {
|
|
snippet: content.slice(0, 120),
|
|
provider: outboundResult.provider,
|
|
status: outboundResult.deliveryStatus
|
|
} as const;
|
|
|
|
const shouldMarkChannelHealthy = outboundResult.success;
|
|
|
|
await prisma.$transaction([
|
|
prisma.message.update({
|
|
where: { id: createdMessage.id },
|
|
data: {
|
|
providerMessageId: outboundResult.providerMessageId,
|
|
deliveryStatus: outboundResult.deliveryStatus,
|
|
failedReason: outboundResult.failureReason
|
|
}
|
|
}),
|
|
prisma.conversation.update({
|
|
where: { id: conversationId },
|
|
data: {
|
|
lastMessageAt: now,
|
|
lastOutboundAt: now,
|
|
status: ConversationStatus.OPEN
|
|
}
|
|
}),
|
|
prisma.conversationActivity.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
actorUserId: session.userId,
|
|
activityType: "MESSAGE_SENT",
|
|
metadataJson: JSON.stringify(metadata)
|
|
}
|
|
}),
|
|
prisma.webhookEvent.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
channelId: conversation.channelId,
|
|
eventType: "message.send_request",
|
|
providerEventId: createdMessage.id,
|
|
payloadJson: JSON.stringify({
|
|
messageId: createdMessage.id,
|
|
conversationId,
|
|
contactPhone: conversation.contact.phoneNumber,
|
|
provider: outboundResult.provider,
|
|
failureReason: outboundResult.failureReason ?? null
|
|
}),
|
|
processStatus: outboundResult.success ? "processed" : "failed",
|
|
failedReason: outboundResult.failureReason ?? null,
|
|
}
|
|
}),
|
|
prisma.channel.update({
|
|
where: { id: conversation.channelId },
|
|
data: {
|
|
webhookStatus: shouldMarkChannelHealthy ? "healthy" : "error",
|
|
lastSyncAt: now
|
|
}
|
|
})
|
|
]);
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "conversation",
|
|
entityId: conversationId,
|
|
action: "reply_conversation",
|
|
metadata: {
|
|
messageId: createdMessage.id,
|
|
replyLength: content.length,
|
|
channelId: conversation.channelId,
|
|
deliveryStatus: outboundResult.deliveryStatus,
|
|
failureReason: outboundResult.failureReason ?? null
|
|
}
|
|
});
|
|
|
|
if (!outboundResult.success) {
|
|
redirect(`${nextPath}?conversationId=${conversationId}&error=reply_failed`);
|
|
}
|
|
|
|
revalidatePath("/inbox");
|
|
revalidatePath("/agent/inbox");
|
|
redirect(`${nextPath}?conversationId=${conversationId}`);
|
|
}
|
|
|
|
export async function updateConversationTags(formData: FormData) {
|
|
const { session, role } = await requireInboxActor("inbox:tags");
|
|
const conversationId = formValue(formData, "conversationId");
|
|
const tagsRaw = formValue(formData, "tags");
|
|
const nextPath = formValue(formData, "nextPath", role === "admin" ? "/inbox" : "/agent/inbox");
|
|
const auditContext = await getAuditContext(session);
|
|
|
|
if (!conversationId) {
|
|
redirect(`${nextPath}?error=invalid_conversation`);
|
|
}
|
|
|
|
const conversation = await prisma.conversation.findFirst({
|
|
where: { id: conversationId, tenantId: session.tenantId }
|
|
});
|
|
if (!conversation) {
|
|
redirect(`${nextPath}?error=conversation_not_found`);
|
|
}
|
|
|
|
const tags = tagsRaw
|
|
.split(",")
|
|
.map((tag) => tag.trim())
|
|
.filter(Boolean);
|
|
const dedupedTags = [...new Set(tags)];
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
await tx.conversationTag.deleteMany({
|
|
where: { conversationId }
|
|
});
|
|
|
|
for (const tagName of dedupedTags) {
|
|
const tag = await tx.tag.upsert({
|
|
where: {
|
|
tenantId_name: {
|
|
tenantId: session.tenantId,
|
|
name: tagName
|
|
}
|
|
},
|
|
create: {
|
|
tenantId: session.tenantId,
|
|
name: tagName
|
|
},
|
|
update: {}
|
|
});
|
|
|
|
await tx.conversationTag.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
tagId: tag.id
|
|
}
|
|
});
|
|
}
|
|
|
|
await tx.conversationActivity.create({
|
|
data: {
|
|
tenantId: session.tenantId,
|
|
conversationId,
|
|
actorUserId: session.userId,
|
|
activityType: "TAGS_UPDATED",
|
|
metadataJson: JSON.stringify({ tags })
|
|
}
|
|
});
|
|
});
|
|
|
|
await writeAuditTrail({
|
|
...auditContext,
|
|
entityType: "conversation",
|
|
entityId: conversationId,
|
|
action: "update_conversation_tags",
|
|
metadata: {
|
|
tags: dedupedTags
|
|
}
|
|
});
|
|
|
|
revalidatePath("/inbox");
|
|
revalidatePath("/agent/inbox");
|
|
redirect(`${nextPath}?conversationId=${conversationId}`);
|
|
}
|
|
|
|
export async function addConversationNote(formData: FormData) {
|
|
return saveConversationNote(formData);
|
|
}
|
|
|
|
export async function replyToConversation(formData: FormData) {
|
|
return sendConversationReply(formData);
|
|
}
|
|
|
|
export async function setConversationTags(formData: FormData) {
|
|
return updateConversationTags(formData);
|
|
}
|