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, 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, tags: conversation.conversationTags.map((item) => item.tag.name), 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, 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, tags: fullConversation.conversationTags.map((item) => item.tag.name), priority: mapConversationPriority(fullConversation.priority), contactId: fullConversation.contact.id, 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); }