mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
feat: add progress indication for long downloads
This commit is contained in:
@@ -3,6 +3,7 @@ import { fileExists, fullPathForFile } from "@/lib/files"
|
|||||||
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
|
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
|
||||||
import { getFields } from "@/models/fields"
|
import { getFields } from "@/models/fields"
|
||||||
import { getFilesByTransactionId } from "@/models/files"
|
import { getFilesByTransactionId } from "@/models/files"
|
||||||
|
import { incrementProgress, updateProgress } from "@/models/progress"
|
||||||
import { getTransactions } from "@/models/transactions"
|
import { getTransactions } from "@/models/transactions"
|
||||||
import { format } from "@fast-csv/format"
|
import { format } from "@fast-csv/format"
|
||||||
import { formatDate } from "date-fns"
|
import { formatDate } from "date-fns"
|
||||||
@@ -14,12 +15,14 @@ import { Readable } from "stream"
|
|||||||
|
|
||||||
const TRANSACTIONS_CHUNK_SIZE = 300
|
const TRANSACTIONS_CHUNK_SIZE = 300
|
||||||
const FILES_CHUNK_SIZE = 50
|
const FILES_CHUNK_SIZE = 50
|
||||||
|
const PROGRESS_UPDATE_INTERVAL = 10 // files
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url)
|
||||||
const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters
|
const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters
|
||||||
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
|
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
|
||||||
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
||||||
|
const progressId = url.searchParams.get("progressId")
|
||||||
|
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
const { transactions } = await getTransactions(user.id, filters)
|
const { transactions } = await getTransactions(user.id, filters)
|
||||||
@@ -102,6 +105,11 @@ export async function GET(request: Request) {
|
|||||||
totalFilesToProcess += transactionFiles.length
|
totalFilesToProcess += transactionFiles.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update progress with total files if progressId is provided
|
||||||
|
if (progressId) {
|
||||||
|
await updateProgress(user.id, progressId, { total: totalFilesToProcess })
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Starting to process ${totalFilesToProcess} files in total`)
|
console.log(`Starting to process ${totalFilesToProcess} files in total`)
|
||||||
|
|
||||||
for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) {
|
for (let i = 0; i < transactions.length; i += FILES_CHUNK_SIZE) {
|
||||||
@@ -136,6 +144,11 @@ export async function GET(request: Request) {
|
|||||||
}${fileExtension}`,
|
}${fileExtension}`,
|
||||||
fileData
|
fileData
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Update progress every PROGRESS_UPDATE_INTERVAL files
|
||||||
|
if (progressId && totalFilesProcessed % PROGRESS_UPDATE_INTERVAL === 0) {
|
||||||
|
await incrementProgress(user.id, progressId)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`Skipping missing file: ${file.filename} for transaction ${transaction.id}`)
|
console.log(`Skipping missing file: ${file.filename} for transaction ${transaction.id}`)
|
||||||
}
|
}
|
||||||
@@ -143,6 +156,11 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final progress update
|
||||||
|
if (progressId) {
|
||||||
|
await updateProgress(user.id, progressId, { current: totalFilesToProcess })
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`Finished processing all ${totalFilesProcessed} files`)
|
console.log(`Finished processing all ${totalFilesProcessed} files`)
|
||||||
|
|
||||||
// Generate zip with progress tracking
|
// Generate zip with progress tracking
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getCurrentUser } from "@/lib/auth"
|
import { getCurrentUser } from "@/lib/auth"
|
||||||
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
|
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
|
||||||
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
|
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
|
||||||
|
import { incrementProgress, updateProgress } from "@/models/progress"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import JSZip from "jszip"
|
import JSZip from "jszip"
|
||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
@@ -8,10 +9,13 @@ import path from "path"
|
|||||||
|
|
||||||
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
|
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
|
||||||
const BACKUP_VERSION = "1.0"
|
const BACKUP_VERSION = "1.0"
|
||||||
|
const PROGRESS_UPDATE_INTERVAL = 10 // files
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: Request) {
|
||||||
const user = await getCurrentUser()
|
const user = await getCurrentUser()
|
||||||
const userUploadsDirectory = getUserUploadsDirectory(user)
|
const userUploadsDirectory = getUserUploadsDirectory(user)
|
||||||
|
const url = new URL(request.url)
|
||||||
|
const progressId = url.searchParams.get("progressId")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const zip = new JSZip()
|
const zip = new JSZip()
|
||||||
@@ -52,6 +56,13 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
||||||
|
|
||||||
|
// Update progress with total files if progressId is provided
|
||||||
|
if (progressId) {
|
||||||
|
await updateProgress(user.id, progressId, { total: uploadedFiles.length })
|
||||||
|
}
|
||||||
|
|
||||||
|
let processedFiles = 0
|
||||||
for (const file of uploadedFiles) {
|
for (const file of uploadedFiles) {
|
||||||
try {
|
try {
|
||||||
// Check file size before reading
|
// Check file size before reading
|
||||||
@@ -62,22 +73,33 @@ export async function GET() {
|
|||||||
MAX_FILE_SIZE / 1024 / 1024
|
MAX_FILE_SIZE / 1024 / 1024
|
||||||
}MB limit)`
|
}MB limit)`
|
||||||
)
|
)
|
||||||
return
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileContent = await fs.readFile(file)
|
const fileContent = await fs.readFile(file)
|
||||||
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
|
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
|
||||||
|
|
||||||
|
processedFiles++
|
||||||
|
// Update progress every PROGRESS_UPDATE_INTERVAL files
|
||||||
|
if (progressId && processedFiles % PROGRESS_UPDATE_INTERVAL === 0) {
|
||||||
|
await incrementProgress(user.id, progressId)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error reading file ${file}:`, error)
|
console.error(`Error reading file ${file}:`, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Final progress update
|
||||||
|
if (progressId) {
|
||||||
|
await updateProgress(user.id, progressId, { current: uploadedFiles.length })
|
||||||
|
}
|
||||||
|
|
||||||
const archive = await zip.generateAsync({ type: "blob" })
|
const archive = await zip.generateAsync({ type: "blob" })
|
||||||
|
|
||||||
return new NextResponse(archive, {
|
return new NextResponse(archive, {
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/octet-stream",
|
"Content-Type": "application/octet-stream",
|
||||||
"Content-Disposition": `attachment; filename="data.zip"`,
|
"Content-Disposition": `attachment; filename="taxhacker-backup.zip"`,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,24 +3,57 @@
|
|||||||
import { FormError } from "@/components/forms/error"
|
import { FormError } from "@/components/forms/error"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card } from "@/components/ui/card"
|
import { Card } from "@/components/ui/card"
|
||||||
|
import { useDownload } from "@/hooks/use-download"
|
||||||
|
import { useProgress } from "@/hooks/use-progress"
|
||||||
import { Download, Loader2 } from "lucide-react"
|
import { Download, Loader2 } from "lucide-react"
|
||||||
import Link from "next/link"
|
|
||||||
import { useActionState } from "react"
|
import { useActionState } from "react"
|
||||||
import { restoreBackupAction } from "./actions"
|
import { restoreBackupAction } from "./actions"
|
||||||
|
|
||||||
export default function BackupSettingsPage() {
|
export default function BackupSettingsPage() {
|
||||||
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
|
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
|
||||||
|
|
||||||
|
const { isLoading, startProgress, progress } = useProgress({
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Backup progress error:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { download, isDownloading } = useDownload({
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Download error:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
try {
|
||||||
|
const progressId = await startProgress("backup")
|
||||||
|
const downloadUrl = `/settings/backups/data?progressId=${progressId || ""}`
|
||||||
|
await download(downloadUrl, "taxhacker-backup.zip")
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start backup:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container flex flex-col gap-4">
|
<div className="container flex flex-col gap-4">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<h1 className="text-2xl font-bold">Download backup</h1>
|
<h1 className="text-2xl font-bold">Download backup</h1>
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex flex-row gap-4">
|
||||||
<Link href="/settings/backups/data">
|
<Button onClick={handleDownload} disabled={isLoading || isDownloading}>
|
||||||
<Button>
|
{isLoading ? (
|
||||||
<Download /> Download Data Archive
|
progress?.current ? (
|
||||||
|
`Archiving ${progress.current}/${progress.total} files`
|
||||||
|
) : (
|
||||||
|
"Preparing backup..."
|
||||||
|
)
|
||||||
|
) : isDownloading ? (
|
||||||
|
"Archive is created. Downloading..."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="mr-2" /> Download Data Archive
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted-foreground max-w-xl">
|
<div className="text-sm text-muted-foreground max-w-xl">
|
||||||
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
|
Inside the archive you will find all the uploaded files, as well as JSON files for transactions, categories,
|
||||||
|
|||||||
64
app/api/progress/[progressId]/route.ts
Normal file
64
app/api/progress/[progressId]/route.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { getSession } from "@/lib/auth"
|
||||||
|
import { getOrCreateProgress, getProgressById } from "@/models/progress"
|
||||||
|
import { NextRequest, NextResponse } from "next/server"
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 2000 // 2 seconds
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: Promise<{ progressId: string }> }) {
|
||||||
|
const session = await getSession()
|
||||||
|
if (!session || !session.user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id
|
||||||
|
const { progressId } = await params
|
||||||
|
const url = new URL(req.url)
|
||||||
|
const type = url.searchParams.get("type") || "unknown"
|
||||||
|
|
||||||
|
await getOrCreateProgress(userId, progressId, type)
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
let lastSent: any = null
|
||||||
|
let stopped = false
|
||||||
|
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
stopped = true
|
||||||
|
controller.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
while (!stopped) {
|
||||||
|
const progress = await getProgressById(userId, progressId)
|
||||||
|
if (!progress) {
|
||||||
|
controller.enqueue(encoder.encode(`event: error\ndata: {"error":"Not found"}\n\n`))
|
||||||
|
controller.close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only send if progress has changed
|
||||||
|
if (JSON.stringify(progress) !== JSON.stringify(lastSent)) {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(progress)}\n\n`))
|
||||||
|
lastSent = progress
|
||||||
|
|
||||||
|
// If progress is complete, close the connection
|
||||||
|
if (progress.current === progress.total && progress.total > 0) {
|
||||||
|
controller.close()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((res) => setTimeout(res, POLL_INTERVAL_MS))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import { useDownload } from "@/hooks/use-download"
|
||||||
|
import { useProgress } from "@/hooks/use-progress"
|
||||||
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
|
import { useTransactionFilters } from "@/hooks/use-transaction-filters"
|
||||||
import { Category, Field, Project } from "@/prisma/client"
|
import { Category, Field, Project } from "@/prisma/client"
|
||||||
import { formatDate } from "date-fns"
|
import { formatDate } from "date-fns"
|
||||||
@@ -34,17 +36,28 @@ export function ExportTransactionsDialog({
|
|||||||
total: number
|
total: number
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [exportFilters, setExportFilters] = useTransactionFilters()
|
const [exportFilters, setExportFilters] = useTransactionFilters()
|
||||||
const [exportFields, setExportFields] = useState<string[]>(
|
const [exportFields, setExportFields] = useState<string[]>(
|
||||||
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
|
fields.map((field) => (deselectedFields.includes(field.code) ? "" : field.code))
|
||||||
)
|
)
|
||||||
const [includeAttachments, setIncludeAttachments] = useState(true)
|
const [includeAttachments, setIncludeAttachments] = useState(true)
|
||||||
|
const { isLoading, startProgress, progress } = useProgress({
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Export progress error:", error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const { download, isDownloading } = useDownload({
|
||||||
setIsLoading(true)
|
onError: (error) => {
|
||||||
const exportWindow = window.open(
|
console.error("Download error:", error)
|
||||||
`/export/transactions?${new URLSearchParams({
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const progressId = await startProgress("transactions-export")
|
||||||
|
|
||||||
|
const exportUrl = `/export/transactions?${new URLSearchParams({
|
||||||
search: exportFilters?.search || "",
|
search: exportFilters?.search || "",
|
||||||
dateFrom: exportFilters?.dateFrom || "",
|
dateFrom: exportFilters?.dateFrom || "",
|
||||||
dateTo: exportFilters?.dateTo || "",
|
dateTo: exportFilters?.dateTo || "",
|
||||||
@@ -53,22 +66,12 @@ export function ExportTransactionsDialog({
|
|||||||
projectCode: exportFilters?.projectCode || "",
|
projectCode: exportFilters?.projectCode || "",
|
||||||
fields: exportFields.join(","),
|
fields: exportFields.join(","),
|
||||||
includeAttachments: includeAttachments.toString(),
|
includeAttachments: includeAttachments.toString(),
|
||||||
|
progressId: progressId || "",
|
||||||
}).toString()}`
|
}).toString()}`
|
||||||
)
|
await download(exportUrl, "transactions.zip")
|
||||||
|
} catch (error) {
|
||||||
// Check if window was opened successfully
|
console.error("Failed to start export:", error)
|
||||||
if (!exportWindow) {
|
|
||||||
setIsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Monitor the export window
|
|
||||||
const checkWindow = setInterval(() => {
|
|
||||||
if (exportWindow.closed) {
|
|
||||||
clearInterval(checkWindow)
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -171,8 +174,14 @@ export function ExportTransactionsDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter className="sm:justify-end">
|
<DialogFooter className="sm:justify-end">
|
||||||
<Button type="button" onClick={handleSubmit} disabled={isLoading}>
|
<Button type="button" onClick={handleSubmit} disabled={isLoading || isDownloading}>
|
||||||
{isLoading ? "Exporting..." : "Export Transactions"}
|
{isLoading
|
||||||
|
? progress?.current
|
||||||
|
? `Archiving ${progress.current}/${progress.total} files`
|
||||||
|
: "Exporting..."
|
||||||
|
: isDownloading
|
||||||
|
? "Archive is created. Downloading..."
|
||||||
|
: "Export Transactions"}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
48
hooks/use-download.tsx
Normal file
48
hooks/use-download.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
interface UseDownloadOptions {
|
||||||
|
onSuccess?: () => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDownload(options: UseDownloadOptions = {}) {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
|
||||||
|
const download = async (url: string, defaultName: string) => {
|
||||||
|
try {
|
||||||
|
setIsDownloading(true)
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) throw new Error("Download failed")
|
||||||
|
|
||||||
|
// Get the filename from the Content-Disposition header
|
||||||
|
const contentDisposition = response.headers.get("Content-Disposition")
|
||||||
|
const filename = contentDisposition ? contentDisposition.split("filename=")[1].replace(/"/g, "") : defaultName
|
||||||
|
|
||||||
|
// Create a blob from the response
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
// Create a download link and trigger it
|
||||||
|
const downloadLink = window.URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement("a")
|
||||||
|
a.href = downloadLink
|
||||||
|
a.download = filename
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
window.URL.revokeObjectURL(downloadLink)
|
||||||
|
document.body.removeChild(a)
|
||||||
|
|
||||||
|
options.onSuccess?.()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Download error:", error)
|
||||||
|
options.onError?.(error instanceof Error ? error : new Error("Download failed"))
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
download,
|
||||||
|
isDownloading,
|
||||||
|
}
|
||||||
|
}
|
||||||
85
hooks/use-progress.tsx
Normal file
85
hooks/use-progress.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
interface Progress {
|
||||||
|
id: string
|
||||||
|
current: number
|
||||||
|
total: number
|
||||||
|
type: string
|
||||||
|
data: any
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseProgressOptions {
|
||||||
|
onSuccess?: (progress: Progress) => void
|
||||||
|
onError?: (error: Error) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgress(options: UseProgressOptions = {}) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [eventSource, setEventSource] = useState<EventSource | null>(null)
|
||||||
|
const [progress, setProgress] = useState<Progress | null>(null)
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [eventSource])
|
||||||
|
|
||||||
|
const startProgress = async (type: string) => {
|
||||||
|
setIsLoading(true)
|
||||||
|
setProgress(null)
|
||||||
|
|
||||||
|
// Close any existing connection
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const progressId = crypto.randomUUID()
|
||||||
|
const source = new EventSource(`/api/progress/${progressId}?type=${type}`)
|
||||||
|
setEventSource(source)
|
||||||
|
|
||||||
|
source.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const progress = JSON.parse(event.data)
|
||||||
|
setProgress(progress)
|
||||||
|
options.onSuccess?.(progress)
|
||||||
|
|
||||||
|
if (progress.current === progress.total && progress.total > 0) {
|
||||||
|
source.close()
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse progress data:", error)
|
||||||
|
source.close()
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source.onerror = (error) => {
|
||||||
|
source.close()
|
||||||
|
setIsLoading(false)
|
||||||
|
const err = new Error("Progress tracking failed")
|
||||||
|
console.error("Progress tracking error:", err)
|
||||||
|
options.onError?.(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return progressId
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false)
|
||||||
|
const err = error instanceof Error ? error : new Error("Failed to start progress")
|
||||||
|
console.error("Failed to start progress:", err)
|
||||||
|
options.onError?.(err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
startProgress,
|
||||||
|
progress,
|
||||||
|
}
|
||||||
|
}
|
||||||
62
models/progress.ts
Normal file
62
models/progress.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { prisma } from "@/lib/db"
|
||||||
|
|
||||||
|
export const getOrCreateProgress = async (
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
type: string | null = null,
|
||||||
|
data: any = null,
|
||||||
|
total: number = 0
|
||||||
|
) => {
|
||||||
|
return await prisma.progress.upsert({
|
||||||
|
where: { id },
|
||||||
|
create: {
|
||||||
|
id,
|
||||||
|
user: { connect: { id: userId } },
|
||||||
|
type: type || "unknown",
|
||||||
|
data,
|
||||||
|
total,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
// Don't update existing progress
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getProgressById = async (userId: string, id: string) => {
|
||||||
|
return await prisma.progress.findFirst({
|
||||||
|
where: { id, userId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateProgress = async (
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
fields: { current?: number; total?: number; data?: any }
|
||||||
|
) => {
|
||||||
|
return await prisma.progress.updateMany({
|
||||||
|
where: { id, userId },
|
||||||
|
data: fields,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const incrementProgress = async (userId: string, id: string, amount: number = 1) => {
|
||||||
|
return await prisma.progress.updateMany({
|
||||||
|
where: { id, userId },
|
||||||
|
data: {
|
||||||
|
current: { increment: amount },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getAllProgressByUser = async (userId: string) => {
|
||||||
|
return await prisma.progress.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteProgress = async (userId: string, id: string) => {
|
||||||
|
return await prisma.progress.deleteMany({
|
||||||
|
where: { id, userId },
|
||||||
|
})
|
||||||
|
}
|
||||||
18
prisma/migrations/20250519130610_progress/migration.sql
Normal file
18
prisma/migrations/20250519130610_progress/migration.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "progress" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"user_id" UUID NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"data" JSONB,
|
||||||
|
"current" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"total" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "progress_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "progress_user_id_idx" ON "progress"("user_id");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "progress" ADD CONSTRAINT "progress_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -39,6 +39,7 @@ model User {
|
|||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
appData AppData[]
|
appData AppData[]
|
||||||
|
progress Progress[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -219,3 +220,17 @@ model AppData {
|
|||||||
@@unique([userId, app])
|
@@unique([userId, app])
|
||||||
@@map("app_data")
|
@@map("app_data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Progress {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @map("user_id") @db.Uuid
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
type String
|
||||||
|
data Json?
|
||||||
|
current Int @default(0)
|
||||||
|
total Int @default(0)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@map("progress")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user