feat: creates backend for the project

This commit is contained in:
vakabunga
2026-03-02 00:32:37 +03:00
parent 9d12702688
commit 4d67636633
24 changed files with 1735 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import * as accountService from '../services/accounts';
const router = Router();
router.get(
'/',
asyncHandler(async (_req, res) => {
const accounts = await accountService.getAccounts();
res.json(accounts);
}),
);
router.put(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (isNaN(id)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid account id' });
return;
}
const { alias } = req.body;
if (typeof alias !== 'string' || !alias.trim()) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias is required and must be non-empty' });
return;
}
if (alias.trim().length > 50) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'alias must be at most 50 characters' });
return;
}
const result = await accountService.updateAccountAlias(id, alias.trim());
if (!result) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Account not found' });
return;
}
res.json(result);
}),
);
export default router;

View File

@@ -0,0 +1,73 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import * as analyticsService from '../services/analytics';
import type { Granularity } from '@family-budget/shared';
const router = Router();
router.get(
'/summary',
asyncHandler(async (req, res) => {
const { from, to, accountId, onlyConfirmed } = req.query;
if (!from || !to) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
return;
}
const result = await analyticsService.getSummary({
from: from as string,
to: to as string,
accountId: accountId ? Number(accountId) : undefined,
onlyConfirmed: onlyConfirmed === 'true',
});
res.json(result);
}),
);
router.get(
'/by-category',
asyncHandler(async (req, res) => {
const { from, to, accountId, onlyConfirmed } = req.query;
if (!from || !to) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from and to are required' });
return;
}
const result = await analyticsService.getByCategory({
from: from as string,
to: to as string,
accountId: accountId ? Number(accountId) : undefined,
onlyConfirmed: onlyConfirmed === 'true',
});
res.json(result);
}),
);
router.get(
'/timeseries',
asyncHandler(async (req, res) => {
const { from, to, accountId, categoryId, onlyConfirmed, granularity } = req.query;
if (!from || !to || !granularity) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'from, to, and granularity are required' });
return;
}
const validGranularities: Granularity[] = ['day', 'week', 'month'];
if (!validGranularities.includes(granularity as Granularity)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'granularity must be day, week, or month' });
return;
}
const result = await analyticsService.getTimeseries({
from: from as string,
to: to as string,
accountId: accountId ? Number(accountId) : undefined,
categoryId: categoryId ? Number(categoryId) : undefined,
onlyConfirmed: onlyConfirmed === 'true',
granularity: granularity as Granularity,
});
res.json(result);
}),
);
export default router;

View File

@@ -0,0 +1,51 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { requireAuth } from '../middleware/auth';
import * as authService from '../services/auth';
const router = Router();
router.post(
'/login',
asyncHandler(async (req, res) => {
const { login, password } = req.body;
if (!login || !password) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'login and password are required' });
return;
}
const result = await authService.login({ login, password });
if (!result) {
res.status(401).json({ error: 'UNAUTHORIZED', message: 'Invalid credentials' });
return;
}
res.cookie('sid', result.sessionId, {
httpOnly: true,
sameSite: 'lax',
path: '/',
});
res.json({ ok: true });
}),
);
router.post(
'/logout',
requireAuth,
asyncHandler(async (req, res) => {
await authService.logout(req.sessionId!);
res.clearCookie('sid');
res.json({ ok: true });
}),
);
router.get(
'/me',
requireAuth,
asyncHandler(async (req, res) => {
const result = await authService.me(req.sessionId!);
res.json(result);
}),
);
export default router;

View File

@@ -0,0 +1,19 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import * as categoryService from '../services/categories';
const router = Router();
router.get(
'/',
asyncHandler(async (req, res) => {
let isActive: boolean | undefined;
if (req.query.isActive === 'true') isActive = true;
else if (req.query.isActive === 'false') isActive = false;
const categories = await categoryService.getCategories(isActive);
res.json(categories);
}),
);
export default router;

View File

