mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
feat: show recent exports
This commit is contained in:
@@ -3,6 +3,10 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ExportJob } from "@/lib/redis-job-store";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ExportStatus {
|
||||
status: string;
|
||||
progress?: string;
|
||||
@@ -31,6 +35,9 @@ export function ExportVisitsModal({
|
||||
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [viewCount, setViewCount] = useState<number | null>(null);
|
||||
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
|
||||
const [showNewExport, setShowNewExport] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const exportStartedRef = useRef<boolean>(false);
|
||||
|
||||
@@ -43,18 +50,45 @@ export function ExportVisitsModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startExport = useCallback(async () => {
|
||||
// Fetch existing exports when modal opens
|
||||
useEffect(() => {
|
||||
const fetchExistingExports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowModal(true);
|
||||
|
||||
const endpoint = groupId
|
||||
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
|
||||
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
|
||||
|
||||
const response = await fetch(endpoint, { method: "GET" });
|
||||
|
||||
if (response.ok) {
|
||||
const exports = await response.json();
|
||||
setExistingExports(exports);
|
||||
} else {
|
||||
console.error("Failed to fetch existing exports");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching existing exports:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExistingExports();
|
||||
}, [teamId, dataroomId, groupId]);
|
||||
|
||||
const startNewExport = useCallback(async () => {
|
||||
// Prevent double triggering
|
||||
if (exportStartedRef.current) {
|
||||
console.warn("Export already started, skipping duplicate request");
|
||||
return;
|
||||
}
|
||||
exportStartedRef.current = true;
|
||||
setShowNewExport(true);
|
||||
|
||||
try {
|
||||
// Show modal immediately
|
||||
setShowModal(true);
|
||||
|
||||
// Get view count first
|
||||
try {
|
||||
const viewCountResponse = await fetch(
|
||||
@@ -72,10 +106,11 @@ export function ExportVisitsModal({
|
||||
}
|
||||
|
||||
// Trigger the background export job
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/datarooms/${dataroomId}${groupId ? `/groups/${groupId}` : ""}/export-visits`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const endpoint = groupId
|
||||
? `/api/teams/${teamId}/datarooms/${dataroomId}/groups/${groupId}/export-visits`
|
||||
: `/api/teams/${teamId}/datarooms/${dataroomId}/export-visits`;
|
||||
|
||||
const response = await fetch(endpoint, { method: "POST" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
@@ -174,11 +209,6 @@ export function ExportVisitsModal({
|
||||
}
|
||||
}, [teamId, dataroomId, groupId, dataroomName, groupName]);
|
||||
|
||||
// Start export immediately when component mounts
|
||||
useEffect(() => {
|
||||
startExport();
|
||||
}, [startExport]);
|
||||
|
||||
// Send export via email
|
||||
const sendExportEmail = async () => {
|
||||
if (!exportStatus?.exportId || !session?.user?.email) return;
|
||||
@@ -224,6 +254,49 @@ export function ExportVisitsModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Download existing export
|
||||
const downloadExport = async (exportId: string, resourceName: string) => {
|
||||
try {
|
||||
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
|
||||
const link = window.document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`${resourceName || dataroomName}_${groupName ? `${groupName}_` : ""}visits_${new Date().toISOString().split("T")[0]}.csv`,
|
||||
);
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.display = "none";
|
||||
|
||||
window.document.body.appendChild(link);
|
||||
link.click();
|
||||
window.document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error("Failed to download export");
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Get status badge color
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "COMPLETED":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
|
||||
case "PROCESSING":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "PENDING":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
case "FAILED":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close - cleanup and call parent onClose
|
||||
const handleClose = () => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -232,6 +305,10 @@ export function ExportVisitsModal({
|
||||
}
|
||||
setShowModal(false);
|
||||
setExportStatus(null);
|
||||
setShowNewExport(false);
|
||||
setExistingExports([]); // Reset existing exports
|
||||
setViewCount(null); // Reset view count
|
||||
setLoading(true); // Reset loading state
|
||||
exportStartedRef.current = false; // Reset for potential reuse
|
||||
onClose();
|
||||
};
|
||||
@@ -247,7 +324,7 @@ export function ExportVisitsModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Export Visits</h3>
|
||||
<button
|
||||
@@ -270,60 +347,141 @@ export function ExportVisitsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{exportStatus?.progress || "Processing export..."}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
Exporting visits for: {displayName}
|
||||
</div>
|
||||
|
||||
{viewCount !== null && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
|
||||
) : showNewExport ? (
|
||||
// Show export progress
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{exportStatus?.progress || "Processing export..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewCount !== null && viewCount > 10 && session?.user?.email && (
|
||||
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
|
||||
<p className="mb-2 font-medium text-muted-foreground">
|
||||
Large export detected ({viewCount} visits)
|
||||
</p>
|
||||
<p className="mb-3 text-muted-foreground">
|
||||
This export may take several minutes. We recommend getting it
|
||||
emailed to you when ready.
|
||||
</p>
|
||||
<div className="text-sm text-gray-600">
|
||||
Exporting visits for: {displayName}
|
||||
</div>
|
||||
|
||||
{viewCount !== null && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Found {viewCount} visit{viewCount !== 1 ? "s" : ""} to export
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewCount !== null && viewCount > 10 && session?.user?.email && (
|
||||
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
|
||||
<p className="mb-2 font-medium text-muted-foreground">
|
||||
Large export detected ({viewCount} visits)
|
||||
</p>
|
||||
<p className="mb-3 text-muted-foreground">
|
||||
This export may take several minutes. We recommend getting it
|
||||
emailed to you when ready.
|
||||
</p>
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Email to {session.user.email}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel button - only show if export is in progress */}
|
||||
{exportStatus?.exportId && (
|
||||
<div className="flex gap-2 px-3">
|
||||
<Button
|
||||
onClick={cancelExport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Show existing exports and new export option
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Exports for: {displayName}
|
||||
</div>
|
||||
|
||||
{existingExports.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Recent Exports
|
||||
</h4>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{existingExports.map((exportJob) => (
|
||||
<div
|
||||
key={exportJob.id}
|
||||
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
|
||||
>
|
||||
{exportJob.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(exportJob.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{exportJob.groupId && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
Group: {exportJob.groupId}
|
||||
</div>
|
||||
)}
|
||||
{exportJob.error && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{exportJob.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{exportJob.status === "COMPLETED" && exportJob.result && (
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadExport(
|
||||
exportJob.id,
|
||||
exportJob.resourceName || dataroomName,
|
||||
)
|
||||
}
|
||||
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
No previous exports found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
onClick={startNewExport}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Email to {session.user.email}
|
||||
Start New Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel button - only show if export is in progress */}
|
||||
{exportStatus?.exportId && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={cancelExport}
|
||||
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
|
||||
>
|
||||
Cancel Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Document } from "@prisma/client";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { ExportJob } from "@/lib/redis-job-store";
|
||||
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
interface ExportStatus {
|
||||
@@ -28,6 +30,9 @@ export function ExportVisitsModal({
|
||||
const [exportStatus, setExportStatus] = useState<ExportStatus | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [viewCount, setViewCount] = useState<number | null>(null);
|
||||
const [existingExports, setExistingExports] = useState<ExportJob[]>([]);
|
||||
const [showNewExport, setShowNewExport] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const exportStartedRef = useRef<boolean>(false);
|
||||
|
||||
@@ -40,18 +45,44 @@ export function ExportVisitsModal({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startExport = useCallback(async () => {
|
||||
// Fetch existing exports when modal opens
|
||||
useEffect(() => {
|
||||
const fetchExistingExports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setShowModal(true);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/documents/${document.id}/export-visits`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const exports = await response.json();
|
||||
setExistingExports(exports);
|
||||
} else {
|
||||
console.error("Failed to fetch existing exports");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching existing exports:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchExistingExports();
|
||||
}, [teamId, document.id]);
|
||||
|
||||
const startNewExport = useCallback(async () => {
|
||||
// Prevent double triggering
|
||||
if (exportStartedRef.current) {
|
||||
console.warn("Export already started, skipping duplicate request");
|
||||
return;
|
||||
}
|
||||
exportStartedRef.current = true;
|
||||
setShowNewExport(true);
|
||||
|
||||
try {
|
||||
// Show modal immediately
|
||||
setShowModal(true);
|
||||
|
||||
// Get view count first
|
||||
try {
|
||||
const viewCountResponse = await fetch(
|
||||
@@ -169,12 +200,7 @@ export function ExportVisitsModal({
|
||||
);
|
||||
handleClose();
|
||||
}
|
||||
}, [document.id, teamId]);
|
||||
|
||||
// Start export immediately when component mounts
|
||||
useEffect(() => {
|
||||
startExport();
|
||||
}, [startExport]);
|
||||
}, [document.id, teamId, document.name]);
|
||||
|
||||
// Send export via email
|
||||
const sendExportEmail = async () => {
|
||||
@@ -221,6 +247,49 @@ export function ExportVisitsModal({
|
||||
}
|
||||
};
|
||||
|
||||
// Download existing export
|
||||
const downloadExport = async (exportId: string, resourceName: string) => {
|
||||
try {
|
||||
const downloadUrl = `/api/teams/${teamId}/export-jobs/${exportId}?download=true`;
|
||||
const link = window.document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.setAttribute(
|
||||
"download",
|
||||
`${resourceName || document.name}_visits_${new Date().toISOString().split("T")[0]}.csv`,
|
||||
);
|
||||
link.rel = "noopener noreferrer";
|
||||
link.style.display = "none";
|
||||
|
||||
window.document.body.appendChild(link);
|
||||
link.click();
|
||||
window.document.body.removeChild(link);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error("Failed to download export");
|
||||
}
|
||||
};
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
// Get status badge color
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case "COMPLETED":
|
||||
return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300";
|
||||
case "PROCESSING":
|
||||
return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300";
|
||||
case "PENDING":
|
||||
return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300";
|
||||
case "FAILED":
|
||||
return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300";
|
||||
default:
|
||||
return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-300";
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close - cleanup and call parent onClose
|
||||
const handleClose = () => {
|
||||
if (pollIntervalRef.current) {
|
||||
@@ -229,6 +298,10 @@ export function ExportVisitsModal({
|
||||
}
|
||||
setShowModal(false);
|
||||
setExportStatus(null);
|
||||
setShowNewExport(false);
|
||||
setExistingExports([]); // Reset existing exports
|
||||
setViewCount(null); // Reset view count
|
||||
setLoading(true); // Reset loading state
|
||||
exportStartedRef.current = false; // Reset for potential reuse
|
||||
onClose();
|
||||
};
|
||||
@@ -240,7 +313,7 @@ export function ExportVisitsModal({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6">
|
||||
<div className="mx-4 w-full max-w-md rounded-lg bg-white p-6 dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Export Visits</h3>
|
||||
<button
|
||||
@@ -263,58 +336,132 @@ export function ExportVisitsModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{exportStatus?.progress || "Processing export..."}
|
||||
</span>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
</div>
|
||||
|
||||
{viewCount !== null && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
|
||||
) : showNewExport ? (
|
||||
// Show export progress
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-muted-foreground"></div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{exportStatus?.progress || "Processing export..."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewCount !== null && viewCount > 10 && session?.user?.email && (
|
||||
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
|
||||
<p className="mb-2 font-medium text-muted-foreground">
|
||||
Large export detected ({viewCount} views)
|
||||
</p>
|
||||
<p className="mb-3 text-muted-foreground">
|
||||
This export may take several minutes. We recommend getting it
|
||||
emailed to you when ready.
|
||||
</p>
|
||||
{viewCount !== null && (
|
||||
<div className="text-sm text-gray-600">
|
||||
Found {viewCount} view{viewCount !== 1 ? "s" : ""} to export
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewCount !== null && viewCount > 10 && session?.user?.email && (
|
||||
<div className="rounded-md bg-gray-50 p-3 text-sm dark:bg-gray-900">
|
||||
<p className="mb-2 font-medium text-muted-foreground">
|
||||
Large export detected ({viewCount} views)
|
||||
</p>
|
||||
<p className="mb-3 text-muted-foreground">
|
||||
This export may take several minutes. We recommend getting it
|
||||
emailed to you when ready.
|
||||
</p>
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Email to {session.user.email}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel button - only show if export is in progress */}
|
||||
{exportStatus?.exportId && (
|
||||
<div className="flex gap-2 px-3">
|
||||
<Button
|
||||
onClick={cancelExport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Show existing exports and new export option
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
Exports for: {document.name}
|
||||
</div>
|
||||
|
||||
{existingExports.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Recent Exports
|
||||
</h4>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{existingExports.map((exportJob) => (
|
||||
<div
|
||||
key={exportJob.id}
|
||||
className="flex items-center justify-between rounded-md border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-medium ${getStatusColor(exportJob.status)}`}
|
||||
>
|
||||
{exportJob.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{formatDate(exportJob.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{exportJob.error && (
|
||||
<p className="mt-1 text-xs text-red-600">
|
||||
{exportJob.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{exportJob.status === "COMPLETED" && exportJob.result && (
|
||||
<button
|
||||
onClick={() =>
|
||||
downloadExport(
|
||||
exportJob.id,
|
||||
exportJob.resourceName || document.name,
|
||||
)
|
||||
}
|
||||
className="ml-2 rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
No previous exports found
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<button
|
||||
onClick={sendExportEmail}
|
||||
onClick={startNewExport}
|
||||
className="w-full rounded-md bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/80"
|
||||
>
|
||||
Email to {session.user.email}
|
||||
Start New Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!viewCount || viewCount <= 10) && (
|
||||
<div className="text-sm text-gray-500">
|
||||
Your export will be ready shortly...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cancel button - only show if export is in progress */}
|
||||
{exportStatus?.exportId && (
|
||||
<div className="flex gap-2 px-3">
|
||||
<Button
|
||||
onClick={cancelExport}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 rounded-md border border-red-300 bg-white px-4 py-2 text-sm text-red-600 transition-colors hover:bg-red-50 hover:text-red-800 dark:border-red-600 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-950"
|
||||
>
|
||||
Cancel Export
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -254,6 +254,27 @@ export class RedisJobStore {
|
||||
);
|
||||
}
|
||||
|
||||
async getResourceJobs(
|
||||
resourceId: string,
|
||||
teamId: string,
|
||||
type?: "document" | "dataroom" | "dataroom-group",
|
||||
groupId?: string,
|
||||
limit: number = 10,
|
||||
): Promise<ExportJob[]> {
|
||||
const teamJobs = await this.getTeamJobs(teamId, limit * 2); // Get more to filter
|
||||
|
||||
// Filter jobs by resource and type
|
||||
return teamJobs
|
||||
.filter((job) => {
|
||||
const matchesResource = job.resourceId === resourceId;
|
||||
const matchesType = !type || job.type === type;
|
||||
const matchesGroup = !groupId || job.groupId === groupId;
|
||||
const matchStatus = job.status !== "FAILED";
|
||||
return matchesResource && matchesType && matchesGroup && matchStatus;
|
||||
})
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getUserTeamJobs(
|
||||
userId: string,
|
||||
teamId: string,
|
||||
|
||||
@@ -3,15 +3,63 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { jobStore } from "@/lib/redis-job-store";
|
||||
import { exportVisitsTask } from "@/lib/trigger/export-visits";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === "GET") {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
return res.status(401).end("Unauthorized");
|
||||
}
|
||||
|
||||
const { teamId, id: dataroomId } = req.query as {
|
||||
teamId: string;
|
||||
id: string;
|
||||
};
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
try {
|
||||
// Verify team access
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).end("Team not found");
|
||||
}
|
||||
|
||||
// Get existing exports for this dataroom
|
||||
const existingExports = await jobStore.getResourceJobs(
|
||||
dataroomId,
|
||||
teamId,
|
||||
"dataroom",
|
||||
undefined,
|
||||
10,
|
||||
);
|
||||
|
||||
return res.status(200).json(existingExports);
|
||||
} catch (error) {
|
||||
console.error("Error fetching existing exports:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch existing exports" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
// Changed to POST to trigger background job
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
@@ -89,11 +137,7 @@ export default async function handler(
|
||||
},
|
||||
{
|
||||
idempotencyKey: exportJob.id,
|
||||
tags: [
|
||||
`team_${teamId}`,
|
||||
`user_${userId}`,
|
||||
`export_${exportJob.id}`,
|
||||
],
|
||||
tags: [`team_${teamId}`, `user_${userId}`, `export_${exportJob.id}`],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -105,7 +149,8 @@ export default async function handler(
|
||||
return res.status(200).json({
|
||||
exportId: updatedJob?.id || exportJob.id,
|
||||
status: updatedJob?.status || exportJob.status,
|
||||
message: "Export job created successfully. You will be notified when it's ready.",
|
||||
message:
|
||||
"Export job created successfully. You will be notified when it's ready.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating export job:", error);
|
||||
|
||||
@@ -3,15 +3,68 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { jobStore } from "@/lib/redis-job-store";
|
||||
import { exportVisitsTask } from "@/lib/trigger/export-visits";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === "GET") {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
return res.status(401).end("Unauthorized");
|
||||
}
|
||||
|
||||
const {
|
||||
teamId,
|
||||
id: dataroomId,
|
||||
groupId,
|
||||
} = req.query as {
|
||||
teamId: string;
|
||||
id: string;
|
||||
groupId: string;
|
||||
};
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
try {
|
||||
// Verify team access
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).end("Team not found");
|
||||
}
|
||||
|
||||
// Get existing exports for this dataroom group
|
||||
const existingExports = await jobStore.getResourceJobs(
|
||||
dataroomId,
|
||||
teamId,
|
||||
"dataroom",
|
||||
groupId,
|
||||
10,
|
||||
);
|
||||
|
||||
return res.status(200).json(existingExports);
|
||||
} catch (error) {
|
||||
console.error("Error fetching existing exports:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch existing exports" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
// Changed to POST to trigger background job
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
@@ -96,11 +149,7 @@ export default async function handler(
|
||||
},
|
||||
{
|
||||
idempotencyKey: exportJob.id,
|
||||
tags: [
|
||||
`team_${teamId}`,
|
||||
`user_${userId}`,
|
||||
`export_${exportJob.id}`,
|
||||
],
|
||||
tags: [`team_${teamId}`, `user_${userId}`, `export_${exportJob.id}`],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,7 +161,8 @@ export default async function handler(
|
||||
return res.status(200).json({
|
||||
exportId: updatedJob?.id || exportJob.id,
|
||||
status: updatedJob?.status || exportJob.status,
|
||||
message: "Export job created successfully. You will be notified when it's ready.",
|
||||
message:
|
||||
"Export job created successfully. You will be notified when it's ready.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating export job:", error);
|
||||
|
||||
@@ -3,15 +3,63 @@ import { NextApiRequest, NextApiResponse } from "next";
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { getServerSession } from "next-auth/next";
|
||||
|
||||
import prisma from "@/lib/prisma";
|
||||
import { jobStore } from "@/lib/redis-job-store";
|
||||
import { exportVisitsTask } from "@/lib/trigger/export-visits";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === "GET") {
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
return res.status(401).end("Unauthorized");
|
||||
}
|
||||
|
||||
const { teamId, id: docId } = req.query as {
|
||||
teamId: string;
|
||||
id: string;
|
||||
};
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
try {
|
||||
// Verify team access
|
||||
const team = await prisma.team.findUnique({
|
||||
where: {
|
||||
id: teamId,
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: { plan: true },
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return res.status(404).end("Team not found");
|
||||
}
|
||||
|
||||
// Get existing exports for this document
|
||||
const existingExports = await jobStore.getResourceJobs(
|
||||
docId,
|
||||
teamId,
|
||||
"document",
|
||||
undefined,
|
||||
10,
|
||||
);
|
||||
|
||||
return res.status(200).json(existingExports);
|
||||
} catch (error) {
|
||||
console.error("Error fetching existing exports:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch existing exports" });
|
||||
}
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
// Changed to POST to trigger background job
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
@@ -86,11 +134,7 @@ export default async function handler(
|
||||
},
|
||||
{
|
||||
idempotencyKey: exportJob.id,
|
||||
tags: [
|
||||
`team_${teamId}`,
|
||||
`user_${userId}`,
|
||||
`export_${exportJob.id}`,
|
||||
],
|
||||
tags: [`team_${teamId}`, `user_${userId}`, `export_${exportJob.id}`],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -102,7 +146,8 @@ export default async function handler(
|
||||
return res.status(200).json({
|
||||
exportId: updatedJob?.id || exportJob.id,
|
||||
status: updatedJob?.status || exportJob.status,
|
||||
message: "Export job created successfully. You will be notified when it's ready.",
|
||||
message:
|
||||
"Export job created successfully. You will be notified when it's ready.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating export job:", error);
|
||||
|
||||
Reference in New Issue
Block a user