Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/backend/src/config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ export const auth = betterAuth({
modelName: 'ba_session',
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // Refresh session daily
// Cache session data in a signed cookie to avoid DB lookups on every request.
// Reduces ba_user queries from ~3.7k/week to ~100-200 (once per 5min per session).
cookieCache: {
enabled: true,
maxAge: 5 * 60, // 5 minutes
},
},
account: {
modelName: 'ba_account',
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { currencyCode } from '@common/lib/zod/custom-types';
import { authPool } from '@config/auth';
import { createController } from '@controllers/helpers/controller-factory';
import { ValidationError } from '@js/errors';
import { invalidateAppUserCache } from '@middlewares/better-auth';
import { ExchangeRatePair } from '@models/user-exchange-rates.model';
import * as userExchangeRates from '@services/user-exchange-rate';
import * as userService from '@services/user.service';
Expand Down Expand Up @@ -43,6 +44,10 @@ export const updateUser = createController(
id: user.id,
...body,
});

// Invalidate cached user so the next request picks up the new username/role
invalidateAppUserCache({ authUserId: user.authUserId });

return { data: userData };
},
);
Expand Down
39 changes: 33 additions & 6 deletions packages/backend/src/middlewares/better-auth.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import { API_ERROR_CODES, API_RESPONSE_STATUS } from '@bt/shared/types';
import { getCurrentSessionId } from '@common/lib/cls/session-id';
import { auth } from '@config/auth';
import { CacheClient } from '@js/utils/cache';
import { setSentryUser } from '@js/utils/sentry';
import Users from '@models/users.model';
import { NextFunction, Request, Response } from 'express';

type AppUser = Pick<Users, 'username' | 'id' | 'authUserId' | 'role'>;

const CACHE_KEY_PREFIX = 'auth_user:';

const appUserCache = new CacheClient<AppUser>({
ttl: 60, // 60 seconds
logPrefix: 'AuthUserCache',
});

/** Remove a user from the cache (e.g., after profile update). */
export function invalidateAppUserCache({ authUserId }: { authUserId: string }): void {
appUserCache.delete(`${CACHE_KEY_PREFIX}${authUserId}`);
}

/**
* Middleware to authenticate requests using better-auth sessions.
*
Expand All @@ -30,12 +45,24 @@ export const authenticateSession = async (req: Request, res: Response, next: Nex
});
}

// Look up the app user by authUserId
const user = (await Users.findOne({
where: { authUserId: session.user.id },
attributes: ['username', 'id', 'authUserId', 'role'],
raw: true,
})) as Pick<Users, 'username' | 'id' | 'authUserId' | 'role'> | null;
const authUserId = session.user.id;
const cacheKey = `${CACHE_KEY_PREFIX}${authUserId}`;

// Check Redis cache first
let user = await appUserCache.read(cacheKey);

if (!user) {
// Cache miss — look up the app user by authUserId
user = (await Users.findOne({
where: { authUserId },
attributes: ['username', 'id', 'authUserId', 'role'],
raw: true,
})) as AppUser | null;

if (user) {
await appUserCache.write({ key: cacheKey, value: user });
}
}

if (!user) {
return res.status(401).json({
Expand Down
23 changes: 15 additions & 8 deletions packages/backend/src/models/transactions.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,12 +632,18 @@ export const findWithFilters = async ({
}

if (budgetIds?.length) {
queryInclude.push({
model: Budgets,
through: { attributes: [], where: { budgetId: { [Op.in]: budgetIds } } },
attributes: [],
required: true,
});
const budgetTransactionIds = await BudgetTransactions.findAll({
attributes: ['transactionId'],
where: {
budgetId: { [Op.in]: budgetIds },
},
raw: true,
}).then((results) => results.map((r) => r.transactionId));

whereClause.id = {
...(whereClause.id as object),
[Op.in]: budgetTransactionIds,
};
}

if (excludedBudgetIds?.length) {
Expand All @@ -653,6 +659,7 @@ export const findWithFilters = async ({

if (excludedTransactionIds.length > 0) {
whereClause.id = {
...(whereClause.id as object),
[Op.notIn]: excludedTransactionIds,
};
}
Expand Down Expand Up @@ -685,12 +692,12 @@ export const findWithFilters = async ({
if (whereClause.id && (whereClause.id as Record<symbol, number[]>)[Op.notIn]) {
const existingExclusions = (whereClause.id as Record<symbol, number[]>)[Op.notIn] as number[];
whereClause.id = {
...(whereClause.id as object),
[Op.notIn]: [...new Set([...existingExclusions, ...excludedTransactionIds])],
};
} else {
whereClause.id = {
// oxlint-disable-next-line unicorn/no-useless-fallback-in-spread
...((whereClause.id as object) || {}),
...(whereClause.id as object),
[Op.notIn]: excludedTransactionIds,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import SecurityPricing from '@models/investments/security-pricing.model';
import { calculateRefAmount } from '@services/calculate-ref-amount.service';
import { withDeduplication } from '@services/common/with-deduplication';
import { calculateAllGains } from '@services/investments/gains/gains-calculator.utils';
import { Op, WhereOptions } from 'sequelize';
import { Op, WhereOptions, fn, col } from 'sequelize';

interface GetHoldingValuesParams {
portfolioId: number;
Expand Down Expand Up @@ -79,35 +79,40 @@ const getHoldingValuesImpl = async ({ portfolioId, date, userId }: GetHoldingVal
{} as Record<number, InvestmentTransaction[]>,
);

// Build price query
// Build price query - fetch only the latest price per security
const priceWhere: WhereOptions = {
securityId: { [Op.in]: securityIds },
};

if (date) {
// Get prices for specific date (or closest before that date)
priceWhere.date = { [Op.lte]: date };
}

// Get the relevant prices
const prices = await SecurityPricing.findAll({
// Step 1: Get the latest price date for each security (fast GROUP BY on index)
const latestPriceDates = (await SecurityPricing.findAll({
where: priceWhere,
order: [
['securityId', 'ASC'],
['date', 'DESC'], // Latest first
],
});

// Group prices by securityId (latest/closest first due to ordering)
const pricesBySecurityId = prices.reduce(
(acc, price) => {
if (!acc[price.securityId]) {
acc[price.securityId] = price;
}
return acc;
},
{} as Record<number, SecurityPricing>,
);
attributes: ['securityId', [fn('MAX', col('date')), 'date']],
group: ['securityId'],
raw: true,
})) as unknown as Array<{ securityId: number; date: string }>;

// Step 2: Fetch only those specific price rows (exactly 1 per security)
const prices =
latestPriceDates.length > 0
? await SecurityPricing.findAll({
where: {
[Op.or]: latestPriceDates.map((pd) => ({
securityId: pd.securityId,
date: pd.date,
})),
},
})
: [];

const pricesBySecurityId = Object.fromEntries(prices.map((price) => [price.securityId, price])) as Record<
number,
SecurityPricing
>;

// Calculate market values for each holding
const holdingValues: HoldingValue[] = [];
Expand Down
Loading