feat: add Redis caching to cospend API endpoints
All checks were successful
CI / update (push) Successful in 1m21s
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
@@ -14,9 +15,17 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const includeAll = url.searchParams.get('all') === 'true';
|
||||
|
||||
await dbConnect();
|
||||
|
||||
|
||||
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,14 +53,27 @@ 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
|
||||
const netBalance = userSplits.reduce((sum, split) => sum + split.amount, 0);
|
||||
|
||||
@@ -78,17 +100,22 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
paymentId: split.paymentId._id,
|
||||
username: { $ne: username }
|
||||
}).lean();
|
||||
|
||||
|
||||
if (otherSplit) {
|
||||
split.otherUser = otherSplit.username;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return json({
|
||||
const result = {
|
||||
netBalance,
|
||||
recentSplits
|
||||
});
|
||||
};
|
||||
|
||||
// Cache for 30 minutes
|
||||
await cache.set(cacheKey, JSON.stringify(result), 1800);
|
||||
|
||||
return json(result);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
|
||||
@@ -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;
|
||||
@@ -25,8 +26,16 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
const currentUser = auth.user.nickname;
|
||||
|
||||
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')
|
||||
@@ -50,13 +59,13 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!payment) continue;
|
||||
|
||||
// Find other participants in this payment
|
||||
const otherSplits = allRelatedSplits.filter(s =>
|
||||
const otherSplits = allRelatedSplits.filter(s =>
|
||||
s.paymentId._id.toString() === split.paymentId._id.toString()
|
||||
);
|
||||
|
||||
for (const otherSplit of otherSplits) {
|
||||
const otherUser = otherSplit.username;
|
||||
|
||||
|
||||
if (!debtsByUser.has(otherUser)) {
|
||||
debtsByUser.set(otherUser, {
|
||||
username: otherUser,
|
||||
@@ -66,11 +75,11 @@ export const GET: RequestHandler = async ({ locals }) => {
|
||||
}
|
||||
|
||||
const debt = debtsByUser.get(otherUser)!;
|
||||
|
||||
|
||||
// Current user's amount: positive = they owe, negative = they are owed
|
||||
// We want to show net between the two users
|
||||
debt.netAmount += split.amount;
|
||||
|
||||
|
||||
debt.transactions.push({
|
||||
paymentId: payment._id.toString(),
|
||||
title: payment.title,
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
@@ -15,8 +16,16 @@ export const GET: RequestHandler = async ({ locals, url }) => {
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0');
|
||||
|
||||
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,9 +152,13 @@ export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
|
||||
await Promise.all(splitPromises);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
payment: payment._id
|
||||
// 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
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
|
||||
@@ -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();
|
||||
@@ -13,15 +14,28 @@ export const GET: RequestHandler = async ({ params, locals }) => {
|
||||
const { id } = params;
|
||||
|
||||
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');
|
||||
@@ -40,10 +54,10 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
const data = await request.json();
|
||||
|
||||
await dbConnect();
|
||||
|
||||
|
||||
try {
|
||||
const payment = await Payment.findById(id);
|
||||
|
||||
|
||||
if (!payment) {
|
||||
throw error(404, 'Payment not found');
|
||||
}
|
||||
@@ -52,9 +66,13 @@ 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,
|
||||
{
|
||||
{
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
amount: data.amount,
|
||||
@@ -67,9 +85,10 @@ export const PUT: RequestHandler = async ({ params, request, locals }) => {
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
let newUsernames: string[] = [];
|
||||
if (data.splits) {
|
||||
await PaymentSplit.deleteMany({ paymentId: id });
|
||||
|
||||
|
||||
const splitPromises = data.splits.map((split: any) => {
|
||||
return PaymentSplit.create({
|
||||
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;
|
||||
@@ -101,10 +125,10 @@ export const DELETE: RequestHandler = async ({ params, locals }) => {
|
||||
const { id } = params;
|
||||
|
||||
await dbConnect();
|
||||
|
||||
|
||||
try {
|
||||
const payment = await Payment.findById(id);
|
||||
|
||||
|
||||
if (!payment) {
|
||||
throw error(404, 'Payment not found');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user