Files
whatsapp-inbox-platform/lib/inbox-ops.ts
wirabasalamah 137edc12b7
Some checks are pending
CI - Production Readiness / Verify (push) Waiting to run
fix: lates
2026-04-21 20:37:59 +07:00

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);
}