mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 21:35:19 +00:00
BREAKING: postgres + saas
This commit is contained in:
39
app/(app)/dashboard/page.tsx
Normal file
39
app/(app)/dashboard/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
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"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
@@ -15,8 +17,9 @@ export async function GET(request: Request) {
|
||||
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
|
||||
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
||||
|
||||
const { transactions } = await getTransactions(filters)
|
||||
const existingFields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const { transactions } = await getTransactions(user.id, filters)
|
||||
const existingFields = await getFields(user.id)
|
||||
|
||||
// Generate CSV file with all transactions
|
||||
try {
|
||||
@@ -40,7 +43,7 @@ export async function GET(request: Request) {
|
||||
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(value)
|
||||
row[key] = await exportFieldSettings.export(user.id, value)
|
||||
} else {
|
||||
row[key] = value
|
||||
}
|
||||
@@ -73,7 +76,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const transactionFiles = await getFilesByTransactionId(transaction.id)
|
||||
const transactionFiles = await getFilesByTransactionId(transaction.id, user.id)
|
||||
|
||||
const transactionFolder = filesFolder.folder(
|
||||
path.join(
|
||||
@@ -87,14 +90,17 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
for (const file of transactionFiles) {
|
||||
const fileData = fs.readFileSync(file.path)
|
||||
const fileExtension = path.extname(file.path)
|
||||
transactionFolder.file(
|
||||
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
|
||||
transaction.name || transaction.id
|
||||
}${fileExtension}`,
|
||||
fileData
|
||||
)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use server"
|
||||
|
||||
import { FILE_UNSORTED_UPLOAD_PATH, getUnsortedFileUploadPath } from "@/lib/files"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files"
|
||||
import { createFile } from "@/models/files"
|
||||
import { existsSync } from "fs"
|
||||
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
|
||||
if (!existsSync(FILE_UNSORTED_UPLOAD_PATH)) {
|
||||
await mkdir(FILE_UNSORTED_UPLOAD_PATH, { recursive: true })
|
||||
}
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
// Process each file
|
||||
const uploadedFiles = await Promise.all(
|
||||
@@ -22,17 +23,21 @@ export async function uploadFilesAction(prevState: any, formData: FormData) {
|
||||
}
|
||||
|
||||
// Save file to filesystem
|
||||
const { fileUuid, filePath } = await getUnsortedFileUploadPath(file.name)
|
||||
const fileUuid = randomUUID()
|
||||
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
await writeFile(filePath, buffer)
|
||||
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({
|
||||
const fileRecord = await createFile(user.id, {
|
||||
id: fileUuid,
|
||||
filename: file.name,
|
||||
path: filePath,
|
||||
path: relativeFilePath,
|
||||
mimetype: file.type,
|
||||
metadata: {
|
||||
size: file.size,
|
||||
@@ -1,9 +1,12 @@
|
||||
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 })
|
||||
@@ -11,20 +14,21 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
|
||||
|
||||
try {
|
||||
// Find file in database
|
||||
const file = await getFileById(fileId)
|
||||
const file = await getFileById(fileId, user.id)
|
||||
|
||||
if (!file) {
|
||||
return new NextResponse("File not found", { status: 404 })
|
||||
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
|
||||
try {
|
||||
await fs.access(file.path)
|
||||
} catch {
|
||||
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(file.path)
|
||||
const fileBuffer = await fs.readFile(fullFilePath)
|
||||
|
||||
// Return file with proper content type
|
||||
return new NextResponse(fileBuffer, {
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resizeImage } from "@/lib/images"
|
||||
import { pdfToImages } from "@/lib/pdf"
|
||||
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"
|
||||
@@ -7,6 +8,7 @@ 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 })
|
||||
@@ -17,45 +19,33 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
|
||||
|
||||
try {
|
||||
// Find file in database
|
||||
const file = await getFileById(fileId)
|
||||
const file = await getFileById(fileId, user.id)
|
||||
|
||||
if (!file) {
|
||||
return new NextResponse("File not found", { status: 404 })
|
||||
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
|
||||
try {
|
||||
await fs.access(file.path)
|
||||
} catch {
|
||||
// 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 })
|
||||
}
|
||||
|
||||
let previewPath = file.path
|
||||
let previewType = file.mimetype
|
||||
|
||||
if (file.mimetype === "application/pdf") {
|
||||
const { contentType, pages } = await pdfToImages(file.path)
|
||||
if (page > pages.length) {
|
||||
return new NextResponse("Page not found", { status: 404 })
|
||||
}
|
||||
previewPath = pages[page - 1] || file.path
|
||||
previewType = contentType
|
||||
} else if (file.mimetype.startsWith("image/")) {
|
||||
const { contentType, resizedPath } = await resizeImage(file.path)
|
||||
previewPath = resizedPath
|
||||
previewType = contentType
|
||||
} else {
|
||||
previewPath = file.path
|
||||
previewType = file.mimetype
|
||||
// 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 filex
|
||||
// Read file
|
||||
const fileBuffer = await fs.readFile(previewPath)
|
||||
|
||||
// Return file with proper content type
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": previewType,
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${path.basename(previewPath)}"`,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
"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"
|
||||
@@ -38,6 +39,7 @@ export async function parseCSVAction(prevState: any, formData: FormData) {
|
||||
}
|
||||
|
||||
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>[]
|
||||
|
||||
@@ -46,13 +48,13 @@ export async function saveTransactionsAction(prevState: any, formData: FormData)
|
||||
for (const [fieldCode, value] of Object.entries(row)) {
|
||||
const fieldDef = EXPORT_AND_IMPORT_FIELD_MAP[fieldCode]
|
||||
if (fieldDef?.import) {
|
||||
transactionData[fieldCode] = await fieldDef.import(value as string)
|
||||
transactionData[fieldCode] = await fieldDef.import(user.id, value as string)
|
||||
} else {
|
||||
transactionData[fieldCode] = value as string
|
||||
}
|
||||
}
|
||||
|
||||
await createTransaction(transactionData)
|
||||
await createTransaction(user.id, transactionData)
|
||||
}
|
||||
|
||||
revalidatePath("/import/csv")
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ImportCSVTable } from "@/components/import/csv"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
|
||||
export default async function CSVImportPage() {
|
||||
const fields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const fields = await getFields(user.id)
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ImportCSVTable fields={fields} />
|
||||
57
app/(app)/layout.tsx
Normal file
57
app/(app)/layout.tsx
Normal 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"
|
||||
@@ -7,17 +7,21 @@ import {
|
||||
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) {
|
||||
@@ -25,7 +29,7 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
|
||||
}
|
||||
|
||||
for (const key in validatedForm.data) {
|
||||
await updateSettings(key, validatedForm.data[key as keyof typeof validatedForm.data])
|
||||
await updateSettings(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data])
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
@@ -33,14 +37,30 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
|
||||
// return { success: true }
|
||||
}
|
||||
|
||||
export async function addProjectAction(data: Prisma.ProjectCreateInput) {
|
||||
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({
|
||||
const project = await createProject(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt || null,
|
||||
@@ -51,14 +71,14 @@ export async function addProjectAction(data: Prisma.ProjectCreateInput) {
|
||||
return { success: true, project }
|
||||
}
|
||||
|
||||
export async function editProjectAction(code: string, data: Prisma.ProjectUpdateInput) {
|
||||
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(code, {
|
||||
const project = await updateProject(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
@@ -68,9 +88,9 @@ export async function editProjectAction(code: string, data: Prisma.ProjectUpdate
|
||||
return { success: true, project }
|
||||
}
|
||||
|
||||
export async function deleteProjectAction(code: string) {
|
||||
export async function deleteProjectAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteProject(code)
|
||||
await deleteProject(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete project" + error }
|
||||
}
|
||||
@@ -78,14 +98,14 @@ export async function deleteProjectAction(code: string) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) {
|
||||
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({
|
||||
const currency = await createCurrency(userId, {
|
||||
code: validatedForm.data.code,
|
||||
name: validatedForm.data.name,
|
||||
})
|
||||
@@ -94,21 +114,21 @@ export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) {
|
||||
return { success: true, currency }
|
||||
}
|
||||
|
||||
export async function editCurrencyAction(code: string, data: Prisma.CurrencyUpdateInput) {
|
||||
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(code, { name: validatedForm.data.name })
|
||||
const currency = await updateCurrency(userId, code, { name: validatedForm.data.name })
|
||||
revalidatePath("/settings/currencies")
|
||||
return { success: true, currency }
|
||||
}
|
||||
|
||||
export async function deleteCurrencyAction(code: string) {
|
||||
export async function deleteCurrencyAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteCurrency(code)
|
||||
await deleteCurrency(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete currency" + error }
|
||||
}
|
||||
@@ -116,14 +136,14 @@ export async function deleteCurrencyAction(code: string) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addCategoryAction(data: Prisma.CategoryCreateInput) {
|
||||
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({
|
||||
const category = await createCategory(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
@@ -134,14 +154,14 @@ export async function addCategoryAction(data: Prisma.CategoryCreateInput) {
|
||||
return { success: true, category }
|
||||
}
|
||||
|
||||
export async function editCategoryAction(code: string, data: Prisma.CategoryUpdateInput) {
|
||||
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(code, {
|
||||
const category = await updateCategory(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
@@ -151,9 +171,9 @@ export async function editCategoryAction(code: string, data: Prisma.CategoryUpda
|
||||
return { success: true, category }
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(code: string) {
|
||||
export async function deleteCategoryAction(code: string, userId: string) {
|
||||
try {
|
||||
await deleteCategory(code)
|
||||
await deleteCategory(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete category" + error }
|
||||
}
|
||||
@@ -161,14 +181,14 @@ export async function deleteCategoryAction(code: string) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addFieldAction(data: Prisma.FieldCreateInput) {
|
||||
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({
|
||||
const field = await createField(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
type: validatedForm.data.type,
|
||||
@@ -182,14 +202,14 @@ export async function addFieldAction(data: Prisma.FieldCreateInput) {
|
||||
return { success: true, field }
|
||||
}
|
||||
|
||||
export async function editFieldAction(code: string, data: Prisma.FieldUpdateInput) {
|
||||
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(code, {
|
||||
const field = await updateField(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
type: validatedForm.data.type,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
@@ -201,9 +221,9 @@ export async function editFieldAction(code: string, data: Prisma.FieldUpdateInpu
|
||||
return { success: true, field }
|
||||
}
|
||||
|
||||
export async function deleteFieldAction(code: string) {
|
||||
export async function deleteFieldAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteField(code)
|
||||
await deleteField(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete field" + error }
|
||||
}
|
||||
145
app/(app)/settings/backups/actions.ts
Normal file
145
app/(app)/settings/backups/actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FILE_UPLOAD_PATH } from "@/lib/files"
|
||||
import { MODEL_BACKUP } from "@/models/backups"
|
||||
import fs, { readdirSync } from "fs"
|
||||
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"
|
||||
@@ -9,6 +10,9 @@ 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")
|
||||
@@ -32,12 +36,12 @@ export async function GET(request: Request) {
|
||||
)
|
||||
|
||||
// Backup models
|
||||
for (const { filename, model } of MODEL_BACKUP) {
|
||||
for (const backup of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonContent = await tableToJSON(model)
|
||||
rootFolder.file(filename, jsonContent)
|
||||
const jsonContent = await modelToJSON(user.id, backup)
|
||||
rootFolder.file(backup.filename, jsonContent)
|
||||
} catch (error) {
|
||||
console.error(`Error exporting table ${filename}:`, error)
|
||||
console.error(`Error exporting table ${backup.filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +51,11 @@ export async function GET(request: Request) {
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
|
||||
const uploadedFiles = getAllFilePaths(FILE_UPLOAD_PATH)
|
||||
uploadedFiles.forEach((file) => {
|
||||
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
// Check file size before reading
|
||||
const stats = fs.statSync(file)
|
||||
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 > ${
|
||||
@@ -61,12 +65,13 @@ export async function GET(request: Request) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(file)
|
||||
uploadsFolder.file(file.replace(FILE_UPLOAD_PATH, ""), fileContent)
|
||||
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, {
|
||||
@@ -81,32 +86,27 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAllFilePaths(dirPath: string): string[] {
|
||||
async function getAllFilePaths(dirPath: string): Promise<string[]> {
|
||||
let filePaths: string[] = []
|
||||
|
||||
function readDirectory(currentPath: string) {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true })
|
||||
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()) {
|
||||
readDirectory(fullPath)
|
||||
await readDirectoryRecursively(fullPath)
|
||||
} else {
|
||||
filePaths.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readDirectory(dirPath)
|
||||
await readDirectoryRecursively(dirPath)
|
||||
|
||||
return filePaths
|
||||
}
|
||||
|
||||
async function tableToJSON(model: any): Promise<string> {
|
||||
const data = await model.findMany()
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
@@ -57,7 +57,14 @@ export default function BackupSettingsPage() {
|
||||
{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.</p>
|
||||
<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>
|
||||
@@ -1,11 +1,13 @@
|
||||
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions"
|
||||
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 categories = await getCategories()
|
||||
const user = await getCurrentUser()
|
||||
const categories = await getCategories(user.id)
|
||||
const categoriesWithActions = categories.map((category) => ({
|
||||
...category,
|
||||
isEditable: true,
|
||||
@@ -29,15 +31,15 @@ export default async function CategoriesSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteCategoryAction(code)
|
||||
return await deleteCategoryAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addCategoryAction(data as Prisma.CategoryCreateInput)
|
||||
return await addCategoryAction(user.id, data as Prisma.CategoryCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editCategoryAction(code, data as Prisma.CategoryUpdateInput)
|
||||
return await editCategoryAction(user.id, code, data as Prisma.CategoryUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,9 +1,11 @@
|
||||
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/settings/actions"
|
||||
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 currencies = await getCurrencies()
|
||||
const user = await getCurrentUser()
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const currenciesWithActions = currencies.map((currency) => ({
|
||||
...currency,
|
||||
isEditable: true,
|
||||
@@ -24,15 +26,15 @@ export default async function CurrenciesSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteCurrencyAction(code)
|
||||
return await deleteCurrencyAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addCurrencyAction(data as { code: string; name: string })
|
||||
return await addCurrencyAction(user.id, data as { code: string; name: string })
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editCurrencyAction(code, data as { name: string })
|
||||
return await editCurrencyAction(user.id, code, data as { name: string })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,10 +1,12 @@
|
||||
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions"
|
||||
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 fields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const fields = await getFields(user.id)
|
||||
const fieldsWithActions = fields.map((field) => ({
|
||||
...field,
|
||||
isEditable: true,
|
||||
@@ -48,15 +50,15 @@ export default async function FieldsSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteFieldAction(code)
|
||||
return await deleteFieldAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addFieldAction(data as Prisma.FieldCreateInput)
|
||||
return await addFieldAction(user.id, data as Prisma.FieldCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editFieldAction(code, data as Prisma.FieldUpdateInput)
|
||||
return await editFieldAction(user.id, code, data as Prisma.FieldUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -12,6 +12,10 @@ const settingsCategories = [
|
||||
title: "General",
|
||||
href: "/settings",
|
||||
},
|
||||
{
|
||||
title: "My Profile",
|
||||
href: "/settings/profile",
|
||||
},
|
||||
{
|
||||
title: "LLM settings",
|
||||
href: "/settings/llm",
|
||||
@@ -1,10 +1,12 @@
|
||||
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 settings = await getSettings()
|
||||
const fields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1,12 +1,14 @@
|
||||
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 settings = await getSettings()
|
||||
const currencies = await getCurrencies()
|
||||
const categories = await getCategories()
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
14
app/(app)/settings/profile/page.tsx
Normal file
14
app/(app)/settings/profile/page.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions"
|
||||
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 projects = await getProjects()
|
||||
const user = await getCurrentUser()
|
||||
const projects = await getProjects(user.id)
|
||||
const projectsWithActions = projects.map((project) => ({
|
||||
...project,
|
||||
isEditable: true,
|
||||
@@ -28,15 +30,15 @@ export default async function ProjectsSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteProjectAction(code)
|
||||
return await deleteProjectAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addProjectAction(data as Prisma.ProjectCreateInput)
|
||||
return await addProjectAction(user.id, data as Prisma.ProjectCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editProjectAction(code, data as Prisma.ProjectUpdateInput)
|
||||
return await editProjectAction(user.id, code, data as Prisma.ProjectUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getTransactionById } from "@/models/transactions"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
@@ -9,7 +10,8 @@ export default async function TransactionLayout({
|
||||
params: Promise<{ transactionId: string }>
|
||||
}) {
|
||||
const { transactionId } = await params
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
|
||||
if (!transaction) {
|
||||
notFound()
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
@@ -13,17 +14,18 @@ import { notFound } from "next/navigation"
|
||||
|
||||
export default async function TransactionPage({ params }: { params: Promise<{ transactionId: string }> }) {
|
||||
const { transactionId } = await params
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const files = await getFilesByTransactionId(transactionId)
|
||||
const categories = await getCategories()
|
||||
const currencies = await getCurrencies()
|
||||
const settings = await getSettings()
|
||||
const fields = await getFields()
|
||||
const projects = await getProjects()
|
||||
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">
|
||||
@@ -1,7 +1,8 @@
|
||||
"use server"
|
||||
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { FILE_UPLOAD_PATH, getTransactionFileUploadPath } from "@/lib/files"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { updateField } from "@/models/fields"
|
||||
import { createFile, deleteFile } from "@/models/files"
|
||||
import {
|
||||
@@ -12,19 +13,21 @@ import {
|
||||
updateTransaction,
|
||||
updateTransactionFiles,
|
||||
} from "@/models/transactions"
|
||||
import { existsSync } from "fs"
|
||||
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(validatedForm.data)
|
||||
const transaction = await createTransaction(user.id, validatedForm.data)
|
||||
|
||||
revalidatePath("/transactions")
|
||||
return { success: true, transactionId: transaction.id }
|
||||
@@ -36,6 +39,7 @@ export async function createTransactionAction(prevState: any, formData: FormData
|
||||
|
||||
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()))
|
||||
|
||||
@@ -43,7 +47,7 @@ export async function saveTransactionAction(prevState: any, formData: FormData)
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const transaction = await updateTransaction(transactionId, validatedForm.data)
|
||||
const transaction = await updateTransaction(transactionId, user.id, validatedForm.data)
|
||||
|
||||
revalidatePath("/transactions")
|
||||
return { success: true, transactionId: transaction.id }
|
||||
@@ -55,10 +59,11 @@ export async function saveTransactionAction(prevState: any, formData: FormData)
|
||||
|
||||
export async function deleteTransactionAction(prevState: any, transactionId: string) {
|
||||
try {
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) throw new Error("Transaction not found")
|
||||
|
||||
await deleteTransaction(transaction.id)
|
||||
await deleteTransaction(transaction.id, user.id)
|
||||
|
||||
revalidatePath("/transactions")
|
||||
|
||||
@@ -77,17 +82,19 @@ export async function deleteTransactionFileAction(
|
||||
return { success: false, error: "File ID and transaction ID are required" }
|
||||
}
|
||||
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
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)
|
||||
await deleteFile(fileId, user.id)
|
||||
revalidatePath(`/transactions/${transactionId}`)
|
||||
return { success: true }
|
||||
}
|
||||
@@ -101,28 +108,35 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
||||
return { success: false, error: "No files or transaction ID provided" }
|
||||
}
|
||||
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
return { success: false, error: "Transaction not found" }
|
||||
}
|
||||
|
||||
// Make sure upload dir exists
|
||||
if (!existsSync(FILE_UPLOAD_PATH)) {
|
||||
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
|
||||
}
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
const fileRecords = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction)
|
||||
const fileUuid = randomUUID()
|
||||
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
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({
|
||||
const fileRecord = await createFile(user.id, {
|
||||
id: fileUuid,
|
||||
filename: file.name,
|
||||
path: filePath,
|
||||
path: relativeFilePath,
|
||||
mimetype: file.type,
|
||||
isReviewed: true,
|
||||
metadata: {
|
||||
@@ -138,6 +152,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
||||
// 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)
|
||||
@@ -153,7 +168,8 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
||||
|
||||
export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
|
||||
try {
|
||||
await bulkDeleteTransactions(transactionIds)
|
||||
const user = await getCurrentUser()
|
||||
await bulkDeleteTransactions(transactionIds, user.id)
|
||||
revalidatePath("/transactions")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -164,7 +180,8 @@ export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
|
||||
|
||||
export async function updateFieldVisibilityAction(fieldCode: string, isVisible: boolean) {
|
||||
try {
|
||||
await updateField(fieldCode, {
|
||||
const user = await getCurrentUser()
|
||||
await updateField(user.id, fieldCode, {
|
||||
isVisibleInList: isVisible,
|
||||
})
|
||||
return { success: true }
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -22,13 +23,14 @@ const TRANSACTIONS_PER_PAGE = 1000
|
||||
|
||||
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const { page, ...filters } = await searchParams
|
||||
const { transactions, total } = await getTransactions(filters, {
|
||||
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()
|
||||
const projects = await getProjects()
|
||||
const fields = await getFields()
|
||||
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) {
|
||||
117
app/(app)/unsorted/actions.ts
Normal file
117
app/(app)/unsorted/actions.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ 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"
|
||||
@@ -20,12 +22,13 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default async function UnsortedPage() {
|
||||
const files = await getUnsortedFiles()
|
||||
const categories = await getCategories()
|
||||
const projects = await getProjects()
|
||||
const currencies = await getCurrencies()
|
||||
const fields = await getFields()
|
||||
const settings = await getSettings()
|
||||
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 (
|
||||
<>
|
||||
@@ -33,7 +36,7 @@ export default async function UnsortedPage() {
|
||||
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
|
||||
</header>
|
||||
|
||||
{!settings.openai_api_key && (
|
||||
{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">
|
||||
28
app/(auth)/actions.ts
Normal file
28
app/(auth)/actions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use server"
|
||||
|
||||
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
|
||||
import { updateSettings } from "@/models/settings"
|
||||
import { createSelfHostedUser } from "@/models/users"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function selfHostedGetStartedAction(formData: FormData) {
|
||||
const user = await createSelfHostedUser()
|
||||
|
||||
if (await isDatabaseEmpty(user.id)) {
|
||||
await createUserDefaults(user.id)
|
||||
}
|
||||
|
||||
const openaiApiKey = formData.get("openai_api_key")
|
||||
if (openaiApiKey) {
|
||||
await updateSettings(user.id, "openai_api_key", openaiApiKey)
|
||||
}
|
||||
|
||||
const defaultCurrency = formData.get("default_currency")
|
||||
if (defaultCurrency) {
|
||||
await updateSettings(user.id, "default_currency", defaultCurrency)
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard")
|
||||
redirect("/dashboard")
|
||||
}
|
||||
23
app/(auth)/enter/page.tsx
Normal file
23
app/(auth)/enter/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { LoginForm } from "@/components/auth/login-form"
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function LoginPage() {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
|
||||
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
|
||||
<CardTitle className="text-3xl font-bold ">
|
||||
<ColoredText>TaxHacker: Cloud Edition</ColoredText>
|
||||
</CardTitle>
|
||||
<CardContent className="w-full">
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
17
app/(auth)/layout.tsx
Normal file
17
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col relative">
|
||||
<a
|
||||
href="/"
|
||||
className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className="text-gray-300 font-bold text-xl">
|
||||
<X />
|
||||
</span>
|
||||
</a>
|
||||
<div className="flex-grow flex flex-col justify-center items-center py-12 px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
app/(auth)/self-hosted/page.tsx
Normal file
78
app/(auth)/self-hosted/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults"
|
||||
import { getSelfHostedUser } from "@/models/users"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
import { redirect } from "next/navigation"
|
||||
import { selfHostedGetStartedAction } from "../actions"
|
||||
|
||||
export default async function SelfHostedWelcomePage() {
|
||||
if (!IS_SELF_HOSTED_MODE) {
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-6">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
<span>Self-Hosted Mode is not enabled</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center text-lg flex flex-col gap-2">
|
||||
<p>
|
||||
To use TaxHacker in self-hosted mode, please set <code className="font-bold">SELF_HOSTED_MODE=true</code> in
|
||||
your environment.
|
||||
</p>
|
||||
<p>In self-hosted mode you can use your own ChatGPT API key and store your data on your own server.</p>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const user = await getSelfHostedUser()
|
||||
if (user) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
|
||||
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
|
||||
<CardTitle className="text-3xl font-bold ">
|
||||
<ColoredText>TaxHacker: Self-Hosted Edition</ColoredText>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-4 text-center text-lg">
|
||||
<p>Welcome to your own instance of TaxHacker. Let's set up a couple of settings to get started.</p>
|
||||
|
||||
<form action={selfHostedGetStartedAction} className="flex flex-col gap-8 pt-8">
|
||||
<div>
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" />
|
||||
|
||||
<small className="text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/settings/organization/api-keys"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
OpenAI Platform Console
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<FormSelectCurrency
|
||||
title="Default Currency"
|
||||
name="default_currency"
|
||||
defaultValue={DEFAULT_SETTINGS.find((s) => s.code === "default_currency")?.value ?? "EUR"}
|
||||
currencies={DEFAULT_CURRENCIES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-auto p-6">
|
||||
Get Started
|
||||
</Button>
|
||||
</form>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
23
app/(auth)/self-hosted/redirect/route.ts
Normal file
23
app/(auth)/self-hosted/redirect/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE, SELF_HOSTED_WELCOME_URL } from "@/lib/constants"
|
||||
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
|
||||
import { getSelfHostedUser } from "@/models/users"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!IS_SELF_HOSTED_MODE) {
|
||||
redirect(AUTH_LOGIN_URL)
|
||||
}
|
||||
|
||||
const user = await getSelfHostedUser()
|
||||
if (!user) {
|
||||
redirect(SELF_HOSTED_WELCOME_URL)
|
||||
}
|
||||
|
||||
if (await isDatabaseEmpty(user.id)) {
|
||||
await createUserDefaults(user.id)
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard")
|
||||
redirect("/dashboard")
|
||||
}
|
||||
25
app/(auth)/signup/page.tsx
Normal file
25
app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function LoginPage() {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
|
||||
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
|
||||
<CardTitle className="text-3xl font-bold ">
|
||||
<ColoredText>TaxHacker: Cloud Edition</ColoredText>
|
||||
</CardTitle>
|
||||
<CardContent className="w-full">
|
||||
<div className="text-center text-md text-muted-foreground">
|
||||
Creating new account is disabled for now. Please use the self-hosted version.
|
||||
</div>
|
||||
{/* <SignupForm /> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Category, Field, File, Project } from "@prisma/client"
|
||||
import OpenAI from "openai"
|
||||
import { buildLLMPrompt } from "./prompt"
|
||||
import { fieldsToJsonSchema } from "./schema"
|
||||
|
||||
const MAX_PAGES_TO_ANALYZE = 4
|
||||
|
||||
type AnalyzeAttachment = {
|
||||
filename: string
|
||||
contentType: string
|
||||
base64: string
|
||||
}
|
||||
|
||||
export const retrieveAllAttachmentsForAI = async (file: File): Promise<AnalyzeAttachment[]> => {
|
||||
const attachments: AnalyzeAttachment[] = []
|
||||
for (let i = 1; i < MAX_PAGES_TO_ANALYZE; i++) {
|
||||
try {
|
||||
const attachment = await retrieveFileContentForAI(file, i)
|
||||
attachments.push(attachment)
|
||||
} catch (error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
export const retrieveFileContentForAI = async (file: File, page: number): Promise<AnalyzeAttachment> => {
|
||||
const response = await fetch(`/files/preview/${file.id}?page=${page}`)
|
||||
if (!response.ok) throw new Error("Failed to retrieve file")
|
||||
|
||||
const blob = await response.blob()
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
|
||||
return {
|
||||
filename: file.filename,
|
||||
contentType: response.headers.get("Content-Type") || file.mimetype,
|
||||
base64: base64,
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeTransaction(
|
||||
promptTemplate: string,
|
||||
settings: Record<string, string>,
|
||||
fields: Field[],
|
||||
categories: Category[] = [],
|
||||
projects: Project[] = [],
|
||||
attachments: AnalyzeAttachment[] = []
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
const openai = new OpenAI({
|
||||
apiKey: settings.openai_api_key,
|
||||
dangerouslyAllowBrowser: true,
|
||||
})
|
||||
|
||||
const prompt = buildLLMPrompt(promptTemplate, fields, categories, projects)
|
||||
const schema = fieldsToJsonSchema(fields)
|
||||
|
||||
console.log("PROMPT:", prompt)
|
||||
console.log("SCHEMA:", schema)
|
||||
|
||||
try {
|
||||
const response = await openai.responses.create({
|
||||
model: "gpt-4o-mini-2024-07-18",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: attachments.map((attachment) => ({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${attachment.contentType};base64,${attachment.base64}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: "transaction",
|
||||
schema: schema,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log("ChatGPT response:", response.output_text)
|
||||
|
||||
const result = JSON.parse(response.output_text)
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
console.error("AI Analysis error:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to analyze invoice",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { Category, Field, Project } from "@prisma/client"
|
||||
|
||||
export function buildLLMPrompt(
|
||||
promptTemplate: string,
|
||||
fields: Field[],
|
||||
categories: Category[] = [],
|
||||
projects: Project[] = []
|
||||
) {
|
||||
let prompt = promptTemplate
|
||||
|
||||
prompt = prompt.replace(
|
||||
"{fields}",
|
||||
fields
|
||||
.filter((field) => field.llm_prompt)
|
||||
.map((field) => `- ${field.code}: ${field.llm_prompt}`)
|
||||
.join("\n")
|
||||
)
|
||||
|
||||
prompt = prompt.replace(
|
||||
"{categories}",
|
||||
categories
|
||||
.filter((category) => category.llm_prompt)
|
||||
.map((category) => `- ${category.code}: for ${category.llm_prompt}`)
|
||||
.join("\n")
|
||||
)
|
||||
|
||||
prompt = prompt.replace(
|
||||
"{projects}",
|
||||
projects
|
||||
.filter((project) => project.llm_prompt)
|
||||
.map((project) => `- ${project.code}: for ${project.llm_prompt}`)
|
||||
.join("\n")
|
||||
)
|
||||
|
||||
prompt = prompt.replace("{categories.code}", categories.map((category) => `${category.code}`).join(", "))
|
||||
prompt = prompt.replace("{projects.code}", projects.map((project) => `${project.code}`).join(", "))
|
||||
|
||||
return prompt
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Field } from "@prisma/client"
|
||||
|
||||
export const fieldsToJsonSchema = (fields: Field[]) => {
|
||||
const fieldsWithPrompt = fields.filter((field) => field.llm_prompt)
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: fieldsWithPrompt.reduce((acc, field) => {
|
||||
acc[field.code] = { type: field.type, description: field.llm_prompt || "" }
|
||||
return acc
|
||||
}, {} as Record<string, { type: string; description: string }>),
|
||||
required: fieldsWithPrompt.map((field) => field.code),
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
36
app/landing/actions.ts
Normal file
36
app/landing/actions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
"use server"
|
||||
|
||||
import { resend, sendNewsletterWelcomeEmail } from "@/lib/email"
|
||||
|
||||
export async function subscribeToNewsletterAction(email: string) {
|
||||
try {
|
||||
if (!email || !email.includes("@")) {
|
||||
return { success: false, error: "Invalid email address" }
|
||||
}
|
||||
|
||||
const existingContacts = await resend.contacts.list({
|
||||
audienceId: process.env.RESEND_AUDIENCE_ID as string,
|
||||
})
|
||||
|
||||
if (existingContacts.data) {
|
||||
const existingContact = existingContacts.data.data.find((contact: any) => contact.email === email)
|
||||
|
||||
if (existingContact) {
|
||||
return { success: false, error: "You are already subscribed to the newsletter" }
|
||||
}
|
||||
}
|
||||
|
||||
await resend.contacts.create({
|
||||
email,
|
||||
audienceId: process.env.RESEND_AUDIENCE_ID as string,
|
||||
unsubscribed: false,
|
||||
})
|
||||
|
||||
await sendNewsletterWelcomeEmail(email)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Newsletter subscription error:", error)
|
||||
return { error: "Failed to subscribe. Please try again later." }
|
||||
}
|
||||
}
|
||||
439
app/landing/landing.tsx
Normal file
439
app/landing/landing.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { NewsletterForm } from "@/app/landing/newsletter"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[#FAFAFA]">
|
||||
<header className="py-6 px-8 bg-white/80 backdrop-blur-md shadow-sm fixed w-full z-10">
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
<img src="/logo/256.png" alt="Logo" className="h-8" />
|
||||
<ColoredText className="text-2xl font-bold">TaxHacker</ColoredText>
|
||||
</a>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="#start"
|
||||
className="text-sm font-medium bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-4 py-2 rounded-full hover:opacity-90 transition-all"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-16 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-block px-4 py-2 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-6">
|
||||
🚀 Under Active Development
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl mb-6 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent pb-2">
|
||||
Organize receipts, track expenses, and prepare your taxes with AI
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
A self-hosted accounting app crafted with love for freelancers and small businesses.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
href="#start"
|
||||
className="px-8 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-medium rounded-full hover:opacity-90 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<a
|
||||
href="mailto:me@vas3k.ru"
|
||||
className="px-8 py-3 border border-gray-200 text-gray-700 font-medium rounded-full hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-blue-500/5 to-transparent z-10" />
|
||||
<video className="w-full h-auto" autoPlay loop muted playsInline poster="/landing/title.webp">
|
||||
<source src="/landing/video.mp4" type="video/mp4" />
|
||||
<Image src="/landing/title.webp" alt="TaxHacker" width={1980} height={1224} priority />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="flex flex-col gap-3 mb-4 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
<span className="text-6xl font-semibold text-muted-foreground">F∗ck Taxes</span>
|
||||
<span className="text-4xl font-bold">TaxHacker can save you time, money and nerves</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* AI Scanner Feature */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-sm font-medium mb-4">
|
||||
LLM-Powered
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">AI Document Analyzer</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Upload photos or PDFs for automatic recognition
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Extract key information like dates, amounts, and vendors
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Works with any language, format and photo quality
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Automatically organize everything into a structured database
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/ai-scanner.webp" alt="AI Document Analyzer" width={1900} height={1524} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-currency Feature */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100 flex-row-reverse">
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-green-50 text-green-600 text-sm font-medium mb-4">
|
||||
Currency Converter
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Multi-Currency Support</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Detects foreign currencies and coverts it to yours
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Historical exchange rate lookup on a date of transaction
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Support for 170+ world currencies
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Even works with cryptocurrencies (BTC, ETH, LTC, etc.)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/multi-currency.webp" alt="Currency Converter" width={1400} height={1005} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Table Feature */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100 flex-row-reverse">
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/transactions.webp" alt="Transactions Table" width={2000} height={1279} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-pink-50 text-pink-600 text-sm font-medium mb-4">
|
||||
Filters
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Income & Expense Tracker</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Add, edit and manage your transactions
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Filter by any column, category or date range
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Customize which columns to show in the table
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Import transactions from CSV
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields & Categories */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/custom-llm.webp" alt="Custom LLM promts" width={1800} height={1081} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-4">
|
||||
Customization
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Custom LLM promts for everything</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Create custom fields and categories with your own LLM prompts
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Extract any additional information you need
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Automatically categorize by project or category
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Ask AI to assess risk level or any other criteria
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Export */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100 flex-row-reverse">
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/export.webp" alt="Export" width={1200} height={1081} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-orange-50 text-orange-600 text-sm font-medium mb-4">
|
||||
Export
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Your Data — Your Rules</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Flexible filters to export your data for tax prep
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Full-text search across documents
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Export to CSV with attached documents
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Download full data archive to migrate to another service
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deployment Options */}
|
||||
<section id="start" className="py-20 px-8 bg-white scroll-mt-20">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold mb-4 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
Choose Your Version of TaxHacker
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Self-Hosted Version */}
|
||||
<div className="bg-gradient-to-b from-white to-gray-50 p-8 rounded-2xl shadow-lg ring-1 ring-gray-100">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-violet-50 text-violet-600 text-sm font-medium mb-4">
|
||||
Use Your Own Server
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">
|
||||
<ColoredText>Self-Hosted Edition</ColoredText>
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-600 mb-8">
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Complete control over your data
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Use at your own infrastructure
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Free and open source
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Bring your own OpenAI keys
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="https://github.com/vas3k/TaxHacker"
|
||||
target="_blank"
|
||||
className="block w-full text-center px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-medium rounded-full hover:opacity-90 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Github + Docker Compose
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Cloud Version */}
|
||||
<div className="bg-gradient-to-b from-white to-gray-50 p-8 rounded-2xl shadow-lg ring-1 ring-gray-100">
|
||||
<div className="absolute top-4 right-4">
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm">Coming Soon</span>
|
||||
</div>
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-4">
|
||||
We Host It For You
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">
|
||||
<ColoredText>Cloud Edition</ColoredText>
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-600 mb-8">
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
SaaS version for those who prefer less hassle
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
We provide AI keys and storage
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
Yearly subscription plans
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
Automatic updates and new features
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
disabled
|
||||
className="block w-full text-center px-6 py-3 bg-gray-100 text-gray-400 font-medium rounded-full cursor-not-allowed"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Upcoming Features */}
|
||||
<section className="py-20 px-8 bg-gradient-to-b from-white to-gray-50 mt-28">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block px-4 py-2 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-6">
|
||||
🚀 Under Active Development
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-4 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
Upcoming Features
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
We're a small, indie project constantly improving. Here's what we're working on next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-16">
|
||||
{/* AI Improvements */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<h3 className="text-xl font-semibold">Better AI Analytics & Agents</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Income & expense insights
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
AI agents to automate your workflows
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Recommendations for tax optimization
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Custom and local LLM models
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Smart Reports */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">📂</span>
|
||||
<h3 className="text-xl font-semibold">Smart Reports & Reminders</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Monthly or quarterly VAT reports
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Tax reminders
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Annual income & expense reports
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Transaction Review */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">📥</span>
|
||||
<h3 className="text-xl font-semibold">Multiple Transaction Review</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Bank statement analysis
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Automatic data completeness checks
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Unpaid invoice tracking
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🤯</span>
|
||||
<h3 className="text-xl font-semibold">Presets and Plugins</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Presets for different countries and industries
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Custom reports for various use-cases
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Community plugins and reports
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="py-8 px-8 bg-white">
|
||||
<div className="max-w-7xl mx-auto text-center text-sm text-gray-500">
|
||||
Made with ❤️ by{" "}
|
||||
<a href="https://github.com/vas3k" className="underline">
|
||||
vas3k
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
app/landing/newsletter.tsx
Normal file
65
app/landing/newsletter.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { subscribeToNewsletterAction } from "@/app/landing/actions"
|
||||
import { useState } from "react"
|
||||
|
||||
export function NewsletterForm() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus("loading")
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const result = await subscribeToNewsletterAction(email)
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
setStatus("success")
|
||||
setMessage("Thanks for subscribing! Check your email for confirmation.")
|
||||
setEmail("")
|
||||
} catch (error) {
|
||||
setStatus("error")
|
||||
setMessage(error instanceof Error ? error.message : "Failed to subscribe. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h3 className="text-2xl font-semibold mb-4">Stay Tuned</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We're working hard on making TaxHacker useful for everyone. Subscribe to our emails to get notified about our
|
||||
plans and new features. No marketing, ads or spam.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md mx-auto">
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
className="flex-1 px-4 py-3 rounded-full border border-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "loading"}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white font-medium rounded-full hover:opacity-90 transition-all shadow-lg shadow-purple-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "loading" ? "Subscribing..." : "Subscribe"}
|
||||
</button>
|
||||
</div>
|
||||
{message && (
|
||||
<p className={`text-sm ${status === "success" ? "text-green-600" : "text-red-600"}`}>{message}</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
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 { getUnsortedFilesCount } from "@/models/files"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { NotificationProvider } from "./context"
|
||||
import "./globals.css"
|
||||
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | TaxHacker",
|
||||
default: "TaxHacker",
|
||||
default: APP_TITLE,
|
||||
},
|
||||
description: "Your personal AI accountant",
|
||||
description: APP_DESCRIPTION,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
shortcut: "/favicon.ico",
|
||||
@@ -24,33 +17,16 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#ffffff",
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const unsortedFilesCount = await getUnsortedFilesCount()
|
||||
const settings = await getSettings()
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</head>
|
||||
<body>
|
||||
<NotificationProvider>
|
||||
<ScreenDropArea>
|
||||
<SidebarProvider>
|
||||
<MobileMenu settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
||||
<AppSidebar settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
||||
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<Toaster />
|
||||
</ScreenDropArea>
|
||||
</NotificationProvider>
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
40
app/page.tsx
40
app/page.tsx
@@ -1,30 +1,16 @@
|
||||
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 { getUnsortedFiles } from "@/models/files"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import { TransactionFilters } from "@/models/transactions"
|
||||
import LandingPage from "@/app/landing/landing"
|
||||
import { getSession } from "@/lib/auth"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const filters = await searchParams
|
||||
const unsortedFiles = await getUnsortedFiles()
|
||||
const settings = await getSettings()
|
||||
export default async function Home() {
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
return <LandingPage />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from "@/lib/db"
|
||||
import { FILE_UPLOAD_PATH } from "@/lib/files"
|
||||
import { MODEL_BACKUP } from "@/models/backups"
|
||||
import fs from "fs"
|
||||
import { mkdir } from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import path from "path"
|
||||
|
||||
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
|
||||
|
||||
export async function restoreBackupAction(prevState: any, formData: FormData) {
|
||||
const file = formData.get("file") as File
|
||||
const removeExistingData = formData.get("removeExistingData") === "true"
|
||||
|
||||
if (!file) {
|
||||
return { success: false, error: "No file provided" }
|
||||
}
|
||||
|
||||
// Restore tables
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
const fileData = Buffer.from(fileBuffer)
|
||||
const zip = await JSZip.loadAsync(fileData)
|
||||
|
||||
// Check backup version
|
||||
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")
|
||||
}
|
||||
|
||||
if (removeExistingData) {
|
||||
await clearAllTables()
|
||||
}
|
||||
|
||||
for (const { filename, model, idField } of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonFile = zip.file(`data/${filename}`)
|
||||
if (jsonFile) {
|
||||
const jsonContent = await jsonFile.async("string")
|
||||
const restoredCount = await restoreModelFromJSON(model, jsonContent, idField)
|
||||
console.log(`Restored ${restoredCount} records from ${filename}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error restoring model from ${filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore files
|
||||
try {
|
||||
const filesToRestore = Object.keys(zip.files).filter(
|
||||
(filename) => filename.startsWith("data/uploads/") && !filename.endsWith("/")
|
||||
)
|
||||
|
||||
if (filesToRestore.length > 0) {
|
||||
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
|
||||
|
||||
// Extract and save each file
|
||||
let restoredFilesCount = 0
|
||||
for (const zipFilePath of filesToRestore) {
|
||||
const file = zip.file(zipFilePath)
|
||||
if (file) {
|
||||
const relativeFilePath = zipFilePath.replace("data/uploads/", "")
|
||||
const fileContent = await file.async("nodebuffer")
|
||||
|
||||
const filePath = path.join(FILE_UPLOAD_PATH, relativeFilePath)
|
||||
const fileName = path.basename(filePath)
|
||||
const fileId = path.basename(fileName, path.extname(fileName))
|
||||
const fileDir = path.dirname(filePath)
|
||||
await mkdir(fileDir, { recursive: true })
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(filePath, fileContent)
|
||||
restoredFilesCount++
|
||||
|
||||
// Update the file record
|
||||
await prisma.file.upsert({
|
||||
where: { id: fileId },
|
||||
update: {
|
||||
path: filePath,
|
||||
},
|
||||
create: {
|
||||
id: relativeFilePath,
|
||||
path: filePath,
|
||||
filename: fileName,
|
||||
mimetype: "application/octet-stream",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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` }
|
||||
} 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 clearAllTables() {
|
||||
// Delete in reverse order to handle foreign key constraints
|
||||
for (const { model } of [...MODEL_BACKUP].reverse()) {
|
||||
try {
|
||||
await model.deleteMany({})
|
||||
} catch (error) {
|
||||
console.error(`Error clearing table:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreModelFromJSON(model: any, jsonContent: string, idField: string): Promise<number> {
|
||||
if (!jsonContent) return 0
|
||||
|
||||
try {
|
||||
const records = JSON.parse(jsonContent)
|
||||
|
||||
if (!records || records.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let insertedCount = 0
|
||||
for (const rawRecord of records) {
|
||||
const record = processRowData(rawRecord)
|
||||
|
||||
try {
|
||||
// Skip records that don't have the required ID field
|
||||
if (record[idField] === undefined) {
|
||||
console.warn(`Skipping record missing required ID field '${idField}'`)
|
||||
continue
|
||||
}
|
||||
|
||||
await model.upsert({
|
||||
where: { [idField]: record[idField] },
|
||||
update: record,
|
||||
create: record,
|
||||
})
|
||||
insertedCount++
|
||||
} catch (error) {
|
||||
console.error(`Error upserting record:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return insertedCount
|
||||
} catch (error) {
|
||||
console.error(`Error parsing JSON content:`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function processRowData(row: Record<string, any>): Record<string, any> {
|
||||
const processedRow: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value === "" || value === "null" || value === undefined) {
|
||||
processedRow[key] = null
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse JSON for object fields
|
||||
if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) {
|
||||
try {
|
||||
processedRow[key] = JSON.parse(value)
|
||||
continue
|
||||
} catch (e) {
|
||||
// Not valid JSON, continue with normal processing
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dates (checking for ISO date format)
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/.test(value)) {
|
||||
processedRow[key] = new Date(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof value === "string" && !isNaN(Number(value)) && key !== "id" && !key.endsWith("Code")) {
|
||||
// Convert numbers but preserving string IDs
|
||||
processedRow[key] = Number(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Default: keep as is
|
||||
processedRow[key] = value
|
||||
}
|
||||
|
||||
return processedRow
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use server"
|
||||
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { getTransactionFileUploadPath } from "@/lib/files"
|
||||
import { deleteFile, getFileById, updateFile } from "@/models/files"
|
||||
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
|
||||
import { mkdir, rename } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
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)
|
||||
if (!file) throw new Error("File not found")
|
||||
|
||||
// Create transaction
|
||||
const transaction = await createTransaction(validatedForm.data)
|
||||
|
||||
// Move file to processed location
|
||||
const originalFileName = path.basename(file.path)
|
||||
const { fileUuid, filePath: newFilePath } = await getTransactionFileUploadPath(originalFileName, transaction)
|
||||
|
||||
// Move file to new location and name
|
||||
await mkdir(path.dirname(newFilePath), { recursive: true })
|
||||
await rename(path.resolve(file.path), path.resolve(newFilePath))
|
||||
|
||||
// Update file record
|
||||
await updateFile(file.id, {
|
||||
id: fileUuid,
|
||||
path: newFilePath,
|
||||
isReviewed: true,
|
||||
})
|
||||
|
||||
await updateTransactionFiles(transaction.id, [fileUuid])
|
||||
|
||||
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 {
|
||||
await deleteFile(fileId)
|
||||
revalidatePath("/unsorted")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error)
|
||||
return { success: false, error: "Failed to delete file" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user