Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ SELF_HOSTED_MODE=true
DISABLE_SIGNUP=true

UPLOAD_PATH="./data/uploads"
MANUAL_UPLOADS_ROOT="./data/manual_uploads"
DATABASE_URL="postgresql://user@localhost:5432/taxhacker"

# You can put it here or the app will ask you to enter it
Expand Down
87 changes: 85 additions & 2 deletions app/(app)/transactions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,20 @@ import {
safePathJoin,
} from "@/lib/files"
import { updateField } from "@/models/fields"
import { createFile, deleteFile } from "@/models/files"
import { createFile, deleteFile, getFilesByTransactionId } from "@/models/files"
import {
bulkDeleteTransactions,
createTransaction,
deleteTransaction,
getTransactionById,
updateTransaction,
updateTransactionFiles,
TransactionData,
} from "@/models/transactions"
import { updateUser } from "@/models/users"
import { Transaction } from "@/prisma/client"
import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises"
import { copyFile, mkdir, readFile, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"

Expand Down Expand Up @@ -214,6 +215,88 @@ export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
}
}

export async function duplicateTransactionAction(transactionId: string): Promise<ActionState<Transaction>> {
try {
const user = await getCurrentUser()
const originalTransaction = await getTransactionById(transactionId, user.id)

if (!originalTransaction) {
return { success: false, error: "Transaction not found" }
}

// Get all files attached to the original transaction
const originalFiles = await getFilesByTransactionId(transactionId, user.id)
const userUploadsDirectory = getUserUploadsDirectory(user)

// Create a new transaction with the same data (without id and timestamps)
const newTransaction = await createTransaction(user.id, {
name: originalTransaction.name,
description: originalTransaction.description,
merchant: originalTransaction.merchant,
total: originalTransaction.total,
currencyCode: originalTransaction.currencyCode,
convertedTotal: originalTransaction.convertedTotal,
convertedCurrencyCode: originalTransaction.convertedCurrencyCode,
type: originalTransaction.type,
categoryCode: originalTransaction.categoryCode,
projectCode: originalTransaction.projectCode,
issuedAt: originalTransaction.issuedAt,
note: originalTransaction.note,
items: originalTransaction.items as TransactionData[] | undefined,
extra: originalTransaction.extra as Record<string, unknown>,
text: originalTransaction.text,
}, { skipExtraSplit: true })

// Clone all files
const newFileIds: string[] = []
for (const originalFile of originalFiles) {
try {
const newFileUuid = randomUUID()
const originalFilePath = safePathJoin(userUploadsDirectory, originalFile.path)
const newRelativeFilePath = getTransactionFileUploadPath(newFileUuid, originalFile.filename, newTransaction)
const newFullFilePath = safePathJoin(userUploadsDirectory, newRelativeFilePath)

// Ensure directory exists
await mkdir(path.dirname(newFullFilePath), { recursive: true })

// Copy the physical file
await copyFile(originalFilePath, newFullFilePath)

// Create new file record in database
const newFileRecord = await createFile(user.id, {
id: newFileUuid,
filename: originalFile.filename,
path: newRelativeFilePath,
mimetype: originalFile.mimetype,
isReviewed: true,
metadata: originalFile.metadata,
})

newFileIds.push(newFileRecord.id)
} catch (fileError) {
console.error("Failed to clone file:", originalFile.filename, fileError)
// Continue with other files even if one fails
}
}

// Link cloned files to the new transaction
if (newFileIds.length > 0) {
await updateTransactionFiles(newTransaction.id, user.id, newFileIds)
}

// Update user storage used
const storageUsed = await getDirectorySize(getUserUploadsDirectory(user))
await updateUser(user.id, { storageUsed })

revalidatePath("/transactions")

return { success: true, data: newTransaction }
} catch (error) {
console.error("Failed to duplicate transaction:", error)
return { success: false, error: "Failed to duplicate transaction" }
}
}

