Files
dataroom/features/conversations/pages/conversation-detail.tsx
zeekay caad90bc04 rip: enterprise dirs + paywalls — full AGPL, no commercial-license code
Drop the dual-licensed `ee/` tree entirely. All features previously
locked behind the Hanzo Dataroom Commercial License are now part of the
AGPL-3.0 core and freely available — custom domains, branded viewing,
advanced analytics, watermarks, access controls, Q&A, AI vector stores,
workflows, SAML SSO, dataroom invitations, conversations, templates.

Layout changes:
* `ee/features/{ai,conversations,workflows,permissions,storage,templates,
  dataroom-invitations,access-notifications,security,conversions}/` →
  `features/<name>/` (AGPL).
* `ee/limits/` → `lib/billing/limits/` with every plan resolving to an
  unlimited profile (no users/links/documents/domains/datarooms cap,
  conversations and watermarks always on).
* `ee/stripe/` → `lib/billing/legacy/` (already a compat shim over
  `@hanzoai/commerce`; renewal-reminder webhook is now a no-op).
* `ee/features/billing/cancellation/` deleted. Pause/unpause/cancel/
  retention routes now compile against `lib/billing/cancellation.ts`
  stubs that return 410 Gone; `isTeamPaused`/`isTeamPausedById` in
  `lib/billing/paused.ts` always report active. `CancellationModal` is
  a no-op shim in `components/billing/`.
* `app/(ee)/api/*` route group folded into `app/api/*` (SAML auth,
  SCIM, AI chat, workflows, FAQ, link-upload).

Build: `npm run build` green.
2026-06-07 11:46:48 -07:00

766 lines
28 KiB
TypeScript

