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:
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
|
||||
}
|
||||
}
|
||||
|
||||
19
lib/previews/generate.ts
Normal file
19
lib/previews/generate.ts
Normal 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] }
|
||||
}
|
||||
}
|
||||
@@ -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}`,
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user