BREAKING: postgres + saas

This commit is contained in:
Vasily Zubarev
2025-04-03 13:07:54 +02:00
parent 54a892ddb0
commit f523b1f8ba
136 changed files with 3971 additions and 1563 deletions

32
app/(app)/context.tsx Normal file
View File

@@ -0,0 +1,32 @@
"use client"
import { createContext, ReactNode, useContext, useState } from "react"
type Notification = {
code: string
message: string
}
type NotificationContextType = {
notification: Notification | null
showNotification: (notification: Notification) => void
}
const NotificationContext = createContext<NotificationContextType>({
notification: null,
showNotification: () => {},
})
export function NotificationProvider({ children }: { children: ReactNode }) {
const [notification, setNotification] = useState<Notification | null>(null)
const showNotification = (notification: Notification) => {
setNotification(notification)
}
return (
<NotificationContext.Provider value={{ notification, showNotification }}>{children}</NotificationContext.Provider>
)
}
export const useNotification = () => useContext(NotificationContext)

View File

@@ -0,0 +1,39 @@
import DashboardDropZoneWidget from "@/components/dashboard/drop-zone-widget"
import { StatsWidget } from "@/components/dashboard/stats-widget"
import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget"
import { WelcomeWidget } from "@/components/dashboard/welcome-widget"
import { Separator } from "@/components/ui/separator"
import { getCurrentUser } from "@/lib/auth"
import { APP_DESCRIPTION } from "@/lib/constants"
import { getUnsortedFiles } from "@/models/files"
import { getSettings } from "@/models/settings"
import { TransactionFilters } from "@/models/transactions"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Dashboard",
description: APP_DESCRIPTION,
}
export default async function Dashboard({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
const filters = await searchParams
const user = await getCurrentUser()
const unsortedFiles = await getUnsortedFiles(user.id)
const settings = await getSettings(user.id)
return (
<div className="flex flex-col gap-5 p-5 w-full max-w-7xl self-center">
<div className="flex flex-col sm:flex-row gap-5 items-stretch h-full">
<DashboardDropZoneWidget />
<DashboardUnsortedWidget files={unsortedFiles} />
</div>
{settings.is_welcome_message_hidden !== "true" && <WelcomeWidget />}
<Separator />
<StatsWidget filters={filters} />
</div>
)
}

View File

@@ -0,0 +1,119 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, fullPathForFile } from "@/lib/files"
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
import { getFields } from "@/models/fields"
import { getFilesByTransactionId } from "@/models/files"
import { getTransactions } from "@/models/transactions"
import { format } from "@fast-csv/format"
import { formatDate } from "date-fns"
import fs from "fs/promises"
import JSZip from "jszip"
import { NextResponse } from "next/server"
import path from "path"
export async function GET(request: Request) {
const url = new URL(request.url)
const filters = Object.fromEntries(url.searchParams.entries()) as ExportFilters
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
const user = await getCurrentUser()
const { transactions } = await getTransactions(user.id, filters)
const existingFields = await getFields(user.id)
// Generate CSV file with all transactions
try {
const fieldKeys = fields.filter((field) => existingFields.some((f) => f.code === field))
let csvContent = ""
const csvStream = format({ headers: fieldKeys, writeBOM: true, writeHeaders: false })
csvStream.on("data", (chunk) => {
csvContent += chunk
})
// Custom CSV headers
const headers = fieldKeys.map((field) => existingFields.find((f) => f.code === field)?.name ?? "UNKNOWN")
csvStream.write(headers)
// CSV rows
for (const transaction of transactions) {
const row: Record<string, any> = {}
for (const key of fieldKeys) {
const value = transaction[key as keyof typeof transaction] ?? ""
const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[key]
if (exportFieldSettings && exportFieldSettings.export) {
row[key] = await exportFieldSettings.export(user.id, value)
} else {
row[key] = value
}
}
csvStream.write(row)
}
csvStream.end()
// Wait for CSV generation to complete
await new Promise((resolve) => csvStream.on("end", resolve))
if (!includeAttachments) {
return new NextResponse(csvContent, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="transactions.csv"`,
},
})
}
// If includeAttachments is true, create a ZIP file with the CSV and attachments
const zip = new JSZip()
zip.file("transactions.csv", csvContent)
const filesFolder = zip.folder("files")
if (!filesFolder) {
console.error("Failed to create zip folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
for (const transaction of transactions) {
const transactionFiles = await getFilesByTransactionId(transaction.id, user.id)
const transactionFolder = filesFolder.folder(
path.join(
transaction.issuedAt ? formatDate(transaction.issuedAt, "yyyy/MM") : "",
transactionFiles.length > 1 ? transaction.name || transaction.id : ""
)
)
if (!transactionFolder) {
console.error(`Failed to create transaction folder for ${transaction.name}`)
continue
}
for (const file of transactionFiles) {
const fullFilePath = await fullPathForFile(user, file)
if (await fileExists(fullFilePath)) {
const fileData = await fs.readFile(fullFilePath)
const fileExtension = path.extname(fullFilePath)
transactionFolder.file(
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
transaction.name || transaction.id
}${fileExtension}`,
fileData
)
}
}
}
const zipContent = await zip.generateAsync({ type: "uint8array" })
return new NextResponse(zipContent, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="transactions.zip"`,
},
})
} catch (error) {
console.error("Error exporting transactions:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

View File

@@ -0,0 +1,57 @@
"use server"
import { getCurrentUser } from "@/lib/auth"
import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files"
import { createFile } from "@/models/files"
import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
export async function uploadFilesAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const files = formData.getAll("files")
// Make sure upload dir exists
const userUploadsDirectory = await getUserUploadsDirectory(user)
// Process each file
const uploadedFiles = await Promise.all(
files.map(async (file) => {
if (!(file instanceof File)) {
return { success: false, error: "Invalid file" }
}
// Save file to filesystem
const fileUuid = randomUUID()
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
await writeFile(fullFilePath, buffer)
// Create file record in database
const fileRecord = await createFile(user.id, {
id: fileUuid,
filename: file.name,
path: relativeFilePath,
mimetype: file.type,
metadata: {
size: file.size,
lastModified: file.lastModified,
},
})
return fileRecord
})
)
console.log("uploadedFiles", uploadedFiles)
revalidatePath("/unsorted")
return { success: true, error: null }
}

View File

@@ -0,0 +1,44 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, fullPathForFile } from "@/lib/files"
import { getFileById } from "@/models/files"
import fs from "fs/promises"
import { NextResponse } from "next/server"
export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params
const user = await getCurrentUser()
if (!fileId) {
return new NextResponse("No fileId provided", { status: 400 })
}
try {
// Find file in database
const file = await getFileById(fileId, user.id)
if (!file || file.userId !== user.id) {
return new NextResponse("File not found or does not belong to the user", { status: 404 })
}
// Check if file exists
const fullFilePath = await fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
}
// Read file
const fileBuffer = await fs.readFile(fullFilePath)
// Return file with proper content type
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": file.mimetype,
"Content-Disposition": `attachment; filename="${file.filename}"`,
},
})
} catch (error) {
console.error("Error serving file:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

10
app/(app)/files/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Metadata } from "next"
import { notFound } from "next/navigation"
export const metadata: Metadata = {
title: "Uploading...",
}
export default function UploadStatusPage() {
notFound()
}

View File

@@ -0,0 +1,56 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, fullPathForFile } from "@/lib/files"
import { generateFilePreviews } from "@/lib/previews/generate"
import { getFileById } from "@/models/files"
import fs from "fs/promises"
import { NextResponse } from "next/server"
import path from "path"
export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) {
const { fileId } = await params
const user = await getCurrentUser()
if (!fileId) {
return new NextResponse("No fileId provided", { status: 400 })
}
const url = new URL(request.url)
const page = parseInt(url.searchParams.get("page") || "1", 10)
try {
// Find file in database
const file = await getFileById(fileId, user.id)
if (!file || file.userId !== user.id) {
return new NextResponse("File not found or does not belong to the user", { status: 404 })
}
// Check if file exists on disk
const fullFilePath = await fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
}
// Generate previews
const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype)
if (page > previews.length) {
return new NextResponse("Page not found", { status: 404 })
}
const previewPath = previews[page - 1] || fullFilePath
// Read file
const fileBuffer = await fs.readFile(previewPath)
// Return file with proper content type
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": contentType,
"Content-Disposition": `inline; filename="${path.basename(previewPath)}"`,
},
})
} catch (error) {
console.error("Error serving file:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}

View File

@@ -0,0 +1,68 @@
"use server"
import { getCurrentUser } from "@/lib/auth"
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
import { createTransaction } from "@/models/transactions"
import { parse } from "@fast-csv/parse"
import { revalidatePath } from "next/cache"
export async function parseCSVAction(prevState: any, formData: FormData) {
const file = formData.get("file") as File
if (!file) {
return { success: false, error: "No file uploaded" }
}
if (!file.name.toLowerCase().endsWith(".csv")) {
return { success: false, error: "Only CSV files are allowed" }
}
try {
const buffer = Buffer.from(await file.arrayBuffer())
const rows: string[][] = []
const parser = parse()
.on("data", (row) => rows.push(row))
.on("error", (error) => {
throw error
})
parser.write(buffer)
parser.end()
// Wait for parsing to complete
await new Promise((resolve) => parser.on("end", resolve))
return { success: true, data: rows }
} catch (error) {
console.error("Error parsing CSV:", error)
return { success: false, error: "Failed to parse CSV file" }
}
}
export async function saveTransactionsAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
try {
const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[]
for (const row of rows) {
const transactionData: Record<string, unknown> = {}
for (const [fieldCode, value] of Object.entries(row)) {
const fieldDef = EXPORT_AND_IMPORT_FIELD_MAP[fieldCode]
if (fieldDef?.import) {
transactionData[fieldCode] = await fieldDef.import(user.id, value as string)
} else {
transactionData[fieldCode] = value as string
}
}
await createTransaction(user.id, transactionData)
}
revalidatePath("/import/csv")
revalidatePath("/transactions")
return { success: true }
} catch (error) {
console.error("Error saving transactions:", error)
return { success: false, error: "Failed to save transactions: " + error }
}
}

View File

@@ -0,0 +1,13 @@
import { ImportCSVTable } from "@/components/import/csv"
import { getCurrentUser } from "@/lib/auth"
import { getFields } from "@/models/fields"
export default async function CSVImportPage() {
const user = await getCurrentUser()
const fields = await getFields(user.id)
return (
<div className="flex flex-col gap-4 p-4">
<ImportCSVTable fields={fields} />
</div>
)
}

57
app/(app)/layout.tsx Normal file
View File

@@ -0,0 +1,57 @@
import ScreenDropArea from "@/components/files/screen-drop-area"
import MobileMenu from "@/components/sidebar/mobile-menu"
import { AppSidebar } from "@/components/sidebar/sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { Toaster } from "@/components/ui/sonner"
import { getCurrentUser } from "@/lib/auth"
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants"
import { getUnsortedFilesCount } from "@/models/files"
import type { Metadata, Viewport } from "next"
import "../globals.css"
import { NotificationProvider } from "./context"
export const metadata: Metadata = {
title: {
template: "%s | TaxHacker",
default: APP_TITLE,
},
description: APP_DESCRIPTION,
icons: {
icon: "/favicon.ico",
shortcut: "/favicon.ico",
apple: "/apple-touch-icon.png",
},
manifest: "/site.webmanifest",
}
export const viewport: Viewport = {
themeColor: "#ffffff",
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const user = await getCurrentUser()
const unsortedFilesCount = await getUnsortedFilesCount(user.id)
return (
<NotificationProvider>
<ScreenDropArea>
<SidebarProvider>
<MobileMenu unsortedFilesCount={unsortedFilesCount} />
<AppSidebar
unsortedFilesCount={unsortedFilesCount}
profile={{
id: user.id,
name: user.name || "",
email: user.email,
avatar: user.avatar || undefined,
}}
/>
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
</SidebarProvider>
<Toaster />
</ScreenDropArea>
</NotificationProvider>
)
}
export const dynamic = "force-dynamic"

View File

@@ -0,0 +1,232 @@
"use server"
import {
categoryFormSchema,
currencyFormSchema,
fieldFormSchema,
projectFormSchema,
settingsFormSchema,
} from "@/forms/settings"
import { userFormSchema } from "@/forms/users"
import { getCurrentUser } from "@/lib/auth"
import { codeFromName, randomHexColor } from "@/lib/utils"
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
import { createField, deleteField, updateField } from "@/models/fields"
import { createProject, deleteProject, updateProject } from "@/models/projects"
import { updateSettings } from "@/models/settings"
import { updateUser } from "@/models/users"
import { Prisma } from "@prisma/client"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
export async function saveSettingsAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
for (const key in validatedForm.data) {
await updateSettings(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data])
}
revalidatePath("/settings")
redirect("/settings")
// return { success: true }
}
export async function saveProfileAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
await updateUser(user.id, {
name: validatedForm.data.name,
})
revalidatePath("/settings/profile")
redirect("/settings/profile")
}
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {
const validatedForm = projectFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const project = await createProject(userId, {
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt || null,
color: validatedForm.data.color || randomHexColor(),
})
revalidatePath("/settings/projects")
return { success: true, project }
}
export async function editProjectAction(userId: string, code: string, data: Prisma.ProjectUpdateInput) {
const validatedForm = projectFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const project = await updateProject(userId, code, {
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/projects")
return { success: true, project }
}
export async function deleteProjectAction(userId: string, code: string) {
try {
await deleteProject(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete project" + error }
}
revalidatePath("/settings/projects")
return { success: true }
}
export async function addCurrencyAction(userId: string, data: Prisma.CurrencyCreateInput) {
const validatedForm = currencyFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const currency = await createCurrency(userId, {
code: validatedForm.data.code,
name: validatedForm.data.name,
})
revalidatePath("/settings/currencies")
return { success: true, currency }
}
export async function editCurrencyAction(userId: string, code: string, data: Prisma.CurrencyUpdateInput) {
const validatedForm = currencyFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const currency = await updateCurrency(userId, code, { name: validatedForm.data.name })
revalidatePath("/settings/currencies")
return { success: true, currency }
}
export async function deleteCurrencyAction(userId: string, code: string) {
try {
await deleteCurrency(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete currency" + error }
}
revalidatePath("/settings/currencies")
return { success: true }
}
export async function addCategoryAction(userId: string, data: Prisma.CategoryCreateInput) {
const validatedForm = categoryFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const category = await createCategory(userId, {
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/categories")
return { success: true, category }
}
export async function editCategoryAction(userId: string, code: string, data: Prisma.CategoryUpdateInput) {
const validatedForm = categoryFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const category = await updateCategory(userId, code, {
name: validatedForm.data.name,
llm_prompt: validatedForm.data.llm_prompt,
color: validatedForm.data.color || "",
})
revalidatePath("/settings/categories")
return { success: true, category }
}
export async function deleteCategoryAction(code: string, userId: string) {
try {
await deleteCategory(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete category" + error }
}
revalidatePath("/settings/categories")
return { success: true }
}
export async function addFieldAction(userId: string, data: Prisma.FieldCreateInput) {
const validatedForm = fieldFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const field = await createField(userId, {
code: codeFromName(validatedForm.data.name),
name: validatedForm.data.name,
type: validatedForm.data.type,
llm_prompt: validatedForm.data.llm_prompt,
isVisibleInList: validatedForm.data.isVisibleInList,
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
isExtra: true,
})
revalidatePath("/settings/fields")
return { success: true, field }
}
export async function editFieldAction(userId: string, code: string, data: Prisma.FieldUpdateInput) {
const validatedForm = fieldFormSchema.safeParse(data)
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const field = await updateField(userId, code, {
name: validatedForm.data.name,
type: validatedForm.data.type,
llm_prompt: validatedForm.data.llm_prompt,
isVisibleInList: validatedForm.data.isVisibleInList,
isVisibleInAnalysis: validatedForm.data.isVisibleInAnalysis,
})
revalidatePath("/settings/fields")
return { success: true, field }
}
export async function deleteFieldAction(userId: string, code: string) {
try {
await deleteField(userId, code)
} catch (error) {
return { success: false, error: "Failed to delete field" + error }
}
revalidatePath("/settings/fields")
return { success: true }
}

View File

@@ -0,0 +1,145 @@
"use server"
import { getCurrentUser } from "@/lib/auth"
import { prisma } from "@/lib/db"
import { getUserUploadsDirectory } from "@/lib/files"
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
import path from "path"
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
const REMOVE_EXISTING_DATA = true
export async function restoreBackupAction(prevState: any, formData: FormData) {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
const file = formData.get("file") as File
if (!file || file.size === 0) {
return { success: false, error: "No file provided" }
}
// Read zip archive
let zip: JSZip
try {
const fileBuffer = await file.arrayBuffer()
const fileData = Buffer.from(fileBuffer)
zip = await JSZip.loadAsync(fileData)
} catch (error) {
return { success: false, error: "Bad zip archive" }
}
if (REMOVE_EXISTING_DATA) {
await cleanupUserTables(user.id)
await fs.rm(userUploadsDirectory, { recursive: true, force: true })
}
// Check metadata and start restoring
try {
const metadataFile = zip.file("data/metadata.json")
if (metadataFile) {
const metadataContent = await metadataFile.async("string")
try {
const metadata = JSON.parse(metadataContent)
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
return {
success: false,
error: `Incompatible backup version: ${
metadata.version || "unknown"
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
}
}
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
} catch (error) {
console.warn("Could not parse backup metadata:", error)
}
} else {
console.warn("No metadata found in backup, assuming legacy format")
}
const counters: Record<string, number> = {}
// Restore tables
for (const backup of MODEL_BACKUP) {
try {
const jsonFile = zip.file(`data/${backup.filename}`)
if (jsonFile) {
const jsonContent = await jsonFile.async("string")
const restoredCount = await modelFromJSON(user.id, backup, jsonContent)
console.log(`Restored ${restoredCount} records from ${backup.filename}`)
counters[backup.filename] = restoredCount
}
} catch (error) {
console.error(`Error restoring model from ${backup.filename}:`, error)
}
}
// Restore files
try {
let restoredFilesCount = 0
const files = await prisma.file.findMany({
where: {
userId: user.id,
},
})
const userUploadsDirectory = await getUserUploadsDirectory(user)
for (const file of files) {
const filePathWithoutPrefix = file.path.replace(/^.*\/uploads\//, "")
const zipFilePath = path.join("data/uploads", filePathWithoutPrefix)
const zipFile = zip.file(zipFilePath)
if (!zipFile) {
console.log(`File ${file.path} not found in backup`)
continue
}
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
const fileContent = await zipFile.async("nodebuffer")
try {
await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
await fs.writeFile(fullFilePath, fileContent)
restoredFilesCount++
} catch (error) {
console.error(`Error writing file ${fullFilePath}:`, error)
continue
}
await prisma.file.update({
where: { id: file.id },
data: {
path: filePathWithoutPrefix,
},
})
}
counters["Uploaded attachments"] = restoredFilesCount
} catch (error) {
console.error("Error restoring uploaded files:", error)
return {
success: false,
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
}
}
return { success: true, message: "Restore completed successfully", counters }
} catch (error) {
console.error("Error restoring from backup:", error)
return {
success: false,
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
}
}
}
async function cleanupUserTables(userId: string) {
// Delete in reverse order to handle foreign key constraints
for (const { model } of [...MODEL_BACKUP].reverse()) {
try {
await model.deleteMany({ where: { userId } })
} catch (error) {
console.error(`Error clearing table:`, error)
}
}
}

