feat: improve pause functionality

This commit is contained in:
Marc Seitz
2025-12-15 13:27:37 +01:00
parent 0b8d204514
commit bf292d420e
26 changed files with 771 additions and 145 deletions
+10 -2
View File
@@ -133,6 +133,7 @@ export async function POST(request: NextRequest) {
plan: true,
globalBlockList: true,
agentsEnabled: true,
pauseStartsAt: true,
},
},
customFields: {
@@ -646,6 +647,11 @@ export async function POST(request: NextRequest) {
}),
};
const isPaused =
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
? true
: false;
// ** DATAROOM_VIEW **
if (viewType === "DATAROOM_VIEW") {
console.log("viewType is DATAROOM_VIEW");
@@ -674,10 +680,11 @@ export async function POST(request: NextRequest) {
dataroomId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
if (link.teamId && !isPreview) {
if (link.teamId && !isPreview && !isPaused) {
waitUntil(
(async () => {
try {
@@ -791,6 +798,7 @@ export async function POST(request: NextRequest) {
dataroomId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
}
@@ -808,7 +816,7 @@ export async function POST(request: NextRequest) {
});
console.timeEnd("create-view");
// Only send Slack notifications for non-preview views
if (link.teamId && !isPreview) {
if (link.teamId && !isPreview && !isPaused) {
waitUntil(
(async () => {
try {
+8 -1
View File
@@ -109,6 +109,7 @@ export async function POST(request: NextRequest) {
plan: true,
globalBlockList: true,
agentsEnabled: true,
pauseStartsAt: true,
},
},
customFields: {
@@ -637,6 +638,11 @@ export async function POST(request: NextRequest) {
console.timeEnd("get-file");
}
const isPaused =
link.team?.pauseStartsAt && link.team?.pauseStartsAt <= new Date()
? true
: false;
if (newView) {
// Record view in the background to avoid blocking the response
waitUntil(
@@ -649,9 +655,10 @@ export async function POST(request: NextRequest) {
documentId,
teamId: link.teamId!,
enableNotification: link.enableNotification,
isPaused,
}),
);
if (!isPreview) {
if (!isPreview && !isPaused) {
waitUntil(
notifyDocumentView({
teamId: link.teamId!,
+23 -8
View File
@@ -16,6 +16,7 @@ import {
} from "@tanstack/react-table";
import { format } from "date-fns";
import {
AlertTriangleIcon,
BadgeCheckIcon,
BadgeInfoIcon,
ChevronDownIcon,
@@ -32,12 +33,7 @@ import { toast } from "sonner";
import useSWR from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import {
cn,
durationFormat,
fetcher,
timeAgo,
} from "@/lib/utils";
import { cn, durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
@@ -287,13 +283,13 @@ export default function ViewsTable({
}) {
const router = useRouter();
const teamInfo = useTeam();
const { isTrial, isFree } = usePlan();
const { isTrial, isFree, isPaused } = usePlan();
const { interval = "7d" } = router.query;
const [sorting, setSorting] = useState<SortingState>([
{ id: "viewedAt", desc: true },
]);
const { data: views } = useSWR<View[]>(
const { data } = useSWR<{ views: View[]; hiddenFromPause: number }>(
teamInfo?.currentTeam?.id
? `/api/analytics?type=views&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
: null,
@@ -304,6 +300,9 @@ export default function ViewsTable({
},
);
const views = data?.views;
const hiddenFromPause = data?.hiddenFromPause ?? 0;
const table = useReactTable({
data: views || [],
columns,
@@ -363,6 +362,22 @@ export default function ViewsTable({
return (
<div className="space-y-4">
{isPaused && hiddenFromPause > 0 && (
<div className="flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1 text-sm">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<a
href="/settings/billing"
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all views
</a>
</div>
)}
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
+26 -5
View File
@@ -15,6 +15,7 @@ import {
} from "@tanstack/react-table";
import { format } from "date-fns";
import {
AlertTriangleIcon,
BadgeCheckIcon,
ChevronDownIcon,
ChevronUpIcon,
@@ -25,6 +26,10 @@ import {
import { toast } from "sonner";
import useSWR from "swr";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { Button } from "@/components/ui/button";
import {
Table,
@@ -38,9 +43,6 @@ import { BadgeTooltip } from "@/components/ui/tooltip";
import { DataTablePagination } from "@/components/visitors/data-table-pagination";
import { VisitorAvatar } from "@/components/visitors/visitor-avatar";
import { usePlan } from "@/lib/swr/use-billing";
import { durationFormat, fetcher, timeAgo } from "@/lib/utils";
import { downloadCSV } from "@/lib/utils/csv";
import { UpgradeButton } from "../ui/upgrade-button";
interface Visitor {
@@ -220,13 +222,13 @@ export default function VisitorsTable({
}) {
const router = useRouter();
const teamInfo = useTeam();
const { isTrial, isFree } = usePlan();
const { isTrial, isFree, isPaused } = usePlan();
const { interval = "7d" } = router.query;
const [sorting, setSorting] = useState<SortingState>([
{ id: "lastActive", desc: true },
]);
const { data: visitors } = useSWR<Visitor[]>(
const { data } = useSWR<{ visitors: Visitor[]; hiddenFromPause: number }>(
teamInfo?.currentTeam?.id
? `/api/analytics?type=visitors&interval=${interval}&teamId=${teamInfo.currentTeam.id}${interval === "custom" ? `&startDate=${format(startDate, "MM-dd-yyyy")}&endDate=${format(endDate, "MM-dd-yyyy")}` : ""}`
: null,
@@ -237,6 +239,9 @@ export default function VisitorsTable({
},
);
const visitors = data?.visitors;
const hiddenFromPause = data?.hiddenFromPause ?? 0;
const table = useReactTable({
data: visitors || [],
columns,
@@ -295,6 +300,22 @@ export default function VisitorsTable({
return (
<div className="space-y-4">
{isPaused && hiddenFromPause > 0 && (
<div className="flex flex-col items-start justify-center gap-2 rounded-lg border border-orange-200 bg-orange-50 p-4 dark:border-orange-800 dark:bg-orange-950 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1 text-sm">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} view{hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<a
href="/settings/billing"
className="text-sm font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all views
</a>
</div>
)}
<div className="flex justify-end">
<UpgradeOrExportButton />
</div>
+32 -8
View File
@@ -6,6 +6,7 @@ import { useTeam } from "@/context/team-context";
import { CancellationModal } from "@/ee/features/billing/cancellation/components";
import { PlanEnum } from "@/ee/stripe/constants";
import {
BanIcon,
CirclePauseIcon,
CreditCardIcon,
MoreVertical,
@@ -52,6 +53,7 @@ export default function UpgradePlanContainer() {
startsAt,
endsAt,
pauseStartsAt,
pauseEndsAt,
discount,
mutate: mutatePlan,
} = usePlan({ withDiscount: true });
@@ -64,7 +66,8 @@ export default function UpgradePlanContainer() {
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update";
| "payment_method_update"
| "cancellation";
}) => {
if (!currentTeamId) return;
@@ -233,12 +236,31 @@ export default function UpgradePlanContainer() {
return (
<div className="flex items-center gap-3">
{isPaused ? (
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
<>
<Button
onClick={handleUnpauseSubscription}
loading={unpauseLoading}
>
Unpause subscription
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-9 w-9 p-0">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => manageSubscription({ type: "cancellation" })}
className="text-red-500"
>
<BanIcon className="h-4 w-4" />
Cancel subscription
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<>
<Button
@@ -323,7 +345,9 @@ export default function UpgradePlanContainer() {
<CardDescription>
<span className="font-medium text-foreground">
Subscription{" "}
{pauseStartsAt > new Date() ? "will pause on" : "paused on"}
{new Date(pauseStartsAt) > new Date()
? "will pause on"
: "paused on"}
:{" "}
</span>
<span className="text-foreground">
@@ -0,0 +1,64 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ViewedDataroomPausedEmail({
dataroomName = "Example Dataroom",
linkName = "Example Link",
}: {
dataroomName?: string;
linkName?: string;
}) {
return (
<Html>
<Head />
<Preview>See who visited your dataroom</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
New Dataroom Visitor
</Text>
<Text className="text-sm leading-6 text-black">
Your dataroom{" "}
<span className="font-semibold">{dataroomName}</span> was just
viewed by <span className="font-semibold">someone</span>
from the link <span className="font-semibold">{linkName}</span>.
</Text>
<Text className="text-sm leading-6 text-black">
Your team is currently paused, so detailed visitor information is
not available. To see who visited your dataroom and access full
analytics, please unpause your subscription.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/settings/billing`}
style={{ padding: "12px 20px" }}
>
Manage Subscription
</Button>
</Section>
<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
}
@@ -0,0 +1,64 @@
import React from "react";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Preview,
Section,
Tailwind,
Text,
} from "@react-email/components";
import { Footer } from "./shared/footer";
export default function ViewedDocumentPausedEmail({
documentName = "Example Document",
linkName = "Example Link",
}: {
documentName?: string;
linkName?: string;
}) {
return (
<Html>
<Head />
<Preview>See who visited your document</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-white font-sans">
<Container className="mx-auto my-10 w-[465px] p-5">
<Text className="mx-0 mb-8 mt-4 p-0 text-center text-2xl font-normal">
<span className="font-bold tracking-tighter">Papermark</span>
</Text>
<Text className="mx-0 my-7 p-0 text-center text-xl font-semibold text-black">
New Document Visitor
</Text>
<Text className="text-sm leading-6 text-black">
Your document{" "}
<span className="font-semibold">{documentName}</span> was just
viewed by <span className="font-semibold">someone</span>
from the link <span className="font-semibold">{linkName}</span>.
</Text>
<Text className="text-sm leading-6 text-black">
Your team is currently paused, so detailed visitor information is
not available. To see who visited your documents and access full
analytics, please unpause your subscription.
</Text>
<Section className="my-8 text-center">
<Button
className="rounded bg-black text-center text-xs font-semibold text-white no-underline"
href={`${process.env.NEXT_PUBLIC_MARKETING_URL}/settings/billing`}
style={{ padding: "12px 20px" }}
>
Manage Subscription
</Button>
</Section>
<Footer />
</Container>
</Body>
</Tailwind>
</Html>
);
}
Binary file not shown.
@@ -2,6 +2,7 @@ import { useState } from "react";
import { useTeam } from "@/context/team-context";
import {
AlertTriangleIcon,
BadgeCheckIcon,
BadgeInfoIcon,
Download,
@@ -9,8 +10,8 @@ import {
FileBadgeIcon,
MailOpenIcon,
} from "lucide-react";
import { toast } from "sonner";
import { usePlan } from "@/lib/swr/use-billing";
import { useDataroom } from "@/lib/swr/use-dataroom";
import { useDataroomVisits } from "@/lib/swr/use-dataroom";
import { timeAgo } from "@/lib/utils";
@@ -51,8 +52,12 @@ export default function DataroomVisitorsTable({
}) {
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { views } = useDataroomVisits({ dataroomId, groupId });
const { views, hiddenFromPause } = useDataroomVisits({
dataroomId,
groupId,
});
const { dataroom } = useDataroom();
const { isPaused } = usePlan();
const [exportModalOpen, setExportModalOpen] = useState(false);
const exportVisitCounts = () => {
@@ -80,7 +85,7 @@ export default function DataroomVisitorsTable({
</TableRow>
</TableHeader>
<TableBody>
{views?.length === 0 && (
{views?.length === 0 && hiddenFromPause === 0 && (
<TableRow>
<TableCell colSpan={5}>
<div className="flex h-40 w-full items-center justify-center">
@@ -89,6 +94,32 @@ export default function DataroomVisitorsTable({
</TableCell>
</TableRow>
)}
{isPaused && hiddenFromPause > 0 && (
<>
<TableRow>
<TableCell colSpan={3} className="text-left sm:text-center">
<div className="flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{hiddenFromPause} visit
{hiddenFromPause !== 1 ? "s" : ""} occurred after your
team was paused and{" "}
{hiddenFromPause !== 1 ? "are" : "is"} hidden.{" "}
</span>
<a
href="/settings/billing"
className="font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all visits
</a>
</div>
</TableCell>
</TableRow>
{Array.from({ length: hiddenFromPause }).map((_, i) => (
<VisitorBlurred key={i} />
))}
</>
)}
{views ? (
views.map((view) => (
<Collapsible key={view.id} asChild>
@@ -302,3 +333,42 @@ export default function DataroomVisitorsTable({
</div>
);
}
// create a component for a blurred view of the visitor
const VisitorBlurred = () => {
return (
<TableRow className="blur-sm">
<TableCell className="">
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar viewerEmail={"abc@example.org"} />
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
Anonymous
</p>
<p className="text-xs text-muted-foreground/60 sm:text-sm">
Demo link
</p>
</div>
</div>
</div>
</TableCell>
{/* Last Viewed */}
<TableCell className="text-sm text-muted-foreground">
<time
dateTime={new Date(
new Date().getTime() - 30 * 24 * 60 * 60 * 1000,
).toISOString()}
>
{timeAgo(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000))}
</time>
</TableCell>
{/* Actions */}
<TableCell className="cursor-pointer p-0 text-center sm:text-right">
<div className="flex justify-end space-x-1 p-5 [&[data-state=open]>svg.chevron]:rotate-180">
<ChevronDown className="chevron h-4 w-4 shrink-0 transition-transform duration-200" />
</div>
</TableCell>
</TableRow>
);
};
+84 -59
View File
@@ -78,7 +78,7 @@ export default function VisitorsTable({
currentPage,
pageSize,
);
const { plan, isTrial } = usePlan();
const { plan, isTrial, isPaused } = usePlan();
const isFreePlan = plan === "free";
const [isLoading, setIsLoading] = useState(false);
@@ -156,6 +156,57 @@ export default function VisitorsTable({
</TableCell>
</TableRow>
)}
{views?.hiddenViewCount! > 0 && (
<>
<TableRow className="">
<TableCell colSpan={5} className="text-left sm:text-center">
{isPaused &&
views?.hiddenFromPause &&
views.hiddenFromPause > 0 ? (
// Show pause-specific message if team is paused and has hidden views from pause
<div className="flex flex-col items-start justify-center gap-2 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1">
<AlertTriangleIcon className="inline-block h-4 w-4 text-orange-500" />
{views.hiddenFromPause} visit
{views.hiddenFromPause !== 1 ? "s" : ""} occurred
after your team was paused and{" "}
{views.hiddenFromPause !== 1 ? "are" : "is"}{" "}
hidden.{" "}
</span>
<a
href="/settings/billing"
className="font-medium text-orange-600 underline hover:text-orange-700"
>
Unpause subscription to see all visits
</a>
</div>
) : (
// Show regular free plan message
<div className="flex flex-col items-start justify-center gap-1 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1">
<AlertTriangleIcon className="inline-block h-4 w-4 text-yellow-500" />
Some older visits may not be shown because your
document has more than 20 views.{" "}
</span>
<UpgradePlanModal
clickedPlan={
isTrial ? PlanEnum.Business : PlanEnum.Pro
}
trigger=""
>
<button className="underline hover:text-gray-800">
Upgrade to see full history
</button>
</UpgradePlanModal>
</div>
)}
</TableCell>
</TableRow>
{Array.from({ length: views?.hiddenViewCount! }).map((_, i) => (
<VisitorBlurred key={i} />
))}
</>
)}
{views?.viewsWithDuration ? (
views.viewsWithDuration.map((view) => {
if (view.isArchived) {
@@ -164,38 +215,36 @@ export default function VisitorsTable({
key={view.id}
className="group/row opacity-50 grayscale"
>
{/* Name */}
<TableCell>
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar
viewerEmail={view.viewerEmail}
isArchived
/>
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{view.viewerEmail ? (
<>
{view.viewerName || view.viewerEmail}
</>
) : (
"Anonymous"
)}
</p>
{view.viewerName && view.viewerEmail && (
<p className="text-xs text-muted-foreground/60">
{view.viewerEmail}
</p>
)}
<p className="text-xs text-muted-foreground/60 sm:text-sm">
{view.link && view.link.name
? view.link.name
: view.linkId}
</p>
</div>
</div>
{/* Name */}
<TableCell>
<div className="flex items-center overflow-visible sm:space-x-3">
<VisitorAvatar
viewerEmail={view.viewerEmail}
isArchived
/>
<div className="min-w-0 flex-1">
<div className="focus:outline-none">
<p className="flex items-center gap-x-2 overflow-visible text-sm font-medium text-gray-800 dark:text-gray-200">
{view.viewerEmail ? (
<>{view.viewerName || view.viewerEmail}</>
) : (
"Anonymous"
)}
</p>
{view.viewerName && view.viewerEmail && (
<p className="text-xs text-muted-foreground/60">
{view.viewerEmail}
</p>
)}
<p className="text-xs text-muted-foreground/60 sm:text-sm">
{view.link && view.link.name
? view.link.name
: view.linkId}
</p>
</div>
</TableCell>
</div>
</div>
</TableCell>
{/* Duration */}
<TableCell className="">
<div className="text-sm text-muted-foreground">
@@ -468,7 +517,9 @@ export default function VisitorsTable({
totalPages={view.versionNumPages}
versionNumber={view.versionNumber}
downloadType={view.downloadType}
downloadMetadata={view.downloadMetadata as any}
downloadMetadata={
view.downloadMetadata as any
}
/>
)}
{!isFreePlan && primaryVersion.type === "pdf" ? (
@@ -502,32 +553,6 @@ export default function VisitorsTable({
</TableCell>
</TableRow>
)}
{views?.hiddenViewCount! > 0 && (
<>
<TableRow className="">
<TableCell colSpan={5} className="text-left sm:text-center">
<div className="flex flex-col items-start justify-center gap-1 sm:flex-row sm:items-center">
<span className="flex items-center gap-x-1">
<AlertTriangleIcon className="inline-block h-4 w-4 text-yellow-500" />
Some older views may not be shown because your document
has more than 20 views.{" "}
</span>
<UpgradePlanModal
clickedPlan={isTrial ? PlanEnum.Business : PlanEnum.Pro}
trigger=""
>
<button className="underline hover:text-gray-800">
Upgrade to see full history
</button>
</UpgradePlanModal>
</div>
</TableCell>
</TableRow>
{Array.from({ length: views?.hiddenViewCount! }).map((_, i) => (
<VisitorBlurred key={i} />
))}
</>
)}
</TableBody>
</Table>
</div>
@@ -128,6 +128,10 @@ export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: "", // Remove pause_collection (unpause)
});
await stripe.subscriptions.update(team.subscriptionId, {
proration_behavior: "create_prorations", // Create prorations for immediate billing
billing_cycle_anchor: "now", // Reset billing cycle to start immediately
});
} else {
// Handle new coupon-based method
// Since 3 months have passed, we definitely need to reset the billing cycle
@@ -82,10 +82,19 @@ export async function handleRoute(req: NextApiRequest, res: NextApiResponse) {
const isOldPauseMethod = subscription.pause_collection !== null;
if (isOldPauseMethod) {
// Handle old pause_collection method
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: "", // Remove pause_collection (unpause)
});
if (isInOriginalBillingCycle) {
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: "", // Remove pause_collection (unpause)
});
} else {
await stripe.subscriptions.update(team.subscriptionId, {
pause_collection: "", // Remove pause_collection (unpause)
});
await stripe.subscriptions.update(team.subscriptionId, {
proration_behavior: "create_prorations", // Create prorations for immediate billing
billing_cycle_anchor: "now", // Reset billing cycle to start immediately
});
}
} else {
// Handle new coupon-based method
if (isInOriginalBillingCycle) {
+40
View File
@@ -0,0 +1,40 @@
import { sendEmail } from "@/lib/resend";
import ViewedDataroomPausedEmail from "@/components/emails/viewed-dataroom-paused";
export const sendViewedDataroomPausedEmail = async ({
ownerEmail,
dataroomName,
linkName,
teamMembers,
}: {
ownerEmail: string | null;
dataroomName: string;
linkName: string;
teamMembers?: string[];
}) => {
const emailTemplate = ViewedDataroomPausedEmail({
dataroomName,
linkName,
});
try {
if (!ownerEmail) {
throw new Error("Dataroom Admin not found");
}
const subjectLine = `Your dataroom has been viewed: ${dataroomName}`;
const data = await sendEmail({
to: ownerEmail,
cc: teamMembers,
subject: subjectLine,
react: emailTemplate,
test: process.env.NODE_ENV === "development",
system: true,
});
return { success: true, data };
} catch (error) {
return { success: false, error };
}
};
+40
View File
@@ -0,0 +1,40 @@
import { sendEmail } from "@/lib/resend";
import ViewedDocumentPausedEmail from "@/components/emails/viewed-document-paused";
export const sendViewedDocumentPausedEmail = async ({
ownerEmail,
documentName,
linkName,
teamMembers,
}: {
ownerEmail: string | null;
documentName: string;
linkName: string;
teamMembers?: string[];
}) => {
const emailTemplate = ViewedDocumentPausedEmail({
documentName,
linkName,
});
try {
if (!ownerEmail) {
throw new Error("Document Owner not found");
}
const subjectLine = `Your document has been viewed: ${documentName}`;
const data = await sendEmail({
to: ownerEmail,
cc: teamMembers,
subject: subjectLine,
react: emailTemplate,
test: process.env.NODE_ENV === "development",
system: true,
});
return { success: true, data };
} catch (error) {
return { success: false, error };
}
};
+12 -4
View File
@@ -1,8 +1,9 @@
import { useMemo } from "react";
import { useTeam } from "@/context/team-context";
import { PLAN_NAME_MAP } from "@/ee/stripe/constants";
import { SubscriptionDiscount } from "@/ee/stripe/functions/get-subscription-item";
import useSWR from "swr";
import { useMemo } from "react";
import { fetcher } from "@/lib/utils";
@@ -52,7 +53,10 @@ type PlanResponse = {
plan: BasePlan | PlanWithTrial | PlanWithOld;
startsAt: Date | null;
endsAt: Date | null;
pausedAt: Date | null;
pauseStartsAt: Date | null;
pauseEndsAt: Date | null;
isPaused: boolean;
cancelledAt: Date | null;
isCustomer: boolean;
subscriptionCycle: "monthly" | "yearly";
@@ -66,7 +70,7 @@ interface PlanDetails {
}
function parsePlan(plan: BasePlan | PlanWithTrial | PlanWithOld): PlanDetails {
if (!plan || typeof plan !== 'string') {
if (!plan || typeof plan !== "string") {
return { plan: null, trial: null, old: false };
}
@@ -95,7 +99,9 @@ export function usePlan({
error,
mutate,
} = useSWR<PlanResponse>(
teamId ? `/api/teams/${teamId}/billing/plan${withDiscount ? "?withDiscount=true" : ""}` : null,
teamId
? `/api/teams/${teamId}/billing/plan${withDiscount ? "?withDiscount=true" : ""}`
: null,
fetcher,
);
@@ -119,9 +125,11 @@ export function usePlan({
startsAt: plan?.startsAt,
endsAt: plan?.endsAt,
cancelledAt: plan?.cancelledAt,
isPaused: !!plan?.pauseStartsAt,
pausedAt: plan?.pausedAt,
isPaused: plan?.isPaused ?? false,
isCancelled: !!plan?.cancelledAt,
pauseStartsAt: plan?.pauseStartsAt,
pauseEndsAt: plan?.pauseEndsAt,
discount: plan?.discount || null,
isFree: parsedPlan.plan === "free",
isStarter: parsedPlan.plan === "starter",
+9 -3
View File
@@ -352,6 +352,11 @@ export function useDataroomViewers({ dataroomId }: { dataroomId: string }) {
};
}
type DataroomVisitsResponse = {
views: any[];
hiddenFromPause: number;
};
export function useDataroomVisits({
dataroomId,
groupId,
@@ -362,7 +367,7 @@ export function useDataroomVisits({
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { data: views, error } = useSWR<any[]>(
const { data, error } = useSWR<DataroomVisitsResponse>(
teamId &&
dataroomId &&
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/views`,
@@ -373,8 +378,9 @@ export function useDataroomVisits({
);
return {
views,
loading: !error && !views,
views: data?.views,
hiddenFromPause: data?.hiddenFromPause ?? 0,
loading: !error && !data,
error,
};
}
+1
View File
@@ -118,6 +118,7 @@ type TStatsData = {
hiddenViewCount: number;
viewsWithDuration: ViewWithDuration[];
totalViews: number;
hiddenFromPause: number;
};
export function useDocumentVisits(page: number, limit: number) {
+8 -4
View File
@@ -20,6 +20,7 @@ export async function recordLinkView({
documentId,
dataroomId,
enableNotification,
isPaused,
}: {
req: NextRequest;
clickId: string;
@@ -29,6 +30,7 @@ export async function recordLinkView({
documentId?: string;
dataroomId?: string;
enableNotification: boolean | null;
isPaused: boolean;
}) {
const ua = userAgent(req);
const bot = isBot(ua.ua);
@@ -108,10 +110,12 @@ export async function recordLinkView({
enableNotification ? sendNotification({ viewId, locationData }) : null,
// send webhook event
sendLinkViewWebhook({
teamId,
clickData,
}),
!isPaused
? sendLinkViewWebhook({
teamId,
clickData,
})
: null,
]);
return clickData;
+3
View File
@@ -0,0 +1,3 @@
import { automaticUnpauseTask } from "@/ee/features/billing/cancellation/lib/trigger/unpause-task";
export { automaticUnpauseTask };
+67 -7
View File
@@ -69,12 +69,20 @@ export default async function handler(
},
},
},
select: {
id: true,
plan: true,
pauseStartsAt: true,
},
});
if (!team) {
return res.status(401).json({ error: "Unauthorized" });
}
// Get pause date filter if team is paused
const pauseStartsAt = team.pauseStartsAt;
// Check if free plan user is trying to access data beyond 30 days
if (interval === "custom" && team.plan.includes("free")) {
const thirtyDaysAgo = new Date();
@@ -428,12 +436,19 @@ export default async function handler(
}
case "visitors": {
// Build interval filter that respects pause date
const visitorsIntervalFilter: any = {
gte: startDate,
lte: endDate,
...(pauseStartsAt && { lt: pauseStartsAt }),
};
const viewers = await prisma.viewer.findMany({
where: {
teamId,
views: {
some: {
viewedAt: intervalFilter,
viewedAt: visitorsIntervalFilter,
isArchived: false,
viewType: "DOCUMENT_VIEW",
},
@@ -446,13 +461,28 @@ export default async function handler(
},
where: {
viewType: "DOCUMENT_VIEW",
viewedAt: intervalFilter,
viewedAt: visitorsIntervalFilter,
isArchived: false,
},
},
},
});
// Count hidden views from pause
const hiddenFromPause = pauseStartsAt
? await prisma.view.count({
where: {
teamId,
viewedAt: {
gte: pauseStartsAt,
lte: endDate,
},
isArchived: false,
viewType: "DOCUMENT_VIEW",
},
})
: 0;
// Transform the data to match the table requirements
const transformedVisitors = await Promise.all(
viewers.map(async (viewer) => {
@@ -480,8 +510,10 @@ export default async function handler(
}
// Get the name from the most recent view that has a name
const viewerName = viewer.views.find((v) => v.viewerName)?.viewerName;
const viewerName = viewer.views.find(
(v) => v.viewerName,
)?.viewerName;
return {
email: viewer.email,
viewerId: viewer.id,
@@ -495,14 +527,24 @@ export default async function handler(
}),
);
return res.status(200).json(transformedVisitors);
return res.status(200).json({
visitors: transformedVisitors,
hiddenFromPause,
});
}
case "views": {
// Build interval filter that respects pause date
const viewsIntervalFilter: any = {
gte: startDate,
lte: endDate,
...(pauseStartsAt && { lt: pauseStartsAt }),
};
const views = await prisma.view.findMany({
where: {
teamId,
viewedAt: intervalFilter,
viewedAt: viewsIntervalFilter,
isArchived: false,
viewType: "DOCUMENT_VIEW",
},
@@ -535,6 +577,21 @@ export default async function handler(
},
});
// Count hidden views from pause
const hiddenFromPause = pauseStartsAt
? await prisma.view.count({
where: {
teamId,
viewedAt: {
gte: pauseStartsAt,
lte: endDate,
},
isArchived: false,
viewType: "DOCUMENT_VIEW",
},
})
: 0;
// Transform the data to match the table requirements
const transformedViews = await Promise.all(
views.map(async (view) => {
@@ -587,7 +644,10 @@ export default async function handler(
}),
);
return res.status(200).json(transformedViews);
return res.status(200).json({
views: transformedViews,
hiddenFromPause,
});
}
default: {
+49 -22
View File
@@ -1,8 +1,11 @@
import { NextApiRequest, NextApiResponse } from "next";
import { sendViewedDataroomEmail } from "@/lib/emails/send-viewed-dataroom";
import { sendViewedDataroomPausedEmail } from "@/lib/emails/send-viewed-dataroom-paused";
import { sendViewedDocumentEmail } from "@/lib/emails/send-viewed-document";
import { sendViewedDocumentPausedEmail } from "@/lib/emails/send-viewed-document-paused";
import prisma from "@/lib/prisma";
import { isTeamPausedById } from "@/lib/team/is-team-paused";
import { log } from "@/lib/utils";
export const config = {
@@ -58,6 +61,7 @@ export default async function handle(
team: {
plan: string | null;
ignoredDomains: string[] | null;
pauseStartsAt: Date | null;
} | null;
} | null;
@@ -191,6 +195,9 @@ export default async function handle(
try {
const adminEmail = users.find((user) => user.role === "ADMIN")?.user.email;
// Check if team is paused
const teamIsPaused = await isTeamPausedById(teamId);
if (view.viewType === "DOCUMENT_VIEW") {
const teamMembers = users
.map((user) => user.user.email!)
@@ -205,29 +212,49 @@ export default async function handle(
teamMembers.push(ownerEmail);
}
// send email to document owner that document
await sendViewedDocumentEmail({
ownerEmail: adminEmail!,
documentId: view.document!.id,
documentName: view.document!.name,
linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,
viewerEmail: view.viewerEmail,
teamMembers,
locationString: includeLocation ? locationString : undefined,
});
// send appropriate email based on team pause status
if (teamIsPaused) {
await sendViewedDocumentPausedEmail({
ownerEmail: adminEmail!,
documentName: view.document!.name,
linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,
teamMembers,
});
} else {
await sendViewedDocumentEmail({
ownerEmail: adminEmail!,
documentId: view.document!.id,
documentName: view.document!.name,
linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,
viewerEmail: view.viewerEmail,
teamMembers,
locationString: includeLocation ? locationString : undefined,
});
}
} else {
// send email to dataroom owner that dataroom
await sendViewedDataroomEmail({
ownerEmail: adminEmail!,
dataroomId: view.dataroom!.id,
dataroomName: view.dataroom!.name,
viewerEmail: view.viewerEmail,
linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,
teamMembers: users
.map((user) => user.user.email!)
.filter((email) => email !== adminEmail),
locationString: includeLocation ? locationString : undefined,
});
// send appropriate email based on team pause status
if (teamIsPaused) {
await sendViewedDataroomPausedEmail({
ownerEmail: adminEmail!,
dataroomName: view.dataroom!.name,
linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,
teamMembers: users
.map((user) => user.user.email!)
.filter((email) => email !== adminEmail),
});
} else {
await sendViewedDataroomEmail({
ownerEmail: adminEmail!,
dataroomId: view.dataroom!.id,
dataroomName: view.dataroom!.name,
viewerEmail: view.viewerEmail,
linkName: view.link!.name || `Link #${view.linkId.slice(-5)}`,
teamMembers: users
.map((user) => user.user.email!)
.filter((email) => email !== adminEmail),
locationString: includeLocation ? locationString : undefined,
});
}
}
res.status(200).json({ message: "Successfully sent notification", viewId });
+18 -1
View File
@@ -54,7 +54,8 @@ export default async function handle(
| "manage"
| "invoices"
| "subscription_update"
| "payment_method_update";
| "payment_method_update"
| "cancellation";
};
try {
const team = await prisma.team.findUnique({
@@ -134,6 +135,22 @@ export default async function handle(
},
},
}),
...(type === "cancellation" && {
flow_data: {
type: "subscription_cancel",
subscription_cancel: {
subscription: team.subscriptionId,
},
after_completion: {
type: "redirect",
redirect: {
return_url:
return_url ??
`${process.env.NEXTAUTH_URL}/settings/billing?cancellation=true`,
},
},
},
}),
});
waitUntil(identifyUser(userEmail ?? userId));
+9
View File
@@ -8,6 +8,7 @@ import { getServerSession } from "next-auth/next";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { isTeamPaused } from "@/lib/team/is-team-paused";
import { CustomUser } from "@/lib/types";
import { authOptions } from "../../../auth/[...nextauth]";
@@ -43,7 +44,9 @@ export default async function handle(
subscriptionId: true,
startsAt: true,
endsAt: true,
pausedAt: true,
pauseStartsAt: true,
pauseEndsAt: true,
cancelledAt: true,
},
});
@@ -86,13 +89,19 @@ export default async function handle(
}
}
// Calculate if team is currently paused
const isPaused = isTeamPaused(team);
return res.status(200).json({
plan: team.plan,
startsAt: team.startsAt,
endsAt: team.endsAt,
isCustomer,
subscriptionCycle,
pausedAt: team.pausedAt,
pauseStartsAt: team.pauseStartsAt,
pauseEndsAt: team.pauseEndsAt,
isPaused,
cancelledAt: team.cancelledAt,
discount,
});
@@ -42,6 +42,7 @@ export default async function handle(
},
select: {
id: true,
pauseStartsAt: true,
},
});
@@ -49,6 +50,8 @@ export default async function handle(
return res.status(403).end("Unauthorized to access this team");
}
const pauseStartedAt = team.pauseStartsAt;
const dataroom = await prisma.dataroom.findUnique({
where: {
id: dataroomId,
@@ -62,6 +65,11 @@ export default async function handle(
where: {
viewType: "DATAROOM_VIEW",
groupId: groupId,
...(pauseStartedAt && {
viewedAt: {
lt: pauseStartedAt,
},
}),
},
orderBy: {
viewedAt: "desc",
@@ -101,6 +109,20 @@ export default async function handle(
},
});
// Calculate hidden views due to pause (views after pause date)
const hiddenViewsFromPause = pauseStartedAt
? await prisma.view.count({
where: {
dataroomId: dataroomId,
viewType: "DATAROOM_VIEW",
groupId: groupId,
viewedAt: {
gte: pauseStartedAt,
},
},
})
: 0;
const views = dataroom?.views || [];
const returnViews = views.map((view) => {
@@ -111,7 +133,10 @@ export default async function handle(
};
});
return res.status(200).json(returnViews);
return res.status(200).json({
views: returnViews,
hiddenFromPause: hiddenViewsFromPause,
});
} catch (error) {
log({
message: `Failed to get views for dataroom: _${dataroomId}_. \n\n ${error} \n\n*Metadata*: \`{teamId: ${teamId}, userId: ${userId}}\``,
@@ -37,6 +37,7 @@ export default async function handle(
},
select: {
id: true,
pauseStartsAt: true,
},
});
@@ -44,6 +45,8 @@ export default async function handle(
return res.status(403).end("Unauthorized to access this team");
}
console.log("pauseStartsAt", team.pauseStartsAt);
const dataroom = await prisma.dataroom.findUnique({
where: {
id: dataroomId,
@@ -56,6 +59,11 @@ export default async function handle(
views: {
where: {
viewType: "DATAROOM_VIEW",
...(team.pauseStartsAt && {
viewedAt: {
lt: team.pauseStartsAt,
},
}),
},
orderBy: {
viewedAt: "desc",
@@ -95,8 +103,24 @@ export default async function handle(
},
});
// Calculate hidden views due to pause (views after pause date)
const hiddenViewsFromPause = team.pauseStartsAt
? await prisma.view.count({
where: {
dataroomId: dataroomId,
viewType: "DATAROOM_VIEW",
viewedAt: {
gte: team.pauseStartsAt,
},
},
})
: 0;
const views = dataroom?.views || [];
console.log("views", views);
console.log("hiddenViewsFromPause", hiddenViewsFromPause);
const returnViews = views.map((view) => {
return {
...view,
@@ -105,7 +129,10 @@ export default async function handle(
};
});
return res.status(200).json(returnViews);
return res.status(200).json({
views: returnViews,
hiddenFromPause: hiddenViewsFromPause,
});
} catch (error) {
log({
message: `Failed to get views for dataroom: _${dataroomId}_. \n\n ${error} \n\n*Metadata*: \`{teamId: ${teamId}, userId: ${userId}}\``,
@@ -8,6 +8,7 @@ import { getServerSession } from "next-auth/next";
import { LIMITS } from "@/lib/constants";
import { errorhandler } from "@/lib/errorHandler";
import prisma from "@/lib/prisma";
import { isTeamPaused } from "@/lib/team/is-team-paused";
import { getViewPageDuration } from "@/lib/tinybird";
import { getVideoEventsByDocument } from "@/lib/tinybird/pipes";
import { CustomUser } from "@/lib/types";
@@ -178,16 +179,17 @@ export default async function handle(
}
const { teamId, id: docId } = req.query as { teamId: string; id: string };
// Parse and validate pagination parameters
const rawPage = Number.parseInt((req.query.page as string) || "1", 10);
const rawLimit = Number.parseInt((req.query.limit as string) || "10", 10);
// Apply defaults for invalid values and enforce constraints
const page = Number.isNaN(rawPage) || rawPage < 1 ? 1 : rawPage;
const limit = Number.isNaN(rawLimit) || rawLimit < 1
? 10
: Math.min(Math.max(rawLimit, 1), 100); // Min 1, Max 100
const limit =
Number.isNaN(rawLimit) || rawLimit < 1
? 10
: Math.min(Math.max(rawLimit, 1), 100); // Min 1, Max 100
const offset = (page - 1) * limit;
const userId = (session.user as CustomUser).id;
@@ -202,7 +204,12 @@ export default async function handle(
},
},
},
select: { plan: true },
select: {
plan: true,
pausedAt: true,
pauseStartsAt: true,
pauseEndsAt: true,
},
});
if (!team) {
@@ -238,12 +245,22 @@ export default async function handle(
return res.status(404).end("Document not found");
}
const pauseStartedAt = team.pauseStartsAt;
// Build where clause for views - if team is paused, only show views before pause date
const viewsWhereClause = {
documentId: docId,
isArchived: false,
...(pauseStartedAt && {
viewedAt: {
lt: pauseStartedAt,
},
}),
};
// Check if document has any views first to avoid expensive query
const viewCount = await prisma.view.count({
where: {
documentId: docId,
isArchived: false,
},
where: viewsWhereClause,
});
if (viewCount === 0) {
@@ -259,6 +276,11 @@ export default async function handle(
take: limit,
where: {
documentId: docId,
...(pauseStartedAt && {
viewedAt: {
lt: pauseStartedAt,
},
}),
},
orderBy: {
viewedAt: "desc",
@@ -306,7 +328,28 @@ export default async function handle(
},
});
// filter the last 20 views
// Get total view count (including views after pause date for accurate count)
const totalViewCount = await prisma.view.count({
where: {
documentId: docId,
isArchived: false,
},
});
// Calculate hidden views due to pause (views after pause date)
const hiddenViewsFromPause = pauseStartedAt
? await prisma.view.count({
where: {
documentId: docId,
isArchived: false,
viewedAt: {
gte: pauseStartedAt,
},
},
})
: 0;
// filter the last 20 views for free plan
const limitedViews =
team.plan === "free" && offset >= LIMITS.views ? [] : views;
@@ -330,10 +373,15 @@ export default async function handle(
internal: users.some((user) => user.email === view.viewerEmail),
}));
// Calculate total hidden views (free plan limits + paused team filtering)
const hiddenFromFreePlan = views.length - limitedViews.length;
const totalHiddenViews = hiddenFromFreePlan + hiddenViewsFromPause;
return res.status(200).json({
viewsWithDuration,
hiddenViewCount: views.length - limitedViews.length,
totalViews: document._count.views || 0,
hiddenViewCount: totalHiddenViews,
totalViews: totalViewCount,
hiddenFromPause: hiddenViewsFromPause, // Optional: to show specific pause-related hidden count
});
} catch (error) {
log({