Files
flyer-crawler.projectium.com/src/services/db/budget.db.ts
Torben Sorensen 2e72ee81dd
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 41s
maybe a few too many fixes
2025-12-28 21:38:31 -08:00

156 lines
6.0 KiB
TypeScript

// src/services/db/budget.db.ts
import type { Pool, PoolClient } from 'pg';
import { getPool, withTransaction } from './connection.db';
import { ForeignKeyConstraintError, NotFoundError } from './errors.db';
import type { Logger } from 'pino';
import type { Budget, SpendingByCategory } from '../../types';
import { GamificationRepository } from './gamification.db';
export class BudgetRepository {
// The repository only needs an object with a `query` method, matching the Pool/PoolClient interface.
// Using `Pick` makes this dependency explicit and simplifies testing by reducing the mock surface.
private db: Pick<Pool | PoolClient, 'query'>;
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
this.db = db;
}
/**
* Retrieves all budgets for a specific user.
* @param userId The UUID of the user.
* @returns A promise that resolves to an array of Budget objects.
*/
async getBudgetsForUser(userId: string, logger: Logger): Promise<Budget[]> {
try {
const res = await this.db.query<Budget>(
'SELECT * FROM public.budgets WHERE user_id = $1 ORDER BY start_date DESC',
[userId],
);
return res.rows;
} catch (error) {
logger.error({ err: error, userId }, 'Database error in getBudgetsForUser');
throw new Error('Failed to retrieve budgets.');
}
}
/**
* Creates a new budget for a user.
* @param userId The ID of the user creating the budget.
* @param budgetData The data for the new budget.
* @returns A promise that resolves to the newly created Budget object.
*/
async createBudget(
userId: string,
budgetData: Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>,
logger: Logger,
): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
try {
return await withTransaction(async (client) => {
const res = await client.query<Budget>(
'INSERT INTO public.budgets (user_id, name, amount_cents, period, start_date) VALUES ($1, $2, $3, $4, $5) RETURNING *',
[userId, name, amount_cents, period, start_date],
);
// After successfully creating the budget, try to award the 'First Budget Created' achievement.
// The award_achievement function handles checking if the user already has it.
const gamificationRepo = new GamificationRepository(client);
await gamificationRepo.awardAchievement(userId, 'First Budget Created', logger);
return res.rows[0];
});
} catch (error) {
// The patch requested this specific error handling.
// Type-safe check for a PostgreSQL error code.
// This ensures 'error' is an object with a 'code' property before we access it.
if (error instanceof Error && 'code' in error && error.code === '23503') {
throw new ForeignKeyConstraintError('The specified user does not exist.');
}
logger.error({ err: error, budgetData, userId }, 'Database error in createBudget');
throw new Error('Failed to create budget.');
}
}
/**
* Updates an existing budget.
* @param budgetId The ID of the budget to update.
* @param userId The ID of the user who owns the budget (for verification).
* @param budgetData The data to update.
* @returns A promise that resolves to the updated Budget object.
*/
async updateBudget(
budgetId: number,
userId: string,
budgetData: Partial<Omit<Budget, 'budget_id' | 'user_id' | 'created_at' | 'updated_at'>>,
logger: Logger,
): Promise<Budget> {
const { name, amount_cents, period, start_date } = budgetData;
try {
const res = await this.db.query<Budget>(
`UPDATE public.budgets SET
name = COALESCE($1, name),
amount_cents = COALESCE($2, amount_cents),
period = COALESCE($3, period),
start_date = COALESCE($4, start_date)
WHERE budget_id = $5 AND user_id = $6 RETURNING *`,
[name, amount_cents, period, start_date, budgetId, userId],
);
if (res.rowCount === 0)
throw new NotFoundError('Budget not found or user does not have permission to update.');
return res.rows[0];
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error({ err: error, budgetId, userId }, 'Database error in updateBudget');
throw new Error('Failed to update budget.');
}
}
/**
* Deletes a budget.
* @param budgetId The ID of the budget to delete.
* @param userId The ID of the user who owns the budget (for verification).
*/
async deleteBudget(budgetId: number, userId: string, logger: Logger): Promise<void> {
try {
const result = await this.db.query(
'DELETE FROM public.budgets WHERE budget_id = $1 AND user_id = $2',
[budgetId, userId],
);
if (result.rowCount === 0) {
throw new NotFoundError('Budget not found or user does not have permission to delete.');
}
} catch (error) {
if (error instanceof NotFoundError) throw error;
logger.error({ err: error, budgetId, userId }, 'Database error in deleteBudget');
throw new Error('Failed to delete budget.');
}
}
/**
* Calls the database function to get a user's spending breakdown by category.
* @param userId The ID of the user.
* @param startDate The start of the date range.
* @param endDate The end of the date range.
* @returns A promise that resolves to an array of spending data.
*/
async getSpendingByCategory(
userId: string,
startDate: string,
endDate: string,
logger: Logger,
): Promise<SpendingByCategory[]> {
try {
const res = await this.db.query<SpendingByCategory>(
'SELECT * FROM public.get_spending_by_category($1, $2, $3)',
[userId, startDate, endDate],
);
return res.rows;
} catch (error) {
logger.error(
{ err: error, userId, startDate, endDate },
'Database error in getSpendingByCategory',
);
throw new Error('Failed to get spending analysis.');
}
}
}