feat: add Redis caching to cospend API endpoints
All checks were successful
CI / update (push) Successful in 1m21s

Implement comprehensive caching for all cospend routes to improve performance:

Cache Implementation:
- Balance API: 30-minute TTL for user balances and global balances
- Debts API: 15-minute TTL for debt breakdown calculations
- Payments List: 10-minute TTL with pagination support
- Individual Payment: 30-minute TTL for payment details

Cache Invalidation:
- Created invalidateCospendCaches() helper function
- Invalidates user balances, debts, and payment lists on mutations
- Applied to payment create, update, and delete operations
- Applied to recurring payment execution (manual and cron)
This commit is contained in:
2026-01-13 19:45:09 +01:00
parent 041d415525
commit baa3f3e533
7 changed files with 163 additions and 29 deletions

View File

@@ -302,10 +302,44 @@ export async function invalidateRecipeCaches(): Promise<void> {
cache.delPattern('recipes:category:*'),
cache.delPattern('recipes:icon:*'),
]);
console.log('[Cache] Invalidated all recipe caches');
} catch (err) {
console.error('[Cache] Error invalidating recipe caches:', err);
}
}
/**
* Helper function to invalidate cospend caches for specific users and/or payments
* Call this after payment create/update/delete operations
* @param usernames - Array of usernames whose caches should be invalidated
* @param paymentId - Optional payment ID to invalidate specific payment cache
*/
export async function invalidateCospendCaches(usernames: string[], paymentId?: string): Promise<void> {
try {
const invalidations: Promise<any>[] = [];
// Invalidate balance and debts caches for all affected users
for (const username of usernames) {
invalidations.push(
cache.del(`cospend:balance:${username}`),
cache.del(`cospend:debts:${username}`)
);
}
// Invalidate global balance cache
invalidations.push(cache.del('cospend:balance:all'));
// Invalidate payment list caches (all pagination variants)
invalidations.push(cache.delPattern('cospend:payments:list:*'));
// If specific payment ID provided, invalidate its cache
if (paymentId) {
invalidations.push(cache.del(`cospend:payment:${paymentId}`));
}
await Promise.all(invalidations);
} catch (err) {
console.error('[Cache] Error invalidating cospend caches:', err);
}
}
export default cache;

View File

