mirror of
https://github.com/marcogll/TaxHacker_s23.git
synced 2026-01-13 13:25:18 +00:00
BREAKING: postgres + saas
This commit is contained in:
22
.env.example
22
.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!"
|
||||
DATABASE_URL="postgresql://user@localhost:5432/taxhacker"
|
||||
BETTER_AUTH_SECRET="random-secret-key"
|
||||
OPENAI_API_KEY=""
|
||||
RESEND_API_KEY=""
|
||||
RESEND_AUDIENCE_ID=""
|
||||
RESEND_FROM_EMAIL="TaxHacker <user@localhost>"
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -28,6 +28,7 @@
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
.vscode
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
|
||||
@@ -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
|
||||
|
||||
32
README.md
32
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,6 +115,13 @@ curl -O https://raw.githubusercontent.com/vas3k/TaxHacker/main/docker-compose.ym
|
||||
docker compose up
|
||||
```
|
||||
|
||||
The Docker Compose setup includes:
|
||||
|
||||
- TaxHacker application container
|
||||
- PostgreSQL 17 database container
|
||||
- Automatic database migrations
|
||||
- Volume mounts for persistent data storage
|
||||
|
||||
New docker image is automatically built and published on every new release. You can use specific version tags (e.g. `v1.0.0`) or `latest` for the most recent version.
|
||||
|
||||
For more advanced setups, you can adapt Docker Compose configuration to your own needs. The default configuration uses the pre-built image from GHCR, but you can still build locally using the provided [Dockerfile](./Dockerfile) if needed.
|
||||
@@ -129,8 +136,9 @@ services:
|
||||
- "7331:7331"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- SELF_HOSTED_MODE=true
|
||||
- UPLOAD_PATH=/app/data/uploads
|
||||
- DATABASE_URL=file:/app/data/db.sqlite
|
||||
- DATABASE_URL=postgresql://postgres:postgres@localhost:5432/taxhacker
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -142,9 +150,15 @@ Configure TaxHacker to suit your needs with these environment variables:
|
||||
|
||||
| Variable | Required | Description | Example |
|
||||
|----------|----------|-------------|---------|
|
||||
| `UPLOAD_PATH` | Yes | Local directory for uploading files | `./upload` |
|
||||
| `DATABASE_URL` | Yes | Database file for SQLite | `file:./db.sqlite` |
|
||||
| `PROMPT_ANALYSE_NEW_FILE` | No | Default prompt for LLM | `Act as an accountant...` |
|
||||
| `PORT` | No | Port to run the server on | `7331` |
|
||||
| `SELF_HOSTED_MODE` | No | Enable self-hosted mode and automatic login | `false` |
|
||||
| `UPLOAD_PATH` | Yes | Local directory for uploading files | `./data/uploads` |
|
||||
| `DATABASE_URL` | Yes | PostgreSQL connection string | `postgresql://postgres:postgres@localhost:5432/taxhacker` |
|
||||
| `OPENAI_API_KEY` | No | OpenAI API key for AI features | `sk-...` |
|
||||
| `RESEND_API_KEY` | No | Resend API key for email notifications | `re_...` |
|
||||
| `RESEND_AUDIENCE_ID` | No | Resend audience ID for newsletters | `fde8dd49-...` |
|
||||
| `RESEND_FROM_EMAIL` | No | Email address to send from | `TaxHacker <hello@taxhacker.app>` |
|
||||
|
||||
|
||||
## ⌨️ Local Development
|
||||
|
||||
@@ -152,7 +166,7 @@ We use:
|
||||
|
||||
- Next.js version 15+ or later
|
||||
- [Prisma](https://www.prisma.io/) for database models and migrations
|
||||
- SQLite as a database
|
||||
- PostgreSQL as a database (PostgreSQL 17+ recommended)
|
||||
- Ghostscript and graphicsmagick libs for PDF files (can be installed on macOS via `brew install gs graphicsmagick`)
|
||||
|
||||
Set up a local development environment with these steps:
|
||||
@@ -167,14 +181,14 @@ npm install
|
||||
|
||||
# Set up environment variables
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your configuration
|
||||
# Make sure to set DATABASE_URL to your PostgreSQL connection string
|
||||
# Example: postgresql://user@localhost:5432/taxhacker
|
||||
|
||||
# Initialize the database
|
||||
npx prisma generate && npx prisma migrate dev
|
||||
|
||||
# Seed the database with default data (optional)
|
||||
npm run seed
|
||||
|
||||
# Start the development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
57
ai/analyze.ts
Normal file
57
ai/analyze.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
"use server"
|
||||
|
||||
import OpenAI from "openai"
|
||||
import { AnalyzeAttachment } from "./attachments"
|
||||
|
||||
export async function analyzeTransaction(
|
||||
prompt: string,
|
||||
schema: Record<string, unknown>,
|
||||
attachments: AnalyzeAttachment[],
|
||||
apiKey: string
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
const openai = new OpenAI({
|
||||
apiKey,
|
||||
})
|
||||
console.log("RUNNING AI ANALYSIS")
|
||||
console.log("PROMPT:", prompt)
|
||||
console.log("SCHEMA:", schema)
|
||||
|
||||
try {
|
||||
const response = await openai.responses.create({
|
||||
model: "gpt-4o-mini-2024-07-18",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: attachments.map((attachment) => ({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${attachment.contentType};base64,${attachment.base64}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: "transaction",
|
||||
schema: schema,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log("ChatGPT response:", response.output_text)
|
||||
|
||||
const result = JSON.parse(response.output_text)
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
console.error("AI Analysis error:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to analyze invoice",
|
||||
}
|
||||
}
|
||||
}
|
||||
35
ai/attachments.ts
Normal file
35
ai/attachments.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { generateFilePreviews } from "@/lib/previews/generate"
|
||||
import { File, User } from "@prisma/client"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const MAX_PAGES_TO_ANALYZE = 4
|
||||
|
||||
export type AnalyzeAttachment = {
|
||||
filename: string
|
||||
contentType: string
|
||||
base64: string
|
||||
}
|
||||
|
||||
export const loadAttachmentsForAI = async (user: User, file: File): Promise<AnalyzeAttachment[]> => {
|
||||
const fullFilePath = await fullPathForFile(user, file)
|
||||
const isFileExists = await fileExists(fullFilePath)
|
||||
if (!isFileExists) {
|
||||
throw new Error("File not found on disk")
|
||||
}
|
||||
|
||||
const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype)
|
||||
|
||||
return Promise.all(
|
||||
previews.slice(0, MAX_PAGES_TO_ANALYZE).map(async (preview) => ({
|
||||
filename: file.filename,
|
||||
contentType: contentType,
|
||||
base64: await loadFileAsBase64(preview),
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
export const loadFileAsBase64 = async (filePath: string): Promise<string> => {
|
||||
const buffer = await fs.readFile(filePath)
|
||||
return Buffer.from(buffer).toString("base64")
|
||||
}
|
||||
39
app/(app)/dashboard/page.tsx
Normal file
39
app/(app)/dashboard/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import DashboardDropZoneWidget from "@/components/dashboard/drop-zone-widget"
|
||||
import { StatsWidget } from "@/components/dashboard/stats-widget"
|
||||
import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget"
|
||||
import { WelcomeWidget } from "@/components/dashboard/welcome-widget"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { APP_DESCRIPTION } from "@/lib/constants"
|
||||
import { getUnsortedFiles } from "@/models/files"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import { TransactionFilters } from "@/models/transactions"
|
||||
import { Metadata } from "next"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Dashboard",
|
||||
description: APP_DESCRIPTION,
|
||||
}
|
||||
|
||||
export default async function Dashboard({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const filters = await searchParams
|
||||
const user = await getCurrentUser()
|
||||
const unsortedFiles = await getUnsortedFiles(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-5 w-full max-w-7xl self-center">
|
||||
<div className="flex flex-col sm:flex-row gap-5 items-stretch h-full">
|
||||
<DashboardDropZoneWidget />
|
||||
|
||||
<DashboardUnsortedWidget files={unsortedFiles} />
|
||||
</div>
|
||||
|
||||
{settings.is_welcome_message_hidden !== "true" && <WelcomeWidget />}
|
||||
|
||||
<Separator />
|
||||
|
||||
<StatsWidget filters={filters} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { EXPORT_AND_IMPORT_FIELD_MAP, ExportFields, ExportFilters } from "@/models/export_and_import"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getFilesByTransactionId } from "@/models/files"
|
||||
import { getTransactions } from "@/models/transactions"
|
||||
import { format } from "@fast-csv/format"
|
||||
import { formatDate } from "date-fns"
|
||||
import fs from "fs"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
@@ -15,8 +17,9 @@ export async function GET(request: Request) {
|
||||
const fields = (url.searchParams.get("fields")?.split(",") ?? []) as ExportFields
|
||||
const includeAttachments = url.searchParams.get("includeAttachments") === "true"
|
||||
|
||||
const { transactions } = await getTransactions(filters)
|
||||
const existingFields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const { transactions } = await getTransactions(user.id, filters)
|
||||
const existingFields = await getFields(user.id)
|
||||
|
||||
// Generate CSV file with all transactions
|
||||
try {
|
||||
@@ -40,7 +43,7 @@ export async function GET(request: Request) {
|
||||
const value = transaction[key as keyof typeof transaction] ?? ""
|
||||
const exportFieldSettings = EXPORT_AND_IMPORT_FIELD_MAP[key]
|
||||
if (exportFieldSettings && exportFieldSettings.export) {
|
||||
row[key] = await exportFieldSettings.export(value)
|
||||
row[key] = await exportFieldSettings.export(user.id, value)
|
||||
} else {
|
||||
row[key] = value
|
||||
}
|
||||
@@ -73,7 +76,7 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const transactionFiles = await getFilesByTransactionId(transaction.id)
|
||||
const transactionFiles = await getFilesByTransactionId(transaction.id, user.id)
|
||||
|
||||
const transactionFolder = filesFolder.folder(
|
||||
path.join(
|
||||
@@ -87,14 +90,17 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
for (const file of transactionFiles) {
|
||||
const fileData = fs.readFileSync(file.path)
|
||||
const fileExtension = path.extname(file.path)
|
||||
transactionFolder.file(
|
||||
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
|
||||
transaction.name || transaction.id
|
||||
}${fileExtension}`,
|
||||
fileData
|
||||
)
|
||||
const fullFilePath = await fullPathForFile(user, file)
|
||||
if (await fileExists(fullFilePath)) {
|
||||
const fileData = await fs.readFile(fullFilePath)
|
||||
const fileExtension = path.extname(fullFilePath)
|
||||
transactionFolder.file(
|
||||
`${formatDate(transaction.issuedAt || new Date(), "yyyy-MM-dd")} - ${
|
||||
transaction.name || transaction.id
|
||||
}${fileExtension}`,
|
||||
fileData
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
"use server"
|
||||
|
||||
import { FILE_UNSORTED_UPLOAD_PATH, getUnsortedFileUploadPath } from "@/lib/files"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getUserUploadsDirectory, unsortedFilePath } from "@/lib/files"
|
||||
import { createFile } from "@/models/files"
|
||||
import { existsSync } from "fs"
|
||||
import { randomUUID } from "crypto"
|
||||
import { mkdir, writeFile } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function uploadFilesAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const files = formData.getAll("files")
|
||||
|
||||
// Make sure upload dir exists
|
||||
if (!existsSync(FILE_UNSORTED_UPLOAD_PATH)) {
|
||||
await mkdir(FILE_UNSORTED_UPLOAD_PATH, { recursive: true })
|
||||
}
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
// Process each file
|
||||
const uploadedFiles = await Promise.all(
|
||||
@@ -22,17 +23,21 @@ export async function uploadFilesAction(prevState: any, formData: FormData) {
|
||||
}
|
||||
|
||||
// Save file to filesystem
|
||||
const { fileUuid, filePath } = await getUnsortedFileUploadPath(file.name)
|
||||
const fileUuid = randomUUID()
|
||||
const relativeFilePath = await unsortedFilePath(fileUuid, file.name)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
|
||||
await writeFile(filePath, buffer)
|
||||
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
|
||||
await mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
|
||||
await writeFile(fullFilePath, buffer)
|
||||
|
||||
// Create file record in database
|
||||
const fileRecord = await createFile({
|
||||
const fileRecord = await createFile(user.id, {
|
||||
id: fileUuid,
|
||||
filename: file.name,
|
||||
path: filePath,
|
||||
path: relativeFilePath,
|
||||
mimetype: file.type,
|
||||
metadata: {
|
||||
size: file.size,
|
||||
@@ -1,9 +1,12 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { getFileById } from "@/models/files"
|
||||
import fs from "fs/promises"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) {
|
||||
const { fileId } = await params
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!fileId) {
|
||||
return new NextResponse("No fileId provided", { status: 400 })
|
||||
@@ -11,20 +14,21 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
|
||||
|
||||
try {
|
||||
// Find file in database
|
||||
const file = await getFileById(fileId)
|
||||
const file = await getFileById(fileId, user.id)
|
||||
|
||||
if (!file) {
|
||||
return new NextResponse("File not found", { status: 404 })
|
||||
if (!file || file.userId !== user.id) {
|
||||
return new NextResponse("File not found or does not belong to the user", { status: 404 })
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(file.path)
|
||||
} catch {
|
||||
const fullFilePath = await fullPathForFile(user, file)
|
||||
const isFileExists = await fileExists(fullFilePath)
|
||||
if (!isFileExists) {
|
||||
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
||||
}
|
||||
|
||||
// Read file
|
||||
const fileBuffer = await fs.readFile(file.path)
|
||||
const fileBuffer = await fs.readFile(fullFilePath)
|
||||
|
||||
// Return file with proper content type
|
||||
return new NextResponse(fileBuffer, {
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resizeImage } from "@/lib/images"
|
||||
import { pdfToImages } from "@/lib/pdf"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, fullPathForFile } from "@/lib/files"
|
||||
import { generateFilePreviews } from "@/lib/previews/generate"
|
||||
import { getFileById } from "@/models/files"
|
||||
import fs from "fs/promises"
|
||||
import { NextResponse } from "next/server"
|
||||
@@ -7,6 +8,7 @@ import path from "path"
|
||||
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ fileId: string }> }) {
|
||||
const { fileId } = await params
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!fileId) {
|
||||
return new NextResponse("No fileId provided", { status: 400 })
|
||||
@@ -17,45 +19,33 @@ export async function GET(request: Request, { params }: { params: Promise<{ file
|
||||
|
||||
try {
|
||||
// Find file in database
|
||||
const file = await getFileById(fileId)
|
||||
const file = await getFileById(fileId, user.id)
|
||||
|
||||
if (!file) {
|
||||
return new NextResponse("File not found", { status: 404 })
|
||||
if (!file || file.userId !== user.id) {
|
||||
return new NextResponse("File not found or does not belong to the user", { status: 404 })
|
||||
}
|
||||
|
||||
// Check if file exists
|
||||
try {
|
||||
await fs.access(file.path)
|
||||
} catch {
|
||||
// Check if file exists on disk
|
||||
const fullFilePath = await fullPathForFile(user, file)
|
||||
const isFileExists = await fileExists(fullFilePath)
|
||||
if (!isFileExists) {
|
||||
return new NextResponse(`File not found on disk: ${file.path}`, { status: 404 })
|
||||
}
|
||||
|
||||
let previewPath = file.path
|
||||
let previewType = file.mimetype
|
||||
|
||||
if (file.mimetype === "application/pdf") {
|
||||
const { contentType, pages } = await pdfToImages(file.path)
|
||||
if (page > pages.length) {
|
||||
return new NextResponse("Page not found", { status: 404 })
|
||||
}
|
||||
previewPath = pages[page - 1] || file.path
|
||||
previewType = contentType
|
||||
} else if (file.mimetype.startsWith("image/")) {
|
||||
const { contentType, resizedPath } = await resizeImage(file.path)
|
||||
previewPath = resizedPath
|
||||
previewType = contentType
|
||||
} else {
|
||||
previewPath = file.path
|
||||
previewType = file.mimetype
|
||||
// Generate previews
|
||||
const { contentType, previews } = await generateFilePreviews(user, fullFilePath, file.mimetype)
|
||||
if (page > previews.length) {
|
||||
return new NextResponse("Page not found", { status: 404 })
|
||||
}
|
||||
const previewPath = previews[page - 1] || fullFilePath
|
||||
|
||||
// Read filex
|
||||
// Read file
|
||||
const fileBuffer = await fs.readFile(previewPath)
|
||||
|
||||
// Return file with proper content type
|
||||
return new NextResponse(fileBuffer, {
|
||||
headers: {
|
||||
"Content-Type": previewType,
|
||||
"Content-Type": contentType,
|
||||
"Content-Disposition": `inline; filename="${path.basename(previewPath)}"`,
|
||||
},
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
"use server"
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { EXPORT_AND_IMPORT_FIELD_MAP } from "@/models/export_and_import"
|
||||
import { createTransaction } from "@/models/transactions"
|
||||
import { parse } from "@fast-csv/parse"
|
||||
@@ -38,6 +39,7 @@ export async function parseCSVAction(prevState: any, formData: FormData) {
|
||||
}
|
||||
|
||||
export async function saveTransactionsAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
try {
|
||||
const rows = JSON.parse(formData.get("rows") as string) as Record<string, unknown>[]
|
||||
|
||||
@@ -46,13 +48,13 @@ export async function saveTransactionsAction(prevState: any, formData: FormData)
|
||||
for (const [fieldCode, value] of Object.entries(row)) {
|
||||
const fieldDef = EXPORT_AND_IMPORT_FIELD_MAP[fieldCode]
|
||||
if (fieldDef?.import) {
|
||||
transactionData[fieldCode] = await fieldDef.import(value as string)
|
||||
transactionData[fieldCode] = await fieldDef.import(user.id, value as string)
|
||||
} else {
|
||||
transactionData[fieldCode] = value as string
|
||||
}
|
||||
}
|
||||
|
||||
await createTransaction(transactionData)
|
||||
await createTransaction(user.id, transactionData)
|
||||
}
|
||||
|
||||
revalidatePath("/import/csv")
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ImportCSVTable } from "@/components/import/csv"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
|
||||
export default async function CSVImportPage() {
|
||||
const fields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const fields = await getFields(user.id)
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4">
|
||||
<ImportCSVTable fields={fields} />
|
||||
57
app/(app)/layout.tsx
Normal file
57
app/(app)/layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import ScreenDropArea from "@/components/files/screen-drop-area"
|
||||
import MobileMenu from "@/components/sidebar/mobile-menu"
|
||||
import { AppSidebar } from "@/components/sidebar/sidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants"
|
||||
import { getUnsortedFilesCount } from "@/models/files"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import "../globals.css"
|
||||
import { NotificationProvider } from "./context"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | TaxHacker",
|
||||
default: APP_TITLE,
|
||||
},
|
||||
description: APP_DESCRIPTION,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
shortcut: "/favicon.ico",
|
||||
apple: "/apple-touch-icon.png",
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await getCurrentUser()
|
||||
const unsortedFilesCount = await getUnsortedFilesCount(user.id)
|
||||
|
||||
return (
|
||||
<NotificationProvider>
|
||||
<ScreenDropArea>
|
||||
<SidebarProvider>
|
||||
<MobileMenu unsortedFilesCount={unsortedFilesCount} />
|
||||
<AppSidebar
|
||||
unsortedFilesCount={unsortedFilesCount}
|
||||
profile={{
|
||||
id: user.id,
|
||||
name: user.name || "",
|
||||
email: user.email,
|
||||
avatar: user.avatar || undefined,
|
||||
}}
|
||||
/>
|
||||
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<Toaster />
|
||||
</ScreenDropArea>
|
||||
</NotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -7,17 +7,21 @@ import {
|
||||
projectFormSchema,
|
||||
settingsFormSchema,
|
||||
} from "@/forms/settings"
|
||||
import { userFormSchema } from "@/forms/users"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { codeFromName, randomHexColor } from "@/lib/utils"
|
||||
import { createCategory, deleteCategory, updateCategory } from "@/models/categories"
|
||||
import { createCurrency, deleteCurrency, updateCurrency } from "@/models/currencies"
|
||||
import { createField, deleteField, updateField } from "@/models/fields"
|
||||
import { createProject, deleteProject, updateProject } from "@/models/projects"
|
||||
import { updateSettings } from "@/models/settings"
|
||||
import { updateUser } from "@/models/users"
|
||||
import { Prisma } from "@prisma/client"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function saveSettingsAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const validatedForm = settingsFormSchema.safeParse(Object.fromEntries(formData))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
@@ -25,7 +29,7 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
|
||||
}
|
||||
|
||||
for (const key in validatedForm.data) {
|
||||
await updateSettings(key, validatedForm.data[key as keyof typeof validatedForm.data])
|
||||
await updateSettings(user.id, key, validatedForm.data[key as keyof typeof validatedForm.data])
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
@@ -33,14 +37,30 @@ export async function saveSettingsAction(prevState: any, formData: FormData) {
|
||||
// return { success: true }
|
||||
}
|
||||
|
||||
export async function addProjectAction(data: Prisma.ProjectCreateInput) {
|
||||
export async function saveProfileAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const validatedForm = userFormSchema.safeParse(Object.fromEntries(formData))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
await updateUser(user.id, {
|
||||
name: validatedForm.data.name,
|
||||
})
|
||||
|
||||
revalidatePath("/settings/profile")
|
||||
redirect("/settings/profile")
|
||||
}
|
||||
|
||||
export async function addProjectAction(userId: string, data: Prisma.ProjectCreateInput) {
|
||||
const validatedForm = projectFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const project = await createProject({
|
||||
const project = await createProject(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt || null,
|
||||
@@ -51,14 +71,14 @@ export async function addProjectAction(data: Prisma.ProjectCreateInput) {
|
||||
return { success: true, project }
|
||||
}
|
||||
|
||||
export async function editProjectAction(code: string, data: Prisma.ProjectUpdateInput) {
|
||||
export async function editProjectAction(userId: string, code: string, data: Prisma.ProjectUpdateInput) {
|
||||
const validatedForm = projectFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const project = await updateProject(code, {
|
||||
const project = await updateProject(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
@@ -68,9 +88,9 @@ export async function editProjectAction(code: string, data: Prisma.ProjectUpdate
|
||||
return { success: true, project }
|
||||
}
|
||||
|
||||
export async function deleteProjectAction(code: string) {
|
||||
export async function deleteProjectAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteProject(code)
|
||||
await deleteProject(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete project" + error }
|
||||
}
|
||||
@@ -78,14 +98,14 @@ export async function deleteProjectAction(code: string) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) {
|
||||
export async function addCurrencyAction(userId: string, data: Prisma.CurrencyCreateInput) {
|
||||
const validatedForm = currencyFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const currency = await createCurrency({
|
||||
const currency = await createCurrency(userId, {
|
||||
code: validatedForm.data.code,
|
||||
name: validatedForm.data.name,
|
||||
})
|
||||
@@ -94,21 +114,21 @@ export async function addCurrencyAction(data: Prisma.CurrencyCreateInput) {
|
||||
return { success: true, currency }
|
||||
}
|
||||
|
||||
export async function editCurrencyAction(code: string, data: Prisma.CurrencyUpdateInput) {
|
||||
export async function editCurrencyAction(userId: string, code: string, data: Prisma.CurrencyUpdateInput) {
|
||||
const validatedForm = currencyFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const currency = await updateCurrency(code, { name: validatedForm.data.name })
|
||||
const currency = await updateCurrency(userId, code, { name: validatedForm.data.name })
|
||||
revalidatePath("/settings/currencies")
|
||||
return { success: true, currency }
|
||||
}
|
||||
|
||||
export async function deleteCurrencyAction(code: string) {
|
||||
export async function deleteCurrencyAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteCurrency(code)
|
||||
await deleteCurrency(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete currency" + error }
|
||||
}
|
||||
@@ -116,14 +136,14 @@ export async function deleteCurrencyAction(code: string) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addCategoryAction(data: Prisma.CategoryCreateInput) {
|
||||
export async function addCategoryAction(userId: string, data: Prisma.CategoryCreateInput) {
|
||||
const validatedForm = categoryFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const category = await createCategory({
|
||||
const category = await createCategory(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
@@ -134,14 +154,14 @@ export async function addCategoryAction(data: Prisma.CategoryCreateInput) {
|
||||
return { success: true, category }
|
||||
}
|
||||
|
||||
export async function editCategoryAction(code: string, data: Prisma.CategoryUpdateInput) {
|
||||
export async function editCategoryAction(userId: string, code: string, data: Prisma.CategoryUpdateInput) {
|
||||
const validatedForm = categoryFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const category = await updateCategory(code, {
|
||||
const category = await updateCategory(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
color: validatedForm.data.color || "",
|
||||
@@ -151,9 +171,9 @@ export async function editCategoryAction(code: string, data: Prisma.CategoryUpda
|
||||
return { success: true, category }
|
||||
}
|
||||
|
||||
export async function deleteCategoryAction(code: string) {
|
||||
export async function deleteCategoryAction(code: string, userId: string) {
|
||||
try {
|
||||
await deleteCategory(code)
|
||||
await deleteCategory(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete category" + error }
|
||||
}
|
||||
@@ -161,14 +181,14 @@ export async function deleteCategoryAction(code: string) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
export async function addFieldAction(data: Prisma.FieldCreateInput) {
|
||||
export async function addFieldAction(userId: string, data: Prisma.FieldCreateInput) {
|
||||
const validatedForm = fieldFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const field = await createField({
|
||||
const field = await createField(userId, {
|
||||
code: codeFromName(validatedForm.data.name),
|
||||
name: validatedForm.data.name,
|
||||
type: validatedForm.data.type,
|
||||
@@ -182,14 +202,14 @@ export async function addFieldAction(data: Prisma.FieldCreateInput) {
|
||||
return { success: true, field }
|
||||
}
|
||||
|
||||
export async function editFieldAction(code: string, data: Prisma.FieldUpdateInput) {
|
||||
export async function editFieldAction(userId: string, code: string, data: Prisma.FieldUpdateInput) {
|
||||
const validatedForm = fieldFormSchema.safeParse(data)
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const field = await updateField(code, {
|
||||
const field = await updateField(userId, code, {
|
||||
name: validatedForm.data.name,
|
||||
type: validatedForm.data.type,
|
||||
llm_prompt: validatedForm.data.llm_prompt,
|
||||
@@ -201,9 +221,9 @@ export async function editFieldAction(code: string, data: Prisma.FieldUpdateInpu
|
||||
return { success: true, field }
|
||||
}
|
||||
|
||||
export async function deleteFieldAction(code: string) {
|
||||
export async function deleteFieldAction(userId: string, code: string) {
|
||||
try {
|
||||
await deleteField(code)
|
||||
await deleteField(userId, code)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Failed to delete field" + error }
|
||||
}
|
||||
145
app/(app)/settings/backups/actions.ts
Normal file
145
app/(app)/settings/backups/actions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
"use server"
|
||||
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { prisma } from "@/lib/db"
|
||||
import { getUserUploadsDirectory } from "@/lib/files"
|
||||
import { MODEL_BACKUP, modelFromJSON } from "@/models/backups"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import path from "path"
|
||||
|
||||
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
|
||||
const REMOVE_EXISTING_DATA = true
|
||||
|
||||
export async function restoreBackupAction(prevState: any, formData: FormData) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
const file = formData.get("file") as File
|
||||
|
||||
if (!file || file.size === 0) {
|
||||
return { success: false, error: "No file provided" }
|
||||
}
|
||||
|
||||
// Read zip archive
|
||||
let zip: JSZip
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
const fileData = Buffer.from(fileBuffer)
|
||||
zip = await JSZip.loadAsync(fileData)
|
||||
} catch (error) {
|
||||
return { success: false, error: "Bad zip archive" }
|
||||
}
|
||||
|
||||
if (REMOVE_EXISTING_DATA) {
|
||||
await cleanupUserTables(user.id)
|
||||
await fs.rm(userUploadsDirectory, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
// Check metadata and start restoring
|
||||
try {
|
||||
const metadataFile = zip.file("data/metadata.json")
|
||||
if (metadataFile) {
|
||||
const metadataContent = await metadataFile.async("string")
|
||||
try {
|
||||
const metadata = JSON.parse(metadataContent)
|
||||
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Incompatible backup version: ${
|
||||
metadata.version || "unknown"
|
||||
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
|
||||
}
|
||||
}
|
||||
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
|
||||
} catch (error) {
|
||||
console.warn("Could not parse backup metadata:", error)
|
||||
}
|
||||
} else {
|
||||
console.warn("No metadata found in backup, assuming legacy format")
|
||||
}
|
||||
|
||||
const counters: Record<string, number> = {}
|
||||
|
||||
// Restore tables
|
||||
for (const backup of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonFile = zip.file(`data/${backup.filename}`)
|
||||
if (jsonFile) {
|
||||
const jsonContent = await jsonFile.async("string")
|
||||
const restoredCount = await modelFromJSON(user.id, backup, jsonContent)
|
||||
console.log(`Restored ${restoredCount} records from ${backup.filename}`)
|
||||
counters[backup.filename] = restoredCount
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error restoring model from ${backup.filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore files
|
||||
try {
|
||||
let restoredFilesCount = 0
|
||||
const files = await prisma.file.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
})
|
||||
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
for (const file of files) {
|
||||
const filePathWithoutPrefix = file.path.replace(/^.*\/uploads\//, "")
|
||||
const zipFilePath = path.join("data/uploads", filePathWithoutPrefix)
|
||||
const zipFile = zip.file(zipFilePath)
|
||||
if (!zipFile) {
|
||||
console.log(`File ${file.path} not found in backup`)
|
||||
continue
|
||||
}
|
||||
|
||||
const fullFilePath = path.join(userUploadsDirectory, filePathWithoutPrefix)
|
||||
const fileContent = await zipFile.async("nodebuffer")
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
await fs.writeFile(fullFilePath, fileContent)
|
||||
restoredFilesCount++
|
||||
} catch (error) {
|
||||
console.error(`Error writing file ${fullFilePath}:`, error)
|
||||
continue
|
||||
}
|
||||
|
||||
await prisma.file.update({
|
||||
where: { id: file.id },
|
||||
data: {
|
||||
path: filePathWithoutPrefix,
|
||||
},
|
||||
})
|
||||
}
|
||||
counters["Uploaded attachments"] = restoredFilesCount
|
||||
} catch (error) {
|
||||
console.error("Error restoring uploaded files:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: "Restore completed successfully", counters }
|
||||
} catch (error) {
|
||||
console.error("Error restoring from backup:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupUserTables(userId: string) {
|
||||
// Delete in reverse order to handle foreign key constraints
|
||||
for (const { model } of [...MODEL_BACKUP].reverse()) {
|
||||
try {
|
||||
await model.deleteMany({ where: { userId } })
|
||||
} catch (error) {
|
||||
console.error(`Error clearing table:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { FILE_UPLOAD_PATH } from "@/lib/files"
|
||||
import { MODEL_BACKUP } from "@/models/backups"
|
||||
import fs, { readdirSync } from "fs"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { fileExists, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { MODEL_BACKUP, modelToJSON } from "@/models/backups"
|
||||
import fs from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import { NextResponse } from "next/server"
|
||||
import path from "path"
|
||||
@@ -9,6 +10,9 @@ const MAX_FILE_SIZE = 64 * 1024 * 1024 // 64MB
|
||||
const BACKUP_VERSION = "1.0"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const user = await getCurrentUser()
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const rootFolder = zip.folder("data")
|
||||
@@ -32,12 +36,12 @@ export async function GET(request: Request) {
|
||||
)
|
||||
|
||||
// Backup models
|
||||
for (const { filename, model } of MODEL_BACKUP) {
|
||||
for (const backup of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonContent = await tableToJSON(model)
|
||||
rootFolder.file(filename, jsonContent)
|
||||
const jsonContent = await modelToJSON(user.id, backup)
|
||||
rootFolder.file(backup.filename, jsonContent)
|
||||
} catch (error) {
|
||||
console.error(`Error exporting table ${filename}:`, error)
|
||||
console.error(`Error exporting table ${backup.filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,11 +51,11 @@ export async function GET(request: Request) {
|
||||
return new NextResponse("Internal Server Error", { status: 500 })
|
||||
}
|
||||
|
||||
const uploadedFiles = getAllFilePaths(FILE_UPLOAD_PATH)
|
||||
uploadedFiles.forEach((file) => {
|
||||
const uploadedFiles = await getAllFilePaths(userUploadsDirectory)
|
||||
for (const file of uploadedFiles) {
|
||||
try {
|
||||
// Check file size before reading
|
||||
const stats = fs.statSync(file)
|
||||
const stats = await fs.stat(file)
|
||||
if (stats.size > MAX_FILE_SIZE) {
|
||||
console.warn(
|
||||
`Skipping large file ${file} (${Math.round(stats.size / 1024 / 1024)}MB > ${
|
||||
@@ -61,12 +65,13 @@ export async function GET(request: Request) {
|
||||
return
|
||||
}
|
||||
|
||||
const fileContent = fs.readFileSync(file)
|
||||
uploadsFolder.file(file.replace(FILE_UPLOAD_PATH, ""), fileContent)
|
||||
const fileContent = await fs.readFile(file)
|
||||
uploadsFolder.file(file.replace(userUploadsDirectory, ""), fileContent)
|
||||
} catch (error) {
|
||||
console.error(`Error reading file ${file}:`, error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const archive = await zip.generateAsync({ type: "blob" })
|
||||
|
||||
return new NextResponse(archive, {
|
||||
@@ -81,32 +86,27 @@ export async function GET(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
function getAllFilePaths(dirPath: string): string[] {
|
||||
async function getAllFilePaths(dirPath: string): Promise<string[]> {
|
||||
let filePaths: string[] = []
|
||||
|
||||
function readDirectory(currentPath: string) {
|
||||
const entries = readdirSync(currentPath, { withFileTypes: true })
|
||||
async function readDirectoryRecursively(currentPath: string) {
|
||||
const isDirExists = await fileExists(currentPath)
|
||||
if (!isDirExists) {
|
||||
return
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(currentPath, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentPath, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
readDirectory(fullPath)
|
||||
await readDirectoryRecursively(fullPath)
|
||||
} else {
|
||||
filePaths.push(fullPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
readDirectory(dirPath)
|
||||
await readDirectoryRecursively(dirPath)
|
||||
|
||||
return filePaths
|
||||
}
|
||||
|
||||
async function tableToJSON(model: any): Promise<string> {
|
||||
const data = await model.findMany()
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return "[]"
|
||||
}
|
||||
|
||||
return JSON.stringify(data, null, 2)
|
||||
}
|
||||
@@ -57,7 +57,14 @@ export default function BackupSettingsPage() {
|
||||
{restoreState?.success && (
|
||||
<Card className="flex flex-col gap-2 p-5 bg-green-100 max-w-xl">
|
||||
<h2 className="text-xl font-semibold">Backup restored successfully</h2>
|
||||
<p className="text-sm text-muted-foreground">You can now continue using the app.</p>
|
||||
<p className="text-sm text-muted-foreground">You can now continue using the app. Import stats:</p>
|
||||
<ul className="list-disc list-inside">
|
||||
{Object.entries(restoreState.counters || {}).map(([key, value]) => (
|
||||
<li key={key}>
|
||||
<span className="font-bold">{key}</span>: {value} items
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
@@ -1,11 +1,13 @@
|
||||
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/settings/actions"
|
||||
import { addCategoryAction, deleteCategoryAction, editCategoryAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { randomHexColor } from "@/lib/utils"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
export default async function CategoriesSettingsPage() {
|
||||
const categories = await getCategories()
|
||||
const user = await getCurrentUser()
|
||||
const categories = await getCategories(user.id)
|
||||
const categoriesWithActions = categories.map((category) => ({
|
||||
...category,
|
||||
isEditable: true,
|
||||
@@ -29,15 +31,15 @@ export default async function CategoriesSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteCategoryAction(code)
|
||||
return await deleteCategoryAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addCategoryAction(data as Prisma.CategoryCreateInput)
|
||||
return await addCategoryAction(user.id, data as Prisma.CategoryCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editCategoryAction(code, data as Prisma.CategoryUpdateInput)
|
||||
return await editCategoryAction(user.id, code, data as Prisma.CategoryUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,9 +1,11 @@
|
||||
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/settings/actions"
|
||||
import { addCurrencyAction, deleteCurrencyAction, editCurrencyAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
|
||||
export default async function CurrenciesSettingsPage() {
|
||||
const currencies = await getCurrencies()
|
||||
const user = await getCurrentUser()
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const currenciesWithActions = currencies.map((currency) => ({
|
||||
...currency,
|
||||
isEditable: true,
|
||||
@@ -24,15 +26,15 @@ export default async function CurrenciesSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteCurrencyAction(code)
|
||||
return await deleteCurrencyAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addCurrencyAction(data as { code: string; name: string })
|
||||
return await addCurrencyAction(user.id, data as { code: string; name: string })
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editCurrencyAction(code, data as { name: string })
|
||||
return await editCurrencyAction(user.id, code, data as { name: string })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,10 +1,12 @@
|
||||
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/settings/actions"
|
||||
import { addFieldAction, deleteFieldAction, editFieldAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
export default async function FieldsSettingsPage() {
|
||||
const fields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const fields = await getFields(user.id)
|
||||
const fieldsWithActions = fields.map((field) => ({
|
||||
...field,
|
||||
isEditable: true,
|
||||
@@ -48,15 +50,15 @@ export default async function FieldsSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteFieldAction(code)
|
||||
return await deleteFieldAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addFieldAction(data as Prisma.FieldCreateInput)
|
||||
return await addFieldAction(user.id, data as Prisma.FieldCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editFieldAction(code, data as Prisma.FieldUpdateInput)
|
||||
return await editFieldAction(user.id, code, data as Prisma.FieldUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -12,6 +12,10 @@ const settingsCategories = [
|
||||
title: "General",
|
||||
href: "/settings",
|
||||
},
|
||||
{
|
||||
title: "My Profile",
|
||||
href: "/settings/profile",
|
||||
},
|
||||
{
|
||||
title: "LLM settings",
|
||||
href: "/settings/llm",
|
||||
@@ -1,10 +1,12 @@
|
||||
import LLMSettingsForm from "@/components/settings/llm-settings-form"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getSettings } from "@/models/settings"
|
||||
|
||||
export default async function LlmSettingsPage() {
|
||||
const settings = await getSettings()
|
||||
const fields = await getFields()
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1,12 +1,14 @@
|
||||
import GlobalSettingsForm from "@/components/settings/global-settings-form"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getSettings } from "@/models/settings"
|
||||
|
||||
export default async function SettingsPage() {
|
||||
const settings = await getSettings()
|
||||
const currencies = await getCurrencies()
|
||||
const categories = await getCategories()
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
14
app/(app)/settings/profile/page.tsx
Normal file
14
app/(app)/settings/profile/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import ProfileSettingsForm from "@/components/settings/profile-settings-form copy"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-2xl">
|
||||
<ProfileSettingsForm user={user} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/settings/actions"
|
||||
import { addProjectAction, deleteProjectAction, editProjectAction } from "@/app/(app)/settings/actions"
|
||||
import { CrudTable } from "@/components/settings/crud"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { randomHexColor } from "@/lib/utils"
|
||||
import { getProjects } from "@/models/projects"
|
||||
import { Prisma } from "@prisma/client"
|
||||
|
||||
export default async function ProjectsSettingsPage() {
|
||||
const projects = await getProjects()
|
||||
const user = await getCurrentUser()
|
||||
const projects = await getProjects(user.id)
|
||||
const projectsWithActions = projects.map((project) => ({
|
||||
...project,
|
||||
isEditable: true,
|
||||
@@ -28,15 +30,15 @@ export default async function ProjectsSettingsPage() {
|
||||
]}
|
||||
onDelete={async (code) => {
|
||||
"use server"
|
||||
return await deleteProjectAction(code)
|
||||
return await deleteProjectAction(user.id, code)
|
||||
}}
|
||||
onAdd={async (data) => {
|
||||
"use server"
|
||||
return await addProjectAction(data as Prisma.ProjectCreateInput)
|
||||
return await addProjectAction(user.id, data as Prisma.ProjectCreateInput)
|
||||
}}
|
||||
onEdit={async (code, data) => {
|
||||
"use server"
|
||||
return await editProjectAction(code, data as Prisma.ProjectUpdateInput)
|
||||
return await editProjectAction(user.id, code, data as Prisma.ProjectUpdateInput)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getTransactionById } from "@/models/transactions"
|
||||
import { notFound } from "next/navigation"
|
||||
|
||||
@@ -9,7 +10,8 @@ export default async function TransactionLayout({
|
||||
params: Promise<{ transactionId: string }>
|
||||
}) {
|
||||
const { transactionId } = await params
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
|
||||
if (!transaction) {
|
||||
notFound()
|
||||
@@ -2,6 +2,7 @@ import { FormTextarea } from "@/components/forms/simple"
|
||||
import TransactionEditForm from "@/components/transactions/edit"
|
||||
import TransactionFiles from "@/components/transactions/transaction-files"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getFields } from "@/models/fields"
|
||||
@@ -13,17 +14,18 @@ import { notFound } from "next/navigation"
|
||||
|
||||
export default async function TransactionPage({ params }: { params: Promise<{ transactionId: string }> }) {
|
||||
const { transactionId } = await params
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const files = await getFilesByTransactionId(transactionId)
|
||||
const categories = await getCategories()
|
||||
const currencies = await getCurrencies()
|
||||
const settings = await getSettings()
|
||||
const fields = await getFields()
|
||||
const projects = await getProjects()
|
||||
const files = await getFilesByTransactionId(transactionId, user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap flex-row items-start justify-center gap-4 max-w-6xl">
|
||||
@@ -1,7 +1,8 @@
|
||||
"use server"
|
||||
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { FILE_UPLOAD_PATH, getTransactionFileUploadPath } from "@/lib/files"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { updateField } from "@/models/fields"
|
||||
import { createFile, deleteFile } from "@/models/files"
|
||||
import {
|
||||
@@ -12,19 +13,21 @@ import {
|
||||
updateTransaction,
|
||||
updateTransactionFiles,
|
||||
} from "@/models/transactions"
|
||||
import { existsSync } from "fs"
|
||||
import { randomUUID } from "crypto"
|
||||
import { mkdir, writeFile } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function createTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const transaction = await createTransaction(validatedForm.data)
|
||||
const transaction = await createTransaction(user.id, validatedForm.data)
|
||||
|
||||
revalidatePath("/transactions")
|
||||
return { success: true, transactionId: transaction.id }
|
||||
@@ -36,6 +39,7 @@ export async function createTransactionAction(prevState: any, formData: FormData
|
||||
|
||||
export async function saveTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
const transactionId = formData.get("transactionId") as string
|
||||
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
|
||||
|
||||
@@ -43,7 +47,7 @@ export async function saveTransactionAction(prevState: any, formData: FormData)
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
const transaction = await updateTransaction(transactionId, validatedForm.data)
|
||||
const transaction = await updateTransaction(transactionId, user.id, validatedForm.data)
|
||||
|
||||
revalidatePath("/transactions")
|
||||
return { success: true, transactionId: transaction.id }
|
||||
@@ -55,10 +59,11 @@ export async function saveTransactionAction(prevState: any, formData: FormData)
|
||||
|
||||
export async function deleteTransactionAction(prevState: any, transactionId: string) {
|
||||
try {
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) throw new Error("Transaction not found")
|
||||
|
||||
await deleteTransaction(transaction.id)
|
||||
await deleteTransaction(transaction.id, user.id)
|
||||
|
||||
revalidatePath("/transactions")
|
||||
|
||||
@@ -77,17 +82,19 @@ export async function deleteTransactionFileAction(
|
||||
return { success: false, error: "File ID and transaction ID are required" }
|
||||
}
|
||||
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
return { success: false, error: "Transaction not found" }
|
||||
}
|
||||
|
||||
await updateTransactionFiles(
|
||||
transactionId,
|
||||
user.id,
|
||||
transaction.files ? (transaction.files as string[]).filter((id) => id !== fileId) : []
|
||||
)
|
||||
|
||||
await deleteFile(fileId)
|
||||
await deleteFile(fileId, user.id)
|
||||
revalidatePath(`/transactions/${transactionId}`)
|
||||
return { success: true }
|
||||
}
|
||||
@@ -101,28 +108,35 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
||||
return { success: false, error: "No files or transaction ID provided" }
|
||||
}
|
||||
|
||||
const transaction = await getTransactionById(transactionId)
|
||||
const user = await getCurrentUser()
|
||||
const transaction = await getTransactionById(transactionId, user.id)
|
||||
if (!transaction) {
|
||||
return { success: false, error: "Transaction not found" }
|
||||
}
|
||||
|
||||
// Make sure upload dir exists
|
||||
if (!existsSync(FILE_UPLOAD_PATH)) {
|
||||
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
|
||||
}
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
|
||||
const fileRecords = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const { fileUuid, filePath } = await getTransactionFileUploadPath(file.name, transaction)
|
||||
const fileUuid = randomUUID()
|
||||
const relativeFilePath = await getTransactionFileUploadPath(fileUuid, file.name, transaction)
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const buffer = Buffer.from(arrayBuffer)
|
||||
await writeFile(filePath, buffer)
|
||||
|
||||
const fullFilePath = path.join(userUploadsDirectory, relativeFilePath)
|
||||
await mkdir(path.dirname(fullFilePath), { recursive: true })
|
||||
|
||||
console.log("userUploadsDirectory", userUploadsDirectory)
|
||||
console.log("relativeFilePath", relativeFilePath)
|
||||
console.log("fullFilePath", fullFilePath)
|
||||
|
||||
await writeFile(fullFilePath, buffer)
|
||||
|
||||
// Create file record in database
|
||||
const fileRecord = await createFile({
|
||||
const fileRecord = await createFile(user.id, {
|
||||
id: fileUuid,
|
||||
filename: file.name,
|
||||
path: filePath,
|
||||
path: relativeFilePath,
|
||||
mimetype: file.type,
|
||||
isReviewed: true,
|
||||
metadata: {
|
||||
@@ -138,6 +152,7 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
||||
// Update invoice with the new file ID
|
||||
await updateTransactionFiles(
|
||||
transactionId,
|
||||
user.id,
|
||||
transaction.files
|
||||
? [...(transaction.files as string[]), ...fileRecords.map((file) => file.id)]
|
||||
: fileRecords.map((file) => file.id)
|
||||
@@ -153,7 +168,8 @@ export async function uploadTransactionFilesAction(formData: FormData): Promise<
|
||||
|
||||
export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
|
||||
try {
|
||||
await bulkDeleteTransactions(transactionIds)
|
||||
const user = await getCurrentUser()
|
||||
await bulkDeleteTransactions(transactionIds, user.id)
|
||||
revalidatePath("/transactions")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
@@ -164,7 +180,8 @@ export async function bulkDeleteTransactionsAction(transactionIds: string[]) {
|
||||
|
||||
export async function updateFieldVisibilityAction(fieldCode: string, isVisible: boolean) {
|
||||
try {
|
||||
await updateField(fieldCode, {
|
||||
const user = await getCurrentUser()
|
||||
await updateField(user.id, fieldCode, {
|
||||
isVisibleInList: isVisible,
|
||||
})
|
||||
return { success: true }
|
||||
@@ -5,6 +5,7 @@ import { TransactionList } from "@/components/transactions/list"
|
||||
import { NewTransactionDialog } from "@/components/transactions/new"
|
||||
import { Pagination } from "@/components/transactions/pagination"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getFields } from "@/models/fields"
|
||||
import { getProjects } from "@/models/projects"
|
||||
@@ -22,13 +23,14 @@ const TRANSACTIONS_PER_PAGE = 1000
|
||||
|
||||
export default async function TransactionsPage({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const { page, ...filters } = await searchParams
|
||||
const { transactions, total } = await getTransactions(filters, {
|
||||
const user = await getCurrentUser()
|
||||
const { transactions, total } = await getTransactions(user.id, filters, {
|
||||
limit: TRANSACTIONS_PER_PAGE,
|
||||
offset: ((page ?? 1) - 1) * TRANSACTIONS_PER_PAGE,
|
||||
})
|
||||
const categories = await getCategories()
|
||||
const projects = await getProjects()
|
||||
const fields = await getFields()
|
||||
const categories = await getCategories(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
|
||||
// Reset page if user clicks a filter and no transactions are found
|
||||
if (page && page > 1 && transactions.length === 0) {
|
||||
117
app/(app)/unsorted/actions.ts
Normal file
117
app/(app)/unsorted/actions.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
"use server"
|
||||
|
||||
import { analyzeTransaction } from "@/ai/analyze"
|
||||
import { AnalyzeAttachment, loadAttachmentsForAI } from "@/ai/attachments"
|
||||
import { buildLLMPrompt } from "@/ai/prompt"
|
||||
import { fieldsToJsonSchema } from "@/ai/schema"
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { getTransactionFileUploadPath, getUserUploadsDirectory } from "@/lib/files"
|
||||
import { DEFAULT_PROMPT_ANALYSE_NEW_FILE } from "@/models/defaults"
|
||||
import { deleteFile, getFileById, updateFile } from "@/models/files"
|
||||
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
|
||||
import { Category, Field, File, Project } from "@prisma/client"
|
||||
import { mkdir, rename } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function analyzeFileAction(
|
||||
file: File,
|
||||
settings: Record<string, string>,
|
||||
fields: Field[],
|
||||
categories: Category[],
|
||||
projects: Project[]
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
const user = await getCurrentUser()
|
||||
|
||||
if (!file || file.userId !== user.id) {
|
||||
return { success: false, error: "File not found or does not belong to the user" }
|
||||
}
|
||||
|
||||
let attachments: AnalyzeAttachment[] = []
|
||||
try {
|
||||
attachments = await loadAttachmentsForAI(user, file)
|
||||
} catch (error) {
|
||||
console.error("Failed to retrieve files:", error)
|
||||
return { success: false, error: "Failed to retrieve files: " + error }
|
||||
}
|
||||
|
||||
const prompt = buildLLMPrompt(
|
||||
settings.prompt_analyse_new_file || DEFAULT_PROMPT_ANALYSE_NEW_FILE,
|
||||
fields,
|
||||
categories,
|
||||
projects
|
||||
)
|
||||
|
||||
const schema = fieldsToJsonSchema(fields)
|
||||
|
||||
const results = await analyzeTransaction(
|
||||
prompt,
|
||||
schema,
|
||||
attachments,
|
||||
IS_SELF_HOSTED_MODE ? settings.openai_api_key : process.env.OPENAI_API_KEY || ""
|
||||
)
|
||||
|
||||
console.log("Analysis results:", results)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
// Get the file record
|
||||
const fileId = formData.get("fileId") as string
|
||||
const file = await getFileById(fileId, user.id)
|
||||
if (!file) throw new Error("File not found")
|
||||
|
||||
// Create transaction
|
||||
const transaction = await createTransaction(user.id, validatedForm.data)
|
||||
|
||||
// Move file to processed location
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
const originalFileName = path.basename(file.path)
|
||||
const newRelativeFilePath = await getTransactionFileUploadPath(file.id, originalFileName, transaction)
|
||||
|
||||
// Move file to new location and name
|
||||
const oldFullFilePath = path.join(userUploadsDirectory, file.path)
|
||||
const newFullFilePath = path.join(userUploadsDirectory, newRelativeFilePath)
|
||||
await mkdir(path.dirname(newFullFilePath), { recursive: true })
|
||||
await rename(path.resolve(oldFullFilePath), path.resolve(newFullFilePath))
|
||||
|
||||
// Update file record
|
||||
await updateFile(file.id, user.id, {
|
||||
path: newRelativeFilePath,
|
||||
isReviewed: true,
|
||||
})
|
||||
|
||||
await updateTransactionFiles(transaction.id, user.id, [file.id])
|
||||
|
||||
revalidatePath("/unsorted")
|
||||
revalidatePath("/transactions")
|
||||
|
||||
return { success: true, transactionId: transaction.id }
|
||||
} catch (error) {
|
||||
console.error("Failed to save transaction:", error)
|
||||
return { success: false, error: `Failed to save transaction: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUnsortedFileAction(prevState: any, fileId: string) {
|
||||
try {
|
||||
const user = await getCurrentUser()
|
||||
await deleteFile(fileId, user.id)
|
||||
revalidatePath("/unsorted")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error)
|
||||
return { success: false, error: "Failed to delete file" }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import AnalyzeForm from "@/components/unsorted/analyze-form"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getFields } from "@/models/fields"
|
||||
@@ -20,12 +22,13 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export default async function UnsortedPage() {
|
||||
const files = await getUnsortedFiles()
|
||||
const categories = await getCategories()
|
||||
const projects = await getProjects()
|
||||
const currencies = await getCurrencies()
|
||||
const fields = await getFields()
|
||||
const settings = await getSettings()
|
||||
const user = await getCurrentUser()
|
||||
const files = await getUnsortedFiles(user.id)
|
||||
const categories = await getCategories(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const fields = await getFields(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -33,7 +36,7 @@ export default async function UnsortedPage() {
|
||||
<h2 className="text-3xl font-bold tracking-tight">You have {files.length} unsorted files</h2>
|
||||
</header>
|
||||
|
||||
{!settings.openai_api_key && (
|
||||
{IS_SELF_HOSTED_MODE && !settings.openai_api_key && (
|
||||
<Alert>
|
||||
<Settings className="h-4 w-4 mt-2" />
|
||||
<div className="flex flex-row justify-between pt-2">
|
||||
28
app/(auth)/actions.ts
Normal file
28
app/(auth)/actions.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
"use server"
|
||||
|
||||
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
|
||||
import { updateSettings } from "@/models/settings"
|
||||
import { createSelfHostedUser } from "@/models/users"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function selfHostedGetStartedAction(formData: FormData) {
|
||||
const user = await createSelfHostedUser()
|
||||
|
||||
if (await isDatabaseEmpty(user.id)) {
|
||||
await createUserDefaults(user.id)
|
||||
}
|
||||
|
||||
const openaiApiKey = formData.get("openai_api_key")
|
||||
if (openaiApiKey) {
|
||||
await updateSettings(user.id, "openai_api_key", openaiApiKey)
|
||||
}
|
||||
|
||||
const defaultCurrency = formData.get("default_currency")
|
||||
if (defaultCurrency) {
|
||||
await updateSettings(user.id, "default_currency", defaultCurrency)
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard")
|
||||
redirect("/dashboard")
|
||||
}
|
||||
23
app/(auth)/enter/page.tsx
Normal file
23
app/(auth)/enter/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { LoginForm } from "@/components/auth/login-form"
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function LoginPage() {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
|
||||
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
|
||||
<CardTitle className="text-3xl font-bold ">
|
||||
<ColoredText>TaxHacker: Cloud Edition</ColoredText>
|
||||
</CardTitle>
|
||||
<CardContent className="w-full">
|
||||
<LoginForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
17
app/(auth)/layout.tsx
Normal file
17
app/(auth)/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { X } from "lucide-react"
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-900 flex flex-col relative">
|
||||
<a
|
||||
href="/"
|
||||
className="absolute top-4 right-4 flex items-center justify-center w-10 h-10 rounded-full bg-gray-800 hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<span className="text-gray-300 font-bold text-xl">
|
||||
<X />
|
||||
</span>
|
||||
</a>
|
||||
<div className="flex-grow flex flex-col justify-center items-center py-12 px-4 sm:px-6 lg:px-8">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
app/(auth)/self-hosted/page.tsx
Normal file
78
app/(auth)/self-hosted/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { DEFAULT_CURRENCIES, DEFAULT_SETTINGS } from "@/models/defaults"
|
||||
import { getSelfHostedUser } from "@/models/users"
|
||||
import { ShieldAlert } from "lucide-react"
|
||||
import { redirect } from "next/navigation"
|
||||
import { selfHostedGetStartedAction } from "../actions"
|
||||
|
||||
export default async function SelfHostedWelcomePage() {
|
||||
if (!IS_SELF_HOSTED_MODE) {
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-6">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldAlert className="w-6 h-6" />
|
||||
<span>Self-Hosted Mode is not enabled</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center text-lg flex flex-col gap-2">
|
||||
<p>
|
||||
To use TaxHacker in self-hosted mode, please set <code className="font-bold">SELF_HOSTED_MODE=true</code> in
|
||||
your environment.
|
||||
</p>
|
||||
<p>In self-hosted mode you can use your own ChatGPT API key and store your data on your own server.</p>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const user = await getSelfHostedUser()
|
||||
if (user) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
|
||||
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
|
||||
<CardTitle className="text-3xl font-bold ">
|
||||
<ColoredText>TaxHacker: Self-Hosted Edition</ColoredText>
|
||||
</CardTitle>
|
||||
<CardDescription className="flex flex-col gap-4 text-center text-lg">
|
||||
<p>Welcome to your own instance of TaxHacker. Let's set up a couple of settings to get started.</p>
|
||||
|
||||
<form action={selfHostedGetStartedAction} className="flex flex-col gap-8 pt-8">
|
||||
<div>
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" />
|
||||
|
||||
<small className="text-xs text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a
|
||||
href="https://platform.openai.com/settings/organization/api-keys"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
>
|
||||
OpenAI Platform Console
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-center gap-2">
|
||||
<FormSelectCurrency
|
||||
title="Default Currency"
|
||||
name="default_currency"
|
||||
defaultValue={DEFAULT_SETTINGS.find((s) => s.code === "default_currency")?.value ?? "EUR"}
|
||||
currencies={DEFAULT_CURRENCIES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-auto p-6">
|
||||
Get Started
|
||||
</Button>
|
||||
</form>
|
||||
</CardDescription>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
23
app/(auth)/self-hosted/redirect/route.ts
Normal file
23
app/(auth)/self-hosted/redirect/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE, SELF_HOSTED_WELCOME_URL } from "@/lib/constants"
|
||||
import { createUserDefaults, isDatabaseEmpty } from "@/models/defaults"
|
||||
import { getSelfHostedUser } from "@/models/users"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
if (!IS_SELF_HOSTED_MODE) {
|
||||
redirect(AUTH_LOGIN_URL)
|
||||
}
|
||||
|
||||
const user = await getSelfHostedUser()
|
||||
if (!user) {
|
||||
redirect(SELF_HOSTED_WELCOME_URL)
|
||||
}
|
||||
|
||||
if (await isDatabaseEmpty(user.id)) {
|
||||
await createUserDefaults(user.id)
|
||||
}
|
||||
|
||||
revalidatePath("/dashboard")
|
||||
redirect("/dashboard")
|
||||
}
|
||||
25
app/(auth)/signup/page.tsx
Normal file
25
app/(auth)/signup/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Card, CardContent, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function LoginPage() {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-xl mx-auto p-8 flex flex-col items-center justify-center gap-4">
|
||||
<img src="/logo/512.png" alt="Logo" className="w-36 h-36" />
|
||||
<CardTitle className="text-3xl font-bold ">
|
||||
<ColoredText>TaxHacker: Cloud Edition</ColoredText>
|
||||
</CardTitle>
|
||||
<CardContent className="w-full">
|
||||
<div className="text-center text-md text-muted-foreground">
|
||||
Creating new account is disabled for now. Please use the self-hosted version.
|
||||
</div>
|
||||
{/* <SignupForm /> */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Category, Field, File, Project } from "@prisma/client"
|
||||
import OpenAI from "openai"
|
||||
import { buildLLMPrompt } from "./prompt"
|
||||
import { fieldsToJsonSchema } from "./schema"
|
||||
|
||||
const MAX_PAGES_TO_ANALYZE = 4
|
||||
|
||||
type AnalyzeAttachment = {
|
||||
filename: string
|
||||
contentType: string
|
||||
base64: string
|
||||
}
|
||||
|
||||
export const retrieveAllAttachmentsForAI = async (file: File): Promise<AnalyzeAttachment[]> => {
|
||||
const attachments: AnalyzeAttachment[] = []
|
||||
for (let i = 1; i < MAX_PAGES_TO_ANALYZE; i++) {
|
||||
try {
|
||||
const attachment = await retrieveFileContentForAI(file, i)
|
||||
attachments.push(attachment)
|
||||
} catch (error) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return attachments
|
||||
}
|
||||
|
||||
export const retrieveFileContentForAI = async (file: File, page: number): Promise<AnalyzeAttachment> => {
|
||||
const response = await fetch(`/files/preview/${file.id}?page=${page}`)
|
||||
if (!response.ok) throw new Error("Failed to retrieve file")
|
||||
|
||||
const blob = await response.blob()
|
||||
const buffer = await blob.arrayBuffer()
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
|
||||
return {
|
||||
filename: file.filename,
|
||||
contentType: response.headers.get("Content-Type") || file.mimetype,
|
||||
base64: base64,
|
||||
}
|
||||
}
|
||||
|
||||
export async function analyzeTransaction(
|
||||
promptTemplate: string,
|
||||
settings: Record<string, string>,
|
||||
fields: Field[],
|
||||
categories: Category[] = [],
|
||||
projects: Project[] = [],
|
||||
attachments: AnalyzeAttachment[] = []
|
||||
): Promise<{ success: boolean; data?: Record<string, any>; error?: string }> {
|
||||
const openai = new OpenAI({
|
||||
apiKey: settings.openai_api_key,
|
||||
dangerouslyAllowBrowser: true,
|
||||
})
|
||||
|
||||
const prompt = buildLLMPrompt(promptTemplate, fields, categories, projects)
|
||||
const schema = fieldsToJsonSchema(fields)
|
||||
|
||||
console.log("PROMPT:", prompt)
|
||||
console.log("SCHEMA:", schema)
|
||||
|
||||
try {
|
||||
const response = await openai.responses.create({
|
||||
model: "gpt-4o-mini-2024-07-18",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: attachments.map((attachment) => ({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${attachment.contentType};base64,${attachment.base64}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
text: {
|
||||
format: {
|
||||
type: "json_schema",
|
||||
name: "transaction",
|
||||
schema: schema,
|
||||
strict: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
console.log("ChatGPT response:", response.output_text)
|
||||
|
||||
const result = JSON.parse(response.output_text)
|
||||
return { success: true, data: result }
|
||||
} catch (error) {
|
||||
console.error("AI Analysis error:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to analyze invoice",
|
||||
}
|
||||
}
|
||||
}
|
||||
4
app/api/auth/[...all]/route.ts
Normal file
4
app/api/auth/[...all]/route.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { auth } from "@/lib/auth"
|
||||
import { toNextJsHandler } from "better-auth/next-js"
|
||||
|
||||
export const { POST, GET } = toNextJsHandler(auth)
|
||||
36
app/landing/actions.ts
Normal file
36
app/landing/actions.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
"use server"
|
||||
|
||||
import { resend, sendNewsletterWelcomeEmail } from "@/lib/email"
|
||||
|
||||
export async function subscribeToNewsletterAction(email: string) {
|
||||
try {
|
||||
if (!email || !email.includes("@")) {
|
||||
return { success: false, error: "Invalid email address" }
|
||||
}
|
||||
|
||||
const existingContacts = await resend.contacts.list({
|
||||
audienceId: process.env.RESEND_AUDIENCE_ID as string,
|
||||
})
|
||||
|
||||
if (existingContacts.data) {
|
||||
const existingContact = existingContacts.data.data.find((contact: any) => contact.email === email)
|
||||
|
||||
if (existingContact) {
|
||||
return { success: false, error: "You are already subscribed to the newsletter" }
|
||||
}
|
||||
}
|
||||
|
||||
await resend.contacts.create({
|
||||
email,
|
||||
audienceId: process.env.RESEND_AUDIENCE_ID as string,
|
||||
unsubscribed: false,
|
||||
})
|
||||
|
||||
await sendNewsletterWelcomeEmail(email)
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Newsletter subscription error:", error)
|
||||
return { error: "Failed to subscribe. Please try again later." }
|
||||
}
|
||||
}
|
||||
439
app/landing/landing.tsx
Normal file
439
app/landing/landing.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import { NewsletterForm } from "@/app/landing/newsletter"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function LandingPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-[#FAFAFA]">
|
||||
<header className="py-6 px-8 bg-white/80 backdrop-blur-md shadow-sm fixed w-full z-10">
|
||||
<div className="max-w-7xl mx-auto flex justify-between items-center">
|
||||
<a href="/" className="flex items-center gap-2">
|
||||
<img src="/logo/256.png" alt="Logo" className="h-8" />
|
||||
<ColoredText className="text-2xl font-bold">TaxHacker</ColoredText>
|
||||
</a>
|
||||
<div className="flex gap-4">
|
||||
<Link
|
||||
href="#start"
|
||||
className="text-sm font-medium bg-gradient-to-r from-blue-600 to-indigo-600 text-white px-4 py-2 rounded-full hover:opacity-90 transition-all"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="pt-32 pb-16 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<div className="inline-block px-4 py-2 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-6">
|
||||
🚀 Under Active Development
|
||||
</div>
|
||||
<h1 className="text-5xl font-bold tracking-tight sm:text-6xl mb-6 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent pb-2">
|
||||
Organize receipts, track expenses, and prepare your taxes with AI
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-2xl mx-auto">
|
||||
A self-hosted accounting app crafted with love for freelancers and small businesses.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
href="#start"
|
||||
className="px-8 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-medium rounded-full hover:opacity-90 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Get Started
|
||||
</Link>
|
||||
<a
|
||||
href="mailto:me@vas3k.ru"
|
||||
className="px-8 py-3 border border-gray-200 text-gray-700 font-medium rounded-full hover:bg-gray-50 transition-all"
|
||||
>
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-blue-500/5 to-transparent z-10" />
|
||||
<video className="w-full h-auto" autoPlay loop muted playsInline poster="/landing/title.webp">
|
||||
<source src="/landing/video.mp4" type="video/mp4" />
|
||||
<Image src="/landing/title.webp" alt="TaxHacker" width={1980} height={1224} priority />
|
||||
</video>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="flex flex-col gap-3 mb-4 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
<span className="text-6xl font-semibold text-muted-foreground">F∗ck Taxes</span>
|
||||
<span className="text-4xl font-bold">TaxHacker can save you time, money and nerves</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* AI Scanner Feature */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-blue-50 text-blue-600 text-sm font-medium mb-4">
|
||||
LLM-Powered
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">AI Document Analyzer</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Upload photos or PDFs for automatic recognition
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Extract key information like dates, amounts, and vendors
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Works with any language, format and photo quality
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Automatically organize everything into a structured database
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/ai-scanner.webp" alt="AI Document Analyzer" width={1900} height={1524} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-currency Feature */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100 flex-row-reverse">
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-green-50 text-green-600 text-sm font-medium mb-4">
|
||||
Currency Converter
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Multi-Currency Support</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Detects foreign currencies and coverts it to yours
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Historical exchange rate lookup on a date of transaction
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Support for 170+ world currencies
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Even works with cryptocurrencies (BTC, ETH, LTC, etc.)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/multi-currency.webp" alt="Currency Converter" width={1400} height={1005} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transaction Table Feature */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100 flex-row-reverse">
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/transactions.webp" alt="Transactions Table" width={2000} height={1279} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-pink-50 text-pink-600 text-sm font-medium mb-4">
|
||||
Filters
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Income & Expense Tracker</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Add, edit and manage your transactions
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Filter by any column, category or date range
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Customize which columns to show in the table
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-green-600 mr-2">✓</span>
|
||||
Import transactions from CSV
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields & Categories */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/custom-llm.webp" alt="Custom LLM promts" width={1800} height={1081} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-4">
|
||||
Customization
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Custom LLM promts for everything</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Create custom fields and categories with your own LLM prompts
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Extract any additional information you need
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Automatically categorize by project or category
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">✓</span>
|
||||
Ask AI to assess risk level or any other criteria
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Export */}
|
||||
<div className="flex flex-wrap items-center gap-12 mb-20 bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100 flex-row-reverse">
|
||||
<div className="flex-1 relative aspect-auto rounded-2xl overflow-hidden shadow-2xl ring-8 ring-gray-100 hover:scale-105 transition-all duration-300">
|
||||
<Image src="/landing/export.webp" alt="Export" width={1200} height={1081} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-60">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-orange-50 text-orange-600 text-sm font-medium mb-4">
|
||||
Export
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">Your Data — Your Rules</h3>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Flexible filters to export your data for tax prep
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Full-text search across documents
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Export to CSV with attached documents
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-orange-600 mr-2">✓</span>
|
||||
Download full data archive to migrate to another service
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deployment Options */}
|
||||
<section id="start" className="py-20 px-8 bg-white scroll-mt-20">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold mb-4 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
Choose Your Version of TaxHacker
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Self-Hosted Version */}
|
||||
<div className="bg-gradient-to-b from-white to-gray-50 p-8 rounded-2xl shadow-lg ring-1 ring-gray-100">
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-violet-50 text-violet-600 text-sm font-medium mb-4">
|
||||
Use Your Own Server
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">
|
||||
<ColoredText>Self-Hosted Edition</ColoredText>
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-600 mb-8">
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Complete control over your data
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Use at your own infrastructure
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Free and open source
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-blue-600 mr-2">✓</span>
|
||||
Bring your own OpenAI keys
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
href="https://github.com/vas3k/TaxHacker"
|
||||
target="_blank"
|
||||
className="block w-full text-center px-6 py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-medium rounded-full hover:opacity-90 transition-all shadow-lg shadow-blue-500/20"
|
||||
>
|
||||
Github + Docker Compose
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Cloud Version */}
|
||||
<div className="bg-gradient-to-b from-white to-gray-50 p-8 rounded-2xl shadow-lg ring-1 ring-gray-100">
|
||||
<div className="absolute top-4 right-4">
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm">Coming Soon</span>
|
||||
</div>
|
||||
<div className="inline-block px-3 py-1 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-4">
|
||||
We Host It For You
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold mb-4">
|
||||
<ColoredText>Cloud Edition</ColoredText>
|
||||
</h3>
|
||||
<ul className="space-y-3 text-gray-600 mb-8">
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
SaaS version for those who prefer less hassle
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
We provide AI keys and storage
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
Yearly subscription plans
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-gray-400 mr-2">✓</span>
|
||||
Automatic updates and new features
|
||||
</li>
|
||||
</ul>
|
||||
<button
|
||||
disabled
|
||||
className="block w-full text-center px-6 py-3 bg-gray-100 text-gray-400 font-medium rounded-full cursor-not-allowed"
|
||||
>
|
||||
Coming Soon
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Upcoming Features */}
|
||||
<section className="py-20 px-8 bg-gradient-to-b from-white to-gray-50 mt-28">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="text-center mb-16">
|
||||
<div className="inline-block px-4 py-2 rounded-full bg-purple-50 text-purple-600 text-sm font-medium mb-6">
|
||||
🚀 Under Active Development
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold mb-4 bg-gradient-to-r from-gray-900 to-gray-600 bg-clip-text text-transparent">
|
||||
Upcoming Features
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
We're a small, indie project constantly improving. Here's what we're working on next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 mb-16">
|
||||
{/* AI Improvements */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<h3 className="text-xl font-semibold">Better AI Analytics & Agents</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Income & expense insights
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
AI agents to automate your workflows
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Recommendations for tax optimization
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Custom and local LLM models
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Smart Reports */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">📂</span>
|
||||
<h3 className="text-xl font-semibold">Smart Reports & Reminders</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Monthly or quarterly VAT reports
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Tax reminders
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Annual income & expense reports
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Transaction Review */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">📥</span>
|
||||
<h3 className="text-xl font-semibold">Multiple Transaction Review</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Bank statement analysis
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Automatic data completeness checks
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Unpaid invoice tracking
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Custom Fields */}
|
||||
<div className="bg-white p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="text-2xl">🤯</span>
|
||||
<h3 className="text-xl font-semibold">Presets and Plugins</h3>
|
||||
</div>
|
||||
<ul className="space-y-3 text-gray-600">
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Presets for different countries and industries
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Custom reports for various use-cases
|
||||
</li>
|
||||
<li className="flex items-center">
|
||||
<span className="text-purple-600 mr-2">→</span>
|
||||
Community plugins and reports
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Newsletter Signup */}
|
||||
<NewsletterForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="py-8 px-8 bg-white">
|
||||
<div className="max-w-7xl mx-auto text-center text-sm text-gray-500">
|
||||
Made with ❤️ by{" "}
|
||||
<a href="https://github.com/vas3k" className="underline">
|
||||
vas3k
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
app/landing/newsletter.tsx
Normal file
65
app/landing/newsletter.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import { subscribeToNewsletterAction } from "@/app/landing/actions"
|
||||
import { useState } from "react"
|
||||
|
||||
export function NewsletterForm() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus("loading")
|
||||
setMessage("")
|
||||
|
||||
try {
|
||||
const result = await subscribeToNewsletterAction(email)
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
setStatus("success")
|
||||
setMessage("Thanks for subscribing! Check your email for confirmation.")
|
||||
setEmail("")
|
||||
} catch (error) {
|
||||
setStatus("error")
|
||||
setMessage(error instanceof Error ? error.message : "Failed to subscribe. Please try again.")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 p-8 rounded-2xl shadow-sm ring-1 ring-gray-100">
|
||||
<div className="max-w-2xl mx-auto text-center">
|
||||
<h3 className="text-2xl font-semibold mb-4">Stay Tuned</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
We're working hard on making TaxHacker useful for everyone. Subscribe to our emails to get notified about our
|
||||
plans and new features. No marketing, ads or spam.
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4 max-w-md mx-auto">
|
||||
<div className="flex gap-4">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="Enter your email"
|
||||
className="flex-1 px-4 py-3 rounded-full border border-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "loading"}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white font-medium rounded-full hover:opacity-90 transition-all shadow-lg shadow-purple-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === "loading" ? "Subscribing..." : "Subscribe"}
|
||||
</button>
|
||||
</div>
|
||||
{message && (
|
||||
<p className={`text-sm ${status === "success" ? "text-green-600" : "text-red-600"}`}>{message}</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import ScreenDropArea from "@/components/files/screen-drop-area"
|
||||
import MobileMenu from "@/components/sidebar/mobile-menu"
|
||||
import { AppSidebar } from "@/components/sidebar/sidebar"
|
||||
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { getUnsortedFilesCount } from "@/models/files"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import type { Metadata, Viewport } from "next"
|
||||
import { NotificationProvider } from "./context"
|
||||
import "./globals.css"
|
||||
import { APP_DESCRIPTION, APP_TITLE } from "@/lib/constants"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: "%s | TaxHacker",
|
||||
default: "TaxHacker",
|
||||
default: APP_TITLE,
|
||||
},
|
||||
description: "Your personal AI accountant",
|
||||
description: APP_DESCRIPTION,
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
shortcut: "/favicon.ico",
|
||||
@@ -24,33 +17,16 @@ export const metadata: Metadata = {
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
themeColor: "#ffffff",
|
||||
}
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const unsortedFilesCount = await getUnsortedFilesCount()
|
||||
const settings = await getSettings()
|
||||
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
</head>
|
||||
<body>
|
||||
<NotificationProvider>
|
||||
<ScreenDropArea>
|
||||
<SidebarProvider>
|
||||
<MobileMenu settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
||||
<AppSidebar settings={settings} unsortedFilesCount={unsortedFilesCount} />
|
||||
<SidebarInset className="w-full h-full mt-[60px] md:mt-0 overflow-auto">{children}</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<Toaster />
|
||||
</ScreenDropArea>
|
||||
</NotificationProvider>
|
||||
</body>
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
40
app/page.tsx
40
app/page.tsx
@@ -1,30 +1,16 @@
|
||||
import DashboardDropZoneWidget from "@/components/dashboard/drop-zone-widget"
|
||||
import { StatsWidget } from "@/components/dashboard/stats-widget"
|
||||
import DashboardUnsortedWidget from "@/components/dashboard/unsorted-widget"
|
||||
import { WelcomeWidget } from "@/components/dashboard/welcome-widget"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { getUnsortedFiles } from "@/models/files"
|
||||
import { getSettings } from "@/models/settings"
|
||||
import { TransactionFilters } from "@/models/transactions"
|
||||
import LandingPage from "@/app/landing/landing"
|
||||
import { getSession } from "@/lib/auth"
|
||||
import { IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default async function Home({ searchParams }: { searchParams: Promise<TransactionFilters> }) {
|
||||
const filters = await searchParams
|
||||
const unsortedFiles = await getUnsortedFiles()
|
||||
const settings = await getSettings()
|
||||
export default async function Home() {
|
||||
const session = await getSession()
|
||||
if (!session) {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
}
|
||||
return <LandingPage />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 p-5 w-full max-w-7xl self-center">
|
||||
<div className="flex flex-col sm:flex-row gap-5 items-stretch h-full">
|
||||
<DashboardDropZoneWidget />
|
||||
|
||||
<DashboardUnsortedWidget files={unsortedFiles} />
|
||||
</div>
|
||||
|
||||
{settings.is_welcome_message_hidden !== "true" && <WelcomeWidget />}
|
||||
|
||||
<Separator />
|
||||
|
||||
<StatsWidget filters={filters} />
|
||||
</div>
|
||||
)
|
||||
redirect("/dashboard")
|
||||
}
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"use server"
|
||||
|
||||
import { prisma } from "@/lib/db"
|
||||
import { FILE_UPLOAD_PATH } from "@/lib/files"
|
||||
import { MODEL_BACKUP } from "@/models/backups"
|
||||
import fs from "fs"
|
||||
import { mkdir } from "fs/promises"
|
||||
import JSZip from "jszip"
|
||||
import path from "path"
|
||||
|
||||
const SUPPORTED_BACKUP_VERSIONS = ["1.0"]
|
||||
|
||||
export async function restoreBackupAction(prevState: any, formData: FormData) {
|
||||
const file = formData.get("file") as File
|
||||
const removeExistingData = formData.get("removeExistingData") === "true"
|
||||
|
||||
if (!file) {
|
||||
return { success: false, error: "No file provided" }
|
||||
}
|
||||
|
||||
// Restore tables
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer()
|
||||
const fileData = Buffer.from(fileBuffer)
|
||||
const zip = await JSZip.loadAsync(fileData)
|
||||
|
||||
// Check backup version
|
||||
const metadataFile = zip.file("data/metadata.json")
|
||||
if (metadataFile) {
|
||||
const metadataContent = await metadataFile.async("string")
|
||||
try {
|
||||
const metadata = JSON.parse(metadataContent)
|
||||
if (!metadata.version || !SUPPORTED_BACKUP_VERSIONS.includes(metadata.version)) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Incompatible backup version: ${
|
||||
metadata.version || "unknown"
|
||||
}. Supported versions: ${SUPPORTED_BACKUP_VERSIONS.join(", ")}`,
|
||||
}
|
||||
}
|
||||
console.log(`Restoring backup version ${metadata.version} created at ${metadata.timestamp}`)
|
||||
} catch (error) {
|
||||
console.warn("Could not parse backup metadata:", error)
|
||||
}
|
||||
} else {
|
||||
console.warn("No metadata found in backup, assuming legacy format")
|
||||
}
|
||||
|
||||
if (removeExistingData) {
|
||||
await clearAllTables()
|
||||
}
|
||||
|
||||
for (const { filename, model, idField } of MODEL_BACKUP) {
|
||||
try {
|
||||
const jsonFile = zip.file(`data/${filename}`)
|
||||
if (jsonFile) {
|
||||
const jsonContent = await jsonFile.async("string")
|
||||
const restoredCount = await restoreModelFromJSON(model, jsonContent, idField)
|
||||
console.log(`Restored ${restoredCount} records from ${filename}`)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error restoring model from ${filename}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Restore files
|
||||
try {
|
||||
const filesToRestore = Object.keys(zip.files).filter(
|
||||
(filename) => filename.startsWith("data/uploads/") && !filename.endsWith("/")
|
||||
)
|
||||
|
||||
if (filesToRestore.length > 0) {
|
||||
await mkdir(FILE_UPLOAD_PATH, { recursive: true })
|
||||
|
||||
// Extract and save each file
|
||||
let restoredFilesCount = 0
|
||||
for (const zipFilePath of filesToRestore) {
|
||||
const file = zip.file(zipFilePath)
|
||||
if (file) {
|
||||
const relativeFilePath = zipFilePath.replace("data/uploads/", "")
|
||||
const fileContent = await file.async("nodebuffer")
|
||||
|
||||
const filePath = path.join(FILE_UPLOAD_PATH, relativeFilePath)
|
||||
const fileName = path.basename(filePath)
|
||||
const fileId = path.basename(fileName, path.extname(fileName))
|
||||
const fileDir = path.dirname(filePath)
|
||||
await mkdir(fileDir, { recursive: true })
|
||||
|
||||
// Write the file
|
||||
fs.writeFileSync(filePath, fileContent)
|
||||
restoredFilesCount++
|
||||
|
||||
// Update the file record
|
||||
await prisma.file.upsert({
|
||||
where: { id: fileId },
|
||||
update: {
|
||||
path: filePath,
|
||||
},
|
||||
create: {
|
||||
id: relativeFilePath,
|
||||
path: filePath,
|
||||
filename: fileName,
|
||||
mimetype: "application/octet-stream",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error restoring uploaded files:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error restoring uploaded files: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, message: `Restore completed successfully` }
|
||||
} catch (error) {
|
||||
console.error("Error restoring from backup:", error)
|
||||
return {
|
||||
success: false,
|
||||
error: `Error restoring from backup: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAllTables() {
|
||||
// Delete in reverse order to handle foreign key constraints
|
||||
for (const { model } of [...MODEL_BACKUP].reverse()) {
|
||||
try {
|
||||
await model.deleteMany({})
|
||||
} catch (error) {
|
||||
console.error(`Error clearing table:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreModelFromJSON(model: any, jsonContent: string, idField: string): Promise<number> {
|
||||
if (!jsonContent) return 0
|
||||
|
||||
try {
|
||||
const records = JSON.parse(jsonContent)
|
||||
|
||||
if (!records || records.length === 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let insertedCount = 0
|
||||
for (const rawRecord of records) {
|
||||
const record = processRowData(rawRecord)
|
||||
|
||||
try {
|
||||
// Skip records that don't have the required ID field
|
||||
if (record[idField] === undefined) {
|
||||
console.warn(`Skipping record missing required ID field '${idField}'`)
|
||||
continue
|
||||
}
|
||||
|
||||
await model.upsert({
|
||||
where: { [idField]: record[idField] },
|
||||
update: record,
|
||||
create: record,
|
||||
})
|
||||
insertedCount++
|
||||
} catch (error) {
|
||||
console.error(`Error upserting record:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return insertedCount
|
||||
} catch (error) {
|
||||
console.error(`Error parsing JSON content:`, error)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function processRowData(row: Record<string, any>): Record<string, any> {
|
||||
const processedRow: Record<string, any> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(row)) {
|
||||
if (value === "" || value === "null" || value === undefined) {
|
||||
processedRow[key] = null
|
||||
continue
|
||||
}
|
||||
|
||||
// Try to parse JSON for object fields
|
||||
if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) {
|
||||
try {
|
||||
processedRow[key] = JSON.parse(value)
|
||||
continue
|
||||
} catch (e) {
|
||||
// Not valid JSON, continue with normal processing
|
||||
}
|
||||
}
|
||||
|
||||
// Handle dates (checking for ISO date format)
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?$/.test(value)) {
|
||||
processedRow[key] = new Date(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle numbers
|
||||
if (typeof value === "string" && !isNaN(Number(value)) && key !== "id" && !key.endsWith("Code")) {
|
||||
// Convert numbers but preserving string IDs
|
||||
processedRow[key] = Number(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// Default: keep as is
|
||||
processedRow[key] = value
|
||||
}
|
||||
|
||||
return processedRow
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
"use server"
|
||||
|
||||
import { transactionFormSchema } from "@/forms/transactions"
|
||||
import { getTransactionFileUploadPath } from "@/lib/files"
|
||||
import { deleteFile, getFileById, updateFile } from "@/models/files"
|
||||
import { createTransaction, updateTransactionFiles } from "@/models/transactions"
|
||||
import { mkdir, rename } from "fs/promises"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import path from "path"
|
||||
|
||||
export async function saveFileAsTransactionAction(prevState: any, formData: FormData) {
|
||||
try {
|
||||
const validatedForm = transactionFormSchema.safeParse(Object.fromEntries(formData.entries()))
|
||||
|
||||
if (!validatedForm.success) {
|
||||
return { success: false, error: validatedForm.error.message }
|
||||
}
|
||||
|
||||
// Get the file record
|
||||
const fileId = formData.get("fileId") as string
|
||||
const file = await getFileById(fileId)
|
||||
if (!file) throw new Error("File not found")
|
||||
|
||||
// Create transaction
|
||||
const transaction = await createTransaction(validatedForm.data)
|
||||
|
||||
// Move file to processed location
|
||||
const originalFileName = path.basename(file.path)
|
||||
const { fileUuid, filePath: newFilePath } = await getTransactionFileUploadPath(originalFileName, transaction)
|
||||
|
||||
// Move file to new location and name
|
||||
await mkdir(path.dirname(newFilePath), { recursive: true })
|
||||
await rename(path.resolve(file.path), path.resolve(newFilePath))
|
||||
|
||||
// Update file record
|
||||
await updateFile(file.id, {
|
||||
id: fileUuid,
|
||||
path: newFilePath,
|
||||
isReviewed: true,
|
||||
})
|
||||
|
||||
await updateTransactionFiles(transaction.id, [fileUuid])
|
||||
|
||||
revalidatePath("/unsorted")
|
||||
revalidatePath("/transactions")
|
||||
|
||||
return { success: true, transactionId: transaction.id }
|
||||
} catch (error) {
|
||||
console.error("Failed to save transaction:", error)
|
||||
return { success: false, error: `Failed to save transaction: ${error}` }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteUnsortedFileAction(prevState: any, fileId: string) {
|
||||
try {
|
||||
await deleteFile(fileId)
|
||||
revalidatePath("/unsorted")
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
console.error("Failed to delete file:", error)
|
||||
return { success: false, error: "Failed to delete file" }
|
||||
}
|
||||
}
|
||||
93
components/auth/login-form.tsx
Normal file
93
components/auth/login-form.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
"use client"
|
||||
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
export function LoginForm() {
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [isOtpSent, setIsOtpSent] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
if (result.error) {
|
||||
setError(result.error.message || "Failed to send the code")
|
||||
return
|
||||
}
|
||||
setIsOtpSent(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send the code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
if (result.error) {
|
||||
setError("The code is invalid or expired")
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to verify the code")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={isOtpSent ? handleVerifyOtp : handleSendOtp} className="flex flex-col gap-4 w-full">
|
||||
<FormInput
|
||||
title="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isOtpSent}
|
||||
/>
|
||||
|
||||
{isOtpSent && (
|
||||
<FormInput
|
||||
title="Check your email for the verification code"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : isOtpSent ? "Verify Code" : "Enter"}
|
||||
</Button>
|
||||
|
||||
{error && <FormError className="text-center">{error}</FormError>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
96
components/auth/signup-form.tsx
Normal file
96
components/auth/signup-form.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function SignupForm() {
|
||||
const [name, setName] = useState("")
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [isOtpSent, setIsOtpSent] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleSendOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await authClient.emailOtp.sendVerificationOtp({
|
||||
email,
|
||||
type: "sign-in",
|
||||
})
|
||||
setIsOtpSent(true)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to send OTP")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await authClient.signIn.emailOtp({
|
||||
email,
|
||||
otp,
|
||||
})
|
||||
await authClient.updateUser({
|
||||
name,
|
||||
})
|
||||
router.push("/dashboard")
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to verify OTP")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={isOtpSent ? handleVerifyOtp : handleSendOtp} className="flex flex-col gap-4 w-full">
|
||||
<FormInput
|
||||
title="Your Name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
disabled={isOtpSent}
|
||||
/>
|
||||
|
||||
<FormInput
|
||||
title="Email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isOtpSent}
|
||||
/>
|
||||
|
||||
{isOtpSent && (
|
||||
<FormInput
|
||||
title="Check your email for the verification code"
|
||||
type="text"
|
||||
value={otp}
|
||||
onChange={(e) => setOtp(e.target.value)}
|
||||
required
|
||||
maxLength={6}
|
||||
pattern="[0-9]{6}"
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-500 text-sm">{error}</p>}
|
||||
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "Loading..." : isOtpSent ? "Verify Code" : "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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]))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardTitle } from "@/components/ui/card"
|
||||
import { ColoredText } from "@/components/ui/colored-text"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getSettings, updateSettings } from "@/models/settings"
|
||||
import { Banknote, ChartBarStacked, FolderOpenDot, Key, TextCursorInput, X } from "lucide-react"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import Link from "next/link"
|
||||
|
||||
export async function WelcomeWidget() {
|
||||
const settings = await getSettings()
|
||||
const user = await getCurrentUser()
|
||||
const settings = await getSettings(user.id)
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col md:flex-row items-start gap-10 p-10 w-full">
|
||||
<Card className="flex flex-col lg:flex-row items-start gap-10 p-10 w-full">
|
||||
<img src="/logo/1024.png" alt="Logo" className="w-64 h-64" />
|
||||
<div className="flex flex-col">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="text-2xl font-bold">Hey, I'm TaxHacker 👋</span>
|
||||
<span className="text-2xl font-bold">
|
||||
<ColoredText>Hey, I'm TaxHacker 👋</ColoredText>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={async () => {
|
||||
"use server"
|
||||
await updateSettings("is_welcome_message_hidden", "true")
|
||||
await updateSettings(user.id, "is_welcome_message_hidden", "true")
|
||||
revalidatePath("/")
|
||||
}}
|
||||
>
|
||||
|
||||
57
components/emails/email-layout.tsx
Normal file
57
components/emails/email-layout.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from "react"
|
||||
|
||||
interface EmailLayoutProps {
|
||||
children: React.ReactNode
|
||||
preview?: string
|
||||
}
|
||||
|
||||
export const EmailLayout: React.FC<EmailLayoutProps> = ({ children, preview = "" }) => (
|
||||
<html>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta name="supported-color-schemes" content="light" />
|
||||
{preview && <title>{preview}</title>}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.header {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div className="container">{children}</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
31
components/emails/newsletter-welcome-email.tsx
Normal file
31
components/emails/newsletter-welcome-email.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from "react"
|
||||
import { EmailLayout } from "./email-layout"
|
||||
|
||||
export const NewsletterWelcomeEmail: React.FC = () => (
|
||||
<EmailLayout preview="Welcome to TaxHacker Newsletter!">
|
||||
<h2 style={{ color: "#4f46e5" }}>👋 Welcome to TaxHacker!</h2>
|
||||
|
||||
<p style={{ fontSize: "16px", lineHeight: "1.5", color: "#333" }}>
|
||||
Thank you for subscribing to our updates. We'll keep you updated about:
|
||||
</p>
|
||||
<ul
|
||||
style={{
|
||||
paddingLeft: "20px",
|
||||
fontSize: "16px",
|
||||
lineHeight: "1.5",
|
||||
color: "#333",
|
||||
}}
|
||||
>
|
||||
<li>New features and improvements</li>
|
||||
<li>Our plans and timelines</li>
|
||||
<li>Updates about our SaaS version</li>
|
||||
</ul>
|
||||
<div style={{ marginTop: "30px", borderTop: "1px solid #eee", paddingTop: "20px" }}>
|
||||
<p style={{ fontSize: "16px", color: "#333" }}>
|
||||
Best regards,
|
||||
<br />
|
||||
The TaxHacker Team
|
||||
</p>
|
||||
</div>
|
||||
</EmailLayout>
|
||||
)
|
||||
38
components/emails/otp-email.tsx
Normal file
38
components/emails/otp-email.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React from "react"
|
||||
import { EmailLayout } from "./email-layout"
|
||||
|
||||
interface OTPEmailProps {
|
||||
otp: string
|
||||
}
|
||||
|
||||
export const OTPEmail: React.FC<OTPEmailProps> = ({ otp }) => (
|
||||
<EmailLayout preview="Your TaxHacker verification code">
|
||||
<h2 style={{ textAlign: "center", color: "#4f46e5" }}>🔑 Your TaxHacker verification code</h2>
|
||||
<div
|
||||
style={{
|
||||
margin: "20px 0",
|
||||
padding: "20px",
|
||||
backgroundColor: "#f3f4f6",
|
||||
borderRadius: "6px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: "16px", marginBottom: "10px" }}>Your verification code is:</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "24px",
|
||||
fontWeight: "bold",
|
||||
color: "#4f46e5",
|
||||
letterSpacing: "2px",
|
||||
margin: "0",
|
||||
}}
|
||||
>
|
||||
{otp}
|
||||
</p>
|
||||
</div>
|
||||
<p style={{ fontSize: "14px", color: "#666", textAlign: "center" }}>This code will expire in 10 minutes.</p>
|
||||
<p style={{ fontSize: "14px", color: "#666", textAlign: "center" }}>
|
||||
If you didn't request this code, please ignore this email.
|
||||
</p>
|
||||
</EmailLayout>
|
||||
)
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { uploadFilesAction } from "@/app/files/actions"
|
||||
import { uploadTransactionFilesAction } from "@/app/transactions/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { uploadFilesAction } from "@/app/(app)/files/actions"
|
||||
import { uploadTransactionFilesAction } from "@/app/(app)/transactions/actions"
|
||||
import { AlertCircle, CloudUpload, Loader2 } from "lucide-react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import { startTransition, useEffect, useRef, useState } from "react"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { uploadFilesAction } from "@/app/files/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { uploadFilesAction } from "@/app/(app)/files/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { ComponentProps, startTransition, useRef, useState } from "react"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export function FormError({ children }: { children: React.ReactNode }) {
|
||||
return <p className="text-red-500 mt-4 overflow-hidden">{children}</p>
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function FormError({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <p className={cn("text-red-500 mt-4 overflow-hidden", className)}>{children}</p>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Currency } from "@prisma/client"
|
||||
import { SelectProps } from "@radix-ui/react-select"
|
||||
import { useMemo } from "react"
|
||||
import { FormSelect } from "./simple"
|
||||
@@ -12,7 +11,7 @@ export const FormSelectCurrency = ({
|
||||
...props
|
||||
}: {
|
||||
title: string
|
||||
currencies: Currency[]
|
||||
currencies: { code: string; name: string }[]
|
||||
emptyValue?: string
|
||||
placeholder?: string
|
||||
hideIfEmpty?: boolean
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { parseCSVAction, saveTransactionsAction } from "@/app/import/csv/actions"
|
||||
import { parseCSVAction, saveTransactionsAction } from "@/app/(app)/import/csv/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field } from "@prisma/client"
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { saveSettingsAction } from "@/app/settings/actions"
|
||||
import { saveSettingsAction } from "@/app/(app)/settings/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
import { FormSelectType } from "@/components/forms/select-type"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Category, Currency } from "@prisma/client"
|
||||
import { CircleCheckBig } from "lucide-react"
|
||||
@@ -24,8 +23,6 @@ export default function GlobalSettingsForm({
|
||||
|
||||
return (
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="App Title" name="app_title" defaultValue={settings.app_title} />
|
||||
|
||||
<FormSelectCurrency
|
||||
title="Default Currency"
|
||||
name="default_currency"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { fieldsToJsonSchema } from "@/app/ai/schema"
|
||||
import { saveSettingsAction } from "@/app/settings/actions"
|
||||
import { fieldsToJsonSchema } from "@/ai/schema"
|
||||
import { saveSettingsAction } from "@/app/(app)/settings/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormInput, FormTextarea } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardTitle } from "@/components/ui/card"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { Field } from "@prisma/client"
|
||||
import { CircleCheckBig, Edit } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
@@ -17,14 +18,18 @@ export default function LLMSettingsForm({ settings, fields }: { settings: Record
|
||||
return (
|
||||
<>
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
||||
{IS_SELF_HOSTED_MODE && (
|
||||
<FormInput title="OpenAI API Key" name="openai_api_key" defaultValue={settings.openai_api_key} />
|
||||
)}
|
||||
|
||||
<small className="text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
||||
OpenAI Platform Console
|
||||
</a>
|
||||
</small>
|
||||
{IS_SELF_HOSTED_MODE && (
|
||||
<small className="text-muted-foreground">
|
||||
Get your API key from{" "}
|
||||
<a href="https://platform.openai.com/settings/organization/api-keys" target="_blank" className="underline">
|
||||
OpenAI Platform Console
|
||||
</a>
|
||||
</small>
|
||||
)}
|
||||
|
||||
<FormTextarea
|
||||
title="Prompt for File Analysis Form"
|
||||
|
||||
33
components/settings/profile-settings-form copy.tsx
Normal file
33
components/settings/profile-settings-form copy.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import { saveProfileAction } from "@/app/(app)/settings/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormInput } from "@/components/forms/simple"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { User } from "@prisma/client"
|
||||
import { CircleCheckBig } from "lucide-react"
|
||||
import { useActionState } from "react"
|
||||
|
||||
export default function ProfileSettingsForm({ user }: { user: User }) {
|
||||
const [saveState, saveAction, pending] = useActionState(saveProfileAction, null)
|
||||
|
||||
return (
|
||||
<form action={saveAction} className="space-y-4">
|
||||
<FormInput title="Your Name" name="name" defaultValue={user.name || ""} />
|
||||
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
{saveState?.success && (
|
||||
<p className="text-green-500 flex flex-row items-center gap-2">
|
||||
<CircleCheckBig />
|
||||
Saved!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{saveState?.error && <FormError>{saveState.error}</FormError>}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -2,15 +2,10 @@
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { useSidebar } from "@/components/ui/sidebar"
|
||||
import { APP_TITLE } from "@/lib/constants"
|
||||
import Link from "next/link"
|
||||
|
||||
export default function MobileMenu({
|
||||
settings,
|
||||
unsortedFilesCount,
|
||||
}: {
|
||||
settings: Record<string, string>
|
||||
unsortedFilesCount: number
|
||||
}) {
|
||||
export default function MobileMenu({ unsortedFilesCount }: { unsortedFilesCount: number }) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
return (
|
||||
@@ -20,7 +15,7 @@ export default function MobileMenu({
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<Link href="/" className="text-lg font-bold">
|
||||
{settings.app_title}
|
||||
{APP_TITLE}
|
||||
</Link>
|
||||
<Link
|
||||
href="/unsorted"
|
||||
|
||||
76
components/sidebar/sidebar-user.tsx
Normal file
76
components/sidebar/sidebar-user.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||
import { UserProfile } from "@/lib/auth"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
import { IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { LogOut, MoreVertical, User } from "lucide-react"
|
||||
import Link from "next/link"
|
||||
import { redirect } from "next/navigation"
|
||||
|
||||
export default function SidebarUser({ profile }: { profile: UserProfile }) {
|
||||
const signOut = async () => {
|
||||
await authClient.signOut({})
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="default"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-6 w-6 rounded-full bg-sidebar-accent">
|
||||
<AvatarImage src={profile.avatar} alt={profile.name || ""} />
|
||||
<AvatarFallback className="rounded-full bg-sidebar-accent text-sidebar-accent-foreground">
|
||||
<User className="h-4 w-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="truncate font-medium">{profile.name || profile.email}</span>
|
||||
<MoreVertical className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={"top"}
|
||||
align="center"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
{/* <DropdownMenuItem>
|
||||
<ThemeToggle />
|
||||
</DropdownMenuItem> */}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/profile" className="flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
Profile Settings
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{/* <DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing" className="flex items-center gap-2">
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Your Subscription
|
||||
</Link>
|
||||
</DropdownMenuItem> */}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
{!IS_SELF_HOSTED_MODE && (
|
||||
<DropdownMenuItem asChild>
|
||||
<span onClick={signOut} className="flex items-center gap-2 text-red-600 cursor-pointer">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Log out
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useNotification } from "@/app/context"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { UploadButton } from "@/components/files/upload-button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -17,20 +16,19 @@ import {
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import { UserProfile } from "@/lib/auth"
|
||||
import { APP_TITLE, IS_SELF_HOSTED_MODE } from "@/lib/constants"
|
||||
import { ClockArrowUp, FileText, Import, LayoutDashboard, Settings, Sparkles, Upload } from "lucide-react"
|
||||
import Image from "next/image"
|
||||
import Link from "next/link"
|
||||
import { usePathname } from "next/navigation"
|
||||
import { useEffect } from "react"
|
||||
import { ColoredText } from "../ui/colored-text"
|
||||
import { Blinker } from "./blinker"
|
||||
import { SidebarMenuItemWithHighlight } from "./sidebar-item"
|
||||
import SidebarUser from "./sidebar-user"
|
||||
|
||||
export function AppSidebar({
|
||||
settings,
|
||||
unsortedFilesCount,
|
||||
}: {
|
||||
settings: Record<string, string>
|
||||
unsortedFilesCount: number
|
||||
}) {
|
||||
export function AppSidebar({ unsortedFilesCount, profile }: { unsortedFilesCount: number; profile: UserProfile }) {
|
||||
const { open, setOpenMobile } = useSidebar()
|
||||
const pathname = usePathname()
|
||||
const { notification } = useNotification()
|
||||
@@ -44,25 +42,14 @@ export function AppSidebar({
|
||||
<>
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarHeader>
|
||||
{open ? (
|
||||
<Link href="/" className="flex items-center gap-2 p-2">
|
||||
<Avatar className="h-12 w-12 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate font-semibold">{settings.app_title}</span>
|
||||
<span className="truncate text-xs">Beta</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/">
|
||||
<Avatar className="h-10 w-10 rounded-lg">
|
||||
<AvatarImage src="/logo/256.png" />
|
||||
<AvatarFallback className="rounded-lg">AI</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
)}
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<Image src="/logo/256.png" alt="Logo" className="h-10 w-10 rounded-lg" width={40} height={40} />
|
||||
<div className="grid flex-1 text-left leading-tight">
|
||||
<span className="truncate font-semibold text-lg">
|
||||
<ColoredText>{APP_TITLE}</ColoredText>
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
@@ -74,9 +61,9 @@ export function AppSidebar({
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItemWithHighlight href="/">
|
||||
<SidebarMenuItemWithHighlight href="/dashboard">
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="/">
|
||||
<Link href="/dashboard">
|
||||
<LayoutDashboard />
|
||||
<span>Home</span>
|
||||
</Link>
|
||||
@@ -137,14 +124,16 @@ export function AppSidebar({
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||
<Sparkles />
|
||||
Thank the author
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{IS_SELF_HOSTED_MODE && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link href="https://vas3k.com/donate/" target="_blank">
|
||||
<Sparkles />
|
||||
Thank the author
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
{!open && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarTrigger />
|
||||
@@ -153,6 +142,15 @@ export function AppSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarUser profile={profile} />
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
</>
|
||||
|
||||
43
components/sidebar/theme-toggle.tsx
Normal file
43
components/sidebar/theme-toggle.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { useEffect, useState } from "react"
|
||||
|
||||
export const ThemeToggle = () => {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
// Ensure component is mounted to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (theme === "dark") {
|
||||
setTheme("light")
|
||||
} else {
|
||||
setTheme("dark")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={toggleTheme} className="flex items-center gap-2 cursor-pointer">
|
||||
{theme === "dark" ? (
|
||||
<>
|
||||
<Sun className="h-4 w-4" />
|
||||
Light Mode
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Moon className="h-4 w-4" />
|
||||
Dark Mode
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { bulkDeleteTransactionsAction } from "@/app/transactions/actions"
|
||||
import { bulkDeleteTransactionsAction } from "@/app/(app)/transactions/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { ChevronUp, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { createTransactionAction } from "@/app/transactions/actions"
|
||||
import { createTransactionAction } from "@/app/(app)/transactions/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTransactionAction, saveTransactionAction } from "@/app/transactions/actions"
|
||||
import { deleteTransactionAction, saveTransactionAction } from "@/app/(app)/transactions/actions"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
import { FormSelectCurrency } from "@/components/forms/select-currency"
|
||||
@@ -47,10 +47,13 @@ export default function TransactionEditForm({
|
||||
projectCode: transaction.projectCode || settings.default_project,
|
||||
issuedAt: transaction.issuedAt ? format(transaction.issuedAt, "yyyy-MM-dd") : "",
|
||||
note: transaction.note || "",
|
||||
...extraFields.reduce((acc, field) => {
|
||||
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
|
||||
return acc
|
||||
}, {} as Record<string, any>),
|
||||
...extraFields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.code] = transaction.extra?.[field.code as keyof typeof transaction.extra] || ""
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>
|
||||
),
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { updateFieldVisibilityAction } from "@/app/transactions/actions"
|
||||
import { updateFieldVisibilityAction } from "@/app/(app)/transactions/actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { getCurrentUser } from "@/lib/auth"
|
||||
import { getCategories } from "@/models/categories"
|
||||
import { getCurrencies } from "@/models/currencies"
|
||||
import { getProjects } from "@/models/projects"
|
||||
@@ -13,10 +14,11 @@ import { getSettings } from "@/models/settings"
|
||||
import TransactionCreateForm from "./create"
|
||||
|
||||
export async function NewTransactionDialog({ children }: { children: React.ReactNode }) {
|
||||
const categories = await getCategories()
|
||||
const currencies = await getCurrencies()
|
||||
const settings = await getSettings()
|
||||
const projects = await getProjects()
|
||||
const user = await getCurrentUser()
|
||||
const categories = await getCategories(user.id)
|
||||
const currencies = await getCurrencies(user.id)
|
||||
const settings = await getSettings(user.id)
|
||||
const projects = await getProjects(user.id)
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client"
|
||||
|
||||
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/transactions/actions"
|
||||
import { deleteTransactionFileAction, uploadTransactionFilesAction } from "@/app/(app)/transactions/actions"
|
||||
import { FilePreview } from "@/components/files/preview"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/files"
|
||||
import { FILE_ACCEPTED_MIMETYPES } from "@/lib/constants"
|
||||
import { File, Transaction } from "@prisma/client"
|
||||
import { Loader2, Upload, X } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
12
components/ui/colored-text.tsx
Normal file
12
components/ui/colored-text.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export function ColoredText({
|
||||
children,
|
||||
className,
|
||||
}: { children: React.ReactNode } & React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span className={cn("bg-gradient-to-r from-pink-600 to-indigo-600 bg-clip-text text-transparent", className)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -37,8 +37,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
@@ -53,8 +52,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
@@ -114,8 +112,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
@@ -147,11 +144,7 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
@@ -161,41 +154,29 @@ const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
<DropdownMenuPrimitive.Separator ref={ref} className={cn("-mx-1 my-1 h-px bg-muted", className)} {...props} />
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return <span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuTrigger,
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { analyzeTransaction, retrieveAllAttachmentsForAI } from "@/app/ai/analyze"
|
||||
import { useNotification } from "@/app/context"
|
||||
import { deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/unsorted/actions"
|
||||
import { useNotification } from "@/app/(app)/context"
|
||||
import { analyzeFileAction, deleteUnsortedFileAction, saveFileAsTransactionAction } from "@/app/(app)/unsorted/actions"
|
||||
import { FormConvertCurrency } from "@/components/forms/convert-currency"
|
||||
import { FormError } from "@/components/forms/error"
|
||||
import { FormSelectCategory } from "@/components/forms/select-category"
|
||||
@@ -40,10 +39,13 @@ export default function AnalyzeForm({
|
||||
|
||||
const fieldsMap = useMemo(
|
||||
() =>
|
||||
fields.reduce((acc, field) => {
|
||||
acc[field.code] = field
|
||||
return acc
|
||||
}, {} as Record<string, Field>),
|
||||
fields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.code] = field
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, Field>
|
||||
),
|
||||
[fields]
|
||||
)
|
||||
const extraFields = useMemo(() => fields.filter((field) => field.isExtra), [fields])
|
||||
@@ -62,10 +64,13 @@ export default function AnalyzeForm({
|
||||
issuedAt: "",
|
||||
note: "",
|
||||
text: "",
|
||||
...extraFields.reduce((acc, field) => {
|
||||
acc[field.code] = ""
|
||||
return acc
|
||||
}, {} as Record<string, string>),
|
||||
...extraFields.reduce(
|
||||
(acc, field) => {
|
||||
acc[field.code] = ""
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}),
|
||||
[file.filename, settings, extraFields]
|
||||
)
|
||||
@@ -89,20 +94,10 @@ export default function AnalyzeForm({
|
||||
|
||||
const startAnalyze = async () => {
|
||||
setIsAnalyzing(true)
|
||||
setAnalyzeStep("Retrieving files...")
|
||||
setAnalyzeError("")
|
||||
try {
|
||||
const attachments = await retrieveAllAttachmentsForAI(file)
|
||||
|
||||
setAnalyzeStep("Analyzing...")
|
||||
const results = await analyzeTransaction(
|
||||
settings.prompt_analyse_new_file || process.env.PROMPT_ANALYSE_NEW_FILE || "",
|
||||
settings,
|
||||
fields,
|
||||
categories,
|
||||
projects,
|
||||
attachments
|
||||
)
|
||||
const results = await analyzeFileAction(file, settings, fields, categories, projects)
|
||||
|
||||
console.log("Analysis results:", results)
|
||||
|
||||
@@ -114,7 +109,6 @@ export default function AnalyzeForm({
|
||||
([_, value]) => value !== null && value !== undefined && value !== ""
|
||||
)
|
||||
)
|
||||
console.log("Setting form data:", nonEmptyFields)
|
||||
setFormData({ ...formData, ...nonEmptyFields })
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,8 +7,9 @@ services:
|
||||
- "7331:7331"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- SELF_HOSTED_MODE=true
|
||||
- UPLOAD_PATH=/app/data/uploads
|
||||
- DATABASE_URL=file:/app/data/db.sqlite
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/taxhacker
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
@@ -17,3 +18,20 @@ services:
|
||||
options:
|
||||
max-size: "100M"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=taxhacker
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
logging:
|
||||
driver: "local"
|
||||
options:
|
||||
max-size: "100M"
|
||||
max-file: "3"
|
||||
|
||||
@@ -5,11 +5,31 @@ services:
|
||||
- "7331:7331"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- SELF_HOSTED_MODE=true
|
||||
- UPLOAD_PATH=/app/data/uploads
|
||||
- DATABASE_URL=file:/app/data/db.sqlite
|
||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/taxhacker
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- postgres
|
||||
logging:
|
||||
driver: "local"
|
||||
options:
|
||||
max-size: "100M"
|
||||
max-file: "3"
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=taxhacker
|
||||
volumes:
|
||||
- ./pgdata:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5432:5432"
|
||||
logging:
|
||||
driver: "local"
|
||||
options:
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Wait for database to be ready
|
||||
echo "Waiting for PostgreSQL to be ready..."
|
||||
until pg_isready -h postgres -p 5432 -U postgres; do
|
||||
echo "PostgreSQL is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL is ready!"
|
||||
|
||||
# Run database migrations
|
||||
echo "Running database migrations..."
|
||||
npx prisma migrate deploy
|
||||
|
||||
# Initialize database
|
||||
echo "Checking and seeding database if needed..."
|
||||
npm run seed
|
||||
|
||||
# Start the application
|
||||
echo "Starting the application..."
|
||||
exec "$@"
|
||||
|
||||
54
docs/migrate-0.3-0.5.md
Normal file
54
docs/migrate-0.3-0.5.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# How to migrate data from v0.3 to v0.5
|
||||
|
||||
In v0.5 we changed the database from SQLite to Postgres. Because of this, it was not possible to seamlessly migrate data from one database to another and you will have to do it yourself.
|
||||
|
||||
Don't worry, even if you already upgraded — your data is not lost!
|
||||
|
||||
Here's how to migrate properly:
|
||||
|
||||
## Step 1: Update your docker-compose to v0.3.0
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/vas3k/taxhacker:v0.3.0
|
||||
ports:
|
||||
- "7331:7331"
|
||||
|
||||
// everything else stays the same
|
||||
```
|
||||
|
||||
## Step 2: Restart your app and make a backup
|
||||
|
||||
```yaml
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Go to your app -> Settings -> Backups -> Download Data Archive
|
||||
|
||||
Save .zip archive on your machine.
|
||||
|
||||
## Step 3: Upgrade your TaxHacker instance
|
||||
|
||||
Update your docker compose to latest version again.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/vas3k/taxhacker:latest
|
||||
ports:
|
||||
- "7331:7331"
|
||||
|
||||
// everything else stays the same
|
||||
```
|
||||
|
||||
Restart again.
|
||||
|
||||
## Step 4: Upload your data to the new instance
|
||||
|
||||
Open your app -> Settings -> Backups -> Restore from a backup
|
||||
|
||||
Upload your zip archive and click restore. After couple of seconds it must show you import stats.
|
||||
|
||||
If import fails with an error about file size, go to [next.config.ts](./next.config.ts) and change `bodySizeLimit: "256mb"` to something bigger.
|
||||
@@ -2,7 +2,6 @@ import { randomHexColor } from "@/lib/utils"
|
||||
import { z } from "zod"
|
||||
|
||||
export const settingsFormSchema = z.object({
|
||||
app_title: z.string().max(128).optional(),
|
||||
default_currency: z.string().max(5).optional(),
|
||||
default_type: z.string().optional(),
|
||||
default_category: z.string().optional(),
|
||||
|
||||
6
forms/users.ts
Normal file
6
forms/users.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const userFormSchema = z.object({
|
||||
name: z.string().max(128).optional(),
|
||||
avatar: z.string().optional(),
|
||||
})
|
||||
6
lib/auth-client.ts
Normal file
6
lib/auth-client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { createAuthClient } from "better-auth/client"
|
||||
import { emailOTPClient } from "better-auth/client/plugins"
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [emailOTPClient()],
|
||||
})
|
||||
88
lib/auth.ts
Normal file
88
lib/auth.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { AUTH_LOGIN_URL, IS_SELF_HOSTED_MODE, SELF_HOSTED_REDIRECT_URL } from "@/lib/constants"
|
||||
import { createUserDefaults } from "@/models/defaults"
|
||||
import { getSelfHostedUser, getUserByEmail } from "@/models/users"
|
||||
import { User } from "@prisma/client"
|
||||
import { betterAuth } from "better-auth"
|
||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||
import { nextCookies } from "better-auth/next-js"
|
||||
import { emailOTP } from "better-auth/plugins/email-otp"
|
||||
import { headers } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
import { prisma } from "./db"
|
||||
import { resend, sendOTPCodeEmail } from "./email"
|
||||
|
||||
export type UserProfile = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
||||
email: {
|
||||
provider: "resend",
|
||||
from: process.env.RESEND_FROM_EMAIL!,
|
||||
resend,
|
||||
},
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
maxAge: 180 * 24 * 60 * 60, // 180 days
|
||||
updateAge: 24 * 60 * 60, // 24 hours
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 24 * 60 * 60, // 24 hours
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
generateId: false,
|
||||
cookiePrefix: "taxhacker",
|
||||
},
|
||||
databaseHooks: {
|
||||
user: {
|
||||
create: {
|
||||
after: async (user) => {
|
||||
await createUserDefaults(user.id)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
emailOTP({
|
||||
disableSignUp: true,
|
||||
otpLength: 6,
|
||||
expiresIn: 10 * 60, // 10 minutes
|
||||
sendVerificationOTP: async ({ email, otp }) => {
|
||||
const user = await getUserByEmail(email as string)
|
||||
if (!user) {
|
||||
throw new Error("User with this email does not exist")
|
||||
}
|
||||
await sendOTPCodeEmail({ email, otp })
|
||||
},
|
||||
}),
|
||||
nextCookies(), // make sure this is the last plugin in the array
|
||||
],
|
||||
})
|
||||
|
||||
export async function getSession() {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
const user = await getSelfHostedUser()
|
||||
return user ? { user } : null
|
||||
}
|
||||
|
||||
return await auth.api.getSession({
|
||||
headers: await headers(),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User> {
|
||||
const session = await getSession()
|
||||
if (!session || !session.user) {
|
||||
if (IS_SELF_HOSTED_MODE) {
|
||||
redirect(SELF_HOSTED_REDIRECT_URL)
|
||||
} else {
|
||||
redirect(AUTH_LOGIN_URL)
|
||||
}
|
||||
}
|
||||
return session.user as User
|
||||
}
|
||||
7
lib/constants.ts
Normal file
7
lib/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const APP_TITLE = "TaxHacker"
|
||||
export const APP_DESCRIPTION = "Your personal AI accountant"
|
||||
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
export const IS_SELF_HOSTED_MODE = process.env.SELF_HOSTED_MODE === "true"
|
||||
export const SELF_HOSTED_REDIRECT_URL = "/self-hosted/redirect"
|
||||
export const SELF_HOSTED_WELCOME_URL = "/self-hosted"
|
||||
export const AUTH_LOGIN_URL = "/enter"
|
||||
@@ -27,7 +27,10 @@ export async function getCurrencyRate(currencyCodeFrom: string, currencyCodeTo:
|
||||
export async function fetchHistoricalCurrencyRates(currency: string = "USD", date: Date): Promise<HistoricRate[]> {
|
||||
const formattedDate = format(date, "yyyy-MM-dd")
|
||||
|
||||
const url = `https://corsproxy.io/?${encodeURIComponent(
|
||||
console.log("DATE", formattedDate)
|
||||
console.log("QUERY", encodeURIComponent(`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`))
|
||||
|
||||
const url = `https://corsproxy.io/?url=${encodeURIComponent(
|
||||
`https://www.xe.com/currencytables/?from=${currency}&date=${formattedDate}`
|
||||
)}`
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
import path from "path"
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
@@ -8,10 +7,3 @@ const globalForPrisma = globalThis as unknown as {
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient({ log: ["query", "info", "warn", "error"] })
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
|
||||
|
||||
export let DATABASE_FILE = process.env.DATABASE_URL?.replace("file:", "") ?? "db.sqlite"
|
||||
if (DATABASE_FILE?.startsWith("/")) {
|
||||
DATABASE_FILE = path.resolve(process.cwd(), DATABASE_FILE)
|
||||
} else {
|
||||
DATABASE_FILE = path.resolve(process.cwd(), "prisma", DATABASE_FILE)
|
||||
}
|
||||
|
||||
28
lib/email.ts
Normal file
28
lib/email.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NewsletterWelcomeEmail } from "@/components/emails/newsletter-welcome-email"
|
||||
import { OTPEmail } from "@/components/emails/otp-email"
|
||||
import React from "react"
|
||||
import { Resend } from "resend"
|
||||
|
||||
export const resend = new Resend(process.env.RESEND_API_KEY)
|
||||
|
||||
export async function sendOTPCodeEmail({ email, otp }: { email: string; otp: string }) {
|
||||
const html = React.createElement(OTPEmail, { otp })
|
||||
|
||||
return await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL!,
|
||||
to: email,
|
||||
subject: "Your TaxHacker verification code",
|
||||
react: html,
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendNewsletterWelcomeEmail(email: string) {
|
||||
const html = React.createElement(NewsletterWelcomeEmail)
|
||||
|
||||
return await resend.emails.send({
|
||||
from: process.env.RESEND_FROM_EMAIL as string,
|
||||
to: email,
|
||||
subject: "Welcome to TaxHacker Newsletter!",
|
||||
react: html,
|
||||
})
|
||||
}
|
||||
53
lib/files.ts
53
lib/files.ts
@@ -1,30 +1,38 @@
|
||||
import { Transaction } from "@prisma/client"
|
||||
import { randomUUID } from "crypto"
|
||||
import { File, Transaction, User } from "@prisma/client"
|
||||
import { access, constants } from "fs/promises"
|
||||
import path from "path"
|
||||
|
||||
export const FILE_ACCEPTED_MIMETYPES = "image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
export const FILE_UPLOAD_PATH = path.resolve(process.env.UPLOAD_PATH || "./uploads")
|
||||
export const FILE_UNSORTED_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "unsorted")
|
||||
export const FILE_PREVIEWS_PATH = path.join(FILE_UPLOAD_PATH, "previews")
|
||||
export const FILE_IMPORT_CSV_UPLOAD_PATH = path.join(FILE_UPLOAD_PATH, "csv")
|
||||
export const FILE_UNSORTED_DIRECTORY_NAME = "unsorted"
|
||||
export const FILE_PREVIEWS_DIRECTORY_NAME = "previews"
|
||||
export const FILE_IMPORT_CSV_DIRECTORY_NAME = "csv"
|
||||
|
||||
export async function getUnsortedFileUploadPath(filename: string) {
|
||||
const fileUuid = randomUUID()
|
||||
const fileExtension = path.extname(filename)
|
||||
const storedFileName = `${fileUuid}${fileExtension}`
|
||||
const filePath = path.join(FILE_UNSORTED_UPLOAD_PATH, storedFileName)
|
||||
|
||||
return { fileUuid, filePath }
|
||||
export async function getUserUploadsDirectory(user: User) {
|
||||
return path.join(FILE_UPLOAD_PATH, user.email)
|
||||
}
|
||||
|
||||
export async function getTransactionFileUploadPath(filename: string, transaction: Transaction) {
|
||||
const fileUuid = randomUUID()
|
||||
export async function getUserPreviewsDirectory(user: User) {
|
||||
return path.join(FILE_UPLOAD_PATH, user.email, FILE_PREVIEWS_DIRECTORY_NAME)
|
||||
}
|
||||
|
||||
export async function unsortedFilePath(fileUuid: string, filename: string): Promise<string> {
|
||||
const fileExtension = path.extname(filename)
|
||||
return path.join(FILE_UNSORTED_DIRECTORY_NAME, `${fileUuid}${fileExtension}`)
|
||||
}
|
||||
|
||||
export async function previewFilePath(fileUuid: string, page: number): Promise<string> {
|
||||
return path.join(FILE_PREVIEWS_DIRECTORY_NAME, `${fileUuid}.${page}.webp`)
|
||||
}
|
||||
|
||||
export async function getTransactionFileUploadPath(fileUuid: string, filename: string, transaction: Transaction) {
|
||||
const fileExtension = path.extname(filename)
|
||||
const storedFileName = `${fileUuid}${fileExtension}`
|
||||
const formattedPath = formatFilePath(storedFileName, transaction.issuedAt || new Date())
|
||||
const filePath = path.join(FILE_UPLOAD_PATH, formattedPath)
|
||||
return formatFilePath(storedFileName, transaction.issuedAt || new Date())
|
||||
}
|
||||
|
||||
return { fileUuid, filePath }
|
||||
export async function fullPathForFile(user: User, file: File) {
|
||||
const userUploadsDirectory = await getUserUploadsDirectory(user)
|
||||
return path.join(userUploadsDirectory, file.path)
|
||||
}
|
||||
|
||||
function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{name}{ext}") {
|
||||
@@ -35,3 +43,12 @@ function formatFilePath(filename: string, date: Date, format = "{YYYY}/{MM}/{nam
|
||||
|
||||
return format.replace("{YYYY}", String(year)).replace("{MM}", month).replace("{name}", name).replace("{ext}", ext)
|
||||
}
|
||||
|
||||
export async function fileExists(filePath: string) {
|
||||
try {
|
||||
await access(filePath, constants.F_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user