414 lines
11 KiB
TypeScript
414 lines
11 KiB
TypeScript
import { CampaignStatus, ConversationPriority, ConversationStatus, RoleCode, TenantStatus } from "@prisma/client";
|
|
|
|
import { getSession } from "@/lib/auth";
|
|
import { prisma } from "@/lib/prisma";
|
|
|
|
export type DashboardStats = {
|
|
label: string;
|
|
value: string;
|
|
delta: string;
|
|
};
|
|
|
|
export type ConversationRecord = {
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
phone: string;
|
|
snippet: string;
|
|
time: string;
|
|
status: "Open" | "Pending" | "Resolved";
|
|
assignee: string;
|
|
channel: string;
|
|
tags: string[];
|
|
priority: "Normal" | "High";
|
|
};
|
|
|
|
export type ContactRecord = {
|
|
id: string;
|
|
tenantId: string;
|
|
fullName: string;
|
|
phone: string;
|
|
email: string;
|
|
tags: string[];
|
|
lastInteraction: string;
|
|
optInStatus: string;
|
|
};
|
|
|
|
export type UserRecord = {
|
|
id: string;
|
|
tenantId: string;
|
|
fullName: string;
|
|
email: string;
|
|
role: "Admin" | "Agent";
|
|
status: "Active" | "Invited";
|
|
lastLogin: string;
|
|
handled: number;
|
|
avgResponse: string;
|
|
};
|
|
|
|
export type CampaignRecord = {
|
|
id: string;
|
|
tenantId: string;
|
|
name: string;
|
|
channel: string;
|
|
audience: string;
|
|
status: "Draft" | "Processing" | "Completed";
|
|
scheduledAt: string;
|
|
delivered: number;
|
|
failed: number;
|
|
};
|
|
|
|
export type TenantRecord = {
|
|
id: string;
|
|
name: string;
|
|
status: "Active" | "Trial" | "Suspended" | "Inactive";
|
|
plan: string;
|
|
channels: string;
|
|
seats: string;
|
|
};
|
|
|
|
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 formatDate(date: Date | null | undefined) {
|
|
if (!date) {
|
|
return "-";
|
|
}
|
|
|
|
return new Intl.DateTimeFormat("id-ID", {
|
|
day: "2-digit",
|
|
month: "short",
|
|
year: "numeric"
|
|
}).format(date);
|
|
}
|
|
|
|
function titleCase(value: string) {
|
|
return value
|
|
.toLowerCase()
|
|
.split("_")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function mapConversationStatus(status: ConversationStatus): ConversationRecord["status"] {
|
|
if (status === ConversationStatus.PENDING) {
|
|
return "Pending";
|
|
}
|
|
|
|
if (status === ConversationStatus.RESOLVED) {
|
|
return "Resolved";
|
|
}
|
|
|
|
return "Open";
|
|
}
|
|
|
|
function mapConversationPriority(priority: ConversationPriority): ConversationRecord["priority"] {
|
|
return priority === ConversationPriority.HIGH || priority === ConversationPriority.URGENT ? "High" : "Normal";
|
|
}
|
|
|
|
function mapCampaignStatus(status: CampaignStatus): CampaignRecord["status"] {
|
|
if (status === CampaignStatus.COMPLETED) {
|
|
return "Completed";
|
|
}
|
|
|
|
if (status === CampaignStatus.PROCESSING || status === CampaignStatus.PARTIAL_FAILED) {
|
|
return "Processing";
|
|
}
|
|
|
|
return "Draft";
|
|
}
|
|
|
|
function mapTenantStatus(status: TenantStatus): TenantRecord["status"] {
|
|
switch (status) {
|
|
case TenantStatus.ACTIVE:
|
|
return "Active";
|
|
case TenantStatus.SUSPENDED:
|
|
return "Suspended";
|
|
case TenantStatus.INACTIVE:
|
|
return "Inactive";
|
|
case TenantStatus.TRIAL:
|
|
default:
|
|
return "Trial";
|
|
}
|
|
}
|
|
|
|
async function getTenantScopedWhere() {
|
|
const session = await getSession();
|
|
|
|
if (!session?.tenantId || session.role === "super_admin") {
|
|
return {};
|
|
}
|
|
|
|
return { tenantId: session.tenantId };
|
|
}
|
|
|
|
export async function getDashboardData() {
|
|
const where = await getTenantScopedWhere();
|
|
|
|
const [openConversations, waitingReply, resolvedToday, deliveredMessages, totalOutbound, priorityQueue] =
|
|
await Promise.all([
|
|
prisma.conversation.count({ where: { ...where, status: ConversationStatus.OPEN } }),
|
|
prisma.conversation.count({ where: { ...where, status: ConversationStatus.PENDING } }),
|
|
prisma.conversation.count({
|
|
where: {
|
|
...where,
|
|
status: ConversationStatus.RESOLVED,
|
|
resolvedAt: {
|
|
gte: new Date(new Date().setHours(0, 0, 0, 0))
|
|
}
|
|
}
|
|
}),
|
|
prisma.message.count({ where: { ...where, direction: "OUTBOUND", deliveryStatus: "DELIVERED" } }),
|
|
prisma.message.count({ where: { ...where, direction: "OUTBOUND" } }),
|
|
prisma.conversation.findMany({
|
|
where,
|
|
orderBy: [{ priority: "desc" }, { lastMessageAt: "desc" }],
|
|
take: 3,
|
|
include: {
|
|
contact: true,
|
|
channel: true,
|
|
assignedUser: true,
|
|
conversationTags: { include: { tag: true } },
|
|
messages: {
|
|
take: 1,
|
|
orderBy: { createdAt: "desc" }
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
const successRate = totalOutbound > 0 ? `${((deliveredMessages / totalOutbound) * 100).toFixed(1)}%` : "0%";
|
|
|
|
return {
|
|
stats: [
|
|
{ label: "Open conversations", value: String(openConversations), delta: "Live" },
|
|
{ label: "Waiting reply", value: String(waitingReply), delta: "Live" },
|
|
{ label: "Resolved today", value: String(resolvedToday), delta: "Today" },
|
|
{ label: "Delivery success", value: successRate, delta: "Outbound" }
|
|
] satisfies DashboardStats[],
|
|
priorityQueue: priorityQueue.map((conversation) => ({
|
|
id: conversation.id,
|
|
tenantId: conversation.tenantId,
|
|
name: conversation.contact.fullName,
|
|
phone: conversation.contact.phoneNumber,
|
|
snippet: conversation.messages[0]?.contentText ?? conversation.subject ?? "No recent message",
|
|
time: formatRelativeDate(conversation.lastMessageAt),
|
|
status: mapConversationStatus(conversation.status),
|
|
assignee: conversation.assignedUser?.fullName ?? "Unassigned",
|
|
channel: conversation.channel.channelName,
|
|
tags: conversation.conversationTags.map((item) => item.tag.name),
|
|
priority: mapConversationPriority(conversation.priority)
|
|
}))
|
|
};
|
|
}
|
|
|
|
export async function getInboxData() {
|
|
const where = await getTenantScopedWhere();
|
|
|
|
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) => ({
|
|
id: conversation.id,
|
|
tenantId: conversation.tenantId,
|
|
name: conversation.contact.fullName,
|
|
phone: conversation.contact.phoneNumber,
|
|
snippet: conversation.messages[0]?.contentText ?? conversation.subject ?? "No recent message",
|
|
time: formatRelativeDate(conversation.lastMessageAt),
|
|
status: mapConversationStatus(conversation.status),
|
|
assignee: conversation.assignedUser?.fullName ?? "Unassigned",
|
|
channel: conversation.channel.channelName,
|
|
tags: conversation.conversationTags.map((item) => item.tag.name),
|
|
priority: mapConversationPriority(conversation.priority)
|
|
})) satisfies ConversationRecord[];
|
|
|
|
return {
|
|
conversations: mapped,
|
|
selectedConversation: mapped[0]
|
|
};
|
|
}
|
|
|
|
export async function getContactsData() {
|
|
const where = await getTenantScopedWhere();
|
|
|
|
const contacts = await prisma.contact.findMany({
|
|
where,
|
|
orderBy: { lastInteractionAt: "desc" },
|
|
include: {
|
|
contactTags: {
|
|
include: { tag: true }
|
|
}
|
|
}
|
|
});
|
|
|
|
return contacts.map((contact) => ({
|
|
id: contact.id,
|
|
tenantId: contact.tenantId,
|
|
fullName: contact.fullName,
|
|
phone: contact.phoneNumber,
|
|
email: contact.email ?? "-",
|
|
tags: contact.contactTags.map((item) => item.tag.name),
|
|
lastInteraction: formatRelativeDate(contact.lastInteractionAt),
|
|
optInStatus: titleCase(contact.optInStatus)
|
|
})) satisfies ContactRecord[];
|
|
}
|
|
|
|
export async function getTeamData() {
|
|
const where = await getTenantScopedWhere();
|
|
|
|
const users = await prisma.user.findMany({
|
|
where,
|
|
include: {
|
|
role: true,
|
|
assignedConversations: true
|
|
},
|
|
orderBy: [{ role: { code: "asc" } }, { fullName: "asc" }]
|
|
});
|
|
|
|
return users.map((user) => ({
|
|
id: user.id,
|
|
tenantId: user.tenantId,
|
|
fullName: user.fullName,
|
|
email: user.email,
|
|
role: user.role.code === RoleCode.ADMIN_CLIENT ? "Admin" : "Agent",
|
|
status: user.status === "ACTIVE" ? "Active" : "Invited",
|
|
lastLogin: formatRelativeDate(user.lastLoginAt),
|
|
handled: user.assignedConversations.length,
|
|
avgResponse: user.role.code === RoleCode.AGENT ? "3m 12s" : "-"
|
|
})) satisfies UserRecord[];
|
|
}
|
|
|
|
export async function getCampaignsData() {
|
|
const where = await getTenantScopedWhere();
|
|
|
|
const campaigns = await prisma.broadcastCampaign.findMany({
|
|
where,
|
|
include: {
|
|
channel: true,
|
|
segment: true
|
|
},
|
|
orderBy: [{ createdAt: "desc" }]
|
|
});
|
|
|
|
return campaigns.map((campaign) => ({
|
|
id: campaign.id,
|
|
tenantId: campaign.tenantId,
|
|
name: campaign.name,
|
|
channel: campaign.channel.channelName,
|
|
audience: campaign.segment ? `Segment: ${campaign.segment.name}` : titleCase(campaign.audienceType),
|
|
status: mapCampaignStatus(campaign.status),
|
|
scheduledAt: formatDate(campaign.scheduledAt),
|
|
delivered: campaign.totalDelivered,
|
|
failed: campaign.totalFailed
|
|
})) satisfies CampaignRecord[];
|
|
}
|
|
|
|
export async function getTenantAdminSummary() {
|
|
const where = await getTenantScopedWhere();
|
|
const [contactCount, campaignCount, activeAgents] = await Promise.all([
|
|
prisma.contact.count({ where }),
|
|
prisma.broadcastCampaign.count({ where }),
|
|
prisma.user.count({
|
|
where: {
|
|
...where,
|
|
role: { code: RoleCode.AGENT }
|
|
}
|
|
})
|
|
]);
|
|
|
|
return {
|
|
contactCount,
|
|
campaignCount,
|
|
activeAgents
|
|
};
|
|
}
|
|
|
|
export async function getPlatformSummary() {
|
|
const [tenantCount, connectedChannels, failedWebhooks, tenants] = await Promise.all([
|
|
prisma.tenant.count(),
|
|
prisma.channel.count({ where: { status: "CONNECTED" } }),
|
|
prisma.webhookEvent.count({ where: { processStatus: "failed" } }),
|
|
prisma.tenant.findMany({
|
|
include: {
|
|
plan: true,
|
|
channels: true,
|
|
users: true
|
|
},
|
|
orderBy: { createdAt: "asc" }
|
|
})
|
|
]);
|
|
|
|
return {
|
|
stats: [
|
|
{ label: "Active tenants", value: String(tenantCount), delta: "Global" },
|
|
{ label: "Connected channels", value: String(connectedChannels), delta: "Healthy" },
|
|
{ label: "Webhook failures", value: String(failedWebhooks), delta: "Watch" },
|
|
{ label: "Tracked seats", value: String(tenants.reduce((sum, tenant) => sum + tenant.users.length, 0)), delta: "Users" }
|
|
] satisfies DashboardStats[],
|
|
tenants: tenants.map((tenant) => ({
|
|
id: tenant.id,
|
|
name: tenant.name,
|
|
status: mapTenantStatus(tenant.status),
|
|
plan: tenant.plan.name,
|
|
channels: `${tenant.channels.filter((channel) => channel.status === "CONNECTED").length} connected`,
|
|
seats: `${tenant.users.length}/${tenant.plan.seatQuota}`
|
|
})) satisfies TenantRecord[]
|
|
};
|
|
}
|
|
|
|
export async function getTenantsData() {
|
|
const tenants = await prisma.tenant.findMany({
|
|
include: {
|
|
plan: true,
|
|
channels: true,
|
|
users: true
|
|
},
|
|
orderBy: { createdAt: "asc" }
|
|
});
|
|
|
|
return tenants.map((tenant) => ({
|
|
id: tenant.id,
|
|
name: tenant.name,
|
|
status: mapTenantStatus(tenant.status),
|
|
plan: tenant.plan.name,
|
|
channels: `${tenant.channels.filter((channel) => channel.status === "CONNECTED").length} connected`,
|
|
seats: `${tenant.users.length}/${tenant.plan.seatQuota}`
|
|
})) satisfies TenantRecord[];
|
|
}
|