BREAKING: postgres + saas

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

View File

@@ -1,17 +1,9 @@
PORT=7331
SELF_HOSTED_MODE=false
UPLOAD_PATH="./data/uploads"
DATABASE_URL="file:../data/db.sqlite"
PROMPT_ANALYSE_NEW_FILE="You are an accountant and invoice analysis assistant.
Extract the following information from the given invoice:
{fields}
Where categories are:
{categories}
And projects are:
{projects}
If you can't find something leave it blank. Return only one object. Do not include any other text in your response!"
DATABASE_URL="postgresql://user@localhost:5432/taxhacker"
BETTER_AUTH_SECRET="random-secret-key"
OPENAI_API_KEY=""
RESEND_API_KEY=""
RESEND_AUDIENCE_ID=""
RESEND_FROM_EMAIL="TaxHacker <user@localhost>"

1
.gitignore vendored
View File

@@ -28,6 +28,7 @@
# misc
.DS_Store
*.pem
.vscode
# debug
npm-debug.log*

View File

@@ -2,9 +2,7 @@ FROM node:23-slim AS base
# Default environment variables
ENV PORT=7331
ENV UPLOAD_PATH=/app/data/uploads
ENV NODE_ENV=production
ENV DATABASE_URL=file:/app/data/db.sqlite
# Build stage
FROM base AS builder
@@ -36,6 +34,7 @@ RUN apt-get update && apt-get install -y \
graphicsmagick \
openssl \
libwebp-dev \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
@@ -55,7 +54,7 @@ COPY --from=builder /app/next.config.ts ./
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Create directory for SQLite database and set permissions
# Create directory for uploads
RUN mkdir -p /app/data
EXPOSE 7331

View File

@@ -47,7 +47,7 @@ https://github.com/user-attachments/assets/3326d0e3-0bf6-4c39-9e00-4bf0983d9b7a
Take a photo on upload or a PDF and TaxHacker will automatically recognise, categorise and store transaction information.
- Upload multiple documents and store in unsorted until you get the time to sort them out by hand or with an AI
- Upload multiple documents and store in "unsorted" until you get the time to sort them out by hand or with an AI
- Use LLM to extract key information like date, amount, and vendor
- Automatically categorize transactions based on its content
- Store everything in a structured format for easy filtering and retrieval
@@ -115,6 +115,13 @@ curl -O https://raw.githubusercontent.com/vas3k/TaxHacker/main/docker-compose.ym
docker compose up
```
The Docker Compose setup includes:
- TaxHacker application container
- PostgreSQL 17 database container
- Automatic database migrations
- Volume mounts for persistent data storage
New docker image is automatically built and published on every new release. You can use specific version tags (e.g. `v1.0.0`) or `latest` for the most recent version.
For more advanced setups, you can adapt Docker Compose configuration to your own needs. The default configuration uses the pre-built image from GHCR, but you can still build locally using the provided [Dockerfile](./Dockerfile) if needed.
@@ -129,8 +136,9 @@ services:
- "7331:7331"
environment:
- NODE_ENV=production
- SELF_HOSTED_MODE=true
- UPLOAD_PATH=/app/data/uploads
- DATABASE_URL=file:/app/data/db.sqlite
- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/taxhacker
volumes:
- ./data:/app/data
restart: unless-stopped
@@ -142,9 +150,15 @@ Configure TaxHacker to suit your needs with these environment variables:
| Variable | Required | Description | Example |
|----------|----------|-------------|---------|
| `UPLOAD_PATH` | Yes | Local directory for uploading files | `./upload` |
| `DATABASE_URL` | Yes | Database file for SQLite | `file:./db.sqlite` |
| `PROMPT_ANALYSE_NEW_FILE` | No | Default prompt for LLM | `Act as an accountant...` |
| `PORT` | No | Port to run the server on | `7331` |
| `SELF_HOSTED_MODE` | No | Enable self-hosted mode and automatic login | `false` |
| `UPLOAD_PATH` | Yes | Local directory for uploading files | `./data/uploads` |
| `DATABASE_URL` | Yes | PostgreSQL connection string | `postgresql://postgres:postgres@localhost:5432/taxhacker` |
| `OPENAI_API_KEY` | No | OpenAI API key for AI features | `sk-...` |
| `RESEND_API_KEY` | No | Resend API key for email notifications | `re_...` |
| `RESEND_AUDIENCE_ID` | No | Resend audience ID for newsletters | `fde8dd49-...` |
| `RESEND_FROM_EMAIL` | No | Email address to send from | `TaxHacker <hello@taxhacker.app>` |
## ⌨️ Local Development
@@ -152,7 +166,7 @@ We use:
- Next.js version 15+ or later
- [Prisma](https://www.prisma.io/) for database models and migrations
- SQLite as a database
- PostgreSQL as a database (PostgreSQL 17+ recommended)
- Ghostscript and graphicsmagick libs for PDF files (can be installed on macOS via `brew install gs graphicsmagick`)
Set up a local development environment with these steps:
@@ -167,14 +181,14 @@ npm install
# Set up environment variables
cp .env.example .env
# Edit .env with your configuration
# Make sure to set DATABASE_URL to your PostgreSQL connection string
# Example: postgresql://user@localhost:5432/taxhacker
# Initialize the database
npx prisma generate && npx prisma migrate dev
# Seed the database with default data (optional)
npm run seed
# Start the development server
npm run dev
```

57
ai/analyze.ts Normal file
View File

@@ -0,0 +1,57 @@
"use server"
import OpenAI from "openai"
import { AnalyzeAttachment } from "./attachments"
export async function analyzeTransaction(
prompt: string,
schema: Record<string, unknown>,
attachments: AnalyzeAttachment[],
apiKey: string
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
const openai = new OpenAI({
apiKey,
})
console.log("RUNNING AI ANALYSIS")
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",
}
}
}

35
ai/attachments.ts Normal file
View File

@@ -0,0 +1,35 @@
import { fileExists, fullPathForFile } from "@/lib/files"
import { generateFilePreviews } from "@/lib/previews/generate"
import { File, User } from "@prisma/client"
import fs from "fs/promises"
const MAX_PAGES_TO_ANALYZE = 4
export type AnalyzeAttachment = {
filename: string
contentType: string
base64: string
}
export const loadAttachmentsForAI = async (user: User, file: File): Promise<AnalyzeAttachment[]> => {
const fullFilePath = await fullPathForFile(user, file)
const isFileExists = await fileExists(fullFilePath)
if (!isFileExists) {
throw new Error("File not found on disk")
}
const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype)
return Promise.all(
previews.slice(0, MAX_PAGES_TO_ANALYZE).map(async (preview) => ({
filename: file.filename,
contentType: contentType,
base64: await loadFileAsBase64(preview),
}))
)
}
export const loadFileAsBase64 = async (filePath: string): Promise<string> => {
const buffer = await fs.readFile(filePath)
return Buffer.from(buffer).toString("base64")
}

View File

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

View File

@@ -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,8 +90,10 @@ export async function GET(request: Request) {
}
for (const file of transactionFiles) {
const fileData = fs.readFileSync(file.path)
const fileExtension = path.extname(file.path)
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
@@ -97,6 +102,7 @@ export async function GET(request: Request) {
)
}
}
}
const zipContent = await zip.generateAsync({ type: "uint8array" })

View File

@@ -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,

View File

@@ -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, {

View File

@@ -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) {
// Generate previews
const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype)
if (page > previews.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
}
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)}"`,
},
})

