Files
zeekay 9dff9e667e 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

500 lines
18 KiB
TypeScript

"use client";
import { useState } from "react";
import { ConversationDocumentContext } from "@/features/conversations/components/shared/conversation-document-context";
import { ConversationMessage } from "@/features/conversations/components/shared/conversation-message";
import { FAQSection } from "@/features/conversations/components/viewer/faq-section";
import { format } from "date-fns";
import { ArrowLeftIcon, BellIcon, BellOffIcon, Plus } from "lucide-react";
import { toast } from "sonner";
import useSWR, { mutate } from "swr";
import { fetcher } from "@/lib/utils";
import { MAX_MESSAGE_LENGTH } from "@/lib/utils/sanitize-html";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent } from "@/components/ui/sheet";
// Type definitions
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;
messages: Message[];
userId: string | null;
viewerId: string | null;
documentPageNumber: number | null;
documentVersionNumber: number | null;
dataroomDocument?: {
document: {
name: string;
};
};
receiveNotifications: boolean;
}
interface CreateConversationData {
title?: string;
initialMessage: string;
}
export type ConversationSidebarProps = {
linkId: string;
viewId: string;
dataroomId?: string;
documentId?: string;
pageNumber?: number;
viewerId?: string;
isEnabled?: boolean;
isOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export function ConversationViewSidebar({
dataroomId,
documentId,
pageNumber,
viewId,
viewerId,
linkId,
isEnabled = true,
isOpen = false,
onOpenChange,
}: ConversationSidebarProps) {
const [activeConversation, setActiveConversation] =
useState<Conversation | null>(null);
const [isNewConversationFormOpen, setIsNewConversationFormOpen] =
useState(false);
const [newMessage, setNewMessage] = useState("");
// SWR hook for fetching conversations
const {
data: conversations = [],
error,
isLoading,
} = useSWR<Conversation[]>(
`/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,
fetcher,
{
revalidateOnFocus: true,
dedupingInterval: 5000, // 5 seconds
keepPreviousData: true,
onError: (err) => {
console.error("Error fetching conversations:", err);
toast.error("Failed to load conversations");
},
},
);
// Create a new conversation
const handleCreateConversation = async (data: CreateConversationData) => {
console.log("Creating conversation", data);
try {
const response = await fetch("/api/conversations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...data,
viewId,
viewerId,
documentId,
pageNumber,
linkId,
dataroomId,
}),
});
if (!response.ok) {
const errorData = await response.json();
toast.error(errorData.error);
return;
}
const newConversation = await response.json();
// Update the SWR cache with the new conversation
mutate(
`/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,
[newConversation, ...(conversations || [])],
false,
);
setActiveConversation(newConversation);
setIsNewConversationFormOpen(false);
toast.success("Conversation created successfully");
} catch (error) {
toast.error("Failed to create conversation");
}
};
// Send a new message to an active conversation
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim() || !activeConversation) return;
try {
const response = await fetch(`/api/conversations/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: newMessage,
viewId,
viewerId,
conversationId: activeConversation.id,
}),
});
if (!response.ok) {
const errorData = await response.json();
toast.error(errorData.error);
return;
}
const message: Message = await response.json();
// Update the SWR cache with the new message
mutate(
`/api/conversations?dataroomId=${dataroomId}&viewerId=${viewerId}`,
conversations?.map((conv) =>
conv.id === activeConversation.id
? {
...conv,
messages: [...conv.messages, message],
updatedAt: new Date().toISOString(),
}
: conv,
),
false,
);
// Also update the active conversation
setActiveConversation((prev) =>
prev
? {
...prev,
messages: [...prev.messages, message],
updatedAt: new Date().toISOString(),
}
: null,
);
setNewMessage("");
} catch (error) {
console.error("Error sending message:", error);
toast.error("Failed to send message");
}
};
return (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
<SheetContent side="right" className="p-0 sm:max-w-md">
<div className="flex h-full flex-col">
{/* Header */}
{/* <div className="flex items-center justify-between border-b px-4 py-3">
<h2 className="text-lg font-medium">Questions</h2>
</div> */}
{/* Content */}
<div className="flex flex-1 flex-col overflow-hidden">
{isEnabled ? (
<>
{/* FAQ Section */}
<FAQSection
dataroomId={dataroomId}
linkId={linkId}
documentId={documentId}
viewerId={viewerId}
/>
{activeConversation ? (
<div className="flex flex-1 flex-col overflow-hidden">
{/* Conversation Header */}
<div className="flex-shrink-0 border-b p-4">
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center">
<Button
variant="ghost"
size="icon"
onClick={() => setActiveConversation(null)}
className="-ml-2 mr-2 shrink-0"
>
<ArrowLeftIcon className="h-5 w-5" />
</Button>
<div className="min-w-0">
<h3 className="truncate font-medium">
{activeConversation.title || "Question"}
</h3>
</div>
</div>
<Button
variant={
activeConversation.receiveNotifications
? "default"
: "ghost"
}
size="icon"
onClick={async () => {
try {
const response = await fetch(
`/api/conversations/notifications`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
conversationId: activeConversation.id,
viewerId: viewerId,
enabled:
!activeConversation.receiveNotifications,
}),
},
);
if (!response.ok)
throw new Error(
"Failed to toggle notifications",
);
// Update the local state
setActiveConversation((prev) =>
prev
? {
...prev,
receiveNotifications:
!prev.receiveNotifications,
}
: null,
);
toast.success(
`Notifications ${!activeConversation.receiveNotifications ? "enabled" : "disabled"}`,
);
} catch (error) {
console.error(
"Error toggling notifications:",
error,
);
toast.error("Failed to toggle notifications");
}
}}
>
{activeConversation.receiveNotifications ? (
<BellIcon className="h-5 w-5 text-background" />
) : (
<BellOffIcon className="h-5 w-5 text-muted-foreground" />
)}
</Button>
</div>
</div>
{/* Messages */}
<ScrollArea className="flex-1">
<div className="flex flex-col gap-2 p-4">
{/* Document Context */}
<ConversationDocumentContext
dataroomDocument={activeConversation.dataroomDocument}
documentPageNumber={
activeConversation.documentPageNumber
}
documentVersionNumber={
activeConversation.documentVersionNumber
}
showVersionNumber={false} // Viewers see simplified context
className="mb-2"
/>
{activeConversation.messages?.map((message) => (
<ConversationMessage
key={message.id}
message={message}
isAuthor={message.viewerId === viewerId}
senderEmail={""}
/>
))}
</div>
</ScrollArea>
{/* Message Input */}
<form
onSubmit={handleSendMessage}
className="flex-shrink-0 border-t p-3"
>
<div className="flex gap-2">
<input
type="text"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
maxLength={MAX_MESSAGE_LENGTH}
className="flex-1 rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
placeholder="Type your message..."
/>
<Button type="submit" disabled={!newMessage.trim()}>
Send
</Button>
</div>
</form>
</div>
) : isNewConversationFormOpen ? (
<div className="flex-1 p-4">
<div className="mb-4 flex items-center">
<Button
variant="ghost"
size="sm"
onClick={() => setIsNewConversationFormOpen(false)}
className="-ml-2 mr-2"
>
<ArrowLeftIcon className="h-5 w-5" />
</Button>
<h3 className="font-medium">New Question</h3>
</div>
<form
onSubmit={(e: React.FormEvent) => {
e.preventDefault();
if (!newMessage.trim()) return;
handleCreateConversation({
initialMessage: newMessage,
});
setNewMessage("");
}}
className="space-y-4"
>
<div>
<textarea
id="message"
value={newMessage}
onChange={(e) => setNewMessage(e.target.value)}
maxLength={MAX_MESSAGE_LENGTH}
placeholder="Type your question..."
className="min-h-[100px] w-full rounded-md border border-input px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
required
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsNewConversationFormOpen(false)}
>
Cancel
</Button>
<Button type="submit" disabled={!newMessage.trim()}>
Ask Question
</Button>
</div>
</form>
</div>
) : (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-shrink-0 p-4">
<Button
onClick={() => setIsNewConversationFormOpen(true)}
className="w-full"
>
<Plus className="mr-2 h-4 w-4" />
New Question
</Button>
</div>
<Separator />
<div className="flex-1 overflow-y-auto">
<div className="divide-y">
{isLoading ? (
<div className="space-y-3 p-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<div className="h-5 w-3/4 animate-pulse bg-muted"></div>
<div className="h-4 w-1/2 animate-pulse bg-muted"></div>
<div className="h-10 w-full animate-pulse bg-muted"></div>
</div>
))}
</div>
) : conversations.length === 0 ? (
<div className="p-4 text-center text-muted-foreground">
No questions yet. Ask a new one!
</div>
) : (
conversations.map((conversation) => {
const lastMessage =
conversation.messages?.[
conversation.messages.length - 1
];
const hasUnread = conversation.messages?.some(
(msg) =>
!msg.isRead &&
(msg.userId ||
msg.viewerId !== conversation.viewerId),
);
return (
<div
key={conversation.id}
className="cursor-pointer p-4 hover:bg-muted/50"
onClick={() =>
setActiveConversation(conversation)
}
>
<div className="mb-1 flex items-center justify-between">
<h3 className="truncate font-medium">
{conversation.title || "Untitled question"}
</h3>
{/* {hasUnread && (
<span className="ml-2 inline-flex h-5 items-center rounded-full bg-primary px-2 text-xs font-medium text-primary-foreground">
New
</span>
)} */}
</div>
<div className="mb-2 text-xs text-muted-foreground">
{format(
new Date(conversation.updatedAt),
"MMM d, h:mm a",
)}
</div>
{lastMessage && (
<p className="line-clamp-2 text-sm">
{lastMessage.content}
</p>
)}
</div>
);
})
)}
</div>
</div>
</div>
)}
</>
) : (
<div className="flex h-full items-center justify-center p-4 text-center text-muted-foreground">
Questions are disabled for this document.
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}