feat: creates backend for the project
This commit is contained in:
43
backend/src/routes/accounts.ts
Normal file
43
backend/src/routes/accounts.ts
Normal 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;
|
||||
73
backend/src/routes/analytics.ts
Normal file
73
backend/src/routes/analytics.ts
Normal 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;
|
||||
51
backend/src/routes/auth.ts
Normal file
51
backend/src/routes/auth.ts
Normal 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;
|
||||
19
backend/src/routes/categories.ts
Normal file
19
backend/src/routes/categories.ts
Normal 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;
|
||||
147
backend/src/routes/categoryRules.ts
Normal file
147
backend/src/routes/categoryRules.ts
Normal 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;
|
||||
22
backend/src/routes/import.ts
Normal file
22
backend/src/routes/import.ts
Normal 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;
|
||||
54
backend/src/routes/transactions.ts
Normal file
54
backend/src/routes/transactions.ts
Normal 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;
|
||||
Reference in New Issue
Block a user