diff --git a/.env.example b/.env.example index 06a2c95..3ed1f11 100644 --- a/.env.example +++ b/.env.example @@ -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!" \ No newline at end of file +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 " diff --git a/.gitignore b/.gitignore index 7e38136..3e39887 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ # misc .DS_Store *.pem +.vscode # debug npm-debug.log* diff --git a/Dockerfile b/Dockerfile index 51712eb..c2c51d4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index ef23576..efac339 100644 --- a/README.md +++ b/README.md @@ -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,9 +115,16 @@ 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. +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. For example: @@ -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 ` | + ## ⌨️ 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 ``` diff --git a/ai/analyze.ts b/ai/analyze.ts new file mode 100644 index 0000000..9ce3cde --- /dev/null +++ b/ai/analyze.ts @@ -0,0 +1,57 @@ +"use server" + +import OpenAI from "openai" +import { AnalyzeAttachment } from "./attachments" + +export async function analyzeTransaction( + prompt: string, + schema: Record, + attachments: AnalyzeAttachment[], + apiKey: string +): Promise<{ success: boolean; data?: Record; 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", + } + } +} diff --git a/ai/attachments.ts b/ai/attachments.ts new file mode 100644 index 0000000..ae8b260 --- /dev/null +++ b/ai/attachments.ts @@ -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 => { + 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 => { + const buffer = await fs.readFile(filePath) + return Buffer.from(buffer).toString("base64") +} diff --git a/app/ai/prompt.ts b/ai/prompt.ts similarity index 100% rename from app/ai/prompt.ts rename to ai/prompt.ts diff --git a/app/ai/schema.ts b/ai/schema.ts similarity index 100% rename from app/ai/schema.ts rename to ai/schema.ts diff --git a/app/context.tsx b/app/(app)/context.tsx similarity index 100% rename from app/context.tsx rename to app/(app)/context.tsx diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..80267b3 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -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 }) { + const filters = await searchParams + const user = await getCurrentUser() + const unsortedFiles = await getUnsortedFiles(user.id) + const settings = await getSettings(user.id) + + return ( +
+
+ + + +
+ + {settings.is_welcome_message_hidden !== "true" && } + + + + +
+ ) +} diff --git a/app/export/transactions/route.ts b/app/(app)/export/transactions/route.ts similarity index 80% rename from app/export/transactions/route.ts rename to app/(app)/export/transactions/route.ts index 9908ce8..2d69fda 100644 --- a/app/export/transactions/route.ts +++ b/app/(app)/export/transactions/route.ts @@ -1,10 +1,12 @@ +import { getCurrentUser } from "@/lib/auth" +import { fileExists, fullPathForFile } from "@/lib/files" import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import" import { getFields } from "@/models/fields" import { getFilesByTransactionId } from "@/models/files" import { getTransactions } from "@/models/transactions" import { format } from "@fast-csv/format" import { formatDate } from "date-fns" -import fs from "fs" +import fs from "fs/promises" import JSZip from "jszip" import { NextResponse } from "next/server" import path from "path" @@ -15,8 +17,9 @@ export async function GET(request: Request) { const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields const includeAttachments = url.searchParams.get("includeAttachments") === "true" - const { transactions } = await getTransactions(filters) - const existingFields = await getFields() + const user = await getCurrentUser() + const { transactions } = await getTransactions(user.id, filters) + const existingFields = await getFields(user.id) // Generate CSV file with all transactions try { @@ -40,7 +43,7 @@ export async function GET(request: Request) { const value = transaction[key as keyof typeof transaction] ?? "" const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[key] if (exportFieldSettings && exportFieldSettings.export) { - row[key] = await exportFieldSettings.export(value) + row[key] = await exportFieldSettings.export(user.id, value) } else { row[key] = value } @@ -73,7 +76,7 @@ export async function GET(request: Request) { } for (const transaction of transactions) { - const transactionFiles = await getFilesByTransactionId(transaction.id) + const transactionFiles = await getFilesByTransactionId(transaction.id, user.id) const transactionFolder = filesFolder.folder( path.join( @@ -87,14 +90,17 @@ export async function GET(request: Request) { } for (const file of transactionFiles) { - const fileData = fs.readFileSync(file.path) - const fileExtension = path.extname(file.path) - transactionFolder.file( - `${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${ - transaction.name || transaction.id - }${fileExtension}`, - fileData - ) + const fullFilePath = await fullPathForFile(user, file) + if (await fileExists(fullFilePath)) { + const fileData = await fs.readFile(fullFilePath) + const fileExtension = path.extname(fullFilePath) + transactionFolder.file( + `${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${ + transaction.name || transaction.id + }${fileExtension}`, + fileData + ) + } } } diff --git a/app/files/actions.ts b/app/(app)/files/actions.ts similarity index 60% rename from app/files/actions.ts rename to app/(app)/files/actions.ts index a7d580e..2d1d83e 100644 --- a/app/files/actions.ts +++ b/app/(app)/files/actions.ts @@ -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, diff --git a/app/files/download/[fileId]/route.ts b/app/(app)/files/download/[fileId]/route.ts similarity index 63% rename from app/files/download/[fileId]/route.ts rename to app/(app)/files/download/[fileId]/route.ts index f4859cc..6a330e0 100644 --- a/app/files/download/[fileId]/route.ts +++ b/app/(app)/files/download/[fileId]/route.ts @@ -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, { diff --git a/app/files/page.tsx b/app/(app)/files/page.tsx similarity index 100% rename from app/files/page.tsx rename to app/(app)/files/page.tsx diff --git a/app/files/preview/[fileId]/route.ts b/app/(app)/files/preview/[fileId]/route.ts similarity index 50% rename from app/files/preview/[fileId]/route.ts rename to app/(app)/files/preview/[fileId]/route.ts index e3057ae..40d1c38 100644 --- a/app/files/preview/[fileId]/route.ts +++ b/app/(app)/files/preview/[fileId]/route.ts @@ -1,5 +1,6 @@ -import { resizeImage } from "@/lib/images" -import { pdfToImages } from "@/lib/pdf" +import { getCurrentUser } from "@/lib/auth" +import { fileExists, fullPathForFile } from "@/lib/files" +import { generateFilePreviews } from "@/lib/previews/generate" import { getFileById } from "@/models/files" import fs from "fs/promises" import { NextResponse } from "next/server" @@ -7,6 +8,7 @@ import path from "path" export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) { const { fileId } = await params + const user = await getCurrentUser() if (!fileId) { return new NextResponse("No fileId provided", { status: 400 }) @@ -17,45 +19,33 @@ export async function GET(request: Request, { params }: { params: Promise<{ file try { // Find file in database - const file = await getFileById(fileId) + const file = await getFileById(fileId, user.id) - if (!file) { - return new NextResponse("File not found", { status: 404 }) + if (!file || file.userId !== user.id) { + return new NextResponse("File not found or does not belong to the user", { status: 404 }) } - // Check if file exists - try { - await fs.access(file.path) - } catch { + // Check if file exists on disk + const fullFilePath = await fullPathForFile(user, file) + const isFileExists = await fileExists(fullFilePath) + if (!isFileExists) { return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 }) } - let previewPath = file.path - let previewType = file.mimetype - - if (file.mimetype === "application/pdf") { - const { contentType, pages } = await pdfToImages(file.path) - if (page > pages.length) { - return new NextResponse("Page not found", { status: 404 }) - } - previewPath = pages[page - 1] || file.path - previewType = contentType - } else if (file.mimetype.startsWith("image/")) { - const { contentType, resizedPath } = await resizeImage(file.path) - previewPath = resizedPath - previewType = contentType - } else { - previewPath = file.path - previewType = file.mimetype + // Generate previews + const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype) + if (page > previews.length) { + return new NextResponse("Page not found", { status: 404 }) } + const previewPath = previews[page - 1] || fullFilePath - // Read filex + // Read file const fileBuffer = await fs.readFile(previewPath) // Return file with proper content type return new NextResponse(fileBuffer, { headers: { - "Content-Type": previewType, + "Content-Type": contentType, "Content-Disposition": `inline; filename="${path.basename(previewPath)}"`, }, }) diff --git a/app/import/csv/actions.tsx b/app/(app)/import/csv/actions.tsx similarity index 89% rename from app/import/csv/actions.tsx rename to app/(app)/import/csv/actions.tsx index b9c30ed..df08c66 100644 --- a/app/import/csv/actions.tsx +++ b/app/(app)/import/csv/actions.tsx @@ -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[] @@ -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") diff --git a/app/import/csv/page.tsx b/app/(app)/import/csv/page.tsx similarity index 68% rename from app/import/csv/page.tsx rename to app/(app)/import/csv/page.tsx index b4c99a4..c2eae9f 100644 --- a/app/import/csv/page.tsx +++ b/app/(app)/import/csv/page.tsx @@ -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 (
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..e927e64 --- /dev/null +++ b/app/(app)/layout.tsx @@ -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 ( + + + + + + {children} + + + + + ) +} + +export const dynamic = "force-dynamic" diff --git a/app/settings/actions.ts b/app/(app)/settings/actions.ts similarity index 67% rename from app/settings/actions.ts rename to app/(app)/settings/actions.ts index 75070f7..f5bd81d 100644 --- a/app/settings/actions.ts +++ b/app/(app)/settings/actions.ts @@ -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 } } diff --git a/app/(app)/settings/backups/actions.ts b/app/(app)/settings/backups/actions.ts new file mode 100644 index 0000000..398a487 --- /dev/null +++ b/app/(app)/settings/backups/actions.ts @@ -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 = {} + + // 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) + } + } +} diff --git a/app/settings/backups/data/route.ts b/app/(app)/settings/backups/data/route.ts similarity index 63% rename from app/settings/backups/data/route.ts rename to app/(app)/settings/backups/data/route.ts index d2bf74b..4e7a971 100644 --- a/app/settings/backups/data/route.ts +++ b/app/(app)/settings/backups/data/route.ts @@ -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 { 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 { - const data = await model.findMany() - - if (!data || data.length === 0) { - return "[]" - } - - return JSON.stringify(data, null, 2) -} diff --git a/app/settings/backups/page.tsx b/app/(app)/settings/backups/page.tsx similarity index 88% rename from app/settings/backups/page.tsx rename to app/(app)/settings/backups/page.tsx index 536c654..2709cea 100644 --- a/app/settings/backups/page.tsx +++ b/app/(app)/settings/backups/page.tsx @@ -57,7 +57,14 @@ export default function BackupSettingsPage() { {restoreState?.success && (