View File

@@ -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")

View File

@@ -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
View File

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

View File

@@ -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 }
}

View File

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

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -12,6 +12,10 @@ const settingsCategories = [
title: "General",
href: "/settings",
},
{
title: "My Profile",
href: "/settings/profile",
},
{
title: "LLM settings",
href: "/settings/llm",

View File

@@ -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 (
<>

View File

@@ -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 (
<>

View File

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

View File

@@ -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>

View File

@@ -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()

View File

@@ -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">

View File

@@ -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 }

View File

@@ -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) {

View File

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

View File

@@ -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
View 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
View 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
View 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>
)
}

View 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>
)
}

View 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")
}

View 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>
)
}

View File

@@ -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",
}
}
}

View 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
View 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
View 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">Fck 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>
)
}

View 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>
)
}

View File

@@ -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"

View File

@@ -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()
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>
)
export default async function Home() {
const session = await getSession()
if (!session) {
if (IS_SELF_HOSTED_MODE) {
redirect(SELF_HOSTED_REDIRECT_URL)
}
return <LandingPage />
}
redirect("/dashboard")
}

View File

@@ -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
}

View File

@@ -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" }
}
}

View File

@@ -0,0 +1,93 @@
"use client"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"
import { useState } from "react"
export function LoginForm() {
const [email, setEmail] = useState("")
const [otp, setOtp] = useState("")
const [isOtpSent, setIsOtpSent] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
const result = await authClient.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
})
if (result.error) {
setError(result.error.message || "Failed to send the code")
return
}
setIsOtpSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send the code")
} finally {
setIsLoading(false)
}
}
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
const result = await authClient.signIn.emailOtp({
email,
otp,
})
if (result.error) {
setError("The code is invalid or expired")
return
}
router.push("/dashboard")
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to verify the code")
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={isOtpSent ? handleVerifyOtp : handleSendOtp} className="flex flex-col gap-4 w-full">
<FormInput
title="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isOtpSent}
/>
{isOtpSent && (
<FormInput
title="Check your email for the verification code"
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value)}
required
maxLength={6}
pattern="[0-9]{6}"
/>
)}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Loading..." : isOtpSent ? "Verify Code" : "Enter"}
</Button>
{error && <FormError className="text-center">{error}</FormError>}
</form>
)
}

View File

@@ -0,0 +1,96 @@
"use client"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { authClient } from "@/lib/auth-client"
import { useRouter } from "next/navigation"
import { useState } from "react"
export default function SignupForm() {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [otp, setOtp] = useState("")
const [isOtpSent, setIsOtpSent] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const router = useRouter()
const handleSendOtp = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
await authClient.emailOtp.sendVerificationOtp({
email,
type: "sign-in",
})
setIsOtpSent(true)
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send OTP")
} finally {
setIsLoading(false)
}
}
const handleVerifyOtp = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError(null)
try {
await authClient.signIn.emailOtp({
email,
otp,
})
await authClient.updateUser({
name,
})
router.push("/dashboard")
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to verify OTP")
} finally {
setIsLoading(false)
}
}
return (
<form onSubmit={isOtpSent ? handleVerifyOtp : handleSendOtp} className="flex flex-col gap-4 w-full">
<FormInput
title="Your Name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={isOtpSent}
/>
<FormInput
title="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isOtpSent}
/>
{isOtpSent && (
<FormInput
title="Check your email for the verification code"
type="text"
value={otp}
onChange={(e) => setOtp(e.target.value)}
required
maxLength={6}
pattern="[0-9]{6}"
/>
)}
{error && <p className="text-red-500 text-sm">{error}</p>}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Loading..." : isOtpSent ? "Verify Code" : "Create Account"}
</Button>
</form>
)
}