export async function updateFieldVisibilityAction(fieldCode: string, isVisible: boolean) {
try {
const user = await getCurrentUser()
Expand Down
18 changes: 18 additions & 0 deletions app/api/manual-upload/count/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { getCurrentUser } from "@/lib/auth";
import config from "@/lib/config";

const MANUAL_UPLOADS_ROOT = path.resolve(config.upload.manualUploadsRoot);

export async function GET(req: NextRequest) {
const user = await getCurrentUser();
const userFolder = path.join(MANUAL_UPLOADS_ROOT, user.email);
try {
const files = await fs.readdir(userFolder);
return NextResponse.json({ count: files.length });
} catch (err) {
return NextResponse.json({ count: 0 });
}
}
52 changes: 52 additions & 0 deletions app/api/manual-upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import fs from "fs/promises";
import path from "path";
import { getCurrentUser } from "@/lib/auth";
import { getUserUploadsDirectory, safePathJoin, unsortedFilePath } from "@/lib/files";
import { createFile } from "@/models/files";
import { randomUUID } from "crypto";
import { revalidatePath } from "next/cache";
import config from "@/lib/config";

const MANUAL_UPLOADS_ROOT = path.resolve(config.upload.manualUploadsRoot);

export async function POST(req: NextRequest) {
const user = await getCurrentUser();
const userFolder = path.join(MANUAL_UPLOADS_ROOT, user.email);
let processed: string[] = [];
let failed: string[] = [];
try {
const files = await fs.readdir(userFolder);
for (const filename of files) {
try {
const filePath = path.join(userFolder, filename);
const fileUuid = randomUUID();
const relativeFilePath = unsortedFilePath(fileUuid, filename);
const userUploadsDirectory = getUserUploadsDirectory(user);
const destPath = safePathJoin(userUploadsDirectory, relativeFilePath);
await fs.mkdir(path.dirname(destPath), { recursive: true });
const buffer = await fs.readFile(filePath);
await fs.writeFile(destPath, buffer);
await createFile(user.id, {
id: fileUuid,
filename: filename,
path: relativeFilePath,
mimetype: "application/octet-stream",
metadata: { size: buffer.length, lastModified: Date.now() },
});
await fs.unlink(filePath);
processed.push(filename);
} catch (err) {
failed.push(filename);
}
}
} catch (err) {
return NextResponse.json({ success: false, error: (err as Error).message, processed, failed }, { status: 500 });
}

// Revalidate unsorted and layout consumers so counts update in the sidebar
revalidatePath("/unsorted");
revalidatePath("/");

return NextResponse.json({ success: true, processed, failed });
}
78 changes: 76 additions & 2 deletions components/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import { useNotification } from "@/app/(app)/context"
import { UploadButton } from "@/components/files/upload-button"
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import {
Sidebar,
SidebarContent,
Expand All @@ -22,11 +24,12 @@ import { ClockArrowUp, FileText, Gift, House, Import, LayoutDashboard, Settings,
import Image from "next/image"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { useEffect, useState } from "react"
import { ColoredText } from "../ui/colored-text"
import { Blinker } from "./blinker"
import { SidebarMenuItemWithHighlight } from "./sidebar-item"
import SidebarUser from "./sidebar-user"
import { useRouter } from "next/navigation";

export function AppSidebar({
profile,
Expand All @@ -39,13 +42,36 @@ export function AppSidebar({
}) {
const { open, setOpenMobile } = useSidebar()
const pathname = usePathname()
const { notification } = useNotification()
const { notification, showNotification } = useNotification() as any
const router = useRouter()
const [manualProcessing, setManualProcessing] = useState(false)
const [manualMessage, setManualMessage] = useState("")
const [manualCount, setManualCount] = useState<number | null>(null);

// Hide sidebar on mobile when clicking an item
useEffect(() => {
setOpenMobile(false)
}, [pathname, setOpenMobile])

useEffect(() => {
let interval: NodeJS.Timeout;
async function fetchCount() {
if (!isSelfHosted) return;
const res = await fetch("/api/manual-upload/count");
if (res.ok) {
const data = await res.json();
setManualCount(data.count ?? 0);
}
}
fetchCount(); // initial load
if (isSelfHosted) {
interval = setInterval(fetchCount, 3000);
}
return () => {
if (interval) clearInterval(interval);
};
}, [isSelfHosted]);

return (
<>
<Sidebar variant="inset" collapsible="icon">
Expand All @@ -65,6 +91,54 @@ export function AppSidebar({
<Upload className="h-4 w-4" />
{open ? <span>Upload</span> : ""}
</UploadButton>
{isSelfHosted && (
<Button
className="w-full mb-2"
disabled={manualProcessing}
type="button"
onClick={async () => {
setManualProcessing(true)
setManualMessage("")
try {
const res = await fetch("/api/manual-upload", { method: "POST" })
const data = await res.json()
if (data.success) {
setManualMessage(`Processed: ${data.processed?.join(", ") || "None"}`)
// mimic Upload button behavior
showNotification({ code: "sidebar.unsorted", message: "new" })
setTimeout(() => showNotification({ code: "sidebar.unsorted", message: "" }), 3000)
router.push("/unsorted")
} else {
setManualMessage(data.error || "Failed to process")
}
// refresh count after processing
const countRes = await fetch("/api/manual-upload/count");
if (countRes.ok) {
const countData = await countRes.json();
setManualCount(countData.count ?? 0);
}
} catch (err) {
setManualMessage("Failed to process (network error)")
}
setManualProcessing(false)
}}
>
{manualProcessing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Processing...
</>
) : (
<>
<Upload className="h-4 w-4" />
{open ? (
<span>Process Manual Uploads{manualCount !== null ? ` (${manualCount})` : ""}</span>
) : ""}
</>
)}
</Button>
)}

</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
Expand Down
33 changes: 29 additions & 4 deletions components/transactions/bulk-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client"

import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions"
import { bulkDeleteTransactionsAction, duplicateTransactionAction } from "@/app/(app)/transactions/actions"
import { Button } from "@/components/ui/button"
import { Trash2 } from "lucide-react"
import { Copy, Trash2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { useState } from "react"

interface BulkActionsMenuProps {
Expand All @@ -12,6 +13,7 @@ interface BulkActionsMenuProps {

export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMenuProps) {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()

const handleDelete = async () => {
const confirmMessage =
Expand All @@ -33,11 +35,34 @@ export function BulkActionsMenu({ selectedIds, onActionComplete }: BulkActionsMe
}
}

const handleDuplicate = async () => {
try {
setIsLoading(true)
const result = await duplicateTransactionAction(selectedIds[0])
if (!result.success || !result.data) {
throw new Error(result.error || "Failed to duplicate transaction")
}
router.push(`/transactions/${result.data.id}`)
onActionComplete?.()
} catch (error) {
console.error("Failed to duplicate transaction:", error)
alert(`Failed to duplicate transaction: ${error}`)
} finally {
setIsLoading(false)
}
}

return (
<div className="fixed bottom-4 right-4 z-50">
<div className="fixed bottom-4 right-4 z-50 flex gap-2">
{selectedIds.length === 1 && (
<Button variant="outline" className="min-w-48 gap-2" disabled={isLoading} onClick={handleDuplicate}>
<Copy className="h-4 w-4" />
Duplicate transaction
</Button>
)}
<Button variant="destructive" className="min-w-48 gap-2" disabled={isLoading} onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
Delete {selectedIds.length} transactions
Delete {selectedIds.length} transaction{selectedIds.length > 1 ? "s" : ""}
</Button>
</div>
)
Expand Down
2 changes: 2 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const envSchema = z.object({
RESEND_AUDIENCE_ID: z.string().default(""),
STRIPE_SECRET_KEY: z.string().default(""),
STRIPE_WEBHOOK_SECRET: z.string().default(""),
MANUAL_UPLOADS_ROOT: z.string().default("data/manual_uploads"),
})

const env = envSchema.parse(process.env)
Expand All @@ -46,6 +47,7 @@ const config = {
maxWidth: 1500,
maxHeight: 1500,
},
manualUploadsRoot: env.MANUAL_UPLOADS_ROOT,
},
selfHosted: {
isEnabled: env.SELF_HOSTED_MODE === "true",
Expand Down
Loading