first commit
This commit is contained in:
24
api/src/app.ts
Normal file
24
api/src/app.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import fs from 'node:fs';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import path from 'node:path';
|
||||
import { env } from './config/env.js';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error-handler.js';
|
||||
import { apiRouter } from './routes/index.js';
|
||||
export const createApp = () => {
|
||||
const app = express();
|
||||
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
app.use(helmet({ crossOriginResourcePolicy: false }));
|
||||
app.use(cors({ origin: env.API_CORS_ORIGIN, credentials: true }));
|
||||
app.use(morgan('dev'));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
app.use('/api', apiRouter);
|
||||
app.use(notFoundHandler);
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
};
|
||||
37
api/src/config/data-source.ts
Normal file
37
api/src/config/data-source.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'reflect-metadata';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { env } from './env.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { Merchant } from '../entities/Merchant.js';
|
||||
import { Proof } from '../entities/Proof.js';
|
||||
import { User } from '../entities/User.js';
|
||||
|
||||
const entities = [User, Category, Expense, Proof, AppSetting, Merchant];
|
||||
const baseOptions = { entities, synchronize: env.DB_SYNC, logging: env.DB_LOGGING };
|
||||
|
||||
if (env.DB_TYPE === 'sqlite') {
|
||||
const file = path.resolve(env.DB_PATH);
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
}
|
||||
|
||||
export const AppDataSource =
|
||||
env.DB_TYPE === 'postgres'
|
||||
? new DataSource({
|
||||
type: 'postgres',
|
||||
host: env.DB_HOST,
|
||||
port: env.DB_PORT,
|
||||
username: env.DB_USER,
|
||||
password: env.DB_PASSWORD,
|
||||
database: env.DB_NAME,
|
||||
...baseOptions
|
||||
})
|
||||
: new DataSource({
|
||||
type: 'sqljs',
|
||||
location: path.resolve(env.DB_PATH),
|
||||
autoSave: true,
|
||||
...baseOptions
|
||||
});
|
||||
33
api/src/config/env.ts
Normal file
33
api/src/config/env.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
type DbType = 'postgres' | 'sqlite';
|
||||
const toNumber = (value: string | undefined, fallback: number) => {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
};
|
||||
const toBoolean = (value: string | undefined, fallback: boolean) => {
|
||||
if (value === undefined) return fallback;
|
||||
return value === 'true' || value === '1';
|
||||
};
|
||||
export const env = {
|
||||
NODE_ENV: process.env.NODE_ENV ?? 'development',
|
||||
APP_PORT: toNumber(process.env.APP_PORT, 4000),
|
||||
JWT_SECRET: process.env.JWT_SECRET ?? 'dev-secret-key',
|
||||
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN ?? '7d',
|
||||
DB_TYPE: (process.env.DB_TYPE as DbType | undefined) ?? 'sqlite',
|
||||
DB_HOST: process.env.DB_HOST ?? 'localhost',
|
||||
DB_PORT: toNumber(process.env.DB_PORT, 5432),
|
||||
DB_NAME: process.env.DB_NAME ?? 'expense_control',
|
||||
DB_USER: process.env.DB_USER ?? 'expense_app',
|
||||
DB_PASSWORD: process.env.DB_PASSWORD ?? 'expense_app',
|
||||
DB_PATH: process.env.DB_PATH ?? './data/dev.sqlite',
|
||||
DB_SYNC: toBoolean(process.env.DB_SYNC, true),
|
||||
DB_LOGGING: toBoolean(process.env.DB_LOGGING, false),
|
||||
APP_NAME: process.env.APP_NAME ?? 'Expense Control',
|
||||
DEFAULT_CURRENCY: process.env.DEFAULT_CURRENCY ?? 'PLN',
|
||||
ADMIN_EMAIL: process.env.ADMIN_EMAIL ?? 'admin@example.com',
|
||||
ADMIN_PASSWORD: process.env.ADMIN_PASSWORD ?? 'Admin123!',
|
||||
UPLOAD_DIR: process.env.UPLOAD_DIR ?? './uploads',
|
||||
MAX_UPLOAD_SIZE_MB: toNumber(process.env.MAX_UPLOAD_SIZE_MB, 10),
|
||||
API_CORS_ORIGIN: process.env.API_CORS_ORIGIN ?? 'http://localhost:4200'
|
||||
};
|
||||
147
api/src/controllers/admin.controller.ts
Normal file
147
api/src/controllers/admin.controller.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type { Response } from 'express';
|
||||
import { createRequire } from 'node:module';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { sanitizeUser } from '../services/auth.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const settingsSchema = z.object({
|
||||
appName: z.string().min(2).max(120),
|
||||
defaultCurrency: z.string().min(3).max(8),
|
||||
registrationEnabled: z.boolean(),
|
||||
allowedProofTypes: z.array(z.string().min(2).max(30)).min(1),
|
||||
uiPreferences: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])),
|
||||
smtpEnabled: z.boolean(),
|
||||
smtpHost: z.string().max(120).nullable().optional(),
|
||||
smtpPort: z.number().int().min(1).max(65535),
|
||||
smtpSecure: z.boolean(),
|
||||
smtpUser: z.string().max(160).nullable().optional(),
|
||||
smtpPassword: z.string().max(255).nullable().optional(),
|
||||
smtpFromName: z.string().max(120).nullable().optional(),
|
||||
smtpFromEmail: z.string().max(160).nullable().optional()
|
||||
});
|
||||
|
||||
const userUpdateSchema = z.object({
|
||||
role: z.enum(['ADMIN', 'USER']).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
defaultCurrency: z.string().min(3).max(8).optional()
|
||||
});
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
|
||||
const sanitizeSettings = (item: AppSetting) => ({
|
||||
id: item.id,
|
||||
appName: item.appName,
|
||||
defaultCurrency: item.defaultCurrency,
|
||||
registrationEnabled: item.registrationEnabled,
|
||||
allowedProofTypes: item.allowedProofTypes,
|
||||
uiPreferences: item.uiPreferences,
|
||||
smtpEnabled: item.smtpEnabled,
|
||||
smtpHost: item.smtpHost,
|
||||
smtpPort: item.smtpPort,
|
||||
smtpSecure: item.smtpSecure,
|
||||
smtpUser: item.smtpUser,
|
||||
smtpPassword: item.smtpPassword,
|
||||
smtpFromName: item.smtpFromName,
|
||||
smtpFromEmail: item.smtpFromEmail,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
});
|
||||
|
||||
const getSettingsEntity = async () => {
|
||||
const [item] = await settingsRepo().find({ take: 1, order: { createdAt: 'ASC' } });
|
||||
return item ?? null;
|
||||
};
|
||||
|
||||
export const getSettings = async (_req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await getSettingsEntity();
|
||||
if (!item) return res.status(404).json({ message: 'Settings were not initialized' });
|
||||
return res.json({ item: sanitizeSettings(item) });
|
||||
};
|
||||
|
||||
export const updateSettings = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = settingsSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid settings payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await getSettingsEntity();
|
||||
if (!item) return res.status(404).json({ message: 'Settings were not initialized' });
|
||||
|
||||
Object.assign(item, {
|
||||
appName: parsed.data.appName,
|
||||
defaultCurrency: parsed.data.defaultCurrency,
|
||||
registrationEnabled: parsed.data.registrationEnabled,
|
||||
allowedProofTypes: parsed.data.allowedProofTypes,
|
||||
uiPreferences: parsed.data.uiPreferences,
|
||||
smtpEnabled: parsed.data.smtpEnabled,
|
||||
smtpHost: parsed.data.smtpHost ?? null,
|
||||
smtpPort: parsed.data.smtpPort,
|
||||
smtpSecure: parsed.data.smtpSecure,
|
||||
smtpUser: parsed.data.smtpUser ?? null,
|
||||
smtpPassword: parsed.data.smtpPassword ?? null,
|
||||
smtpFromName: parsed.data.smtpFromName ?? null,
|
||||
smtpFromEmail: parsed.data.smtpFromEmail ?? null
|
||||
});
|
||||
|
||||
await settingsRepo().save(item);
|
||||
return res.json({ item: sanitizeSettings(item) });
|
||||
};
|
||||
|
||||
export const listUsers = async (_req: AuthenticatedRequest, res: Response) => {
|
||||
const items = await userRepo().find({ order: { createdAt: 'DESC' } });
|
||||
return res.json({ items: items.map(sanitizeUser) });
|
||||
};
|
||||
|
||||
export const updateUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = userUpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid user update payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const itemId = String(req.params.id);
|
||||
const item = await userRepo().findOne({ where: { id: itemId } });
|
||||
if (!item) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
if (parsed.data.role) item.role = parsed.data.role;
|
||||
if (typeof parsed.data.isActive === 'boolean') item.isActive = parsed.data.isActive;
|
||||
if (parsed.data.defaultCurrency) item.defaultCurrency = parsed.data.defaultCurrency;
|
||||
|
||||
await userRepo().save(item);
|
||||
return res.json({ item: sanitizeUser(item) });
|
||||
};
|
||||
|
||||
export const testSmtp = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = z.object({ to: z.email() }).safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid test email payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const settings = await getSettingsEntity();
|
||||
if (!settings || !settings.smtpEnabled || !settings.smtpHost || !settings.smtpFromEmail) {
|
||||
return res.status(400).json({ message: 'SMTP is not configured' });
|
||||
}
|
||||
|
||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
||||
const transport = nodemailer.createTransport({
|
||||
host: settings.smtpHost,
|
||||
port: settings.smtpPort,
|
||||
secure: settings.smtpSecure,
|
||||
auth: settings.smtpUser ? { user: settings.smtpUser, pass: settings.smtpPassword ?? '' } : undefined
|
||||
});
|
||||
|
||||
await transport.sendMail({
|
||||
from: settings.smtpFromName
|
||||
? `"${settings.smtpFromName}" <${settings.smtpFromEmail}>`
|
||||
: settings.smtpFromEmail,
|
||||
to: parsed.data.to,
|
||||
subject: `${settings.appName} - SMTP test`,
|
||||
html: `<div style="font-family:Arial,sans-serif"><h2>SMTP connection works</h2><p>This message was sent from the admin panel test action.</p></div>`
|
||||
});
|
||||
|
||||
return res.json({ message: 'SMTP test message was sent' });
|
||||
};
|
||||
88
api/src/controllers/auth.controller.ts
Normal file
88
api/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import {
|
||||
comparePassword,
|
||||
createUser,
|
||||
findUserByEmail,
|
||||
sanitizeUser,
|
||||
signToken
|
||||
} from '../services/auth.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const registerSchema = z.object({
|
||||
fullName: z.string().min(2).max(120),
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(100)
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8).max(100)
|
||||
});
|
||||
|
||||
|
||||
export const publicConfig = async (_req: Request, res: Response) => {
|
||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
return res.json({
|
||||
appName: settings[0]?.appName ?? 'Expense Control',
|
||||
registrationEnabled: settings[0]?.registrationEnabled ?? true
|
||||
});
|
||||
};
|
||||
|
||||
export const register = async (req: Request, res: Response) => {
|
||||
const parsed = registerSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid registration payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const settings = await AppDataSource.getRepository(AppSetting).find({
|
||||
take: 1,
|
||||
order: { createdAt: 'ASC' }
|
||||
});
|
||||
|
||||
if (settings[0] && !settings[0].registrationEnabled) {
|
||||
return res.status(403).json({ message: 'Registration is disabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await createUser(parsed.data);
|
||||
const token = signToken({ id: user.id, email: user.email, role: user.role });
|
||||
return res.status(201).json({ token, user: sanitizeUser(user) });
|
||||
} catch (error) {
|
||||
return res.status(409).json({ message: (error as Error).message });
|
||||
}
|
||||
};
|
||||
|
||||
export const login = async (req: Request, res: Response) => {
|
||||
const parsed = loginSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid login payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await findUserByEmail(parsed.data.email);
|
||||
if (!user || !(await comparePassword(parsed.data.password, user.passwordHash))) {
|
||||
return res.status(401).json({ message: 'Invalid email or password' });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
return res.status(403).json({ message: 'Your account is inactive' });
|
||||
}
|
||||
|
||||
return res.json({
|
||||
token: signToken({ id: user.id, email: user.email, role: user.role }),
|
||||
user: sanitizeUser(user)
|
||||
});
|
||||
};
|
||||
|
||||
export const me = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await AppDataSource.getRepository(User).findOne({ where: { id: req.user?.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
return res.json({ user: sanitizeUser(user) });
|
||||
};
|
||||
76
api/src/controllers/category.controller.ts
Normal file
76
api/src/controllers/category.controller.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2).max(80),
|
||||
color: z.string().min(4).max(32).default('#111827')
|
||||
});
|
||||
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
|
||||
const serialize = (category: Category) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
color: category.color,
|
||||
isSystem: category.isSystem,
|
||||
ownerId: category.user?.id ?? null
|
||||
});
|
||||
|
||||
export const listCategories = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const items = await categoryRepo()
|
||||
.createQueryBuilder('category')
|
||||
.leftJoinAndSelect('category.user', 'user')
|
||||
.where('category.isSystem = :isSystem', { isSystem: true })
|
||||
.orWhere('user.id = :userId', { userId: req.user?.id })
|
||||
.orderBy('category.name', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return res.json({ items: items.map(serialize) });
|
||||
};
|
||||
|
||||
export const createCategory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid category payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOneOrFail({ where: { id: req.user!.id } });
|
||||
const item = await categoryRepo().save(categoryRepo().create({ ...parsed.data, isSystem: false, user }));
|
||||
return res.status(201).json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const updateCategory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid category payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const itemId = String(req.params.id);
|
||||
const item = await categoryRepo().findOne({ where: { id: itemId }, relations: { user: true } });
|
||||
if (!item) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const canEdit = req.user?.role === 'ADMIN' || (!item.isSystem && item.user?.id === req.user?.id);
|
||||
if (!canEdit) return res.status(403).json({ message: 'You cannot edit this category' });
|
||||
|
||||
item.name = parsed.data.name;
|
||||
item.color = parsed.data.color;
|
||||
await categoryRepo().save(item);
|
||||
return res.json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const deleteCategory = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const itemId = String(req.params.id);
|
||||
const item = await categoryRepo().findOne({ where: { id: itemId }, relations: { user: true } });
|
||||
if (!item) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
const canDelete = req.user?.role === 'ADMIN' || (!item.isSystem && item.user?.id === req.user?.id);
|
||||
if (!canDelete) return res.status(403).json({ message: 'You cannot delete this category' });
|
||||
|
||||
await categoryRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
263
api/src/controllers/expense.controller.ts
Normal file
263
api/src/controllers/expense.controller.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
import { Proof } from '../entities/Proof.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
import { serializeProof } from '../utils/http.js';
|
||||
|
||||
const paymentMethodSchema = z.enum(['CARD', 'CASH', 'TRANSFER', 'BLIK', 'OTHER']).nullable().optional();
|
||||
const proofTypeSchema = z.enum(['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER']);
|
||||
|
||||
const createExpenseSchema = z.object({
|
||||
title: z.string().min(2).max(140),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
amount: z.coerce.number().positive(),
|
||||
expenseDate: z.string().min(10).max(10),
|
||||
categoryId: z.string().uuid(),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN'),
|
||||
proofType: proofTypeSchema.optional(),
|
||||
proofLabel: z.string().max(150).nullable().optional(),
|
||||
proofNote: z.string().max(1000).nullable().optional()
|
||||
});
|
||||
|
||||
const updateExpenseSchema = z.object({
|
||||
title: z.string().min(2).max(140),
|
||||
description: z.string().max(1000).nullable().optional(),
|
||||
amount: z.coerce.number().positive(),
|
||||
expenseDate: z.string().min(10).max(10),
|
||||
categoryId: z.string().uuid(),
|
||||
merchant: z.string().max(120).nullable().optional(),
|
||||
paymentMethod: paymentMethodSchema,
|
||||
currency: z.string().min(3).max(8).default('PLN')
|
||||
});
|
||||
|
||||
const addProofSchema = z.object({
|
||||
type: proofTypeSchema,
|
||||
label: z.string().max(150).nullable().optional(),
|
||||
note: z.string().max(1000).nullable().optional()
|
||||
});
|
||||
|
||||
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
||||
const categoryRepo = () => AppDataSource.getRepository(Category);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const proofRepo = () => AppDataSource.getRepository(Proof);
|
||||
|
||||
const serializeExpense = (expense: Expense) => ({
|
||||
id: expense.id,
|
||||
title: expense.title,
|
||||
description: expense.description,
|
||||
amount: expense.amount,
|
||||
expenseDate: expense.expenseDate,
|
||||
merchant: expense.merchant,
|
||||
paymentMethod: expense.paymentMethod,
|
||||
currency: expense.currency,
|
||||
possibleDuplicate: expense.possibleDuplicate,
|
||||
category: {
|
||||
id: expense.category.id,
|
||||
name: expense.category.name,
|
||||
color: expense.category.color,
|
||||
isSystem: expense.category.isSystem,
|
||||
ownerId: expense.category.user?.id ?? null
|
||||
},
|
||||
proofs: expense.proofs?.map(serializeProof) ?? [],
|
||||
createdAt: expense.createdAt,
|
||||
updatedAt: expense.updatedAt
|
||||
});
|
||||
|
||||
const removeUploadedFile = (filename?: string) => {
|
||||
if (!filename) return;
|
||||
const filePath = path.resolve(env.UPLOAD_DIR, filename);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
};
|
||||
|
||||
const isDuplicate = async (userId: string, amount: number, expenseDate: string, merchant?: string | null) => {
|
||||
const items = await expenseRepo().find({
|
||||
where: { user: { id: userId }, expenseDate },
|
||||
order: { createdAt: 'DESC' },
|
||||
take: 5
|
||||
});
|
||||
|
||||
const merchantKey = merchant?.trim().toLowerCase();
|
||||
return items.some(
|
||||
(item) =>
|
||||
Math.abs(item.amount - amount) < 0.001 &&
|
||||
((merchantKey && item.merchant?.trim().toLowerCase() === merchantKey) || !merchantKey)
|
||||
);
|
||||
};
|
||||
|
||||
export const listExpenses = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const startDate = typeof req.query.startDate === 'string' ? req.query.startDate : undefined;
|
||||
const endDate = typeof req.query.endDate === 'string' ? req.query.endDate : undefined;
|
||||
const categoryId = typeof req.query.categoryId === 'string' ? req.query.categoryId : undefined;
|
||||
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase() : undefined;
|
||||
|
||||
const items = await expenseRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
relations: { category: { user: true }, proofs: true, user: true },
|
||||
order: { expenseDate: 'DESC', createdAt: 'DESC' }
|
||||
});
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
if (startDate && item.expenseDate < startDate) return false;
|
||||
if (endDate && item.expenseDate > endDate) return false;
|
||||
if (categoryId && item.category.id !== categoryId) return false;
|
||||
if (search) {
|
||||
const haystack = [item.title, item.description ?? '', item.merchant ?? ''].join(' ').toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return res.json({ items: filtered.map(serializeExpense) });
|
||||
};
|
||||
|
||||
export const createExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = createExpenseSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
const category = await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
});
|
||||
|
||||
if (!user || !category) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(404).json({ message: 'Category not found' });
|
||||
}
|
||||
|
||||
const proofs: Proof[] = [];
|
||||
if (req.file || parsed.data.proofLabel || parsed.data.proofNote || parsed.data.proofType) {
|
||||
proofs.push(
|
||||
proofRepo().create({
|
||||
type: parsed.data.proofType ?? 'OTHER',
|
||||
label: parsed.data.proofLabel ?? req.file?.originalname ?? 'Attachment',
|
||||
note: parsed.data.proofNote ?? null,
|
||||
originalName: req.file?.originalname ?? null,
|
||||
storedName: req.file?.filename ?? null,
|
||||
mimeType: req.file?.mimetype ?? null,
|
||||
fileSize: req.file?.size ?? null
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const item = await expenseRepo().save(
|
||||
expenseRepo().create({
|
||||
title: parsed.data.title,
|
||||
description: parsed.data.description ?? null,
|
||||
amount: parsed.data.amount,
|
||||
expenseDate: parsed.data.expenseDate,
|
||||
merchant: parsed.data.merchant ?? null,
|
||||
paymentMethod: parsed.data.paymentMethod ?? null,
|
||||
currency: parsed.data.currency,
|
||||
possibleDuplicate: await isDuplicate(req.user!.id, parsed.data.amount, parsed.data.expenseDate, parsed.data.merchant),
|
||||
user,
|
||||
category,
|
||||
proofs
|
||||
})
|
||||
);
|
||||
|
||||
const fullItem = await expenseRepo().findOneOrFail({
|
||||
where: { id: item.id },
|
||||
relations: { category: { user: true }, proofs: true, user: true }
|
||||
});
|
||||
|
||||
return res.status(201).json({ item: serializeExpense(fullItem) });
|
||||
};
|
||||
|
||||
export const updateExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = updateExpenseSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid expense payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await expenseRepo().findOne({
|
||||
where: { id: String(req.params.id) },
|
||||
relations: { user: true, category: { user: true }, proofs: true }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||
}
|
||||
|
||||
const category = await categoryRepo().findOne({
|
||||
where: [{ id: parsed.data.categoryId, isSystem: true }, { id: parsed.data.categoryId, user: { id: req.user!.id } }],
|
||||
relations: { user: true }
|
||||
});
|
||||
if (!category) return res.status(404).json({ message: 'Category not found' });
|
||||
|
||||
item.title = parsed.data.title;
|
||||
item.description = parsed.data.description ?? null;
|
||||
item.amount = parsed.data.amount;
|
||||
item.expenseDate = parsed.data.expenseDate;
|
||||
item.merchant = parsed.data.merchant ?? null;
|
||||
item.paymentMethod = parsed.data.paymentMethod ?? null;
|
||||
item.currency = parsed.data.currency;
|
||||
item.category = category;
|
||||
|
||||
await expenseRepo().save(item);
|
||||
return res.json({ item: serializeExpense(item) });
|
||||
};
|
||||
|
||||
export const deleteExpense = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await expenseRepo().findOne({
|
||||
where: { id: String(req.params.id) },
|
||||
relations: { user: true, proofs: true }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Expense not found' });
|
||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||
return res.status(403).json({ message: 'You cannot delete this expense' });
|
||||
}
|
||||
|
||||
for (const proof of item.proofs ?? []) removeUploadedFile(proof.storedName ?? undefined);
|
||||
await expenseRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
|
||||
export const addProof = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = addProofSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(400).json({ message: 'Invalid attachment payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await expenseRepo().findOne({
|
||||
where: { id: String(req.params.id) },
|
||||
relations: { user: true, proofs: true, category: { user: true } }
|
||||
});
|
||||
if (!item) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(404).json({ message: 'Expense not found' });
|
||||
}
|
||||
if (req.user?.role !== 'ADMIN' && item.user.id !== req.user?.id) {
|
||||
removeUploadedFile(req.file?.filename);
|
||||
return res.status(403).json({ message: 'You cannot edit this expense' });
|
||||
}
|
||||
|
||||
const proof = await proofRepo().save(
|
||||
proofRepo().create({
|
||||
type: parsed.data.type,
|
||||
label: parsed.data.label ?? req.file?.originalname ?? 'Attachment',
|
||||
note: parsed.data.note ?? null,
|
||||
originalName: req.file?.originalname ?? null,
|
||||
storedName: req.file?.filename ?? null,
|
||||
mimeType: req.file?.mimetype ?? null,
|
||||
fileSize: req.file?.size ?? null,
|
||||
expense: item
|
||||
})
|
||||
);
|
||||
|
||||
item.proofs = [...(item.proofs ?? []), proof];
|
||||
return res.status(201).json({ proof: serializeProof(proof), expense: serializeExpense(item) });
|
||||
};
|
||||
75
api/src/controllers/merchant.controller.ts
Normal file
75
api/src/controllers/merchant.controller.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Merchant } from '../entities/Merchant.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2).max(120),
|
||||
kind: z.enum(['MERCHANT', 'SERVICE_PROVIDER', 'OTHER']).default('MERCHANT'),
|
||||
notes: z.string().max(1000).nullable().optional(),
|
||||
isActive: z.boolean().default(true)
|
||||
});
|
||||
|
||||
const merchantRepo = () => AppDataSource.getRepository(Merchant);
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
|
||||
const serialize = (item: Merchant) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
notes: item.notes,
|
||||
isActive: item.isActive,
|
||||
createdAt: item.createdAt,
|
||||
updatedAt: item.updatedAt
|
||||
});
|
||||
|
||||
export const listMerchants = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const items = await merchantRepo().find({
|
||||
where: { user: { id: req.user!.id } },
|
||||
order: { name: 'ASC' }
|
||||
});
|
||||
return res.json({ items: items.map(serialize) });
|
||||
};
|
||||
|
||||
export const createMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid partner payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOneOrFail({ where: { id: req.user!.id } });
|
||||
const item = await merchantRepo().save(merchantRepo().create({ ...parsed.data, user }));
|
||||
return res.status(201).json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const updateMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = schema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid partner payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const item = await merchantRepo().findOne({
|
||||
where: { id: String(req.params.id), user: { id: req.user!.id } }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Partner not found' });
|
||||
|
||||
item.name = parsed.data.name;
|
||||
item.kind = parsed.data.kind;
|
||||
item.notes = parsed.data.notes ?? null;
|
||||
item.isActive = parsed.data.isActive;
|
||||
await merchantRepo().save(item);
|
||||
|
||||
return res.json({ item: serialize(item) });
|
||||
};
|
||||
|
||||
export const deleteMerchant = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const item = await merchantRepo().findOne({
|
||||
where: { id: String(req.params.id), user: { id: req.user!.id } }
|
||||
});
|
||||
if (!item) return res.status(404).json({ message: 'Partner not found' });
|
||||
|
||||
await merchantRepo().remove(item);
|
||||
return res.status(204).send();
|
||||
};
|
||||
156
api/src/controllers/report.controller.ts
Normal file
156
api/src/controllers/report.controller.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { Response } from 'express';
|
||||
import { createRequire } from 'node:module';
|
||||
import { z } from 'zod';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { User } from '../entities/User.js';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
frequency: z.enum(['monthly', 'yearly', 'threshold']),
|
||||
thresholdAmount: z.number().min(0).default(0),
|
||||
sendToEmail: z.email().nullable().optional(),
|
||||
categoryIds: z.array(z.string().uuid()).default([])
|
||||
});
|
||||
|
||||
const previewOverrideSchema = preferencesSchema.partial();
|
||||
|
||||
const userRepo = () => AppDataSource.getRepository(User);
|
||||
const require = createRequire(import.meta.url);
|
||||
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
||||
|
||||
const defaultPrefs = (email: string) => ({
|
||||
enabled: false,
|
||||
frequency: 'monthly' as const,
|
||||
thresholdAmount: 0,
|
||||
sendToEmail: email,
|
||||
categoryIds: [] as string[]
|
||||
});
|
||||
|
||||
const periodRange = (frequency: 'monthly' | 'yearly' | 'threshold') => {
|
||||
const now = new Date();
|
||||
const endDate = now.toISOString().slice(0, 10);
|
||||
|
||||
if (frequency === 'yearly') {
|
||||
return { startDate: `${now.getUTCFullYear()}-01-01`, endDate, bucket: 'month' as const, label: 'Year to date' };
|
||||
}
|
||||
|
||||
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
|
||||
return { startDate: start.toISOString().slice(0, 10), endDate, bucket: 'month' as const, label: 'Current month' };
|
||||
};
|
||||
|
||||
const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>) => {
|
||||
const categoryRows = summary.byCategory
|
||||
.slice(0, 6)
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.categoryName}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
const timelineRows = summary.timeline
|
||||
.slice(-6)
|
||||
.map((item) => `<tr><td style="padding:8px 0;border-bottom:1px solid #e5e7eb">${item.label}</td><td style="padding:8px 0;border-bottom:1px solid #e5e7eb;text-align:right">${item.total.toFixed(2)}</td></tr>`)
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<div style="font-family:Arial,sans-serif;color:#111827;max-width:760px;margin:0 auto">
|
||||
<h1 style="margin-bottom:8px">${title}</h1>
|
||||
<p style="color:#4b5563;margin-top:0">Total: <strong>${summary.total.toFixed(2)}</strong> | Count: <strong>${summary.count}</strong> | Average: <strong>${summary.average.toFixed(2)}</strong></p>
|
||||
<h2 style="margin-top:32px">Top categories</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${categoryRows || '<tr><td>No data</td></tr>'}</table>
|
||||
<h2 style="margin-top:32px">Timeline</h2>
|
||||
<table style="width:100%;border-collapse:collapse">${timelineRows || '<tr><td>No data</td></tr>'}</table>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
export const getPreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
return res.json({ item: user.reportPreferences ?? defaultPrefs(user.email) });
|
||||
};
|
||||
|
||||
export const updatePreferences = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = preferencesSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid report preferences payload', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
user.reportPreferences = parsed.data;
|
||||
await userRepo().save(user);
|
||||
return res.json({ item: user.reportPreferences });
|
||||
};
|
||||
|
||||
export const previewReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
const overridesParsed = previewOverrideSchema.safeParse(req.body ?? {});
|
||||
if (!overridesParsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid report preview payload', issues: overridesParsed.error.issues });
|
||||
}
|
||||
|
||||
const prefs = { ...defaultPrefs(user.email), ...(user.reportPreferences ?? {}), ...overridesParsed.data };
|
||||
const range = periodRange(prefs.frequency ?? 'monthly');
|
||||
const summary = await getStatistics(
|
||||
{
|
||||
userId: user.id,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
categoryIds: prefs.categoryIds?.length ? prefs.categoryIds : undefined
|
||||
},
|
||||
range.bucket
|
||||
);
|
||||
|
||||
return res.json({
|
||||
range,
|
||||
summary,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
});
|
||||
};
|
||||
|
||||
export const sendReport = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const user = await userRepo().findOne({ where: { id: req.user!.id } });
|
||||
if (!user) return res.status(404).json({ message: 'User not found' });
|
||||
|
||||
const settings = await settingsRepo().find({ take: 1, order: { createdAt: 'ASC' } });
|
||||
const appSettings = settings[0];
|
||||
if (!appSettings || !appSettings.smtpEnabled || !appSettings.smtpHost || !appSettings.smtpFromEmail) {
|
||||
return res.status(400).json({ message: 'SMTP is not configured' });
|
||||
}
|
||||
|
||||
const prefs = user.reportPreferences ?? defaultPrefs(user.email);
|
||||
const range = periodRange(prefs.frequency ?? 'monthly');
|
||||
const summary = await getStatistics(
|
||||
{
|
||||
userId: user.id,
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
categoryIds: prefs.categoryIds?.length ? prefs.categoryIds : undefined
|
||||
},
|
||||
range.bucket
|
||||
);
|
||||
|
||||
const nodemailer = require('nodemailer') as { createTransport: (options: unknown) => { sendMail: (message: unknown) => Promise<unknown> } };
|
||||
const transport = nodemailer.createTransport({
|
||||
host: appSettings.smtpHost,
|
||||
port: appSettings.smtpPort,
|
||||
secure: appSettings.smtpSecure,
|
||||
auth: appSettings.smtpUser ? { user: appSettings.smtpUser, pass: appSettings.smtpPassword ?? '' } : undefined
|
||||
});
|
||||
|
||||
const to = prefs.sendToEmail || user.email;
|
||||
await transport.sendMail({
|
||||
from: appSettings.smtpFromName
|
||||
? `"${appSettings.smtpFromName}" <${appSettings.smtpFromEmail}>`
|
||||
: appSettings.smtpFromEmail,
|
||||
to,
|
||||
subject: `${appSettings.appName} - ${range.label} report`,
|
||||
html: buildReportHtml(`${range.label} report`, summary)
|
||||
});
|
||||
|
||||
return res.json({ message: 'Report email was sent', sentTo: to });
|
||||
};
|
||||
31
api/src/controllers/statistics.controller.ts
Normal file
31
api/src/controllers/statistics.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getStatistics } from '../services/statistics.service.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
|
||||
const querySchema = z.object({
|
||||
startDate: z.string().optional(),
|
||||
endDate: z.string().optional(),
|
||||
categoryIds: z.string().optional(),
|
||||
bucket: z.enum(['month', 'quarter', 'year']).optional()
|
||||
});
|
||||
|
||||
export const getOverview = async (req: AuthenticatedRequest, res: Response) => {
|
||||
const parsed = querySchema.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ message: 'Invalid statistics filters', issues: parsed.error.issues });
|
||||
}
|
||||
|
||||
const categoryIds = parsed.data.categoryIds ? parsed.data.categoryIds.split(',').filter(Boolean) : [];
|
||||
return res.json(
|
||||
await getStatistics(
|
||||
{
|
||||
userId: req.user!.id,
|
||||
startDate: parsed.data.startDate,
|
||||
endDate: parsed.data.endDate,
|
||||
categoryIds
|
||||
},
|
||||
parsed.data.bucket ?? 'month'
|
||||
)
|
||||
);
|
||||
};
|
||||
52
api/src/entities/AppSetting.ts
Normal file
52
api/src/entities/AppSetting.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
|
||||
@Entity('app_settings')
|
||||
export class AppSetting {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, default: 'Expense Control' })
|
||||
appName!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
||||
defaultCurrency!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
registrationEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
allowedProofTypes!: string[];
|
||||
|
||||
@Column({ type: 'simple-json' })
|
||||
uiPreferences!: Record<string, string | number | boolean>;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
smtpEnabled!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
smtpHost!: string | null;
|
||||
|
||||
@Column({ type: 'int', default: 587 })
|
||||
smtpPort!: number;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
smtpSecure!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||
smtpUser!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
smtpPassword!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
smtpFromName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 160, nullable: true })
|
||||
smtpFromEmail!: string | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
27
api/src/entities/Category.ts
Normal file
27
api/src/entities/Category.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Expense } from './Expense.js';
|
||||
import { User } from './User.js';
|
||||
|
||||
@Entity('categories')
|
||||
export class Category {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 32, default: '#0d6efd' })
|
||||
color!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isSystem!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.categories, { nullable: true, onDelete: 'CASCADE' })
|
||||
user!: User | null;
|
||||
|
||||
@OneToMany(() => Expense, (expense) => expense.category)
|
||||
expenses!: Expense[];
|
||||
}
|
||||
50
api/src/entities/Expense.ts
Normal file
50
api/src/entities/Expense.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { Category } from './Category.js';
|
||||
import { Proof } from './Proof.js';
|
||||
import { User } from './User.js';
|
||||
import { decimalTransformer } from '../utils/decimal.js';
|
||||
|
||||
@Entity('expenses')
|
||||
export class Expense {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 140 })
|
||||
title!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
@Column({ type: 'decimal', precision: 12, scale: 2, transformer: decimalTransformer })
|
||||
amount!: number;
|
||||
|
||||
@Column({ type: 'date' })
|
||||
expenseDate!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 80, nullable: true })
|
||||
merchant!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 50, nullable: true })
|
||||
paymentMethod!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 12, default: 'PLN' })
|
||||
currency!: string;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
possibleDuplicate!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, (user) => user.expenses, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
|
||||
@ManyToOne(() => Category, (category) => category.expenses, { eager: true, onDelete: 'RESTRICT' })
|
||||
category!: Category;
|
||||
|
||||
@OneToMany(() => Proof, (proof) => proof.expense, { eager: true, cascade: true })
|
||||
proofs!: Proof[];
|
||||
}
|
||||
29
api/src/entities/Merchant.ts
Normal file
29
api/src/entities/Merchant.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
|
||||
import { User } from './User.js';
|
||||
|
||||
@Entity('merchants')
|
||||
export class Merchant {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, default: 'MERCHANT' })
|
||||
kind!: 'MERCHANT' | 'SERVICE_PROVIDER' | 'OTHER';
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes!: string | null;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'datetime' })
|
||||
updatedAt!: Date;
|
||||
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
user!: User;
|
||||
}
|
||||
37
api/src/entities/Proof.ts
Normal file
37
api/src/entities/Proof.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Column, CreateDateColumn, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Expense } from './Expense.js';
|
||||
|
||||
export type ProofType = 'RECEIPT' | 'INVOICE' | 'NOTE' | 'BANK_STATEMENT' | 'OTHER';
|
||||
|
||||
@Entity('proofs')
|
||||
export class Proof {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 30, default: 'OTHER' })
|
||||
type!: ProofType;
|
||||
|
||||
@Column({ type: 'varchar', length: 150, nullable: true })
|
||||
label!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
note!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
originalName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
storedName!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 120, nullable: true })
|
||||
mimeType!: string | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
fileSize!: number | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@ManyToOne(() => Expense, (expense) => expense.proofs, { onDelete: 'CASCADE' })
|
||||
expense!: Expense;
|
||||
}
|
||||
47
api/src/entities/User.ts
Normal file
47
api/src/entities/User.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Category } from './Category.js';
|
||||
import { Expense } from './Expense.js';
|
||||
|
||||
export type UserRole = 'ADMIN' | 'USER';
|
||||
|
||||
@Entity('users')
|
||||
export class User {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 120 })
|
||||
fullName!: string;
|
||||
|
||||
@Column({ type: 'varchar', unique: true, length: 160 })
|
||||
email!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 255 })
|
||||
passwordHash!: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'USER' })
|
||||
role!: UserRole;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@Column({ type: 'varchar', length: 8, default: 'PLN' })
|
||||
defaultCurrency!: string;
|
||||
|
||||
@Column({ type: 'simple-json', nullable: true })
|
||||
reportPreferences!: {
|
||||
enabled?: boolean;
|
||||
frequency?: 'monthly' | 'yearly' | 'threshold';
|
||||
thresholdAmount?: number;
|
||||
sendToEmail?: string | null;
|
||||
categoryIds?: string[];
|
||||
} | null;
|
||||
|
||||
@CreateDateColumn({ type: 'datetime' })
|
||||
createdAt!: Date;
|
||||
|
||||
@OneToMany(() => Category, (category) => category.user)
|
||||
categories!: Category[];
|
||||
|
||||
@OneToMany(() => Expense, (expense) => expense.user)
|
||||
expenses!: Expense[];
|
||||
}
|
||||
18
api/src/middleware/auth.ts
Normal file
18
api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NextFunction, Response } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { env } from '../config/env.js';
|
||||
import type { AuthenticatedRequest } from '../types/express.js';
|
||||
export const requireAuth = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) return res.status(401).json({ message: 'Brak tokenu autoryzacji' });
|
||||
try {
|
||||
req.user = jwt.verify(header.replace('Bearer ', ''), env.JWT_SECRET) as { id: string; email: string; role: 'ADMIN' | 'USER' };
|
||||
return next();
|
||||
} catch {
|
||||
return res.status(401).json({ message: 'Nieprawidlowy token' });
|
||||
}
|
||||
};
|
||||
export const requireAdmin = (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user || req.user.role !== 'ADMIN') return res.status(403).json({ message: 'Wymagane uprawnienia administratora' });
|
||||
return next();
|
||||
};
|
||||
20
api/src/middleware/error-handler.ts
Normal file
20
api/src/middleware/error-handler.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
|
||||
export const notFoundHandler = (_req: Request, res: Response) =>
|
||||
res.status(404).json({ message: 'Resource not found' });
|
||||
|
||||
export const errorHandler = (
|
||||
error: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) => {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ message: 'Attachment is too large' });
|
||||
}
|
||||
|
||||
return res.status(500).json({ message: error.message || 'Internal server error' });
|
||||
};
|
||||
9
api/src/middleware/upload.ts
Normal file
9
api/src/middleware/upload.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import multer from 'multer';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { env } from '../config/env.js';
|
||||
const uploadDir = path.resolve(env.UPLOAD_DIR);
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
const storage = multer.diskStorage({ destination: (_req, _file, cb) => cb(null, uploadDir), filename: (_req, file, cb) => cb(null, `${Date.now()}-${uuidv4()}${path.extname(file.originalname || '')}`) });
|
||||
export const uploadSingleProof = multer({ storage, limits: { fileSize: env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 } }).single('proofFile');
|
||||
18
api/src/routes/admin.routes.ts
Normal file
18
api/src/routes/admin.routes.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getSettings,
|
||||
listUsers,
|
||||
testSmtp,
|
||||
updateSettings,
|
||||
updateUser
|
||||
} from '../controllers/admin.controller.js';
|
||||
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const adminRouter = Router();
|
||||
|
||||
adminRouter.use(requireAuth, requireAdmin);
|
||||
adminRouter.get('/settings', getSettings);
|
||||
adminRouter.put('/settings', updateSettings);
|
||||
adminRouter.get('/users', listUsers);
|
||||
adminRouter.patch('/users/:id', updateUser);
|
||||
adminRouter.post('/test-smtp', testSmtp);
|
||||
8
api/src/routes/auth.routes.ts
Normal file
8
api/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Router } from 'express';
|
||||
import { login, me, publicConfig, register } from '../controllers/auth.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
export const authRouter = Router();
|
||||
authRouter.post('/register', register);
|
||||
authRouter.post('/login', login);
|
||||
authRouter.get('/config', publicConfig);
|
||||
authRouter.get('/me', requireAuth, me);
|
||||
9
api/src/routes/category.routes.ts
Normal file
9
api/src/routes/category.routes.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Router } from 'express';
|
||||
import { createCategory, deleteCategory, listCategories, updateCategory } from '../controllers/category.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
export const categoryRouter = Router();
|
||||
categoryRouter.use(requireAuth);
|
||||
categoryRouter.get('/', listCategories);
|
||||
categoryRouter.post('/', createCategory);
|
||||
categoryRouter.put('/:id', updateCategory);
|
||||
categoryRouter.delete('/:id', deleteCategory);
|
||||
11
api/src/routes/expense.routes.ts
Normal file
11
api/src/routes/expense.routes.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Router } from 'express';
|
||||
import { addProof, createExpense, deleteExpense, listExpenses, updateExpense } from '../controllers/expense.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { uploadSingleProof } from '../middleware/upload.js';
|
||||
export const expenseRouter = Router();
|
||||
expenseRouter.use(requireAuth);
|
||||
expenseRouter.get('/', listExpenses);
|
||||
expenseRouter.post('/', uploadSingleProof, createExpense);
|
||||
expenseRouter.put('/:id', updateExpense);
|
||||
expenseRouter.delete('/:id', deleteExpense);
|
||||
expenseRouter.post('/:id/proofs', uploadSingleProof, addProof);
|
||||
19
api/src/routes/index.ts
Normal file
19
api/src/routes/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Router } from 'express';
|
||||
import { adminRouter } from './admin.routes.js';
|
||||
import { authRouter } from './auth.routes.js';
|
||||
import { categoryRouter } from './category.routes.js';
|
||||
import { expenseRouter } from './expense.routes.js';
|
||||
import { merchantRouter } from './merchant.routes.js';
|
||||
import { reportRouter } from './report.routes.js';
|
||||
import { statisticsRouter } from './statistics.routes.js';
|
||||
|
||||
export const apiRouter = Router();
|
||||
|
||||
apiRouter.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||||
apiRouter.use('/auth', authRouter);
|
||||
apiRouter.use('/categories', categoryRouter);
|
||||
apiRouter.use('/expenses', expenseRouter);
|
||||
apiRouter.use('/statistics', statisticsRouter);
|
||||
apiRouter.use('/merchants', merchantRouter);
|
||||
apiRouter.use('/reports', reportRouter);
|
||||
apiRouter.use('/admin', adminRouter);
|
||||
16
api/src/routes/merchant.routes.ts
Normal file
16
api/src/routes/merchant.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
createMerchant,
|
||||
deleteMerchant,
|
||||
listMerchants,
|
||||
updateMerchant
|
||||
} from '../controllers/merchant.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const merchantRouter = Router();
|
||||
|
||||
merchantRouter.use(requireAuth);
|
||||
merchantRouter.get('/', listMerchants);
|
||||
merchantRouter.post('/', createMerchant);
|
||||
merchantRouter.put('/:id', updateMerchant);
|
||||
merchantRouter.delete('/:id', deleteMerchant);
|
||||
16
api/src/routes/report.routes.ts
Normal file
16
api/src/routes/report.routes.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
getPreferences,
|
||||
previewReport,
|
||||
sendReport,
|
||||
updatePreferences
|
||||
} from '../controllers/report.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
export const reportRouter = Router();
|
||||
|
||||
reportRouter.use(requireAuth);
|
||||
reportRouter.get('/preferences', getPreferences);
|
||||
reportRouter.put('/preferences', updatePreferences);
|
||||
reportRouter.post('/preview', previewReport);
|
||||
reportRouter.post('/send', sendReport);
|
||||
6
api/src/routes/statistics.routes.ts
Normal file
6
api/src/routes/statistics.routes.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { getOverview } from '../controllers/statistics.controller.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
export const statisticsRouter = Router();
|
||||
statisticsRouter.use(requireAuth);
|
||||
statisticsRouter.get('/overview', getOverview);
|
||||
10
api/src/server.ts
Normal file
10
api/src/server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AppDataSource } from './config/data-source.js';
|
||||
import { env } from './config/env.js';
|
||||
import { createApp } from './app.js';
|
||||
import { bootstrapData } from './services/seed.service.js';
|
||||
const start = async () => {
|
||||
await AppDataSource.initialize();
|
||||
await bootstrapData();
|
||||
createApp().listen(env.APP_PORT, () => console.log(`API running on http://localhost:${env.APP_PORT}/api`));
|
||||
};
|
||||
start().catch((error) => { console.error('Failed to start API', error); process.exit(1); });
|
||||
60
api/src/services/auth.service.ts
Normal file
60
api/src/services/auth.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt, { type SignOptions } from 'jsonwebtoken';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { User, type UserRole } from '../entities/User.js';
|
||||
|
||||
const repo = () => AppDataSource.getRepository(User);
|
||||
|
||||
export const sanitizeUser = (user: User) => ({
|
||||
id: user.id,
|
||||
fullName: user.fullName,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
isActive: user.isActive,
|
||||
defaultCurrency: user.defaultCurrency,
|
||||
reportPreferences: user.reportPreferences ?? {
|
||||
enabled: false,
|
||||
frequency: 'monthly',
|
||||
thresholdAmount: 0,
|
||||
sendToEmail: user.email,
|
||||
categoryIds: []
|
||||
},
|
||||
createdAt: user.createdAt
|
||||
});
|
||||
|
||||
export const hashPassword = async (password: string) => bcrypt.hash(password, 10);
|
||||
export const comparePassword = async (password: string, hash: string) => bcrypt.compare(password, hash);
|
||||
|
||||
export const signToken = (payload: { id: string; email: string; role: UserRole }) =>
|
||||
jwt.sign(payload, env.JWT_SECRET, { expiresIn: env.JWT_EXPIRES_IN as SignOptions['expiresIn'] });
|
||||
|
||||
export const findUserByEmail = (email: string) => repo().findOne({ where: { email: email.toLowerCase() } });
|
||||
|
||||
export const createUser = async (input: {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
role?: UserRole;
|
||||
defaultCurrency?: string;
|
||||
}) => {
|
||||
const existing = await repo().findOne({ where: { email: input.email.toLowerCase() } });
|
||||
if (existing) throw new Error('Email already exists');
|
||||
|
||||
const user = repo().create({
|
||||
fullName: input.fullName,
|
||||
email: input.email.toLowerCase(),
|
||||
passwordHash: await hashPassword(input.password),
|
||||
role: input.role ?? 'USER',
|
||||
defaultCurrency: input.defaultCurrency ?? env.DEFAULT_CURRENCY,
|
||||
reportPreferences: {
|
||||
enabled: false,
|
||||
frequency: 'monthly',
|
||||
thresholdAmount: 0,
|
||||
sendToEmail: input.email.toLowerCase(),
|
||||
categoryIds: []
|
||||
}
|
||||
});
|
||||
|
||||
return repo().save(user);
|
||||
};
|
||||
60
api/src/services/seed.service.ts
Normal file
60
api/src/services/seed.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { env } from '../config/env.js';
|
||||
import { AppSetting } from '../entities/AppSetting.js';
|
||||
import { Category } from '../entities/Category.js';
|
||||
import { createUser, findUserByEmail } from './auth.service.js';
|
||||
|
||||
const systemCategories = [
|
||||
{ name: 'Rachunki', color: '#b91c1c' },
|
||||
{ name: 'Zakupy', color: '#2563eb' },
|
||||
{ name: 'Transport', color: '#0891b2' },
|
||||
{ name: 'Zdrowie', color: '#16a34a' },
|
||||
{ name: 'Rozrywka', color: '#7c3aed' },
|
||||
{ name: 'Inne', color: '#475569' }
|
||||
];
|
||||
|
||||
export const bootstrapData = async () => {
|
||||
const categoryRepo = AppDataSource.getRepository(Category);
|
||||
const settingsRepo = AppDataSource.getRepository(AppSetting);
|
||||
|
||||
if (!(await findUserByEmail(env.ADMIN_EMAIL))) {
|
||||
await createUser({
|
||||
fullName: 'Master Admin',
|
||||
email: env.ADMIN_EMAIL,
|
||||
password: env.ADMIN_PASSWORD,
|
||||
role: 'ADMIN',
|
||||
defaultCurrency: env.DEFAULT_CURRENCY
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of systemCategories) {
|
||||
const existing = await categoryRepo.findOne({ where: { name: item.name, isSystem: true } });
|
||||
if (!existing) {
|
||||
await categoryRepo.save(categoryRepo.create({ ...item, isSystem: true, user: null }));
|
||||
} else if (existing.color !== item.color) {
|
||||
existing.color = item.color;
|
||||
await categoryRepo.save(existing);
|
||||
}
|
||||
}
|
||||
|
||||
const [settings] = await settingsRepo.find({ take: 1, order: { createdAt: 'ASC' } });
|
||||
if (!settings) {
|
||||
await settingsRepo.save(
|
||||
settingsRepo.create({
|
||||
appName: env.APP_NAME,
|
||||
defaultCurrency: env.DEFAULT_CURRENCY,
|
||||
registrationEnabled: true,
|
||||
allowedProofTypes: ['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER'],
|
||||
uiPreferences: { theme: 'dark', density: 'comfortable', defaultStatsPeriod: 'month' },
|
||||
smtpEnabled: false,
|
||||
smtpHost: null,
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUser: null,
|
||||
smtpPassword: null,
|
||||
smtpFromName: env.APP_NAME,
|
||||
smtpFromEmail: env.ADMIN_EMAIL
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
41
api/src/services/statistics.service.ts
Normal file
41
api/src/services/statistics.service.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Between, In } from 'typeorm';
|
||||
import { AppDataSource } from '../config/data-source.js';
|
||||
import { Expense } from '../entities/Expense.js';
|
||||
export type StatsFilters = { userId?: string; startDate?: string; endDate?: string; categoryIds?: string[] };
|
||||
export type FlatExpense = { id: string; amount: number; expenseDate: string; categoryId: string; categoryName: string };
|
||||
const labelMonth = (date: string) => date.slice(0, 7);
|
||||
const labelQuarter = (date: string) => { const [year, month] = date.split('-').map(Number); return `${year}-Q${Math.ceil(month / 3)}`; };
|
||||
const labelYear = (date: string) => date.slice(0, 4);
|
||||
export const buildBucketLabel = (date: string, bucket: 'month' | 'quarter' | 'year') => bucket === 'year' ? labelYear(date) : bucket === 'quarter' ? labelQuarter(date) : labelMonth(date);
|
||||
export const aggregateStatistics = (expenses: FlatExpense[], bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||
const total = expenses.reduce((sum, item) => sum + item.amount, 0);
|
||||
const byCategoryMap = new Map<string, { categoryId: string; categoryName: string; total: number; count: number }>();
|
||||
const timelineMap = new Map<string, number>();
|
||||
for (const expense of expenses) {
|
||||
const existing = byCategoryMap.get(expense.categoryId) ?? { categoryId: expense.categoryId, categoryName: expense.categoryName, total: 0, count: 0 };
|
||||
existing.total += expense.amount;
|
||||
existing.count += 1;
|
||||
byCategoryMap.set(expense.categoryId, existing);
|
||||
const bucketLabel = buildBucketLabel(expense.expenseDate, bucket);
|
||||
timelineMap.set(bucketLabel, (timelineMap.get(bucketLabel) ?? 0) + expense.amount);
|
||||
}
|
||||
const byCategory = [...byCategoryMap.values()].sort((a, b) => b.total - a.total).map((item) => ({ ...item, total: Number(item.total.toFixed(2)) }));
|
||||
const timeline = [...timelineMap.entries()].map(([label, totalValue]) => ({ label, total: Number(totalValue.toFixed(2)) })).sort((a, b) => a.label.localeCompare(b.label));
|
||||
return { total: Number(total.toFixed(2)), count: expenses.length, average: expenses.length ? Number((total / expenses.length).toFixed(2)) : 0, byCategory, timeline, topCategory: byCategory[0] ?? null };
|
||||
};
|
||||
export const getStatistics = async (filters: StatsFilters, bucket: 'month' | 'quarter' | 'year' = 'month') => {
|
||||
const repo = AppDataSource.getRepository(Expense);
|
||||
const where: Record<string, unknown> = {};
|
||||
if (filters.userId) where.user = { id: filters.userId };
|
||||
if (filters.startDate && filters.endDate) where.expenseDate = Between(filters.startDate, filters.endDate);
|
||||
else if (filters.startDate) where.expenseDate = Between(filters.startDate, '2999-12-31');
|
||||
else if (filters.endDate) where.expenseDate = Between('1900-01-01', filters.endDate);
|
||||
if (filters.categoryIds?.length) where.category = { id: In(filters.categoryIds) };
|
||||
const expenses = await repo.find({ where, relations: { category: true }, order: { expenseDate: 'DESC' } });
|
||||
return aggregateStatistics(expenses.map((expense) => ({ id: expense.id, amount: expense.amount, expenseDate: expense.expenseDate, categoryId: expense.category.id, categoryName: expense.category.name })), bucket);
|
||||
};
|
||||
export const detectPotentialDuplicate = async (input: { userId: string; amount: number; expenseDate: string; merchant?: string | null }) => {
|
||||
const repo = AppDataSource.getRepository(Expense);
|
||||
const candidates = await repo.find({ where: { user: { id: input.userId }, expenseDate: Between(input.expenseDate, input.expenseDate) } });
|
||||
return candidates.some((item) => Math.abs(item.amount - input.amount) < 0.001 && (input.merchant ? item.merchant?.toLowerCase() === input.merchant.toLowerCase() : true));
|
||||
};
|
||||
3
api/src/types/express.ts
Normal file
3
api/src/types/express.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { Request } from 'express';
|
||||
export type AuthUser = { id: string; role: 'ADMIN' | 'USER'; email: string };
|
||||
export type AuthenticatedRequest = Request & { user?: AuthUser };
|
||||
4
api/src/utils/decimal.ts
Normal file
4
api/src/utils/decimal.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const decimalTransformer = {
|
||||
to: (value: number | null | undefined) => value == null ? null : value.toFixed(2),
|
||||
from: (value: string | number | null | undefined) => value == null ? 0 : Number(value)
|
||||
};
|
||||
13
api/src/utils/http.ts
Normal file
13
api/src/utils/http.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Proof } from '../entities/Proof.js';
|
||||
export const buildProofUrl = (storedName: string | null) => storedName ? `/uploads/${storedName}` : null;
|
||||
export const serializeProof = (proof: Proof) => ({
|
||||
id: proof.id,
|
||||
type: proof.type,
|
||||
label: proof.label,
|
||||
note: proof.note,
|
||||
originalName: proof.originalName,
|
||||
mimeType: proof.mimeType,
|
||||
fileSize: proof.fileSize,
|
||||
fileUrl: buildProofUrl(proof.storedName),
|
||||
createdAt: proof.createdAt
|
||||
});
|
||||
Reference in New Issue
Block a user