From 988d58d7611839a833d930eec91de1a2180dfa33 Mon Sep 17 00:00:00 2001 From: Jose Selesan Date: Fri, 29 May 2026 19:25:15 -0300 Subject: [PATCH] feat(cache): implement a simple caching mechanism with TTL and prefix invalidation refactor(expenses): integrate caching for periodic expenses and related operations refactor(quotes): add caching for quotes retrieval and history endpoints --- apps/backend/src/index.ts | 6 ++ apps/backend/src/lib/cache.ts | 42 +++++++++++++ .../src/modules/expenses/expenses.service.ts | 62 ++++++++++++++++--- .../expenses/handlers/importExpenses.ts | 3 + .../handlers/importPeriodicExpenses.ts | 3 + .../quotes/handlers/getCurrentQuotes.ts | 5 ++ .../modules/quotes/handlers/getDailyMinMax.ts | 6 ++ .../modules/quotes/handlers/getDailyQuotes.ts | 6 ++ .../quotes/handlers/getQuoteHistory.ts | 6 ++ 9 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/lib/cache.ts diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 2e80e6c..7f02e72 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -7,6 +7,8 @@ import { startQuoteJob } from "./modules/quotes/quote.job"; import quotesRouter from "./modules/quotes/quotes.routes"; import expensesRouter from "./modules/expenses/expenses.routes"; import { startExpenseJob } from "./modules/expenses/expense.job"; +import { cache } from "./lib/cache"; +import { eventBus } from "./lib/event-bus"; import { logger } from "./lib/logger"; const app = new Hono(); @@ -32,6 +34,10 @@ app.route("/api", expensesRouter); app.use("/assets/*", serveStatic({ root: "./web" })); app.get("*", serveStatic({ path: "./web/index.html" })); +eventBus.on("quotes-updated", () => { + cache.invalidateByPrefix("quotes:"); +}); + startQuoteJob(); startExpenseJob(); diff --git a/apps/backend/src/lib/cache.ts b/apps/backend/src/lib/cache.ts new file mode 100644 index 0000000..ebd5984 --- /dev/null +++ b/apps/backend/src/lib/cache.ts @@ -0,0 +1,42 @@ +const TEN_MINUTES = 10 * 60 * 1000; + +interface CacheEntry { + data: T; + expiresAt: number; +} + +class SimpleCache { + private store = new Map>(); + + get(key: string): T | undefined { + const entry = this.store.get(key); + if (!entry) return undefined; + if (Date.now() > entry.expiresAt) { + this.store.delete(key); + return undefined; + } + return entry.data as T; + } + + set(key: string, data: T, ttlMs: number = TEN_MINUTES): void { + this.store.set(key, { data, expiresAt: Date.now() + ttlMs }); + } + + delete(key: string): void { + this.store.delete(key); + } + + invalidateByPrefix(prefix: string): void { + for (const key of this.store.keys()) { + if (key.startsWith(prefix)) { + this.store.delete(key); + } + } + } + + clear(): void { + this.store.clear(); + } +} + +export const cache = new SimpleCache(); diff --git a/apps/backend/src/modules/expenses/expenses.service.ts b/apps/backend/src/modules/expenses/expenses.service.ts index 13f7100..617df9e 100644 --- a/apps/backend/src/modules/expenses/expenses.service.ts +++ b/apps/backend/src/modules/expenses/expenses.service.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { prisma } from "../../lib/prisma"; +import { cache } from "../../lib/cache"; import { PaymentStatus } from "../../generated/prisma/client"; export const createPeriodicExpenseSchema = z.object({ @@ -37,28 +38,38 @@ export function getCurrentYearMonthUTC(): { year: number; month: number } { } export async function listPeriodicExpenses() { - return prisma.periodicExpense.findMany({ + const cached = cache.get("expenses:periodic"); + if (cached) return cached; + + const data = await prisma.periodicExpense.findMany({ where: { isDeleted: false }, orderBy: { description: "asc" }, }); + cache.set("expenses:periodic", data); + return data; } export async function createPeriodicExpense(data: z.infer) { const result = await prisma.periodicExpense.create({ data }); const { year, month } = getCurrentYearMonthUTC(); const currentMonthApplicable = data.periods.includes(month); + cache.invalidateByPrefix("expenses:"); return { ...result, currentMonthApplicable }; } export async function updatePeriodicExpense(id: number, data: z.infer) { - return prisma.periodicExpense.update({ where: { id }, data }); + const result = await prisma.periodicExpense.update({ where: { id }, data }); + cache.invalidateByPrefix("expenses:"); + return result; } export async function softDeletePeriodicExpense(id: number) { - return prisma.periodicExpense.update({ + const result = await prisma.periodicExpense.update({ where: { id }, data: { isDeleted: true }, }); + cache.invalidateByPrefix("expenses:"); + return result; } export async function generateMonthlyExpense(id: number) { @@ -71,7 +82,7 @@ export async function generateMonthlyExpense(id: number) { if (existing) return existing; const dueDate = buildDueDate(year, month, periodic.defaultDueDay); - return prisma.expense.create({ + const result = await prisma.expense.create({ data: { description: periodic.description, periodicExpenseId: id, @@ -82,6 +93,8 @@ export async function generateMonthlyExpense(id: number) { status: PaymentStatus.PENDING, }, }); + cache.invalidateByPrefix("expenses:"); + return result; } export async function listExpenses(params: { @@ -92,6 +105,11 @@ export async function listExpenses(params: { search?: string; }) { const { status, page = 1, pageSize = 10, periodicExpenseId, search } = params; + + const cacheKey = `expenses:list:${status ?? "all"}:${page}:${pageSize}:${periodicExpenseId ?? "none"}:${search ?? ""}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + const where: Record = {}; if (status === "PENDING" || status === "PAYED") { where.status = status; @@ -112,14 +130,16 @@ export async function listExpenses(params: { }), prisma.expense.count({ where }), ]); - return { data, total, page, pageSize }; + const result = { data, total, page, pageSize }; + cache.set(cacheKey, result); + return result; } export async function createNonPeriodicExpense(data: z.infer) { const dueDate = new Date(data.dueDate); const year = dueDate.getUTCFullYear(); const month = dueDate.getUTCMonth() + 1; - return prisma.expense.create({ + const result = await prisma.expense.create({ data: { description: data.description, amount: data.amount, @@ -131,11 +151,13 @@ export async function createNonPeriodicExpense(data: z.infer) { const paymentDate = data.paymentDate ? new Date(data.paymentDate) : new Date(); - return prisma.expense.update({ + const result = await prisma.expense.update({ where: { id }, data: { status: PaymentStatus.PAYED, @@ -143,36 +165,55 @@ export async function payExpense(id: number, data: z.infer>` SELECT CAST(SUM("amountPayed") AS NUMERIC) as total FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus" `; - return { total: Number(result[0]?.total ?? 0) }; + const data = { total: Number(result[0]?.total ?? 0) }; + cache.set(cacheKey, data); + return data; } export async function getMonthlyTotals(year: number, month: number) { + const cacheKey = `expenses:totals:${year}:${month}`; + const cached = cache.get(cacheKey); + if (cached) return cached; + const result = await prisma.$queryRaw>` SELECT (SELECT CAST(SUM("amountPayed") AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PAYED}::"PaymentStatus") as payed, (SELECT CAST(SUM(amount) AS NUMERIC) FROM expenses WHERE year = ${year} AND month = ${month} AND status = ${PaymentStatus.PENDING}::"PaymentStatus") as pending `; - return { + const data = { payed: Number(result[0]?.payed ?? 0), pending: Number(result[0]?.pending ?? 0), }; + cache.set(cacheKey, data); + return data; } export async function getTotalPending() { + const cached = cache.get("expenses:pending-total"); + if (cached) return cached; + const result = await prisma.$queryRaw>` SELECT CAST(SUM(amount) AS NUMERIC) as total FROM expenses WHERE status = ${PaymentStatus.PENDING}::"PaymentStatus" `; - return { total: Number(result[0]?.total ?? 0) }; + const data = { total: Number(result[0]?.total ?? 0) }; + cache.set("expenses:pending-total", data); + return data; } export async function generateExpensesForCurrentMonth() { @@ -205,5 +246,6 @@ export async function generateExpensesForCurrentMonth() { }); results.push({ id: periodic.id, status: "created" }); } + cache.invalidateByPrefix("expenses:"); return results; } diff --git a/apps/backend/src/modules/expenses/handlers/importExpenses.ts b/apps/backend/src/modules/expenses/handlers/importExpenses.ts index b91376a..1a669b5 100644 --- a/apps/backend/src/modules/expenses/handlers/importExpenses.ts +++ b/apps/backend/src/modules/expenses/handlers/importExpenses.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { prisma } from "../../../lib/prisma"; +import { cache } from "../../../lib/cache"; import { logger } from "../../../lib/logger"; import { PaymentStatus } from "../../../generated/prisma/client"; @@ -113,6 +114,8 @@ export async function importExpensesHandler(c: Context) { } } + cache.invalidateByPrefix("expenses:"); + return c.json({ imported, skipped, diff --git a/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts b/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts index eebe56c..78c594c 100644 --- a/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts +++ b/apps/backend/src/modules/expenses/handlers/importPeriodicExpenses.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { prisma } from "../../../lib/prisma"; +import { cache } from "../../../lib/cache"; import { logger } from "../../../lib/logger"; function parseArgentineAmount(raw: string): number { @@ -84,6 +85,8 @@ export async function importPeriodicExpensesHandler(c: Context) { } } + cache.invalidateByPrefix("expenses:"); + return c.json({ imported, skipped: skippedNoPeriods + skippedDuplicate, diff --git a/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts b/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts index 92d7c79..c63ed06 100644 --- a/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts +++ b/apps/backend/src/modules/quotes/handlers/getCurrentQuotes.ts @@ -1,7 +1,12 @@ import type { Context } from "hono"; import { prisma } from "../../../lib/prisma"; +import { cache } from "../../../lib/cache"; export async function getCurrentQuotes(c: Context) { + const cached = cache.get("quotes:current"); + if (cached) return c.json(cached); + const quotes = await prisma.quote.findMany(); + cache.set("quotes:current", quotes); return c.json(quotes); } diff --git a/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts b/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts index e917d9b..9ff2114 100644 --- a/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts +++ b/apps/backend/src/modules/quotes/handlers/getDailyMinMax.ts @@ -1,5 +1,6 @@ import type { Context } from "hono"; import { prisma } from "../../../lib/prisma"; +import { cache } from "../../../lib/cache"; import type { QuoteType } from "../../../generated/prisma/client"; const VALID_TYPES = ["BLUE", "BNA", "BELO"] as const; @@ -26,6 +27,10 @@ export async function getDailyMinMax(c: Context) { return c.json({ error: "Invalid date format. Use ISO 8601." }, 400); } + const cacheKey = `quotes:minmax:${rawType}:${startDate.toISOString()}:${endDate.toISOString()}`; + const cached = cache.get(cacheKey); + if (cached) return c.json(cached); + const rows = await prisma.$queryRaw