@@ -0,0 +1,147 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { pool } from '../db/pool';
import * as ruleService from '../services/categoryRules';
const router = Router();
router.get(
'/',
asyncHandler(async (req, res) => {
const q = req.query;
const rules = await ruleService.getRules({
isActive: q.isActive === 'true' ? true : q.isActive === 'false' ? false : undefined,
categoryId: q.categoryId ? Number(q.categoryId) : undefined,
search: q.search as string | undefined,
});
res.json(rules);
}),
);
router.post(
'/',
asyncHandler(async (req, res) => {
const { pattern, matchType, categoryId, priority, requiresConfirmation } = req.body;
if (typeof pattern !== 'string' || !pattern.trim()) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern is required and must be non-empty' });
return;
}
if (pattern.trim().length > 200) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern must be at most 200 characters' });
return;
}
if (matchType !== undefined && matchType !== 'contains') {
res.status(422).json({ error: 'VALIDATION_ERROR', message: 'Only matchType "contains" is supported in MVP' });
return;
}
if (categoryId == null) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'categoryId is required' });
return;
}
if (priority !== undefined && (typeof priority !== 'number' || priority < 0 || priority > 1000)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'priority must be an integer between 0 and 1000' });
return;
}
const catResult = await pool.query(
'SELECT id, is_active FROM categories WHERE id = $1',
[categoryId],
);
if (catResult.rows.length === 0 || !catResult.rows[0].is_active) {
res.status(422).json({ error: 'VALIDATION_ERROR', message: 'Category not found or inactive' });
return;
}
const rule = await ruleService.createRule({ pattern, matchType, categoryId, priority, requiresConfirmation });
res.status(201).json(rule);
}),
);
router.patch(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (isNaN(id)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid rule id' });
return;
}
if (req.body.matchType !== undefined) {
res.status(422).json({ error: 'VALIDATION_ERROR', message: 'matchType update is not supported in MVP' });
return;
}
const { pattern, categoryId, priority, requiresConfirmation, isActive } = req.body;
if (pattern !== undefined && (typeof pattern !== 'string' || !pattern.trim())) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern must be non-empty' });
return;
}
if (pattern !== undefined && pattern.trim().length > 200) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'pattern must be at most 200 characters' });
return;
}
if (priority !== undefined && (typeof priority !== 'number' || priority < 0 || priority > 1000)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'priority must be between 0 and 1000' });
return;
}
if (categoryId !== undefined) {
const catResult = await pool.query(
'SELECT id, is_active FROM categories WHERE id = $1',
[categoryId],
);
if (catResult.rows.length === 0 || !catResult.rows[0].is_active) {
res.status(422).json({ error: 'VALIDATION_ERROR', message: 'Category not found or inactive' });
return;
}
}
const hasUpdatableFields =
pattern !== undefined ||
categoryId !== undefined ||
priority !== undefined ||
requiresConfirmation !== undefined ||
isActive !== undefined;
if (!hasUpdatableFields) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'No updatable fields provided' });
return;
}
const result = await ruleService.updateRule(id, {
pattern,
categoryId,
priority,
requiresConfirmation,
isActive,
});
if (!result) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Rule not found' });
return;
}
res.json(result);
}),
);
router.post(
'/:id/apply',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (isNaN(id)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid rule id' });
return;
}
const result = await ruleService.applyRule(id);
if ('status' in result) {
res.status(result.status).json({ error: result.error, message: result.message });
return;
}
res.json(result);
}),
);
export default router;

View File

@@ -0,0 +1,22 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import { importStatement, isValidationError } from '../services/import';
const router = Router();
router.post(
'/statement',
asyncHandler(async (req, res) => {
const result = await importStatement(req.body);
if (isValidationError(result)) {
res.status((result as { status: number }).status).json({
error: (result as { error: string }).error,
message: (result as { message: string }).message,
});
return;
}
res.json(result);
}),
);
export default router;

View File

@@ -0,0 +1,54 @@
import { Router } from 'express';
import { asyncHandler } from '../utils';
import * as transactionService from '../services/transactions';
const router = Router();
router.get(
'/',
asyncHandler(async (req, res) => {
const q = req.query;
const result = await transactionService.getTransactions({
accountId: q.accountId ? Number(q.accountId) : undefined,
from: q.from as string | undefined,
to: q.to as string | undefined,
direction: q.direction as string | undefined,
categoryId: q.categoryId ? Number(q.categoryId) : undefined,
search: q.search as string | undefined,
amountMin: q.amountMin ? Number(q.amountMin) : undefined,
amountMax: q.amountMax ? Number(q.amountMax) : undefined,
onlyUnconfirmed: q.onlyUnconfirmed === 'true',
sortBy: q.sortBy as 'date' | 'amount' | undefined,
sortOrder: q.sortOrder as 'asc' | 'desc' | undefined,
page: q.page ? Number(q.page) : undefined,
pageSize: q.pageSize ? Number(q.pageSize) : undefined,
});
res.json(result);
}),
);
router.put(
'/:id',
asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (isNaN(id)) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'Invalid transaction id' });
return;
}
const { categoryId, comment } = req.body;
if (categoryId === undefined && comment === undefined) {
res.status(400).json({ error: 'BAD_REQUEST', message: 'No fields to update' });
return;
}
const result = await transactionService.updateTransaction(id, { categoryId, comment });
if (!result) {
res.status(404).json({ error: 'NOT_FOUND', message: 'Transaction not found' });
return;
}
res.json(result);
}),
);
export default router;