View File

@@ -1,9 +1,9 @@
"use client"
import { useNotification } from "@/app/context"
import { uploadFilesAction } from "@/app/files/actions"
import { useNotification } from "@/app/(app)/context"
import { uploadFilesAction } from "@/app/(app)/files/actions"
import { FormError } from "@/components/forms/error"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
import { Camera, Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { startTransition, useState } from "react"

View File

@@ -1,6 +1,7 @@
import { FiltersWidget } from "@/components/dashboard/filters-widget"
import { ProjectsWidget } from "@/components/dashboard/projects-widget"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { getCurrentUser } from "@/lib/auth"
import { formatCurrency } from "@/lib/utils"
import { getProjects } from "@/models/projects"
import { getDashboardStats, getProjectStats } from "@/models/stats"
@@ -8,11 +9,12 @@ import { TransactionFilters } from "@/models/transactions"
import { ArrowDown, ArrowUp, BicepsFlexed } from "lucide-react"
export async function StatsWidget({ filters }: { filters: TransactionFilters }) {
const projects = await getProjects()
const stats = await getDashboardStats(filters)
const user = await getCurrentUser()
const projects = await getProjects(user.id)
const stats = await getDashboardStats(user.id, filters)
const statsPerProject = Object.fromEntries(
await Promise.all(
projects.map((project) => getProjectStats(project.code, filters).then((stats) => [project.code, stats]))
projects.map((project) => getProjectStats(user.id, project.code, filters).then((stats) => [project.code, stats]))
)
)

View File

@@ -1,25 +1,30 @@
import { Button } from "@/components/ui/button"
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
import { ColoredText } from "@/components/ui/colored-text"
import { getCurrentUser } from "@/lib/auth"
import { getSettings, updateSettings } from "@/models/settings"
import { Banknote, ChartBarStacked, FolderOpenDot, Key, TextCursorInput, X } from "lucide-react"
import { revalidatePath } from "next/cache"
import Link from "next/link"
export async function WelcomeWidget() {
const settings = await getSettings()
const user = await getCurrentUser()
const settings = await getSettings(user.id)
return (
<Card className="flex flex-col md:flex-row items-start gap-10 p-10 w-full">
<Card className="flex flex-col lg:flex-row items-start gap-10 p-10 w-full">
<img src="/logo/1024.png" alt="Logo" className="w-64 h-64" />
<div className="flex flex-col">
<CardTitle className="flex items-center justify-between">
<span className="text-2xl font-bold">Hey, I'm TaxHacker 👋</span>
<span className="text-2xl font-bold">
<ColoredText>Hey, I'm TaxHacker 👋</ColoredText>
</span>
<Button
variant="outline"
size="icon"
onClick={async () => {
"use server"
await updateSettings("is_welcome_message_hidden", "true")
await updateSettings(user.id, "is_welcome_message_hidden", "true")
revalidatePath("/")
}}
>

View File

@@ -0,0 +1,57 @@
import React from "react"
interface EmailLayoutProps {
children: React.ReactNode
preview?: string
}
export const EmailLayout: React.FC<EmailLayoutProps> = ({ children, preview = "" }) => (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light" />
<meta name="supported-color-schemes" content="light" />
{preview && <title>{preview}</title>}
<style
dangerouslySetInnerHTML={{
__html: `
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
color: #333;
background-color: #f9f9f9;
}
.container {
max-width: 600px;
margin: 40px auto;
padding: 20px;
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.header {
margin-bottom: 20px;
text-align: left;
}
.logo {
width: 60px;
height: 60px;
margin-bottom: 10px;
}
.footer {
margin-top: 30px;
text-align: center;
font-size: 12px;
color: #666;
}
`,
}}
/>
</head>
<body>
<div className="container">{children}</div>
</body>
</html>
)

View File

@@ -0,0 +1,31 @@
import React from "react"
import { EmailLayout } from "./email-layout"
export const NewsletterWelcomeEmail: React.FC = () => (
<EmailLayout preview="Welcome to TaxHacker Newsletter!">
<h2 style={{ color: "#4f46e5" }}>👋 Welcome to TaxHacker!</h2>
<p style={{ fontSize: "16px", lineHeight: "1.5", color: "#333" }}>
Thank you for subscribing to our updates. We'll keep you updated about:
</p>
<ul
style={{
paddingLeft: "20px",
fontSize: "16px",
lineHeight: "1.5",
color: "#333",
}}
>
<li>New features and improvements</li>
<li>Our plans and timelines</li>
<li>Updates about our SaaS version</li>
</ul>
<div style={{ marginTop: "30px", borderTop: "1px solid #eee", paddingTop: "20px" }}>
<p style={{ fontSize: "16px", color: "#333" }}>
Best regards,
<br />
The TaxHacker Team
</p>
</div>
</EmailLayout>
)

View File

@@ -0,0 +1,38 @@
import React from "react"
import { EmailLayout } from "./email-layout"
interface OTPEmailProps {
otp: string
}
export const OTPEmail: React.FC<OTPEmailProps> = ({ otp }) => (
<EmailLayout preview="Your TaxHacker verification code">
<h2 style={{ textAlign: "center", color: "#4f46e5" }}>🔑 Your TaxHacker verification code</h2>
<div
style={{
margin: "20px 0",
padding: "20px",
backgroundColor: "#f3f4f6",
borderRadius: "6px",
textAlign: "center",
}}
>
<p style={{ fontSize: "16px", marginBottom: "10px" }}>Your verification code is:</p>
<p
style={{
fontSize: "24px",
fontWeight: "bold",
color: "#4f46e5",
letterSpacing: "2px",
margin: "0",
}}
>
{otp}
</p>
</div>
<p style={{ fontSize: "14px", color: "#666", textAlign: "center" }}>This code will expire in 10 minutes.</p>
<p style={{ fontSize: "14px", color: "#666", textAlign: "center" }}>
If you didn't request this code, please ignore this email.
</p>
</EmailLayout>
)

View File

@@ -1,8 +1,8 @@
"use client"
import { useNotification } from "@/app/context"
import { uploadFilesAction } from "@/app/files/actions"
import { uploadTransactionFilesAction } from "@/app/transactions/actions"
import { useNotification } from "@/app/(app)/context"
import { uploadFilesAction } from "@/app/(app)/files/actions"
import { uploadTransactionFilesAction } from "@/app/(app)/transactions/actions"
import { AlertCircle, CloudUpload, Loader2 } from "lucide-react"
import { useParams, useRouter } from "next/navigation"
import { startTransition, useEffect, useRef, useState } from "react"

View File

@@ -1,9 +1,9 @@
"use client"
import { useNotification } from "@/app/context"
import { uploadFilesAction } from "@/app/files/actions"
import { useNotification } from "@/app/(app)/context"
import { uploadFilesAction } from "@/app/(app)/files/actions"
import { Button } from "@/components/ui/button"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
import { Loader2 } from "lucide-react"
import { useRouter } from "next/navigation"
import { ComponentProps, startTransition, useRef, useState } from "react"

View File

@@ -1,3 +1,5 @@
export function FormError({ children }: { children: React.ReactNode }) {
return <p className="text-red-500 mt-4 overflow-hidden">{children}</p>
import { cn } from "@/lib/utils"
export function FormError({ children, className }: { children: React.ReactNode; className?: string }) {
return <p className={cn("text-red-500 mt-4 overflow-hidden", className)}>{children}</p>
}

View File

@@ -1,4 +1,3 @@
import { Currency } from "@prisma/client"
import { SelectProps } from "@radix-ui/react-select"
import { useMemo } from "react"
import { FormSelect } from "./simple"
@@ -12,7 +11,7 @@ export const FormSelectCurrency = ({
...props
}: {
title: string
currencies: Currency[]
currencies: { code: string; name: string }[]
emptyValue?: string
placeholder?: string
hideIfEmpty?: boolean

View File

@@ -1,6 +1,6 @@
"use client"
import { parseCSVAction, saveTransactionsAction } from "@/app/import/csv/actions"
import { parseCSVAction, saveTransactionsAction } from "@/app/(app)/import/csv/actions"
import { FormError } from "@/components/forms/error"
import { Button } from "@/components/ui/button"
import { Field } from "@prisma/client"

View File

@@ -1,11 +1,10 @@
"use client"
import { saveSettingsAction } from "@/app/settings/actions"
import { saveSettingsAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
import { FormSelectType } from "@/components/forms/select-type"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Category, Currency } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
@@ -24,8 +23,6 @@ export default function GlobalSettingsForm({
return (
<form action={saveAction} className="space-y-4">
<FormInput title="App Title" name="app_title" defaultValue={settings.app_title} />
<FormSelectCurrency
title="Default Currency"
name="default_currency"

View File

@@ -1,11 +1,12 @@
"use client"
import { fieldsToJsonSchema } from "@/app/ai/schema"
import { saveSettingsAction } from "@/app/settings/actions"
import { fieldsToJsonSchema } from "@/ai/schema"
import { saveSettingsAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput, FormTextarea } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { Card, CardTitle } from "@/components/ui/card"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { Field } from "@prisma/client"
import { CircleCheckBig, Edit } from "lucide-react"
import Link from "next/link"
@@ -17,14 +18,18 @@ export default function LLMSettingsForm({ settings, fields }: { settings: Record
return (
<>
<form action={saveAction} className="space-y-4">
{IS_SELF_HOSTED_MODE && (
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
)}
{IS_SELF_HOSTED_MODE && (
<small className="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>
)}
<FormTextarea
title="Prompt for File Analysis Form"

View File

@@ -0,0 +1,33 @@
"use client"
import { saveProfileAction } from "@/app/(app)/settings/actions"
import { FormError } from "@/components/forms/error"
import { FormInput } from "@/components/forms/simple"
import { Button } from "@/components/ui/button"
import { User } from "@prisma/client"
import { CircleCheckBig } from "lucide-react"
import { useActionState } from "react"
export default function ProfileSettingsForm({ user }: { user: User }) {
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
return (
<form action={saveAction} className="space-y-4">
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
<div className="flex flex-row items-center gap-4">
<Button type="submit" disabled={pending}>
{pending ? "Saving..." : "Save"}
</Button>
{saveState?.success && (
<p className="text-green-500 flex flex-row items-center gap-2">
<CircleCheckBig />
Saved!
</p>
)}
</div>
{saveState?.error && <FormError>{saveState.error}</FormError>}
</form>
)
}

View File

@@ -2,15 +2,10 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { useSidebar } from "@/components/ui/sidebar"
import { APP_TITLE } from "@/lib/constants"
import Link from "next/link"
export default function MobileMenu({
settings,
unsortedFilesCount,
}: {
settings: Record<string, string>
unsortedFilesCount: number
}) {
export default function MobileMenu({ unsortedFilesCount }: { unsortedFilesCount: number }) {
const { toggleSidebar } = useSidebar()
return (
@@ -20,7 +15,7 @@ export default function MobileMenu({
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
</Avatar>
<Link href="/" className="text-lg font-bold">
{settings.app_title}
{APP_TITLE}
</Link>
<Link
href="/unsorted"

View File

@@ -0,0 +1,76 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SidebarMenuButton } from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { LogOut, MoreVertical, User } from "lucide-react"
import Link from "next/link"
import { redirect } from "next/navigation"
export default function SidebarUser({ profile }: { profile: UserProfile }) {
const signOut = async () => {
await authClient.signOut({})
redirect("/")
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="default"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar className="h-6 w-6 rounded-full bg-sidebar-accent">
<AvatarImage src={profile.avatar} alt={profile.name || ""} />
<AvatarFallback className="rounded-full bg-sidebar-accent text-sidebar-accent-foreground">
<User className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<span className="truncate font-medium">{profile.name || profile.email}</span>
<MoreVertical className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={"top"}
align="center"
sideOffset={4}
>
<DropdownMenuGroup>
{/* <DropdownMenuItem>
<ThemeToggle />
</DropdownMenuItem> */}
<DropdownMenuItem asChild>
<Link href="/settings/profile" className="flex items-center gap-2">
<User className="h-4 w-4" />
Profile Settings
</Link>
</DropdownMenuItem>
{/* <DropdownMenuItem asChild>
<Link href="/settings/billing" className="flex items-center gap-2">
<CreditCard className="h-4 w-4" />
Your Subscription
</Link>
</DropdownMenuItem> */}
</DropdownMenuGroup>
<DropdownMenuSeparator />
{!IS_SELF_HOSTED_MODE && (
<DropdownMenuItem asChild>
<span onClick={signOut} className="flex items-center gap-2 text-red-600 cursor-pointer">
<LogOut className="h-4 w-4" />
Log out
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -1,8 +1,7 @@
"use client"
import { useNotification } from "@/app/context"
import { useNotification } from "@/app/(app)/context"
import { UploadButton } from "@/components/files/upload-button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
Sidebar,
SidebarContent,
@@ -17,20 +16,19 @@ import {
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar"
import { UserProfile } from "@/lib/auth"
import { APP_TITLE, IS_SELF_HOSTED_MODE } from "@/lib/constants"
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
import Image from "next/image"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useEffect } from "react"
import { ColoredText } from "../ui/colored-text"
import { Blinker } from "./blinker"
import { SidebarMenuItemWithHighlight } from "./sidebar-item"
import SidebarUser from "./sidebar-user"
export function AppSidebar({
settings,
unsortedFilesCount,
}: {
settings: Record<string, string>
unsortedFilesCount: number
}) {
export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount: number; profile: UserProfile }) {
const { open, setOpenMobile } = useSidebar()
const pathname = usePathname()
const { notification } = useNotification()
@@ -44,25 +42,14 @@ export function AppSidebar({
<>
<Sidebar variant="inset" collapsible="icon">
<SidebarHeader>
{open ? (
<Link href="/" className="flex items-center gap-2 p-2">
<Avatar className="h-12 w-12 rounded-lg">
<AvatarImage src="/logo/256.png" />
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
</Avatar>
<Link href="/" className="flex items-center gap-2">
<Image src="/logo/256.png" alt="Logo" className="h-10 w-10 rounded-lg" width={40} height={40} />
<div className="grid flex-1 text-left leading-tight">
<span className="truncate font-semibold">{settings.app_title}</span>
<span className="truncate text-xs">Beta</span>
<span className="truncate font-semibold text-lg">
<ColoredText>{APP_TITLE}</ColoredText>
</span>
</div>
</Link>
) : (
<Link href="/">
<Avatar className="h-10 w-10 rounded-lg">
<AvatarImage src="/logo/256.png" />
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
</Avatar>
</Link>
)}
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
@@ -74,9 +61,9 @@ export function AppSidebar({
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItemWithHighlight href="/">
<SidebarMenuItemWithHighlight href="/dashboard">
<SidebarMenuButton asChild>
<Link href="/">
<Link href="/dashboard">
<LayoutDashboard />
<span>Home</span>
</Link>
@@ -137,6 +124,7 @@ export function AppSidebar({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
{IS_SELF_HOSTED_MODE && (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link href="https://vas3k.com/donate/" target="_blank">
@@ -145,6 +133,7 @@ export function AppSidebar({
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
{!open && (
<SidebarMenuItem>
<SidebarTrigger />
@@ -153,6 +142,15 @@ export function AppSidebar({
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarUser profile={profile} />
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarFooter>
</Sidebar>
</>

View File

@@ -0,0 +1,43 @@
"use client"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { useEffect, useState } from "react"
export const ThemeToggle = () => {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
// Ensure component is mounted to avoid hydration mismatch
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return null
}
const toggleTheme = () => {
if (theme === "dark") {
setTheme("light")
} else {
setTheme("dark")
}
}
return (
<div onClick={toggleTheme} className="flex items-center gap-2 cursor-pointer">
{theme === "dark" ? (
<>
<Sun className="h-4 w-4" />
Light Mode
</>
) : (
<>
<Moon className="h-4 w-4" />
Dark Mode
</>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { bulkDeleteTransactionsAction } from "@/app/transactions/actions"
import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions"
import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { ChevronUp, Trash2 } from "lucide-react"

View File

@@ -1,6 +1,6 @@
"use client"
import { createTransactionAction } from "@/app/transactions/actions"
import { createTransactionAction } from "@/app/(app)/transactions/actions"
import { FormError } from "@/components/forms/error"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"

View File

@@ -1,6 +1,6 @@
"use client"
import { deleteTransactionAction, saveTransactionAction } from "@/app/transactions/actions"
import { deleteTransactionAction, saveTransactionAction } from "@/app/(app)/transactions/actions"
import { FormError } from "@/components/forms/error"
import { FormSelectCategory } from "@/components/forms/select-category"
import { FormSelectCurrency } from "@/components/forms/select-currency"
@@ -47,10 +47,13 @@ export default function TransactionEditForm({
projectCode: transaction.projectCode || settings.default_project,
issuedAt: transaction.issuedAt ? format(transaction.issuedAt, "yyyy-MM-dd") : "",
note: transaction.note || "",
...extraFields.reduce((acc, field) => {
...extraFields.reduce(
(acc, field) => {
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
return acc
}, {} as Record<string, any>),
},
{} as Record<string, any>
),
})
const handleDelete = async () => {

View File

@@ -1,6 +1,6 @@
"use client"
import { updateFieldVisibilityAction } from "@/app/transactions/actions"
import { updateFieldVisibilityAction } from "@/app/(app)/transactions/actions"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,

View File

@@ -6,6 +6,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { getCurrentUser } from "@/lib/auth"
import { getCategories } from "@/models/categories"
import { getCurrencies } from "@/models/currencies"
import { getProjects } from "@/models/projects"
@@ -13,10 +14,11 @@ import { getSettings } from "@/models/settings"
import TransactionCreateForm from "./create"
export async function NewTransactionDialog({ children }: { children: React.ReactNode }) {
const categories = await getCategories()
const currencies = await getCurrencies()
const settings = await getSettings()
const projects = await getProjects()
const user = await getCurrentUser()
const categories = await getCategories(user.id)
const currencies = await getCurrencies(user.id)
const settings = await getSettings(user.id)
const projects = await getProjects(user.id)
return (
<Dialog>

View File

@@ -1,10 +1,10 @@
"use client"
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/transactions/actions"
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/(app)/transactions/actions"
import { FilePreview } from "@/components/files/preview"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
import { File, Transaction } from "@prisma/client"
import { Loader2, Upload, X } from "lucide-react"
import { useState } from "react"

View File

@@ -0,0 +1,12 @@
import { cn } from "@/lib/utils"
export function ColoredText({
children,
className,
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) {
return (
<span className={cn("bg-gradient-to-r from-pink-600 to-indigo-600 bg-clip-text text-transparent", className)}>
{children}
</span>
)
}

View File

@@ -1,8 +1,8 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
@@ -37,8 +37,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@@ -53,8 +52,7 @@ const DropdownMenuSubContent = React.forwardRef<
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@@ -114,8 +112,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@@ -147,11 +144,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
@@ -161,41 +154,29 @@ const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
DropdownMenuTrigger,
}

View File

@@ -1,178 +0,0 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
ControllerProps,
FieldPath,
FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState, formState } = useFormContext()
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
)
})
FormItem.displayName = "FormItem"
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField()
return (
<Label
ref={ref}
className={cn(error && "text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
})
FormLabel.displayName = "FormLabel"
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
})
FormControl.displayName = "FormControl"
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField()
return (
<p
ref={ref}
id={formDescriptionId}
className={cn("text-[0.8rem] text-muted-foreground", className)}
{...props}
/>
)
})
FormDescription.displayName = "FormDescription"
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : children
if (!body) {
return null
}
return (
<p
ref={ref}
id={formMessageId}
className={cn("text-[0.8rem] font-medium text-destructive", className)}
{...props}
>
{body}
</p>
)
})
FormMessage.displayName = "FormMessage"
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}

View File

@@ -1,8 +1,7 @@
"use client"
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
import { useNotification } from "@/app/context"
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
import { useNotification } from "@/app/(app)/context"
import { analyzeFileAction, deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/(app)/unsorted/actions"
import { FormConvertCurrency } from "@/components/forms/convert-currency"
import { FormError } from "@/components/forms/error"
import { FormSelectCategory } from "@/components/forms/select-category"
@@ -40,10 +39,13 @@ export default function AnalyzeForm({
const fieldsMap = useMemo(
() =>
fields.reduce((acc, field) => {
fields.reduce(
(acc, field) => {
acc[field.code] = field
return acc
}, {} as Record<string, Field>),
},
{} as Record<string, Field>
),
[fields]
)
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
@@ -62,10 +64,13 @@ export default function AnalyzeForm({
issuedAt: "",
note: "",
text: "",
...extraFields.reduce((acc, field) => {
...extraFields.reduce(
(acc, field) => {
acc[field.code] = ""
return acc
}, {} as Record<string, string>),
},
{} as Record<string, string>
),
}),
[file.filename, settings, extraFields]
)
@@ -89,20 +94,10 @@ export default function AnalyzeForm({
const startAnalyze = async () => {
setIsAnalyzing(true)
setAnalyzeStep("Retrieving files...")
setAnalyzeError("")
try {
const attachments = await retrieveAllAttachmentsForAI(file)
setAnalyzeStep("Analyzing...")
const results = await analyzeTransaction(
settings.prompt_analyse_new_file || process.env.PROMPT_ANALYSE_NEW_FILE || "",
settings,
fields,
categories,
projects,
attachments
)
const results = await analyzeFileAction(file, settings, fields, categories, projects)
console.log("Analysis results:", results)
@@ -114,7 +109,6 @@ export default function AnalyzeForm({
([_, value]) => value !== null && value !== undefined && value !== ""
)
)
console.log("Setting form data:", nonEmptyFields)
setFormData({ ...formData, ...nonEmptyFields })
}
} catch (error) {

View File

@@ -7,8 +7,9 @@ services:
- "7331:7331"
environment:
- NODE_ENV=production
- SELF_HOSTED_MODE=true
- UPLOAD_PATH=/app/data/uploads
- DATABASE_URL=file:/app/data/db.sqlite
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/taxhacker
volumes:
- ./data:/app/data
restart: unless-stopped
@@ -17,3 +18,20 @@ services:
options:
max-size: "100M"
max-file: "3"
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=taxhacker
volumes:
- ./pgdata:/var/lib/postgresql/data
restart: unless-stopped
ports:
- "5432:5432"
logging:
driver: "local"
options:
max-size: "100M"
max-file: "3"

View File

@@ -5,11 +5,31 @@ services:
- "7331:7331"
environment:
- NODE_ENV=production
- SELF_HOSTED_MODE=true
- UPLOAD_PATH=/app/data/uploads
- DATABASE_URL=file:/app/data/db.sqlite
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/taxhacker
volumes:
- ./data:/app/data
restart: unless-stopped
depends_on:
- postgres
logging:
driver: "local"
options:
max-size: "100M"
max-file: "3"
postgres:
image: postgres:17-alpine
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=taxhacker
volumes:
- ./pgdata:/var/lib/postgresql/data
restart: unless-stopped
ports:
- "5432:5432"
logging:
driver: "local"
options:

View File

@@ -1,14 +1,18 @@
#!/bin/sh
set -e
# Wait for database to be ready
echo "Waiting for PostgreSQL to be ready..."
until pg_isready -h postgres -p 5432 -U postgres; do
echo "PostgreSQL is unavailable - sleeping"
sleep 1
done
echo "PostgreSQL is ready!"
# Run database migrations
echo "Running database migrations..."
npx prisma migrate deploy
# Initialize database
echo "Checking and seeding database if needed..."
npm run seed
# Start the application
echo "Starting the application..."
exec "$@"

54
docs/migrate-0.3-0.5.md Normal file
View File

@@ -0,0 +1,54 @@
# How to migrate data from v0.3 to v0.5
In v0.5 we changed the database from SQLite to Postgres. Because of this, it was not possible to seamlessly migrate data from one database to another and you will have to do it yourself.
Don't worry, even if you already upgraded — your data is not lost!
Here's how to migrate properly:
## Step 1: Update your docker-compose to v0.3.0
```yaml
services:
app:
image: ghcr.io/vas3k/taxhacker:v0.3.0
ports:
- "7331:7331"
// everything else stays the same
```
## Step 2: Restart your app and make a backup
```yaml
docker compose down
docker compose up -d
```
Go to your app -> Settings -> Backups -> Download Data Archive
Save .zip archive on your machine.
## Step 3: Upgrade your TaxHacker instance
Update your docker compose to latest version again.
```yaml
services:
app:
image: ghcr.io/vas3k/taxhacker:latest
ports:
- "7331:7331"
// everything else stays the same
```
Restart again.
## Step 4: Upload your data to the new instance
Open your app -> Settings -> Backups -> Restore from a backup
Upload your zip archive and click restore. After couple of seconds it must show you import stats.
If import fails with an error about file size, go to [next.config.ts](./next.config.ts) and change `bodySizeLimit: "256mb"` to something bigger.

View File

@@ -2,7 +2,6 @@ import { randomHexColor } from "@/lib/utils"
import { z } from "zod"
export const settingsFormSchema = z.object({
app_title: z.string().max(128).optional(),
default_currency: z.string().max(5).optional(),
default_type: z.string().optional(),
default_category: z.string().optional(),

6
forms/users.ts Normal file
View File

@@ -0,0 +1,6 @@
import { z } from "zod"
export const userFormSchema = z.object({
name: z.string().max(128).optional(),
avatar: z.string().optional(),
})

6
lib/auth-client.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createAuthClient } from "better-auth/client"
import { emailOTPClient } from "better-auth/client/plugins"
export const authClient = createAuthClient({
plugins: [emailOTPClient()],
})

88
lib/auth.ts Normal file
View File

@@ -0,0 +1,88 @@
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
import { createUserDefaults } from "@/models/defaults"
import { getSelfHostedUser, getUserByEmail } from "@/models/users"
import { User } from "@prisma/client"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { nextCookies } from "better-auth/next-js"
import { emailOTP } from "better-auth/plugins/email-otp"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { prisma } from "./db"
import { resend, sendOTPCodeEmail } from "./email"
export type UserProfile = {
id: string
name: string
email: string
avatar?: string
}
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
email: {
provider: "resend",
from: process.env.RESEND_FROM_EMAIL!,
resend,
},
session: {
strategy: "jwt",
maxAge: 180 * 24 * 60 * 60, // 180 days
updateAge: 24 * 60 * 60, // 24 hours
cookieCache: {
enabled: true,
maxAge: 24 * 60 * 60, // 24 hours
},
},
advanced: {
generateId: false,
cookiePrefix: "taxhacker",
},
databaseHooks: {
user: {
create: {
after: async (user) => {
await createUserDefaults(user.id)
},
},
},
},
plugins: [
emailOTP({
disableSignUp: true,
otpLength: 6,
expiresIn: 10 * 60, // 10 minutes
sendVerificationOTP: async ({ email, otp }) => {
const user = await getUserByEmail(email as string)
if (!user) {
throw new Error("User with this email does not exist")
}
await sendOTPCodeEmail({ email, otp })
},
}),
nextCookies(), // make sure this is the last plugin in the array
],
})
export async function getSession() {
if (IS_SELF_HOSTED_MODE) {
const user = await getSelfHostedUser()
return user ? { user } : null
}
return await auth.api.getSession({
headers: await headers(),
})
}
export async function getCurrentUser(): Promise<User> {
const session = await getSession()
if (!session || !session.user) {
if (IS_SELF_HOSTED_MODE) {
redirect(SELF_HOSTED_REDIRECT_URL)
} else {
redirect(AUTH_LOGIN_URL)
}
}
return session.user as User
}

7
lib/constants.ts Normal file
View File

@@ -0,0 +1,7 @@
export const APP_TITLE = "TaxHacker"
export const APP_DESCRIPTION = "Your personal AI accountant"
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
export const IS_SELF_HOSTED_MODE = process.env.SELF_HOSTED_MODE === "true"
export const SELF_HOSTED_REDIRECT_URL = "/self-hosted/redirect"
export const SELF_HOSTED_WELCOME_URL = "/self-hosted"
export const AUTH_LOGIN_URL = "/enter"

View File

@@ -27,7 +27,10 @@ export async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo:
export async function fetchHistoricalCurrencyRates(currency: string = "USD", date: Date): Promise<HistoricRate[]> {
const formattedDate = format(date, "yyyy-MM-dd")
const url = `https://corsproxy.io/?${encodeURIComponent(
console.log("DATE", formattedDate)
console.log("QUERY", encodeURIComponent(`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`))
const url = `https://corsproxy.io/?url=${encodeURIComponent(
`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`
)}`

View File

@@ -1,5 +1,4 @@
import { PrismaClient } from "@prisma/client"
import path from "path"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
@@ -8,10 +7,3 @@ const globalForPrisma = globalThis as unknown as {
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["query", "info", "warn", "error"] })
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
export let DATABASE_FILE = process.env.DATABASE_URL?.replace("file:", "") ?? "db.sqlite"
if (DATABASE_FILE?.startsWith("/")) {
DATABASE_FILE = path.resolve(process.cwd(), DATABASE_FILE)
} else {
DATABASE_FILE = path.resolve(process.cwd(), "prisma", DATABASE_FILE)
}

28
lib/email.ts Normal file
View File

@@ -0,0 +1,28 @@
import { NewsletterWelcomeEmail } from "@/components/emails/newsletter-welcome-email"
import { OTPEmail } from "@/components/emails/otp-email"
import React from "react"
import { Resend } from "resend"
export const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendOTPCodeEmail({ email, otp }: { email: string; otp: string }) {
const html = React.createElement(OTPEmail, { otp })
return await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL!,
to: email,
subject: "Your TaxHacker verification code",
react: html,
})
}
export async function sendNewsletterWelcomeEmail(email: string) {
const html = React.createElement(NewsletterWelcomeEmail)
return await resend.emails.send({
from: process.env.RESEND_FROM_EMAIL as string,
to: email,
subject: "Welcome to TaxHacker Newsletter!",
react: html,
})
}

View File

@@ -1,30 +1,38 @@
import { Transaction } from "@prisma/client"
import { randomUUID } from "crypto"
import { File, Transaction, User } from "@prisma/client"
import { access, constants } from "fs/promises"
import path from "path"
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
export const FILE_UNSORTED_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "unsorted")
export const FILE_PREVIEWS_PATH = path.join(FILE_UPLOAD_PATH, "previews")
export const FILE_IMPORT_CSV_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "csv")
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
export const FILE_PREVIEWS_DIRECTORY_NAME = "previews"
export const FILE_IMPORT_CSV_DIRECTORY_NAME = "csv"
export async function getUnsortedFileUploadPath(filename: string) {
const fileUuid = randomUUID()
const fileExtension = path.extname(filename)
const storedFileName = `${fileUuid}${fileExtension}`
const filePath = path.join(FILE_UNSORTED_UPLOAD_PATH, storedFileName)
return { fileUuid, filePath }
export async function getUserUploadsDirectory(user: User) {
return path.join(FILE_UPLOAD_PATH, user.email)
}
export async function getTransactionFileUploadPath(filename: string, transaction: Transaction) {
const fileUuid = randomUUID()
export async function getUserPreviewsDirectory(user: User) {
return path.join(FILE_UPLOAD_PATH, user.email, FILE_PREVIEWS_DIRECTORY_NAME)
}
export async function unsortedFilePath(fileUuid: string, filename: string): Promise<string> {
const fileExtension = path.extname(filename)
return path.join(FILE_UNSORTED_DIRECTORY_NAME, `${fileUuid}${fileExtension}`)
}
export async function previewFilePath(fileUuid: string, page: number): Promise<string> {
return path.join(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
}
export async function getTransactionFileUploadPath(fileUuid: string, filename: string, transaction: Transaction) {
const fileExtension = path.extname(filename)
const storedFileName = `${fileUuid}${fileExtension}`
const formattedPath = formatFilePath(storedFileName, transaction.issuedAt || new Date())
const filePath = path.join(FILE_UPLOAD_PATH, formattedPath)
return formatFilePath(storedFileName, transaction.issuedAt || new Date())
}
return { fileUuid, filePath }
export async function fullPathForFile(user: User, file: File) {
const userUploadsDirectory = await getUserUploadsDirectory(user)
return path.join(userUploadsDirectory, file.path)
}
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
@@ -35,3 +43,12 @@ function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{nam
return format.replace("{YYYY}", String(year)).replace("{MM}", month).replace("{name}", name).replace("{ext}", ext)
}
export async function fileExists(filePath: string) {
try {
await access(filePath, constants.F_OK)
return true
} catch {
return false
}
}

Some files were not shown because too many files have changed in this diff Show More