import Link from "next/link";
import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { ConversationListItem } from "@/features/conversations/components/dashboard/conversation-list-item";
import { PublishFAQModal } from "@/features/conversations/components/dashboard/publish-faq-modal";
import { ConversationDocumentContext } from "@/features/conversations/components/shared/conversation-document-context";
import { ConversationMessage } from "@/features/conversations/components/shared/conversation-message";
import {
BookOpenCheckIcon,
Loader2,
MessageSquare,
SearchIcon,
Send,
Trash2,
X,
} from "lucide-react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import z from "zod";
import { useDataroom } from "@/lib/swr/use-dataroom";
import { CustomUser } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/utils/sanitize-html";
import { DataroomHeader } from "@/components/datarooms/dataroom-header";
import { DataroomNavigation } from "@/components/datarooms/dataroom-navigation";
import AppLayout from "@/components/layouts/app";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PublishedFAQ } from "./faq-overview";
// Types for conversation
interface Message {
id: string;
content: string;
createdAt: string;
updatedAt: string;
userId: string | null;
viewerId: string | null;
isRead: boolean;
}
interface Conversation {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
participants: { id: string; email: string | null }[];
documentPageNumber: number | null;
documentVersionNumber: number | null;
unreadCount: number;
messages: Message[];
dataroomDocument?: {
document: {
name: string;
};
};
}
interface ConversationSummary {
id: string;
title: string | null;
createdAt: string;
updatedAt: string;
viewerId: string | null;
viewerEmail?: string;
documentPageNumber: number | null;
documentVersionNumber: number | null;
unreadCount: number;
lastMessage?: {
content: string;
createdAt: string;
};
dataroomDocument?: {
document: {
name: string;
};
};
}
export default function ConversationDetailPage() {
const router = useRouter();
const { dataroom } = useDataroom();
const { data: session } = useSession();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isPublishFAQModalOpen, setIsPublishFAQModalOpen] = useState(false);
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(
null,
);
const [selectedAnswerId, setSelectedAnswerId] = useState<string | null>(null);
const { conversationId, id: dataroomId } = router.query;
const teamId = router.query.teamId || dataroom?.teamId;
const [searchQuery, setSearchQuery] = useState("");
// Clear selections when switching conversations
useEffect(() => {
setSelectedQuestionId(null);
setSelectedAnswerId(null);
}, [conversationId]);
// Auto-select latest question and answer
const autoSelectLatestQA = () => {
if (!conversation) return;
// Find latest visitor message (question)
const visitorMessages = conversation.messages.filter(
(msg) => msg.viewerId !== null,
);
const latestQuestion = visitorMessages[visitorMessages.length - 1];
// Find latest admin message (answer)
const adminMessages = conversation.messages.filter(
(msg) => msg.userId !== null,
);
const latestAnswer = adminMessages[adminMessages.length - 1];
if (latestQuestion) setSelectedQuestionId(latestQuestion.id);
if (latestAnswer) setSelectedAnswerId(latestAnswer.id);
};
// Handle message selection for FAQ publishing
const handleMessageSelect = (messageId: string, isVisitor: boolean) => {
if (isVisitor) {
// Selecting/deselecting a question
setSelectedQuestionId((prev) => (prev === messageId ? null : messageId));
} else {
// Selecting/deselecting an answer
setSelectedAnswerId((prev) => (prev === messageId ? null : messageId));
}
};
const getSelectedMessages = () => {
if (!conversation || !selectedQuestionId || !selectedAnswerId) return null;
const questionMessage = conversation.messages.find(
(m) => m.id === selectedQuestionId,
);
const answerMessage = conversation.messages.find(
(m) => m.id === selectedAnswerId,
);
return { questionMessage, answerMessage };
};
// SWR hook for fetching the conversation with messages
const { data: conversation, isLoading } = useSWR<Conversation>(
conversationId && dataroomId && teamId
? `/api/teams/${teamId}/datarooms/${dataroomId}/conversations/${conversationId}`
: null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 3000, // 3 seconds
keepPreviousData: true,
onSuccess: (data) => {
if (
data &&
data.messages.some((msg) => !msg.isRead && msg.viewerId !== null)
) {
markMessagesAsRead(data.id);
}
},
onError: (err) => {
console.error("Error fetching conversation:", err);
toast.error("Failed to load conversation");
},
},
);
// SWR hook for fetching all conversations
const { data: conversations = [], isLoading: isLoadingConversations } =
useSWR<ConversationSummary[]>(
dataroomId && teamId
? `/api/teams/${teamId}/datarooms/${dataroomId}/conversations`
: null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 5000, // 5 seconds
keepPreviousData: true,
onError: (err) => {
console.error("Error fetching conversations:", err);
toast.error("Failed to load conversations");
},
},
);
// Fetch published FAQs
const { data: faqs = [] } = useSWR<PublishedFAQ[]>(
dataroom && teamId
? `/api/teams/${teamId}/datarooms/${dataroom.id}/faqs`
: null,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 10000,
keepPreviousData: true,
},
);
// Filter conversations based on search query
const filteredConversations = conversations.filter((conversation) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
// Search by viewer email
if (conversation.viewerEmail?.toLowerCase().includes(query)) return true;
// Search in conversation titles and last messages
return (
conversation.title?.toLowerCase().includes(query) ||
conversation.lastMessage?.content.toLowerCase().includes(query) ||
conversation.dataroomDocument?.document.name.toLowerCase().includes(query)
);
});
const isPublishFAQDisabled =
!conversation?.messages.some((msg) => msg.viewerId !== null) ||
!conversation?.messages.some((msg) => msg.userId !== null);
// Mark messages as read
const markMessagesAsRead = async (conversationId: string) => {
if (!dataroomId || !teamId) return;
try {
const conversationIdParsed = z.string().cuid().parse(conversationId);
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}/read`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
);
if (!response.ok) throw new Error("Failed to mark messages as read");
// Revalidate both the conversation and the summaries
mutate(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}`,
);
mutate(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations`,
);
} catch (error) {
console.error("Error marking messages as read:", error);
}
};
// Handle conversation deletion
const handleDeleteConversation = async () => {
if (!conversation || !dataroomId || !teamId) return;
setIsDeleting(true);
try {
const conversationIdParsed = z.string().cuid().parse(conversation.id);
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}`,
{
method: "DELETE",
},
);
if (!response.ok) throw new Error("Failed to delete conversation");
// Revalidate the summaries
mutate(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations`,
);
// Navigate back to conversations list
router.push(`/datarooms/${dataroomIdParsed}/conversations`);
toast.success("Conversation deleted successfully");
} catch (error) {
console.error("Error deleting conversation:", error);
toast.error("Failed to delete conversation");
} finally {
setIsDeleting(false);
setIsDeleteDialogOpen(false);
}
};
// Handle sending a new message
const handleSendMessage = async (e: React.FormEvent, newMessage: string) => {
e.preventDefault();
if (!newMessage.trim() || !conversation || !dataroomId || !teamId) return;
try {
const conversationIdParsed = z.string().cuid().parse(conversation.id);
const dataroomIdParsed = z.string().cuid().parse(dataroomId);
const teamIdParsed = z.string().cuid().parse(teamId);
const response = await fetch(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}/messages`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: newMessage,
}),
},
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to send message");
}
// Revalidate both the conversation and the summaries
mutate(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations/${conversationIdParsed}`,
);
mutate(
`/api/teams/${teamIdParsed}/datarooms/${dataroomIdParsed}/conversations`,
);
toast.success("Message sent");
} catch (error) {
console.error("Error sending message:", error);
toast.error(
error instanceof Error ? error.message : "Failed to send message",
);
}
};
// Navigate to a different conversation
const navigateToConversation = (id: string) => {
router.push(`/datarooms/${dataroomId}/conversations/${id}`);
};
if (!dataroom) {
return <div>Loading...</div>;
}
return (
<AppLayout>
<div className="relative mx-2 my-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
<header>
<DataroomHeader title={dataroom.name} description={dataroom.pId} internalName={dataroom.internalName} />
<DataroomNavigation dataroomId={dataroom.id} />
</header>
<Tabs value="conversations" className="space-y-6">
<TabsList>
<TabsTrigger
value="conversations"
className="flex items-center gap-2"
>
<MessageSquare className="h-4 w-4" />
Conversations
<Badge variant="notification">{conversations.length}</Badge>
</TabsTrigger>
<TabsTrigger value="faqs" asChild>
<Link
href={`/datarooms/${dataroom.id}/conversations/faqs`}
className="flex items-center gap-2"
>
<BookOpenCheckIcon className="h-4 w-4" />
Published FAQs
<Badge variant="notification">{faqs.length}</Badge>
</Link>
</TabsTrigger>
</TabsList>
<TabsContent value="conversations" className="space-y-0">
<div className="h-[calc(100vh-20rem)] overflow-hidden rounded-md border">
<div className="flex h-full flex-col md:flex-row">
{/* Sidebar with conversations list */}
<div className="flex h-full w-full flex-col border-r md:w-96">
{/* <div className="flex items-center justify-between p-4">
<h2 className="text-lg font-semibold">Conversations</h2>
<Badge variant="secondary">{conversations.length}</Badge>
</div> */}
<div className="flex items-center p-4">
<div className="relative w-full">
<SearchIcon className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search conversations..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="flex h-[calc(100%-7.5rem)] flex-col">
<div className="m-0 flex-1 overflow-hidden">
<ScrollArea className="h-full">
<div className="flex flex-col gap-2 p-4 pt-0">
{isLoadingConversations ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : filteredConversations.length === 0 ? (
<div className="flex h-20 items-center justify-center">
<p className="text-sm text-muted-foreground">
No conversations found
</p>
</div>
) : (
// Sort by most recent first
[...filteredConversations]
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() -
new Date(a.updatedAt).getTime(),
)
.map((conversation) => (
<ConversationListItem
key={conversation.id}
navigateToConversation={
navigateToConversation
}
conversation={conversation}
isActive={conversation.id === conversationId}
/>
))
)}
</div>
</ScrollArea>
</div>
</div>
</div>
{/* Conversation content */}
<div className="flex h-full flex-1 flex-col">
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : conversation ? (
<>
{/* Conversation header */}
<div className="flex items-center justify-between border-b p-4">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src="" />
<AvatarFallback>
{conversation.participants[0].email
? conversation.participants[0].email
.charAt(0)
.toUpperCase()
: "?"}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<h2 className="text-base font-semibold">
{conversation.title || "Conversation"}
</h2>
<p className="text-sm text-muted-foreground">
{conversation.participants[0].email ||
"Anonymous Viewer"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{(selectedQuestionId || selectedAnswerId) && (
<Button
variant="ghost"
size="sm"
title="Clear message selections"
onClick={() => {
setSelectedQuestionId(null);
setSelectedAnswerId(null);
}}
>
<X className="mr-1 h-4 w-4" />
Clear
</Button>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button
size="sm"
onClick={() => {
if (
selectedQuestionId &&
selectedAnswerId
) {
setIsPublishFAQModalOpen(true);
} else {
autoSelectLatestQA();
}
}}
disabled={isPublishFAQDisabled}
>
<BookOpenCheckIcon className="mr-2 h-4 w-4" />
{selectedQuestionId && selectedAnswerId
? "Publish FAQ"
: "Select messages to publish"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>
{isPublishFAQDisabled
? "Need both visitor questions and admin answers to publish FAQ"
: selectedQuestionId && selectedAnswerId
? "Publish selected question and answer as FAQ"
: "Select the latest question and answer for FAQ publishing"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="ghost"
size="icon"
title="Delete conversation"
onClick={() => setIsDeleteDialogOpen(true)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-4 p-4">
{/* Document Context */}
<ConversationDocumentContext
dataroomDocument={conversation.dataroomDocument}
documentPageNumber={conversation.documentPageNumber}
documentVersionNumber={
conversation.documentVersionNumber
}
showVersionNumber={true} // Admin sees full context
className="mb-2"
/>
{/* Selection Guidance */}
{/* {conversation.messages.length > 1 && (
<div className="mb-2 rounded-lg border border-blue-200 bg-blue-50 p-3 text-sm">
<div className="flex items-start gap-2">
<BookOpenCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-blue-600" />
<div className="text-blue-800">
{!selectedQuestionId && !selectedAnswerId ? (
<p>
<strong>💡 Step 1:</strong> Click "Select
messages to publish" to automatically choose
the latest question and answer, or manually
select different messages by clicking them
first.
</p>
) : selectedQuestionId && !selectedAnswerId ? (
<p>
<strong>Question selected!</strong> Now select
an admin answer to complete your FAQ pair.
</p>
) : !selectedQuestionId && selectedAnswerId ? (
<p>
<strong>Answer selected!</strong> Now select a
visitor question to complete your FAQ pair.
</p>
) : (
<p>
<strong>Step 2:</strong> Perfect! Both
question and answer are selected. Click
"Publish FAQ" to review and publish.
</p>
)}
</div>
</div>
</div>
)} */}
{conversation.messages.map((message) => (
<ConversationMessage
key={message.id}
message={message}
isAuthor={
message.userId ===
(session?.user as CustomUser).id
}
senderEmail={
conversation.participants[0].email || "Viewer"
}
isSelectable={true}
isSelected={
message.id === selectedQuestionId ||
message.id === selectedAnswerId
}
onSelect={handleMessageSelect}
/>
))}
</div>
</ScrollArea>
<ConversationReplyForm
onSendMessage={handleSendMessage}
/>
</>
) : (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground">
Conversation not found
</p>
</div>
)}
</div>
</div>
</div>
</TabsContent>
</Tabs>
</div>
{/* Delete Confirmation Dialog */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Conversation</DialogTitle>
<DialogDescription>
Are you sure you want to delete this conversation? This action
cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConversation}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Publish FAQ Modal */}
{conversation && (
<PublishFAQModal
conversation={conversation}
dataroomId={dataroomId as string}
teamId={teamId as string}
isOpen={isPublishFAQModalOpen}
onClose={() => {
setIsPublishFAQModalOpen(false);
// Keep selections when modal closes so user can reselect
}}
onSuccess={() => {
// Reset selections after successful publish
setSelectedQuestionId(null);
setSelectedAnswerId(null);
toast.success("FAQ published successfully!");
mutate(
teamId && dataroom?.id
? `/api/teams/${teamId}/datarooms/${dataroom.id}/faqs`
: null,
);
}}
selectedQuestionMessage={getSelectedMessages()?.questionMessage}
selectedAnswerMessage={getSelectedMessages()?.answerMessage}
/>
)}
</AppLayout>
);
}
// Extracted reply form component
function ConversationReplyForm({
onSendMessage,
}: {
onSendMessage: (e: React.FormEvent, message: string) => Promise<void>;
}) {
const [newMessage, setNewMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const isOverLimit = newMessage.length > MAX_MESSAGE_LENGTH;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || isOverLimit) return;
setIsSending(true);
try {
await onSendMessage(e, newMessage);
setNewMessage("");
} finally {
setIsSending(false);
}
};
return (
<div className="border-t p-4">
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Textarea
placeholder="Type your reply..."
className={`min-h-[100px] ${isOverLimit ? "border-destructive focus-visible:ring-destructive" : ""}`}
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
/>
<div className="flex items-center justify-between">
<span
className={`text-xs ${isOverLimit ? "font-medium text-destructive" : "text-muted-foreground"}`}
>
{newMessage.length > 0 &&
`${newMessage.length} / ${MAX_MESSAGE_LENGTH}`}
</span>
<Button
type="submit"
disabled={!newMessage.trim() || isOverLimit || isSending}
>
{isSending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<Send className="mr-2 h-4 w-4" />
Send
</>
)}
</Button>
</div>
</form>
</div>
);
}