View File

@@ -0,0 +1,112 @@
import { getCurrentUser } from "@/lib/auth"
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
import fs from "fs/promises"
import JSZip from "jszip"
import { NextResponse } from "next/server"
import path from "path"
const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
const BACKUP_VERSION = "1.0"
export async function GET(request: Request) {
const user = await getCurrentUser()
const userUploadsDirectory = await getUserUploadsDirectory(user)
try {
const zip = new JSZip()
const rootFolder = zip.folder("data")
if (!rootFolder) {
console.error("Failed to create zip folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
// Add metadata with version information
rootFolder.file(
"metadata.json",
JSON.stringify(
{
version: BACKUP_VERSION,
timestamp: new Date().toISOString(),
models: MODEL_BACKUP.map((m) => m.filename),
},
null,
2
)
)
// Backup models
for (const backup of MODEL_BACKUP) {
try {
const jsonContent = await modelToJSON(user.id, backup)
rootFolder.file(backup.filename, jsonContent)
} catch (error) {
console.error(`Error exporting table ${backup.filename}:`, error)
}
}
const uploadsFolder = rootFolder.folder("uploads")
if (!uploadsFolder) {
console.error("Failed to create uploads folder")
return new NextResponse("Internal Server Error", { status: 500 })
}
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
for (const file of uploadedFiles) {
try {
// Check file size before reading
const stats = await fs.stat(file)
if (stats.size > MAX_FILE_SIZE) {
console.warn(
`Skipping large file ${file} (${Math.round(stats.size / 1024 / 1024)}MB > ${
MAX_FILE_SIZE / 1024 / 1024
}MB limit)`
)
return
}
const fileContent = await fs.readFile(file)
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
} catch (error) {
console.error(`Error reading file ${file}:`, error)
}
}
const archive = await zip.generateAsync({ type: "blob" })
return new NextResponse(archive, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="data.zip"`,
},
})
} catch (error) {
console.error("Error exporting database:", error)
return new NextResponse("Internal Server Error", { status: 500 })
}
}
async function getAllFilePaths(dirPath: string): Promise<string[]> {
let filePaths: string[] = []
async function readDirectoryRecursively(currentPath: string) {
const isDirExists = await fileExists(currentPath)
if (!isDirExists) {
return
}
const entries = await fs.readdir(currentPath, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name)
if (entry.isDirectory()) {
await readDirectoryRecursively(fullPath)
} else {
filePaths.push(fullPath)
}
}
}
await readDirectoryRecursively(dirPath)
return filePaths
}

View File

@@ -0,0 +1,72 @@
"use client"
import { FormError } from "@/components/forms/error"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { Download, Loader2 } from "lucide-react"
import Link from "next/link"
import { useActionState } from "react"
import { restoreBackupAction } from "./actions"
export default function BackupSettingsPage() {
const [restoreState, restoreBackup, restorePending] = useActionState(restoreBackupAction, null)
return (
<div className="container flex flex-col gap-4">
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Download backup</h1>
<div className="flex flex-row gap-4">
<Link href="/settings/backups/data">
<Button>
<Download /> Download Data Archive
</Button>
</Link>
</div>
<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,
projects, fields, currencies, and settings. You can view, edit or migrate your data to another service.
</div>
</div>
<Card className="flex flex-col gap-2 mt-16 p-5 bg-red-100 max-w-xl">
<h2 className="text-xl font-semibold">Restore from a backup</h2>
<p className="text-sm text-muted-foreground">
This action will delete all existing data from your current database and remove all uploaded files. Be
careful and make a backup first!
</p>
<form action={restoreBackup}>
<div className="flex flex-col gap-4 pt-4">
<input type="hidden" name="removeExistingData" value="true" />
<label>
<input type="file" name="file" />
</label>
<Button type="submit" variant="destructive" disabled={restorePending}>
{restorePending ? (
<>
<Loader2 className="animate-spin" /> Restoring from backup...
</>
) : (
"Delete existing data and restore from backup"
)}
</Button>
</div>
</form>
{restoreState?.error && <FormError>{restoreState.error}</FormError>}
</Card>
{restoreState?.success && (
<Card className="flex flex-col gap-2 p-5 bg-green-100 max-w-xl">
<h2 className="text-xl font-semibold">Backup restored successfully</h2>
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
<ul className="list-disc list-inside">
{Object.entries(restoreState.counters || {}).map(([key, value]) => (
<li key={key}>
<span className="font-bold">{key}</span>: {value} items
</li>
))}
</ul>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { randomHexColor } from "@/lib/utils"
import { getCategories } from "@/models/categories"
import { Prisma } from "@prisma/client"
export default async function CategoriesSettingsPage() {
const user = await getCurrentUser()
const categories = await getCategories(user.id)
const categoriesWithActions = categories.map((category) => ({
...category,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Categories</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
Create your own categories that better reflect the type of income and expenses you have. Define an LLM Prompt so
that AI can determine this category automatically.
</p>
<CrudTable
items={categoriesWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
]}
onDelete={async (code) => {
"use server"
return await deleteCategoryAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addCategoryAction(user.id, data as Prisma.CategoryCreateInput)
}}
onEdit={async (code, data) => {
"use server"
return await editCategoryAction(user.id, code, data as Prisma.CategoryUpdateInput)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,42 @@
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { getCurrencies } from "@/models/currencies"
export default async function CurrenciesSettingsPage() {
const user = await getCurrentUser()
const currencies = await getCurrencies(user.id)
const currenciesWithActions = currencies.map((currency) => ({
...currency,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Currencies</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
Custom currencies would not be automatically converted but you still can have them.
</p>
<CrudTable
items={currenciesWithActions}
columns={[
{ key: "code", label: "Code", editable: true },
{ key: "name", label: "Name", editable: true },
]}
onDelete={async (code) => {
"use server"
return await deleteCurrencyAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addCurrencyAction(user.id, data as { code: string; name: string })
}}
onEdit={async (code, data) => {
"use server"
return await editCurrencyAction(user.id, code, data as { name: string })
}}
/>
</div>
)
}

View File

@@ -0,0 +1,66 @@
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { getFields } from "@/models/fields"
import { Prisma } from "@prisma/client"
export default async function FieldsSettingsPage() {
const user = await getCurrentUser()
const fields = await getFields(user.id)
const fieldsWithActions = fields.map((field) => ({
...field,
isEditable: true,
isDeletable: field.isExtra,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Custom Fields</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
You can add new fields to your transactions. Standard fields can't be removed but you can tweak their prompts or
hide them. If you don't want a field to be analyzed by AI but filled in by hand, leave the "LLM prompt" empty.
</p>
<CrudTable
items={fieldsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{
key: "type",
label: "Type",
type: "select",
options: ["string", "number", "boolean"],
defaultValue: "string",
editable: true,
},
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{
key: "isVisibleInList",
label: "Show in transactions table",
type: "checkbox",
defaultValue: false,
editable: true,
},
{
key: "isVisibleInAnalysis",
label: "Show in analysis form",
type: "checkbox",
defaultValue: false,
editable: true,
},
]}
onDelete={async (code) => {
"use server"
return await deleteFieldAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addFieldAction(user.id, data as Prisma.FieldCreateInput)
}}
onEdit={async (code, data) => {
"use server"
return await editFieldAction(user.id, code, data as Prisma.FieldUpdateInput)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { SideNav } from "@/components/settings/side-nav"
import { Separator } from "@/components/ui/separator"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Settings",
description: "Customize your settings here",
}
const settingsCategories = [
{
title: "General",
href: "/settings",
},
{
title: "My Profile",
href: "/settings/profile",
},
{
title: "LLM settings",
href: "/settings/llm",
},
{
title: "Fields",
href: "/settings/fields",
},
{
title: "Categories",
href: "/settings/categories",
},
{
title: "Projects",
href: "/settings/projects",
},
{
title: "Currencies",
href: "/settings/currencies",
},
{
title: "Backups",
href: "/settings/backups",
},
]
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<>
<div className="space-y-6 p-10 pb-16">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">Customize your settings here</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-12 lg:space-y-0">
<aside className="-mx-4 lg:w-1/5">
<SideNav items={settingsCategories} />
</aside>
<div className="flex w-full">{children}</div>
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,18 @@
import LLMSettingsForm from "@/components/settings/llm-settings-form"
import { getCurrentUser } from "@/lib/auth"
import { getFields } from "@/models/fields"
import { getSettings } from "@/models/settings"
export default async function LlmSettingsPage() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const fields = await getFields(user.id)
return (
<>
<div className="w-full max-w-2xl">
<LLMSettingsForm settings={settings} fields={fields} />
</div>
</>
)
}

View File

@@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex flex-col gap-4 w-full">
<Skeleton className="h-10 w-56" />
<Skeleton className="w-full h-[350px]" />
</div>
)
}

View File

@@ -0,0 +1,20 @@
import GlobalSettingsForm from "@/components/settings/global-settings-form"
import { getCurrentUser } from "@/lib/auth"
import { getCategories } from "@/models/categories"
import { getCurrencies } from "@/models/currencies"
import { getSettings } from "@/models/settings"
export default async function SettingsPage() {
const user = await getCurrentUser()
const settings = await getSettings(user.id)
const currencies = await getCurrencies(user.id)
const categories = await getCategories(user.id)
return (
<>
<div className="w-full max-w-2xl">
<GlobalSettingsForm settings={settings} currencies={currencies} categories={categories} />
</div>
</>
)
}

View File

@@ -0,0 +1,14 @@
import ProfileSettingsForm from "@/components/settings/profile-settings-form copy"
import { getCurrentUser } from "@/lib/auth"
export default async function ProfileSettingsPage() {
const user = await getCurrentUser()
return (
<>
<div className="w-full max-w-2xl">
<ProfileSettingsForm user={user} />
</div>
</>
)
}

View File

@@ -0,0 +1,46 @@
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/(app)/settings/actions"
import { CrudTable } from "@/components/settings/crud"
import { getCurrentUser } from "@/lib/auth"
import { randomHexColor } from "@/lib/utils"
import { getProjects } from "@/models/projects"
import { Prisma } from "@prisma/client"
export default async function ProjectsSettingsPage() {
const user = await getCurrentUser()
const projects = await getProjects(user.id)
const projectsWithActions = projects.map((project) => ({
...project,
isEditable: true,
isDeletable: true,
}))
return (
<div className="container">
<h1 className="text-2xl font-bold mb-2">Projects</h1>
<p className="text-sm text-gray-500 mb-6 max-w-prose">
Use projects to differentiate between the type of activities you do For example: Freelancing, YouTube channel,
Blogging. Projects are just a convenient way to separate statistics.
</p>
<CrudTable
items={projectsWithActions}
columns={[
{ key: "name", label: "Name", editable: true },
{ key: "llm_prompt", label: "LLM Prompt", editable: true },
{ key: "color", label: "Color", defaultValue: randomHexColor(), editable: true },
]}
onDelete={async (code) => {
"use server"
return await deleteProjectAction(user.id, code)
}}
onAdd={async (data) => {
"use server"
return await addProjectAction(user.id, data as Prisma.ProjectCreateInput)
}}
onEdit={async (code, data) => {
"use server"
return await editProjectAction(user.id, code, data as Prisma.ProjectUpdateInput)
}}
/>
</div>
)
}

View File

@@ -0,0 +1,30 @@
import { getCurrentUser } from "@/lib/auth"
import { getTransactionById } from "@/models/transactions"
import { notFound } from "next/navigation"
export default async function TransactionLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ transactionId: string }>
}) {
const { transactionId } = await params
const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id)
if (!transaction) {
notFound()
}
return (
<>
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Transaction Details</h2>
</header>
<main>
<div className="flex flex-1 flex-col gap-4 pt-0">{children}</div>
</main>
</>
)
}

View File

@@ -0,0 +1,10 @@
import { Skeleton } from "@/components/ui/skeleton"
export default function Loading() {
return (
<div className="flex flex-wrap flex-row items-start justify-center gap-4 max-w-6xl">
<Skeleton className="w-full h-[800px]" />
<Skeleton className="w-1/3 max-w-[380px]" />
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { FormTextarea } from "@/components/forms/simple"
import TransactionEditForm from "@/components/transactions/edit"
import TransactionFiles from "@/components/transactions/transaction-files"
import { Card } from "@/components/ui/card"
import { getCurrentUser } from "@/lib/auth"
import { getCategories } from "@/models/categories"
import { getCurrencies } from "@/models/currencies"
import { getFields } from "@/models/fields"
import { getFilesByTransactionId } from "@/models/files"
import { getProjects } from "@/models/projects"
import { getSettings } from "@/models/settings"
import { getTransactionById } from "@/models/transactions"
import { notFound } from "next/navigation"
export default async function TransactionPage({ params }: { params: Promise<{ transactionId: string }> }) {
const { transactionId } = await params
const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id)
if (!transaction) {
notFound()
}
const files = await getFilesByTransactionId(transactionId, user.id)
const categories = await getCategories(user.id)
const currencies = await getCurrencies(user.id)
const settings = await getSettings(user.id)
const fields = await getFields(user.id)
const projects = await getProjects(user.id)
return (
<div className="flex flex-wrap flex-row items-start justify-center gap-4 max-w-6xl">
<Card className="w-full flex-1 flex flex-col flex-wrap justify-center items-start gap-10 p-5 bg-accent">
<div className="w-full">
<TransactionEditForm
transaction={transaction}
categories={categories}
currencies={currencies}
settings={settings}
fields={fields}
projects={projects}
/>
{transaction.text && (
<details className="mt-10">
<summary className="cursor-pointer text-sm font-medium">Recognized Text</summary>
<Card className="flex items-stretch p-2 max-w-6xl">
<div className="flex-1">
<FormTextarea
title=""
name="text"
defaultValue={transaction.text || ""}
hideIfEmpty={true}
className="w-full h-[400px]"
/>
</div>
</Card>
</details>
)}
</div>
</Card>
<div className="w-1/2 max-w-[400px] space-y-4">
<TransactionFiles transaction={transaction} files={files} />
</div>
</div>
)
}

View File

@@ -0,0 +1,192 @@
"use server"
import { transactionFormSchema } from "@/forms/transactions"
import { getCurrentUser } from "@/lib/auth"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { updateField } from "@/models/fields"
import { createFile, deleteFile } from "@/models/files"
import {
bulkDeleteTransactions,
createTransaction,
deleteTransaction,
getTransactionById,
updateTransaction,
updateTransactionFiles,
} from "@/models/transactions"
import { randomUUID } from "crypto"
import { mkdir, writeFile } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
export async function createTransactionAction(prevState: any, formData: FormData) {
try {
const user = await getCurrentUser()
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const transaction = await createTransaction(user.id, validatedForm.data)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to create transaction:", error)
return { success: false, error: "Failed to create transaction" }
}
}
export async function saveTransactionAction(prevState: any, formData: FormData) {
try {
const user = await getCurrentUser()
const transactionId = formData.get("transactionId") as string
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
const transaction = await updateTransaction(transactionId, user.id, validatedForm.data)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to update transaction:", error)
return { success: false, error: "Failed to save transaction" }
}
}
export async function deleteTransactionAction(prevState: any, transactionId: string) {
try {
const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id)
if (!transaction) throw new Error("Transaction not found")
await deleteTransaction(transaction.id, user.id)
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to delete transaction:", error)
return { success: false, error: "Failed to delete transaction" }
}
}
export async function deleteTransactionFileAction(
transactionId: string,
fileId: string
): Promise<{ success: boolean; error?: string }> {
if (!fileId || !transactionId) {
return { success: false, error: "File ID and transaction ID are required" }
}
const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id)
if (!transaction) {
return { success: false, error: "Transaction not found" }
}
await updateTransactionFiles(
transactionId,
user.id,
transaction.files ? (transaction.files as string[]).filter((id) => id !== fileId) : []
)
await deleteFile(fileId, user.id)
revalidatePath(`/transactions/${transactionId}`)
return { success: true }
}
export async function uploadTransactionFilesAction(formData: FormData): Promise<{ success: boolean; error?: string }> {
try {
const transactionId = formData.get("transactionId") as string
const files = formData.getAll("files") as File[]
if (!files || !transactionId) {
return { success: false, error: "No files or transaction ID provided" }
}
const user = await getCurrentUser()
const transaction = await getTransactionById(transactionId, user.id)
if (!transaction) {
return { success: false, error: "Transaction not found" }
}
const userUploadsDirectory = await getUserUploadsDirectory(user)
const fileRecords = await Promise.all(
files.map(async (file) => {
const fileUuid = randomUUID()
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
await mkdir(path.dirname(fullFilePath), { recursive: true })
console.log("userUploadsDirectory", userUploadsDirectory)
console.log("relativeFilePath", relativeFilePath)
console.log("fullFilePath", fullFilePath)
await writeFile(fullFilePath, buffer)
// Create file record in database
const fileRecord = await createFile(user.id, {
id: fileUuid,
filename: file.name,
path: relativeFilePath,
mimetype: file.type,
isReviewed: true,
metadata: {
size: file.size,
lastModified: file.lastModified,
},
})
return fileRecord
})
)
// Update invoice with the new file ID
await updateTransactionFiles(
transactionId,
user.id,
transaction.files
? [...(transaction.files as string[]), ...fileRecords.map((file) => file.id)]
: fileRecords.map((file) => file.id)
)
revalidatePath(`/transactions/${transactionId}`)
return { success: true }
} catch (error) {
console.error("Upload error:", error)
return { success: false, error: `File upload failed: ${error}` }
}
}
export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
try {
const user = await getCurrentUser()
await bulkDeleteTransactions(transactionIds, user.id)
revalidatePath("/transactions")
return { success: true }
} catch (error) {
console.error("Failed to delete transactions:", error)
return { success: false, error: "Failed to delete transactions" }
}
}
export async function updateFieldVisibilityAction(fieldCode: string, isVisible: boolean) {
try {
const user = await getCurrentUser()
await updateField(user.id, fieldCode, {
isVisibleInList: isVisible,
})
return { success: true }
} catch (error) {
console.error("Failed to update field visibility:", error)
return { success: false, error: "Failed to update field visibility" }
}
}

View File

@@ -0,0 +1,3 @@
export default async function TransactionsLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4">{children}</div>
}

View File

@@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Download, Plus } from "lucide-react"
export default function Loading() {
return (
<>
<header className="flex items-center justify-between mb-12">
<h2 className="flex flex-row gap-3 md:gap-5">
<span className="text-3xl font-bold tracking-tight">Transactions</span>
<Skeleton className="h-10 w-16" />
</h2>
<div className="flex gap-2">
<Button variant="outline">
<Download />
Export
</Button>
<Button>
<Plus /> Add Transaction
</Button>
</div>
</header>
<div className="flex flex-row gap-2 w-full">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
<main>
<div className="flex flex-col gap-3 w-full">
{[...Array(15)].map((_, i) => (
<Skeleton key={i} className="h-8" />
))}
</div>
</main>
</>
)
}

View File

@@ -0,0 +1,91 @@
import { ExportTransactionsDialog } from "@/components/export/transactions"
import { UploadButton } from "@/components/files/upload-button"
import { TransactionSearchAndFilters } from "@/components/transactions/filters"
import { TransactionList } from "@/components/transactions/list"
import { NewTransactionDialog } from "@/components/transactions/new"
import { Pagination } from "@/components/transactions/pagination"
import { Button } from "@/components/ui/button"
import { getCurrentUser } from "@/lib/auth"
import { getCategories } from "@/models/categories"
import { getFields } from "@/models/fields"
import { getProjects } from "@/models/projects"
import { getTransactions, TransactionFilters } from "@/models/transactions"
import { Download, Plus, Upload } from "lucide-react"
import { Metadata } from "next"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: "Transactions",
description: "Manage your transactions",
}
const TRANSACTIONS_PER_PAGE = 1000
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
const { page, ...filters } = await searchParams
const user = await getCurrentUser()
const { transactions, total } = await getTransactions(user.id, filters, {
limit: TRANSACTIONS_PER_PAGE,
offset: ((page ?? 1) - 1) * TRANSACTIONS_PER_PAGE,
})
const categories = await getCategories(user.id)
const projects = await getProjects(user.id)
const fields = await getFields(user.id)
// Reset page if user clicks a filter and no transactions are found
if (page && page > 1 && transactions.length === 0) {
const params = new URLSearchParams(filters as Record<string, string>)
redirect(`?${params.toString()}`)
}
return (
<>
<header className="flex flex-wrap items-center justify-between gap-2 mb-8">
<h2 className="flex flex-row gap-3 md:gap-5">
<span className="text-3xl font-bold tracking-tight">Transactions</span>
<span className="text-3xl tracking-tight opacity-20">{total}</span>
</h2>
<div className="flex gap-2">
<ExportTransactionsDialog fields={fields} categories={categories} projects={projects}>
<Button variant="outline">
<Download />
<span className="hidden md:block">Export</span>
</Button>
</ExportTransactionsDialog>
<NewTransactionDialog>
<Button>
<Plus /> <span className="hidden md:block">Add Transaction</span>
</Button>
</NewTransactionDialog>
</div>
</header>
<TransactionSearchAndFilters categories={categories} projects={projects} fields={fields} />
<main>
<TransactionList transactions={transactions} fields={fields} />
{total > TRANSACTIONS_PER_PAGE && <Pagination totalItems={total} itemsPerPage={TRANSACTIONS_PER_PAGE} />}
{transactions.length === 0 && (
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[400px]">
<p className="text-muted-foreground">
You don't seem to have any transactions yet. Let's start and create the first one!
</p>
<div className="flex flex-row gap-5 mt-8">
<UploadButton>
<Upload /> Analyze New Invoice
</UploadButton>
<NewTransactionDialog>
<Button variant="outline">
<Plus />
Add Manually
</Button>
</NewTransactionDialog>
</div>
</div>
)}
</main>
</>
)
}

View File

@@ -0,0 +1,117 @@
"use server"
import { analyzeTransaction } from "@/ai/analyze"
import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments"
import { buildLLMPrompt } from "@/ai/prompt"
import { fieldsToJsonSchema } from "@/ai/schema"
import { transactionFormSchema } from "@/forms/transactions"
import { getCurrentUser } from "@/lib/auth"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
import { deleteFile, getFileById, updateFile } from "@/models/files"
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
import { Category, Field, File, Project } from "@prisma/client"
import { mkdir, rename } from "fs/promises"
import { revalidatePath } from "next/cache"
import path from "path"
export async function analyzeFileAction(
file: File,
settings: Record<string, string>,
fields: Field[],
categories: Category[],
projects: Project[]
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
const user = await getCurrentUser()
if (!file || file.userId !== user.id) {
return { success: false, error: "File not found or does not belong to the user" }
}
let attachments: AnalyzeAttachment[] = []
try {
attachments = await loadAttachmentsForAI(user, file)
} catch (error) {
console.error("Failed to retrieve files:", error)
return { success: false, error: "Failed to retrieve files: " + error }
}
const prompt = buildLLMPrompt(
settings.prompt_analyse_new_file || DEFAULT_PROMPT_ANALYSE_NEW_FILE,
fields,
categories,
projects
)
const schema = fieldsToJsonSchema(fields)
const results = await analyzeTransaction(
prompt,
schema,
attachments,
IS_SELF_HOSTED_MODE ? settings.openai_api_key : process.env.OPENAI_API_KEY || ""
)
console.log("Analysis results:", results)
return results
}
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
try {
const user = await getCurrentUser()
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
if (!validatedForm.success) {
return { success: false, error: validatedForm.error.message }
}
// Get the file record
const fileId = formData.get("fileId") as string
const file = await getFileById(fileId, user.id)
if (!file) throw new Error("File not found")
// Create transaction
const transaction = await createTransaction(user.id, validatedForm.data)
// Move file to processed location
const userUploadsDirectory = await getUserUploadsDirectory(user)
const originalFileName = path.basename(file.path)
const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction)
// Move file to new location and name
const oldFullFilePath = path.join(userUploadsDirectory, file.path)
const newFullFilePath = path.join(userUploadsDirectory, newRelativeFilePath)
await mkdir(path.dirname(newFullFilePath), { recursive: true })
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))
// Update file record
await updateFile(file.id, user.id, {
path: newRelativeFilePath,
isReviewed: true,
})
await updateTransactionFiles(transaction.id, user.id, [file.id])
revalidatePath("/unsorted")
revalidatePath("/transactions")
return { success: true, transactionId: transaction.id }
} catch (error) {
console.error("Failed to save transaction:", error)
return { success: false, error: `Failed to save transaction: ${error}` }
}
}
export async function deleteUnsortedFileAction(prevState: any, fileId: string) {
try {
const user = await getCurrentUser()
await deleteFile(fileId, user.id)
revalidatePath("/unsorted")
return { success: true }
} catch (error) {
console.error("Failed to delete file:", error)
return { success: false, error: "Failed to delete file" }
}
}

View File

@@ -0,0 +1,3 @@
export default function UnsortedLayout({ children }: { children: React.ReactNode }) {
return <div className="flex flex-col gap-4 p-4 max-w-6xl">{children}</div>
}

View File

@@ -0,0 +1,31 @@
import { Skeleton } from "@/components/ui/skeleton"
import { Loader2 } from "lucide-react"
export default function Loading() {
return (
<div className="flex flex-col gap-4">
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight flex flex-row gap-2">
<Loader2 className="h-10 w-10 animate-spin" /> <span>Loading unsorted files...</span>
</h2>
</header>
<Skeleton className="w-full h-[800px] flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-5 p-6">
<Skeleton className="w-full h-full" />
<div className="w-full flex flex-col gap-5">
<Skeleton className="w-full h-12 mb-7" />
{[...Array(4)].map((_, i) => (
<div key={i} className="flex flex-col gap-2">
<Skeleton className="w-[120px] h-4" />
<Skeleton className="w-full h-9" />
</div>
))}
<div className="flex flex-row justify-end gap-2 mt-2">
<Skeleton className="w-[80px] h-9" />
<Skeleton className="w-[130px] h-9" />
</div>
</div>
</Skeleton>
</div>
)
}

106
app/(app)/unsorted/page.tsx Normal file
View File

@@ -0,0 +1,106 @@
import { FilePreview } from "@/components/files/preview"
import { UploadButton } from "@/components/files/upload-button"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import AnalyzeForm from "@/components/unsorted/analyze-form"
import { getCurrentUser } from "@/lib/auth"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { getCategories } from "@/models/categories"
import { getCurrencies } from "@/models/currencies"
import { getFields } from "@/models/fields"
import { getUnsortedFiles } from "@/models/files"
import { getProjects } from "@/models/projects"
import { getSettings } from "@/models/settings"
import { FileText, PartyPopper, Settings, Upload } from "lucide-react"
import { Metadata } from "next"
import Link from "next/link"
export const metadata: Metadata = {
title: "Unsorted",
description: "Analyze unsorted files",
}
export default async function UnsortedPage() {
const user = await getCurrentUser()
const files = await getUnsortedFiles(user.id)
const categories = await getCategories(user.id)
const projects = await getProjects(user.id)
const currencies = await getCurrencies(user.id)
const fields = await getFields(user.id)
const settings = await getSettings(user.id)
return (
<>
<header className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
</header>
{IS_SELF_HOSTED_MODE && !settings.openai_api_key && (
<Alert>
<Settings className="h-4 w-4 mt-2" />
<div className="flex flex-row justify-between pt-2">
<div className="flex flex-col">
<AlertTitle>ChatGPT API Key is required for analyzing files</AlertTitle>
<AlertDescription>
Please set your OpenAI API key in the settings to use the analyze form.
</AlertDescription>
</div>
<Link href="/settings/llm">
<Button>Go to Settings</Button>
</Link>
</div>
</Alert>
)}
<main className="flex flex-col gap-5">
{files.map((file) => (
<Card
key={file.id}
id={file.id}
className="flex flex-row flex-wrap md:flex-nowrap justify-center items-start gap-5 p-5 bg-accent"
>
<div className="w-full max-w-[500px]">
<Card>
<FilePreview file={file} />
</Card>
</div>
<div className="w-full">
<AnalyzeForm
file={file}
categories={categories}
projects={projects}
currencies={currencies}
fields={fields}
settings={settings}
/>
</div>
</Card>
))}
{files.length == 0 && (
<div className="flex flex-col items-center justify-center gap-2 h-full min-h-[600px]">
<PartyPopper className="w-12 h-12 text-muted-foreground" />
<p className="pt-4 text-muted-foreground">Everything is clear! Congrats!</p>
<p className="flex flex-row gap-2 text-muted-foreground">
<span>Drag and drop new files here to analyze</span>
<Upload />
</p>
<div className="flex flex-row gap-5 mt-8">
<UploadButton>
<Upload /> Upload New File
</UploadButton>
<Button variant="outline" asChild>
<Link href="/transactions">
<FileText />
Go to Transactions
</Link>
</Button>
</div>
</div>
)}
</main>
</>
)
}