@@ -3,6 +3,7 @@ import { PaymentSplit } from '../../../../models/PaymentSplit';
import { Payment } from '../../../../models/Payment'; // Need to import Payment for populate to work
import { dbConnect } from '../../../../utils/db';
import { error, json } from '@sveltejs/kit';
import cache from '$lib/server/cache';
export const GET: RequestHandler = async ({ locals, url }) => {
const auth = await locals.auth();
@@ -17,6 +18,14 @@ export const GET: RequestHandler = async ({ locals, url }) => {
try {
if (includeAll) {
// Try cache first for all balances
const cacheKey = 'cospend:balance:all';
const cached = await cache.get(cacheKey);
if (cached) {
return json(JSON.parse(cached));
}
const allSplits = await PaymentSplit.aggregate([
{
$group: {
@@ -44,12 +53,25 @@ export const GET: RequestHandler = async ({ locals, url }) => {
netBalance: 0
};
return json({
const result = {
currentUser: currentUserBalance,
allBalances: allSplits
});
};
// Cache for 30 minutes
await cache.set(cacheKey, JSON.stringify(result), 1800);
return json(result);
} else {
// Try cache first for individual user balance
const cacheKey = `cospend:balance:${username}`;
const cached = await cache.get(cacheKey);
if (cached) {
return json(JSON.parse(cached));
}
const userSplits = await PaymentSplit.find({ username }).lean();
// Calculate net balance: negative = you are owed money, positive = you owe money
@@ -85,10 +107,15 @@ export const GET: RequestHandler = async ({ locals, url }) => {
}
}
return json({
const result = {
netBalance,
recentSplits
});
};
// Cache for 30 minutes
await cache.set(cacheKey, JSON.stringify(result), 1800);
return json(result);
}
} catch (e) {

View File

@@ -3,6 +3,7 @@ import { PaymentSplit } from '../../../../models/PaymentSplit';
import { Payment } from '../../../../models/Payment';
import { dbConnect } from '../../../../utils/db';
import { error, json } from '@sveltejs/kit';
import cache from '$lib/server/cache';
interface DebtSummary {
username: string;
@@ -27,6 +28,14 @@ export const GET: RequestHandler = async ({ locals }) => {
await dbConnect();
try {
// Try cache first
const cacheKey = `cospend:debts:${currentUser}`;
const cached = await cache.get(cacheKey);
if (cached) {
return json(JSON.parse(cached));
}
// Get all splits for the current user
const userSplits = await PaymentSplit.find({ username: currentUser })
.populate('paymentId')
@@ -97,12 +106,17 @@ export const GET: RequestHandler = async ({ locals }) => {
netAmount: Math.round(debt.netAmount * 100) / 100 // Round to 2 decimal places
}));
return json({
const result = {
whoOwesMe,
whoIOwe,
totalOwedToMe: whoOwesMe.reduce((sum, debt) => sum + debt.netAmount, 0),
totalIOwe: whoIOwe.reduce((sum, debt) => sum + debt.netAmount, 0)
});
};
// Cache for 15 minutes (as suggested in plan for debt breakdown)
await cache.set(cacheKey, JSON.stringify(result), 900);
return json(result);
} catch (e) {
console.error('Error calculating debt breakdown:', e);

View File

@@ -4,6 +4,7 @@ import { PaymentSplit } from '../../../../models/PaymentSplit';
import { dbConnect } from '../../../../utils/db';
import { convertToCHF, isValidCurrencyCode } from '../../../../lib/utils/currency';
import { error, json } from '@sveltejs/kit';
import cache, { invalidateCospendCaches } from '$lib/server/cache';
export const GET: RequestHandler = async ({ locals, url }) => {
const auth = await locals.auth();
@@ -17,6 +18,14 @@ export const GET: RequestHandler = async ({ locals, url }) => {
await dbConnect();
try {
// Try cache first (include pagination params in key)
const cacheKey = `cospend:payments:list:${limit}:${offset}`;
const cached = await cache.get(cacheKey);
if (cached) {
return json(JSON.parse(cached));
}
const payments = await Payment.find()
.populate('splits')
.sort({ date: -1, createdAt: -1 })
@@ -24,7 +33,12 @@ export const GET: RequestHandler = async ({ locals, url }) => {
.skip(offset)
.lean();
return json({ payments });
const result = { payments };
// Cache for 10 minutes (shorter TTL since this changes frequently)
await cache.set(cacheKey, JSON.stringify(result), 600);
return json(result);
} catch (e) {
throw error(500, 'Failed to fetch payments');
} finally {
@@ -138,6 +152,10 @@ export const POST: RequestHandler = async ({ request, locals }) => {
await Promise.all(splitPromises);
// Invalidate caches for all affected users
const affectedUsernames = splits.map((split: any) => split.username);
await invalidateCospendCaches(affectedUsernames, payment._id.toString());
return json({
success: true,
payment: payment._id

View File

@@ -3,6 +3,7 @@ import { Payment } from '../../../../../models/Payment';
import { PaymentSplit } from '../../../../../models/PaymentSplit';
import { dbConnect } from '../../../../../utils/db';
import { error, json } from '@sveltejs/kit';
import cache, { invalidateCospendCaches } from '$lib/server/cache';
export const GET: RequestHandler = async ({ params, locals }) => {
const auth = await locals.auth();
@@ -15,13 +16,26 @@ export const GET: RequestHandler = async ({ params, locals }) => {
await dbConnect();
try {
// Try cache first
const cacheKey = `cospend:payment:${id}`;
const cached = await cache.get(cacheKey);
if (cached) {
return json(JSON.parse(cached));
}
const payment = await Payment.findById(id).populate('splits').lean();
if (!payment) {
throw error(404, 'Payment not found');
}
return json({ payment });
const result = { payment };
// Cache for 30 minutes
await cache.set(cacheKey, JSON.stringify(result), 1800);
return json(result);
} catch (e) {
if (e.status === 404) throw e;
throw error(500, 'Failed to fetch payment');
@@ -52,6 +66,10 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
throw error(403, 'Not authorized to edit this payment');
}
// Get old splits to invalidate caches for users who were in the original payment
const oldSplits = await PaymentSplit.find({ paymentId: id }).lean();
const oldUsernames = oldSplits.map(split => split.username);
const updatedPayment = await Payment.findByIdAndUpdate(
id,
{
@@ -67,6 +85,7 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
{ new: true }
);
let newUsernames: string[] = [];
if (data.splits) {
await PaymentSplit.deleteMany({ paymentId: id });
@@ -81,8 +100,13 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
});
await Promise.all(splitPromises);
newUsernames = data.splits.map((split: any) => split.username);
}
// Invalidate caches for all users (old and new)
const allAffectedUsers = [...new Set([...oldUsernames, ...newUsernames])];
await invalidateCospendCaches(allAffectedUsers, id);
return json({ success: true, payment: updatedPayment });
} catch (e) {
if (e.status) throw e;
@@ -113,9 +137,16 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
throw error(403, 'Not authorized to delete this payment');
}
// Get splits to invalidate caches for affected users
const splits = await PaymentSplit.find({ paymentId: id }).lean();
const affectedUsernames = splits.map(split => split.username);
await PaymentSplit.deleteMany({ paymentId: id });
await Payment.findByIdAndDelete(id);
// Invalidate caches for all affected users
await invalidateCospendCaches(affectedUsernames, id);
return json({ success: true });
} catch (e) {
if (e.status) throw e;

View File

@@ -5,6 +5,7 @@ import { PaymentSplit } from '../../../../../models/PaymentSplit';
import { dbConnect } from '../../../../../utils/db';
import { error, json } from '@sveltejs/kit';
import { calculateNextExecutionDate } from '../../../../../lib/utils/recurring';
import { invalidateCospendCaches } from '$lib/server/cache';
// This endpoint is designed to be called by a cron job or external scheduler
// It processes all recurring payments that are due for execution
@@ -65,6 +66,10 @@ export const POST: RequestHandler = async ({ request }) => {
await Promise.all(splitPromises);
// Invalidate caches for all affected users
const affectedUsernames = recurringPayment.splits.map((split) => split.username);
await invalidateCospendCaches(affectedUsernames, payment._id.toString());
// Calculate next execution date
const nextExecutionDate = calculateNextExecutionDate(recurringPayment, now);

View File

@@ -6,6 +6,7 @@ import { dbConnect } from '../../../../../utils/db';
import { error, json } from '@sveltejs/kit';
import { calculateNextExecutionDate } from '../../../../../lib/utils/recurring';
import { convertToCHF } from '../../../../../lib/utils/currency';
import { invalidateCospendCaches } from '$lib/server/cache';
export const POST: RequestHandler = async ({ locals }) => {
const auth = await locals.auth();
@@ -98,6 +99,10 @@ export const POST: RequestHandler = async ({ locals }) => {
await Promise.all(splitPromises);
// Invalidate caches for all affected users
const affectedUsernames = recurringPayment.splits.map((split) => split.username);
await invalidateCospendCaches(affectedUsernames, payment._id.toString());
// Calculate next execution date
const nextExecutionDate = calculateNextExecutionDate(recurringPayment, now);