feat: pagina Conversazioni — inbox unificata messaggi clienti
Nuova pagina admin /admin/conversazioni: vista WhatsApp-style (lista conversazioni a sinistra, thread + risposta a destra) che aggrega i messaggi di tutti i clienti dalla tabella comments, cross-cliente. - Migration additiva 0014: clients.admin_last_read_at per tracciare letto/non-letto (pallini + badge in sidebar). Applicata a prod. - conversations-queries.ts: aggregazione entity_id → cliente (general/phase/task/deliverable), getConversations / getConversationThread / getUnreadConversationsCount. - Risposte admin salvate come commento "general" (visibili anche nella chat cliente e nel CommentsTab della scheda). - Voce di menu + badge non-letti (AdminSidebar/AdminShell/layout). - Token semantici per dual light/dark; refresh manuale (no polling). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { db } from "@/db";
|
||||
import { clients, comments } from "@/db/schema";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin reply from the Conversazioni inbox. Per project decision, replies are
|
||||
* saved as a "general" comment on the client (entity_id = clientId), so they
|
||||
* surface in the client's general chat and in the client detail CommentsTab.
|
||||
*/
|
||||
export async function replyToConversation(clientId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
const body = (formData.get("body") as string)?.trim();
|
||||
if (!clientId || !body) throw new Error("Dati mancanti");
|
||||
|
||||
// Validate the client exists (entity_id integrity).
|
||||
const rows = await db
|
||||
.select({ id: clients.id })
|
||||
.from(clients)
|
||||
.where(eq(clients.id, clientId))
|
||||
.limit(1);
|
||||
if (rows.length === 0) throw new Error("Cliente non trovato");
|
||||
|
||||
await db.insert(comments).values({
|
||||
entity_type: "general",
|
||||
entity_id: clientId,
|
||||
author: "admin",
|
||||
body,
|
||||
});
|
||||
|
||||
// Replying implies the admin has read the incoming messages.
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ admin_last_read_at: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
|
||||
revalidatePath("/admin/conversazioni");
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
/** Mark a client's conversation as read up to now (clears unread dot/badge). */
|
||||
export async function markConversationRead(clientId: string) {
|
||||
await requireAdmin();
|
||||
if (!clientId) return;
|
||||
await db
|
||||
.update(clients)
|
||||
.set({ admin_last_read_at: new Date() })
|
||||
.where(eq(clients.id, clientId));
|
||||
revalidatePath("/admin/conversazioni");
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
import { ConversationsView } from "@/components/admin/conversazioni/ConversationsView";
|
||||
import {
|
||||
getConversations,
|
||||
getConversationThread,
|
||||
} from "@/lib/conversations-queries";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function ConversazioniPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ c?: string }>;
|
||||
}) {
|
||||
const { c } = await searchParams;
|
||||
|
||||
const conversations = await getConversations();
|
||||
// Default selection: the requested client, else the most recent conversation.
|
||||
const activeClientId =
|
||||
(c && conversations.some((conv) => conv.clientId === c) ? c : null) ??
|
||||
conversations[0]?.clientId ??
|
||||
null;
|
||||
|
||||
const activeThread = activeClientId
|
||||
? await getConversationThread(activeClientId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Conversazioni"
|
||||
subtitle="Centro messaggi clienti — tutte le richieste in un unico posto"
|
||||
/>
|
||||
<ConversationsView
|
||||
conversations={conversations}
|
||||
activeThread={activeThread}
|
||||
activeClientId={activeClientId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AdminShell } from "@/components/admin/AdminShell";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { getUnreadConversationsCount } from "@/lib/conversations-queries";
|
||||
|
||||
export default async function AdminLayout({
|
||||
children,
|
||||
@@ -11,5 +12,6 @@ export default async function AdminLayout({
|
||||
if (!session) {
|
||||
return <div className="min-h-screen bg-background">{children}</div>;
|
||||
}
|
||||
return <AdminShell>{children}</AdminShell>;
|
||||
const unreadConversations = await getUnreadConversationsCount();
|
||||
return <AdminShell unreadConversations={unreadConversations}>{children}</AdminShell>;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ const SIDEBAR_STORAGE_KEY = "iamcavalli:sidebar";
|
||||
* (starts expanded on server/first paint to avoid a hydration mismatch,
|
||||
* then reconciles client-side — mirrors the pattern used by useTheme).
|
||||
*/
|
||||
export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
export function AdminShell({
|
||||
children,
|
||||
unreadConversations = 0,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
unreadConversations?: number;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -30,7 +36,7 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
<AdminSidebar collapsed={collapsed} />
|
||||
<AdminSidebar collapsed={collapsed} unreadConversations={unreadConversations} />
|
||||
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Top header bar */}
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
LogOut,
|
||||
Zap,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
import ThemeToggle from "@/components/ThemeToggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/admin", label: "Dashboard", icon: LayoutDashboard, exact: true },
|
||||
{ href: "/admin/conversazioni", label: "Conversazioni", icon: MessageSquare },
|
||||
{ href: "/admin/pipeline", label: "Pipeline", icon: Zap },
|
||||
{ href: "/admin/clients", label: "Clienti", icon: Users },
|
||||
{ href: "/admin/projects", label: "Progetti", icon: FolderOpen },
|
||||
@@ -28,7 +30,13 @@ const NAV_ITEMS = [
|
||||
{ href: "/admin/impostazioni", label: "Impostazioni", icon: Settings },
|
||||
];
|
||||
|
||||
export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
export function AdminSidebar({
|
||||
collapsed = false,
|
||||
unreadConversations = 0,
|
||||
}: {
|
||||
collapsed?: boolean;
|
||||
unreadConversations?: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isActive = (href: string, exact?: boolean) =>
|
||||
@@ -64,6 +72,10 @@ export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
<nav className="flex-1 px-2 py-4 flex flex-col gap-0.5">
|
||||
{NAV_ITEMS.map(({ href, label, icon: Icon, exact }) => {
|
||||
const active = isActive(href, exact);
|
||||
const badge =
|
||||
href === "/admin/conversazioni" && unreadConversations > 0
|
||||
? unreadConversations
|
||||
: 0;
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
@@ -77,12 +89,22 @@ export function AdminSidebar({ collapsed = false }: { collapsed?: boolean }) {
|
||||
: "text-white/65 hover:text-white hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
size={16}
|
||||
strokeWidth={1.8}
|
||||
className="shrink-0"
|
||||
/>
|
||||
{!collapsed && <span className="whitespace-nowrap">{label}</span>}
|
||||
<span className="relative shrink-0">
|
||||
<Icon size={16} strokeWidth={1.8} className="shrink-0" />
|
||||
{badge > 0 && collapsed && (
|
||||
<span className="absolute -top-1.5 -right-1.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-[#1A463C]" />
|
||||
)}
|
||||
</span>
|
||||
{!collapsed && (
|
||||
<span className="flex-1 flex items-center justify-between gap-2 min-w-0">
|
||||
<span className="whitespace-nowrap">{label}</span>
|
||||
{badge > 0 && (
|
||||
<span className="shrink-0 min-w-[18px] h-[18px] px-1.5 inline-flex items-center justify-center rounded-full bg-emerald-500 text-white text-[10px] font-bold">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||
import { SearchInput } from "@/components/ui/SearchInput";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
markConversationRead,
|
||||
replyToConversation,
|
||||
} from "@/app/admin/conversazioni/actions";
|
||||
import type {
|
||||
ConversationSummary,
|
||||
ConversationThread,
|
||||
} from "@/lib/conversations-queries";
|
||||
|
||||
type Props = {
|
||||
conversations: ConversationSummary[];
|
||||
activeThread: ConversationThread | null;
|
||||
activeClientId: string | null;
|
||||
};
|
||||
|
||||
export function ConversationsView({
|
||||
conversations,
|
||||
activeThread,
|
||||
activeClientId,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return conversations;
|
||||
return conversations.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(q) ||
|
||||
c.brand_name.toLowerCase().includes(q) ||
|
||||
c.lastMessage.toLowerCase().includes(q)
|
||||
);
|
||||
}, [conversations, query]);
|
||||
|
||||
const activeSummary = conversations.find((c) => c.clientId === activeClientId);
|
||||
|
||||
// Mark the active conversation as read when it is opened and still unread.
|
||||
// Guarded on `unread` so the refresh (which flips it to read) won't re-fire.
|
||||
useEffect(() => {
|
||||
if (!activeClientId || !activeSummary?.unread) return;
|
||||
(async () => {
|
||||
await markConversationRead(activeClientId);
|
||||
router.refresh();
|
||||
})();
|
||||
}, [activeClientId, activeSummary?.unread, router]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 h-[calc(100vh-15rem)] min-h-[520px]">
|
||||
{/* ── Left: conversation list ─────────────────────────────── */}
|
||||
<aside className="w-80 lg:w-96 shrink-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
|
||||
<div className="p-4 border-b border-border">
|
||||
<SearchInput
|
||||
placeholder="Cerca conversazione..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-border">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="p-6 text-sm text-muted-foreground text-center">
|
||||
Nessuna conversazione.
|
||||
</p>
|
||||
) : (
|
||||
filtered.map((c) => (
|
||||
<ConversationListItem
|
||||
key={c.clientId}
|
||||
conv={c}
|
||||
active={c.clientId === activeClientId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right: active thread ────────────────────────────────── */}
|
||||
<section className="flex-1 min-w-0 flex flex-col bg-card border border-border rounded-xl shadow-card overflow-hidden">
|
||||
{activeThread ? (
|
||||
<ActiveThread thread={activeThread} />
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
||||
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
||||
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationListItem({
|
||||
conv,
|
||||
active,
|
||||
}: {
|
||||
conv: ConversationSummary;
|
||||
active: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/conversazioni?c=${conv.clientId}`}
|
||||
className={cn(
|
||||
"block p-4 transition-colors hover:bg-muted/40",
|
||||
active && "bg-muted/60 border-l-2 border-primary",
|
||||
!active && "border-l-2 border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate",
|
||||
conv.unread ? "font-bold text-foreground" : "font-semibold text-foreground"
|
||||
)}
|
||||
>
|
||||
{conv.name}
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||
{formatListTime(conv.lastMessageAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||
{conv.lastMessageAuthor === "admin" && (
|
||||
<span className="text-muted-foreground/70">Tu: </span>
|
||||
)}
|
||||
{conv.lastMessage}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<EntityBadge label={conv.lastEntityLabel} />
|
||||
{conv.unread && (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
{conv.unreadCount > 1 && (
|
||||
<span className="text-[10px] font-semibold text-emerald-600 dark:text-emerald-400">
|
||||
{conv.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveThread({ thread }: { thread: ConversationThread }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const portalHref = `/client/${thread.slug ?? thread.token}`;
|
||||
|
||||
// Keep the thread pinned to the latest message.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [thread.clientId, thread.messages.length]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-border flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-bold text-foreground truncate">{thread.name}</h2>
|
||||
<p className="text-[11px] text-muted-foreground truncate">{thread.brand_name}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Link
|
||||
href={`/admin/clients/${thread.clientId}`}
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<IdCard className="w-3.5 h-3.5" /> Scheda
|
||||
</Link>
|
||||
<Link
|
||||
href={portalHref}
|
||||
target="_blank"
|
||||
className="inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Portale
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||
{thread.messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-10">
|
||||
Nessun messaggio in questa conversazione.
|
||||
</p>
|
||||
) : (
|
||||
thread.messages.map((m) => <MessageBubble key={m.id} message={m} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reply box */}
|
||||
<form
|
||||
action={replyToConversation.bind(null, thread.clientId)}
|
||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
||||
>
|
||||
<Textarea
|
||||
name="body"
|
||||
rows={2}
|
||||
required
|
||||
placeholder="Scrivi una risposta..."
|
||||
className="flex-1 resize-none"
|
||||
/>
|
||||
<Button type="submit" size="sm" className="shrink-0">
|
||||
Invia
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
}: {
|
||||
message: ConversationThread["messages"][number];
|
||||
}) {
|
||||
const isAdmin = message.author === "admin";
|
||||
const showEntity = message.entityType !== "general";
|
||||
return (
|
||||
<div className={cn("flex", isAdmin && "justify-end")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
|
||||
isAdmin
|
||||
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||
: "bg-background border border-border text-foreground rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-bold",
|
||||
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{isAdmin ? "Tu (Admin)" : "Cliente"}
|
||||
</span>
|
||||
{showEntity && <EntityBadge label={message.entityLabel} />}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[9px] font-mono",
|
||||
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
|
||||
)}
|
||||
>
|
||||
{formatMessageTime(message.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-block text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-400 truncate max-w-[140px]">
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Date helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function formatListTime(date: Date): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const sameDay = d.toDateString() === now.toDateString();
|
||||
if (sameDay) {
|
||||
return d.toLocaleTimeString("it-IT", { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(now.getDate() - 1);
|
||||
if (d.toDateString() === yesterday.toDateString()) return "Ieri";
|
||||
return d.toLocaleDateString("it-IT", { day: "numeric", month: "short" });
|
||||
}
|
||||
|
||||
function formatMessageTime(date: Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString("it-IT", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Additive: track when the admin last read a client's conversation (Conversazioni inbox).
|
||||
-- A conversation is "unread" when the latest client-authored comment created_at is
|
||||
-- greater than admin_last_read_at (or admin_last_read_at IS NULL).
|
||||
-- nullable — existing clients keep NULL (treated as fully unread until first open).
|
||||
-- Apply to prod via SSH+docker exec BEFORE pushing schema-dependent code.
|
||||
-- No drops, no truncates, no data loss.
|
||||
|
||||
ALTER TABLE clients ADD COLUMN IF NOT EXISTS admin_last_read_at timestamptz;
|
||||
@@ -37,6 +37,9 @@ export const clients = pgTable("clients", {
|
||||
"0"
|
||||
),
|
||||
archived: boolean("archived").notNull().default(false),
|
||||
// Conversazioni inbox: timestamp of the admin's last read of this client's
|
||||
// conversation. NULL = never read (treated as unread). Set via markConversationRead.
|
||||
admin_last_read_at: timestamp("admin_last_read_at", { withTimezone: true }),
|
||||
created_at: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { db } from "@/db";
|
||||
import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema";
|
||||
import { eq, inArray, asc } from "drizzle-orm";
|
||||
import type { Comment } from "@/db/schema";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type ConversationSummary = {
|
||||
clientId: string;
|
||||
name: string;
|
||||
brand_name: string;
|
||||
token: string;
|
||||
slug: string | null;
|
||||
lastMessage: string;
|
||||
lastMessageAuthor: "client" | "admin";
|
||||
lastMessageAt: Date;
|
||||
lastEntityLabel: string;
|
||||
unread: boolean;
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
export type ThreadMessage = {
|
||||
id: string;
|
||||
author: "client" | "admin";
|
||||
body: string;
|
||||
created_at: Date;
|
||||
entityLabel: string;
|
||||
entityType: string;
|
||||
};
|
||||
|
||||
export type ConversationThread = {
|
||||
clientId: string;
|
||||
name: string;
|
||||
brand_name: string;
|
||||
token: string;
|
||||
slug: string | null;
|
||||
messages: ThreadMessage[];
|
||||
};
|
||||
|
||||
type ClientMeta = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand_name: string;
|
||||
token: string;
|
||||
slug: string | null;
|
||||
admin_last_read_at: Date | null;
|
||||
};
|
||||
|
||||
type EntityInfo = { clientId: string; label: string; type: string };
|
||||
|
||||
// ── Shared aggregation ──────────────────────────────────────────────────────────
|
||||
// Builds the entity_id → owning client map by walking
|
||||
// clients → projects → phases → tasks → deliverables, then groups every comment
|
||||
// under its owning client. Same pattern as admin-queries.getClientFullDetail,
|
||||
// but across ALL non-archived clients at once (cross-client inbox).
|
||||
|
||||
async function buildEntityMap(): Promise<{
|
||||
clientMeta: Map<string, ClientMeta>;
|
||||
entityMap: Map<string, EntityInfo>;
|
||||
allEntityIds: string[];
|
||||
}> {
|
||||
const clientMeta = new Map<string, ClientMeta>();
|
||||
const entityMap = new Map<string, EntityInfo>();
|
||||
|
||||
const clientRows = await db
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.archived, false));
|
||||
|
||||
for (const c of clientRows) {
|
||||
clientMeta.set(c.id, {
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
brand_name: c.brand_name,
|
||||
token: c.token,
|
||||
slug: c.slug,
|
||||
admin_last_read_at: c.admin_last_read_at,
|
||||
});
|
||||
// A client's own id is the entity_id for "general" messages.
|
||||
entityMap.set(c.id, { clientId: c.id, label: "Generale", type: "general" });
|
||||
}
|
||||
|
||||
const clientIds = clientRows.map((c) => c.id);
|
||||
if (clientIds.length === 0) {
|
||||
return { clientMeta, entityMap, allEntityIds: [] };
|
||||
}
|
||||
|
||||
const projectRows = await db
|
||||
.select({ id: projects.id, client_id: projects.client_id })
|
||||
.from(projects)
|
||||
.where(inArray(projects.client_id, clientIds));
|
||||
const projectToClient = new Map(projectRows.map((p) => [p.id, p.client_id]));
|
||||
const projectIds = projectRows.map((p) => p.id);
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const phaseRows = await db
|
||||
.select({ id: phases.id, project_id: phases.project_id, title: phases.title })
|
||||
.from(phases)
|
||||
.where(inArray(phases.project_id, projectIds));
|
||||
const phaseToClient = new Map<string, string>();
|
||||
for (const ph of phaseRows) {
|
||||
const clientId = projectToClient.get(ph.project_id);
|
||||
if (!clientId) continue;
|
||||
phaseToClient.set(ph.id, clientId);
|
||||
entityMap.set(ph.id, { clientId, label: `Fase: ${ph.title}`, type: "phase" });
|
||||
}
|
||||
|
||||
const phaseIds = phaseRows.map((p) => p.id);
|
||||
if (phaseIds.length > 0) {
|
||||
const taskRows = await db
|
||||
.select({ id: tasks.id, phase_id: tasks.phase_id, title: tasks.title })
|
||||
.from(tasks)
|
||||
.where(inArray(tasks.phase_id, phaseIds));
|
||||
const taskToClient = new Map<string, string>();
|
||||
for (const t of taskRows) {
|
||||
const clientId = phaseToClient.get(t.phase_id);
|
||||
if (!clientId) continue;
|
||||
taskToClient.set(t.id, clientId);
|
||||
entityMap.set(t.id, { clientId, label: `Task: ${t.title}`, type: "task" });
|
||||
}
|
||||
|
||||
const taskIds = taskRows.map((t) => t.id);
|
||||
if (taskIds.length > 0) {
|
||||
const delivRows = await db
|
||||
.select({ id: deliverables.id, task_id: deliverables.task_id, title: deliverables.title })
|
||||
.from(deliverables)
|
||||
.where(inArray(deliverables.task_id, taskIds));
|
||||
for (const d of delivRows) {
|
||||
const clientId = taskToClient.get(d.task_id);
|
||||
if (!clientId) continue;
|
||||
entityMap.set(d.id, {
|
||||
clientId,
|
||||
label: `Deliverable: ${d.title}`,
|
||||
type: "deliverable",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()] };
|
||||
}
|
||||
|
||||
// Groups comments (already sorted asc) by owning client id.
|
||||
function groupCommentsByClient(
|
||||
commentRows: Comment[],
|
||||
entityMap: Map<string, EntityInfo>
|
||||
): Map<string, Comment[]> {
|
||||
const byClient = new Map<string, Comment[]>();
|
||||
for (const c of commentRows) {
|
||||
const info = entityMap.get(c.entity_id);
|
||||
if (!info) continue; // orphaned comment (deleted entity) — skip
|
||||
const arr = byClient.get(info.clientId) ?? [];
|
||||
arr.push(c);
|
||||
byClient.set(info.clientId, arr);
|
||||
}
|
||||
return byClient;
|
||||
}
|
||||
|
||||
async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
|
||||
if (allEntityIds.length === 0) return [];
|
||||
return db
|
||||
.select()
|
||||
.from(comments)
|
||||
.where(inArray(comments.entity_id, allEntityIds))
|
||||
.orderBy(asc(comments.created_at));
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One entry per non-archived client that has at least one comment, newest first. */
|
||||
export async function getConversations(): Promise<ConversationSummary[]> {
|
||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
||||
const commentRows = await fetchComments(allEntityIds);
|
||||
const byClient = groupCommentsByClient(commentRows, entityMap);
|
||||
|
||||
const summaries: ConversationSummary[] = [];
|
||||
for (const [clientId, msgs] of byClient) {
|
||||
const meta = clientMeta.get(clientId);
|
||||
if (!meta || msgs.length === 0) continue;
|
||||
|
||||
const last = msgs[msgs.length - 1];
|
||||
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
|
||||
const clientMsgsAfterRead = msgs.filter(
|
||||
(m) => m.author === "client" && m.created_at.getTime() > lastReadAt
|
||||
);
|
||||
|
||||
summaries.push({
|
||||
clientId,
|
||||
name: meta.name,
|
||||
brand_name: meta.brand_name,
|
||||
token: meta.token,
|
||||
slug: meta.slug,
|
||||
lastMessage: last.body,
|
||||
lastMessageAuthor: last.author as "client" | "admin",
|
||||
lastMessageAt: last.created_at,
|
||||
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
|
||||
unread: clientMsgsAfterRead.length > 0,
|
||||
unreadCount: clientMsgsAfterRead.length,
|
||||
});
|
||||
}
|
||||
|
||||
summaries.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime());
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/** Full ordered thread for a single client, with per-message entity labels. */
|
||||
export async function getConversationThread(
|
||||
clientId: string
|
||||
): Promise<ConversationThread | null> {
|
||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
||||
const meta = clientMeta.get(clientId);
|
||||
if (!meta) return null;
|
||||
|
||||
// Restrict to entity ids owned by this client.
|
||||
const ownEntityIds = allEntityIds.filter(
|
||||
(id) => entityMap.get(id)?.clientId === clientId
|
||||
);
|
||||
const commentRows = await fetchComments(ownEntityIds);
|
||||
|
||||
const messages: ThreadMessage[] = commentRows.map((c) => {
|
||||
const info = entityMap.get(c.entity_id);
|
||||
return {
|
||||
id: c.id,
|
||||
author: c.author as "client" | "admin",
|
||||
body: c.body,
|
||||
created_at: c.created_at,
|
||||
entityLabel: info?.label ?? "Generale",
|
||||
entityType: info?.type ?? "general",
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
clientId: meta.id,
|
||||
name: meta.name,
|
||||
brand_name: meta.brand_name,
|
||||
token: meta.token,
|
||||
slug: meta.slug,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/** Number of clients whose conversation has unread client messages (sidebar badge). */
|
||||
export async function getUnreadConversationsCount(): Promise<number> {
|
||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
||||
const commentRows = await fetchComments(allEntityIds);
|
||||
const byClient = groupCommentsByClient(commentRows, entityMap);
|
||||
|
||||
let count = 0;
|
||||
for (const [clientId, msgs] of byClient) {
|
||||
const meta = clientMeta.get(clientId);
|
||||
if (!meta) continue;
|
||||
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
|
||||
const hasUnread = msgs.some(
|
||||
(m) => m.author === "client" && m.created_at.getTime() > lastReadAt
|
||||
);
|
||||
if (hasUnread) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
Reference in New Issue
Block a user