BREAKING: postgres + saas

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

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

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

88
lib/auth.ts Normal file
View File

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

7
lib/constants.ts Normal file
View File

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

View File

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

View File

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

28
lib/email.ts Normal file
View File

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

View File

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

19
lib/previews/generate.ts Normal file
View File

@@ -0,0 +1,19 @@
import { resizeImage } from "@/lib/previews/images"
import { pdfToImages } from "@/lib/previews/pdf"
import { User } from "@prisma/client"
export async function generateFilePreviews(
user: User,
filePath: string,
mimetype: string
): Promise<{ contentType: string; previews: string[] }> {
if (mimetype === "application/pdf") {
const { contentType, pages } = await pdfToImages(user, filePath)
return { contentType, previews: pages }
} else if (mimetype.startsWith("image/")) {
const { contentType, resizedPath } = await resizeImage(user, filePath)
return { contentType, previews: [resizedPath] }
} else {
return { contentType: mimetype, previews: [filePath] }
}
}

View File

@@ -1,5 +1,7 @@
import { FILE_PREVIEWS_PATH } from "@/lib/files"
import { existsSync } from "fs"
"use server"
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
import { User } from "@prisma/client"
import fs from "fs/promises"
import path from "path"
import sharp from "sharp"
@@ -9,17 +11,19 @@ const MAX_HEIGHT = 1800
const QUALITY = 90
export async function resizeImage(
user: User,
origFilePath: string,
maxWidth: number = MAX_WIDTH,
maxHeight: number = MAX_HEIGHT
): Promise<{ contentType: string; resizedPath: string }> {
try {
await fs.mkdir(FILE_PREVIEWS_PATH, { recursive: true })
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
await fs.mkdir(userPreviewsDirectory, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
const outputPath = path.join(FILE_PREVIEWS_PATH, `${basename}.webp`)
const outputPath = path.join(userPreviewsDirectory, `${basename}.webp`)
if (existsSync(outputPath)) {
if (await fileExists(outputPath)) {
const metadata = await sharp(outputPath).metadata()
return {
contentType: `image/${metadata.format}`,

View File

@@ -1,8 +1,10 @@
import { existsSync } from "fs"
"use server"
import { fileExists, getUserPreviewsDirectory } from "@/lib/files"
import { User } from "@prisma/client"
import fs from "fs/promises"
import path from "path"
import { fromPath } from "pdf2pic"
import { FILE_PREVIEWS_PATH } from "./files"
const MAX_PAGES = 10
const DPI = 150
@@ -10,16 +12,16 @@ const QUALITY = 90
const MAX_WIDTH = 1500
const MAX_HEIGHT = 1500
export async function pdfToImages(origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
await fs.mkdir(FILE_PREVIEWS_PATH, { recursive: true })
export async function pdfToImages(user: User, origFilePath: string): Promise<{ contentType: string; pages: string[] }> {
const userPreviewsDirectory = await getUserPreviewsDirectory(user)
await fs.mkdir(userPreviewsDirectory, { recursive: true })
const basename = path.basename(origFilePath, path.extname(origFilePath))
// Check if converted pages already exist
const existingPages: string[] = []
for (let i = 1; i <= MAX_PAGES; i++) {
const convertedFilePath = path.join(FILE_PREVIEWS_PATH, `${basename}.${i}.webp`)
if (existsSync(convertedFilePath)) {
const convertedFilePath = path.join(userPreviewsDirectory, `${basename}.${i}.webp`)
if (await fileExists(convertedFilePath)) {
existingPages.push(convertedFilePath)
} else {
break
@@ -34,7 +36,7 @@ export async function pdfToImages(origFilePath: string): Promise<{ contentType:
const pdf2picOptions = {
density: DPI,
saveFilename: basename,
savePath: FILE_PREVIEWS_PATH,
savePath: userPreviewsDirectory,
format: "webp",
quality: QUALITY,
width: MAX_WIDTH,

View File

@@ -7,9 +7,12 @@ export function cn(...inputs: ClassValue[]) {
}
export function formatCurrency(total: number, currency: string) {
return new Intl.NumberFormat("en", {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: true,
}).format(total / 100)
}