feat: improve pause functionality
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ type TStatsData = {
|
||||
hiddenViewCount: number;
|
||||
viewsWithDuration: ViewWithDuration[];
|
||||
totalViews: number;
|
||||
hiddenFromPause: number;
|
||||
};
|
||||
|
||||
export function useDocumentVisits(page: number, limit: number) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { automaticUnpauseTask } from "@/ee/features/billing/cancellation/lib/trigger/unpause-task";
|
||||
|
||||
export { automaticUnpauseTask };
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user