Backup restored successfully

-

You can now continue using the app.

+

You can now continue using the app. Import stats:

+
    + {Object.entries(restoreState.counters || {}).map(([key, value]) => ( +
  • + {key}: {value} items +
  • + ))} +
)}
diff --git a/app/settings/categories/page.tsx b/app/(app)/settings/categories/page.tsx similarity index 76% rename from app/settings/categories/page.tsx rename to app/(app)/settings/categories/page.tsx index 70e528d..0a6555d 100644 --- a/app/settings/categories/page.tsx +++ b/app/(app)/settings/categories/page.tsx @@ -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) }} /> diff --git a/app/settings/currencies/page.tsx b/app/(app)/settings/currencies/page.tsx similarity index 72% rename from app/settings/currencies/page.tsx rename to app/(app)/settings/currencies/page.tsx index d68c34a..7ecf694 100644 --- a/app/settings/currencies/page.tsx +++ b/app/(app)/settings/currencies/page.tsx @@ -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 }) }} /> diff --git a/app/settings/fields/page.tsx b/app/(app)/settings/fields/page.tsx similarity index 83% rename from app/settings/fields/page.tsx rename to app/(app)/settings/fields/page.tsx index f5b3e36..69ff624 100644 --- a/app/settings/fields/page.tsx +++ b/app/(app)/settings/fields/page.tsx @@ -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) }} /> diff --git a/app/settings/layout.tsx b/app/(app)/settings/layout.tsx similarity index 95% rename from app/settings/layout.tsx rename to app/(app)/settings/layout.tsx index cee9ddd..6c41f7b 100644 --- a/app/settings/layout.tsx +++ b/app/(app)/settings/layout.tsx @@ -12,6 +12,10 @@ const settingsCategories = [ title: "General", href: "/settings", }, + { + title: "My Profile", + href: "/settings/profile", + }, { title: "LLM settings", href: "/settings/llm", diff --git a/app/settings/llm/page.tsx b/app/(app)/settings/llm/page.tsx similarity index 68% rename from app/settings/llm/page.tsx rename to app/(app)/settings/llm/page.tsx index cb5eaac..eee2ee3 100644 --- a/app/settings/llm/page.tsx +++ b/app/(app)/settings/llm/page.tsx @@ -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 ( <> diff --git a/app/settings/loading.tsx b/app/(app)/settings/loading.tsx similarity index 100% rename from app/settings/loading.tsx rename to app/(app)/settings/loading.tsx diff --git a/app/settings/page.tsx b/app/(app)/settings/page.tsx similarity index 66% rename from app/settings/page.tsx rename to app/(app)/settings/page.tsx index 8d5d969..e4ce658 100644 --- a/app/settings/page.tsx +++ b/app/(app)/settings/page.tsx @@ -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 ( <> diff --git a/app/(app)/settings/profile/page.tsx b/app/(app)/settings/profile/page.tsx new file mode 100644 index 0000000..84d071b --- /dev/null +++ b/app/(app)/settings/profile/page.tsx @@ -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 ( + <> +
+ +
+ + ) +} diff --git a/app/settings/projects/page.tsx b/app/(app)/settings/projects/page.tsx similarity index 77% rename from app/settings/projects/page.tsx rename to app/(app)/settings/projects/page.tsx index 72e83ad..a996b2d 100644 --- a/app/settings/projects/page.tsx +++ b/app/(app)/settings/projects/page.tsx @@ -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) }} /> diff --git a/app/transactions/[transactionId]/layout.tsx b/app/(app)/transactions/[transactionId]/layout.tsx similarity index 80% rename from app/transactions/[transactionId]/layout.tsx rename to app/(app)/transactions/[transactionId]/layout.tsx index b4de861..c8ec079 100644 --- a/app/transactions/[transactionId]/layout.tsx +++ b/app/(app)/transactions/[transactionId]/layout.tsx @@ -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() diff --git a/app/transactions/[transactionId]/loading.tsx b/app/(app)/transactions/[transactionId]/loading.tsx similarity index 100% rename from app/transactions/[transactionId]/loading.tsx rename to app/(app)/transactions/[transactionId]/loading.tsx diff --git a/app/transactions/[transactionId]/page.tsx b/app/(app)/transactions/[transactionId]/page.tsx similarity index 82% rename from app/transactions/[transactionId]/page.tsx rename to app/(app)/transactions/[transactionId]/page.tsx index 580fae9..d5a9aec 100644 --- a/app/transactions/[transactionId]/page.tsx +++ b/app/(app)/transactions/[transactionId]/page.tsx @@ -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 (
diff --git a/app/transactions/actions.ts b/app/(app)/transactions/actions.ts similarity index 72% rename from app/transactions/actions.ts rename to app/(app)/transactions/actions.ts index 88c4bb7..48b6741 100644 --- a/app/transactions/actions.ts +++ b/app/(app)/transactions/actions.ts @@ -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 } diff --git a/app/transactions/layout.tsx b/app/(app)/transactions/layout.tsx similarity index 100% rename from app/transactions/layout.tsx rename to app/(app)/transactions/layout.tsx diff --git a/app/transactions/loading.tsx b/app/(app)/transactions/loading.tsx similarity index 100% rename from app/transactions/loading.tsx rename to app/(app)/transactions/loading.tsx diff --git a/app/transactions/page.tsx b/app/(app)/transactions/page.tsx similarity index 91% rename from app/transactions/page.tsx rename to app/(app)/transactions/page.tsx index 867c2ec..3a688f8 100644 --- a/app/transactions/page.tsx +++ b/app/(app)/transactions/page.tsx @@ -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 }) { 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) { diff --git a/app/(app)/unsorted/actions.ts b/app/(app)/unsorted/actions.ts new file mode 100644 index 0000000..23a1d89 --- /dev/null +++ b/app/(app)/unsorted/actions.ts @@ -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, + fields: Field[], + categories: Category[], + projects: Project[] +): Promise<{ success: boolean; data?: Record; 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" } + } +} diff --git a/app/unsorted/layout.tsx b/app/(app)/unsorted/layout.tsx similarity index 100% rename from app/unsorted/layout.tsx rename to app/(app)/unsorted/layout.tsx diff --git a/app/unsorted/loading.tsx b/app/(app)/unsorted/loading.tsx similarity index 100% rename from app/unsorted/loading.tsx rename to app/(app)/unsorted/loading.tsx diff --git a/app/unsorted/page.tsx b/app/(app)/unsorted/page.tsx similarity index 87% rename from app/unsorted/page.tsx rename to app/(app)/unsorted/page.tsx index 6da3254..53e4aa1 100644 --- a/app/unsorted/page.tsx +++ b/app/(app)/unsorted/page.tsx @@ -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() {

You have {files.length} unsorted files

- {!settings.openai_api_key && ( + {IS_SELF_HOSTED_MODE && !settings.openai_api_key && (
diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts new file mode 100644 index 0000000..c824a38 --- /dev/null +++ b/app/(auth)/actions.ts @@ -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") +} diff --git a/app/(auth)/enter/page.tsx b/app/(auth)/enter/page.tsx new file mode 100644 index 0000000..eb2e8e4 --- /dev/null +++ b/app/(auth)/enter/page.tsx @@ -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 ( + + Logo + + TaxHacker: Cloud Edition + + + + + + ) +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..0fa7b99 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,17 @@ +import { X } from "lucide-react" + +export default function AuthLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + + + + +
{children}
+
+ ) +} diff --git a/app/(auth)/self-hosted/page.tsx b/app/(auth)/self-hosted/page.tsx new file mode 100644 index 0000000..fad9a7d --- /dev/null +++ b/app/(auth)/self-hosted/page.tsx @@ -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 ( + + + + Self-Hosted Mode is not enabled + + +

+ To use TaxHacker in self-hosted mode, please set SELF_HOSTED_MODE=true in + your environment. +

+

In self-hosted mode you can use your own ChatGPT API key and store your data on your own server.

+
+
+ ) + } + + const user = await getSelfHostedUser() + if (user) { + redirect(SELF_HOSTED_REDIRECT_URL) + } + + return ( + + Logo + + TaxHacker: Self-Hosted Edition + + +

Welcome to your own instance of TaxHacker. Let's set up a couple of settings to get started.

+ +
+
+ + + + Get your API key from{" "} + + OpenAI Platform Console + + +
+ +
+ s.code === "default_currency")?.value ?? "EUR"} + currencies={DEFAULT_CURRENCIES} + /> +
+ + +
+
+
+ ) +} diff --git a/app/(auth)/self-hosted/redirect/route.ts b/app/(auth)/self-hosted/redirect/route.ts new file mode 100644 index 0000000..b19254e --- /dev/null +++ b/app/(auth)/self-hosted/redirect/route.ts @@ -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") +} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..4c1b1f3 --- /dev/null +++ b/app/(auth)/signup/page.tsx @@ -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 ( + + Logo + + TaxHacker: Cloud Edition + + +
+ Creating new account is disabled for now. Please use the self-hosted version. +
+ {/* */} +
+
+ ) +} diff --git a/app/ai/analyze.ts b/app/ai/analyze.ts deleted file mode 100644 index e667392..0000000 --- a/app/ai/analyze.ts +++ /dev/null @@ -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 => { - 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 => { - 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, - fields: Field[], - categories: Category[] = [], - projects: Project[] = [], - attachments: AnalyzeAttachment[] = [] -): Promise<{ success: boolean; data?: Record; 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", - } - } -} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..5cd4846 --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "@/lib/auth" +import { toNextJsHandler } from "better-auth/next-js" + +export const { POST, GET } = toNextJsHandler(auth) diff --git a/app/landing/actions.ts b/app/landing/actions.ts new file mode 100644 index 0000000..9f649fe --- /dev/null +++ b/app/landing/actions.ts @@ -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." } + } +} diff --git a/app/landing/landing.tsx b/app/landing/landing.tsx new file mode 100644 index 0000000..ecbbbbe --- /dev/null +++ b/app/landing/landing.tsx @@ -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 ( +
+
+
+ + Logo + TaxHacker + +
+ + Get Started + +
+
+
+ + {/* Hero Section */} +
+
+
+
+ 🚀 Under Active Development +
+

+ Organize receipts, track expenses, and prepare your taxes with AI +

+

+ A self-hosted accounting app crafted with love for freelancers and small businesses. +

+
+ + Get Started + + + Contact Us + +
+
+
+
+ +
+
+
+ + {/* Features Section */} +
+
+
+

+ F∗ck Taxes + TaxHacker can save you time, money and nerves +

+
+ + {/* AI Scanner Feature */} +
+
+
+ LLM-Powered +
+

AI Document Analyzer

+
    +
  • + + Upload photos or PDFs for automatic recognition +
  • +
  • + + Extract key information like dates, amounts, and vendors +
  • +
  • + + Works with any language, format and photo quality +
  • +
  • + + Automatically organize everything into a structured database +
  • +
+
+
+ AI Document Analyzer +
+
+ + {/* Multi-currency Feature */} +
+
+
+ Currency Converter +
+

Multi-Currency Support

+
    +
  • + + Detects foreign currencies and coverts it to yours +
  • +
  • + + Historical exchange rate lookup on a date of transaction +
  • +
  • + + Support for 170+ world currencies +
  • +
  • + + Even works with cryptocurrencies (BTC, ETH, LTC, etc.) +
  • +
+
+
+ Currency Converter +
+
+ + {/* Transaction Table Feature */} +
+
+ Transactions Table +
+
+
+ Filters +
+

Income & Expense Tracker

+
    +
  • + + Add, edit and manage your transactions +
  • +
  • + + Filter by any column, category or date range +
  • +
  • + + Customize which columns to show in the table +
  • +
  • + + Import transactions from CSV +
  • +
+
+
+ + {/* Custom Fields & Categories */} +
+
+ Custom LLM promts +
+
+
+ Customization +
+

Custom LLM promts for everything

+
    +
  • + + Create custom fields and categories with your own LLM prompts +
  • +
  • + + Extract any additional information you need +
  • +
  • + + Automatically categorize by project or category +
  • +
  • + + Ask AI to assess risk level or any other criteria +
  • +
+
+
+ + {/* Data Export */} +
+
+ Export +
+
+
+ Export +
+

Your Data — Your Rules

+
    +
  • + + Flexible filters to export your data for tax prep +
  • +
  • + + Full-text search across documents +
  • +
  • + + Export to CSV with attached documents +
  • +
  • + + Download full data archive to migrate to another service +
  • +
+
+
+
+
+ + {/* Deployment Options */} +
+
+
+

+ Choose Your Version of TaxHacker +

+
+
+ {/* Self-Hosted Version */} +
+
+ Use Your Own Server +
+

+ Self-Hosted Edition +

+
    +
  • + + Complete control over your data +
  • +
  • + + Use at your own infrastructure +
  • +
  • + + Free and open source +
  • +
  • + + Bring your own OpenAI keys +
  • +
+ + Github + Docker Compose + +
+ + {/* Cloud Version */} +
+
+ Coming Soon +
+
+ We Host It For You +
+

+ Cloud Edition +

+
    +
  • + + SaaS version for those who prefer less hassle +
  • +
  • + + We provide AI keys and storage +
  • +
  • + + Yearly subscription plans +
  • +
  • + + Automatic updates and new features +
  • +
+ +
+
+
+
+ + {/* Upcoming Features */} +
+
+
+
+ 🚀 Under Active Development +
+

+ Upcoming Features +

+

+ We're a small, indie project constantly improving. Here's what we're working on next. +

+
+ +
+ {/* AI Improvements */} +
+
+ 🤖 +

Better AI Analytics & Agents

+
+
    +
  • + + Income & expense insights +
  • +
  • + + AI agents to automate your workflows +
  • +
  • + + Recommendations for tax optimization +
  • +
  • + + Custom and local LLM models +
  • +
+
+ + {/* Smart Reports */} +
+
+ 📂 +

Smart Reports & Reminders

+
+
    +
  • + + Monthly or quarterly VAT reports +
  • +
  • + + Tax reminders +
  • +
  • + + Annual income & expense reports +
  • +
+
+ + {/* Transaction Review */} +
+
+ 📥 +

Multiple Transaction Review

+
+
    +
  • + + Bank statement analysis +
  • +
  • + + Automatic data completeness checks +
  • +
  • + + Unpaid invoice tracking +
  • +
+
+ + {/* Custom Fields */} +
+
+ 🤯 +

Presets and Plugins

+
+
    +
  • + + Presets for different countries and industries +
  • +
  • + + Custom reports for various use-cases +
  • +
  • + + Community plugins and reports +
  • +
+
+
+ + {/* Newsletter Signup */} + +
+
+ + +
+ ) +} diff --git a/app/landing/newsletter.tsx b/app/landing/newsletter.tsx new file mode 100644 index 0000000..6707518 --- /dev/null +++ b/app/landing/newsletter.tsx @@ -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 ( +
+
+

Stay Tuned

+

+ 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. +

+
+
+ 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 + /> + +
+ {message && ( +

{message}

+ )} +
+
+
+ ) +} diff --git a/app/layout.tsx b/app/layout.tsx index d9706b4..561efae 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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 ( - - - - - - - {children} - - - - - + {children} ) } - -export const dynamic = "force-dynamic" diff --git a/app/page.tsx b/app/page.tsx index c93d68d..2efda84 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,30 +1,16 @@ -import DashboardDropZoneWidget from "@/components/dashboard/drop-zone-widget" -import { StatsWidget } from "@/components/dashboard/stats-widget" -import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget" -import { WelcomeWidget } from "@/components/dashboard/welcome-widget" -import { Separator } from "@/components/ui/separator" -import { getUnsortedFiles } from "@/models/files" -import { getSettings } from "@/models/settings" -import { TransactionFilters } from "@/models/transactions" +import LandingPage from "@/app/landing/landing" +import { getSession } from "@/lib/auth" +import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants" +import { redirect } from "next/navigation" -export default async function Home({ searchParams }: { searchParams: Promise }) { - const filters = await searchParams - const unsortedFiles = await getUnsortedFiles() - const settings = await getSettings() +export default async function Home() { + const session = await getSession() + if (!session) { + if (IS_SELF_HOSTED_MODE) { + redirect(SELF_HOSTED_REDIRECT_URL) + } + return + } - return ( -
-
- - - -
- - {settings.is_welcome_message_hidden !== "true" && } - - - - -
- ) + redirect("/dashboard") } diff --git a/app/settings/backups/actions.ts b/app/settings/backups/actions.ts deleted file mode 100644 index 2b0212e..0000000 --- a/app/settings/backups/actions.ts +++ /dev/null @@ -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 { - 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): Record { - const processedRow: Record = {} - - 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 -} diff --git a/app/unsorted/actions.ts b/app/unsorted/actions.ts deleted file mode 100644 index 3bbe048..0000000 --- a/app/unsorted/actions.ts +++ /dev/null @@ -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" } - } -} diff --git a/components/auth/login-form.tsx b/components/auth/login-form.tsx new file mode 100644 index 0000000..d4aae5e --- /dev/null +++ b/components/auth/login-form.tsx @@ -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(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 ( +
+ setEmail(e.target.value)} + required + disabled={isOtpSent} + /> + + {isOtpSent && ( + setOtp(e.target.value)} + required + maxLength={6} + pattern="[0-9]{6}" + /> + )} + + + + {error && {error}} + + ) +} diff --git a/components/auth/signup-form.tsx b/components/auth/signup-form.tsx new file mode 100644 index 0000000..2707700 --- /dev/null +++ b/components/auth/signup-form.tsx @@ -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(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 ( +
+ setName(e.target.value)} + required + disabled={isOtpSent} + /> + + setEmail(e.target.value)} + required + disabled={isOtpSent} + /> + + {isOtpSent && ( + setOtp(e.target.value)} + required + maxLength={6} + pattern="[0-9]{6}" + /> + )} + + {error &&

{error}

} + + + + ) +} diff --git a/components/dashboard/drop-zone-widget.tsx b/components/dashboard/drop-zone-widget.tsx index 051903d..92c8e3a 100644 --- a/components/dashboard/drop-zone-widget.tsx +++ b/components/dashboard/drop-zone-widget.tsx @@ -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" diff --git a/components/dashboard/stats-widget.tsx b/components/dashboard/stats-widget.tsx index 6f2a95d..4fa0afb 100644 --- a/components/dashboard/stats-widget.tsx +++ b/components/dashboard/stats-widget.tsx @@ -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])) ) ) diff --git a/components/dashboard/welcome-widget.tsx b/components/dashboard/welcome-widget.tsx index e59f555..25e43f5 100644 --- a/components/dashboard/welcome-widget.tsx +++ b/components/dashboard/welcome-widget.tsx @@ -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 ( - + Logo
- Hey, I'm TaxHacker 👋 + + Hey, I'm TaxHacker 👋 +