This commit is contained in:
Mateusz Gruszczyński
2026-04-06 10:46:48 +02:00
parent 1ba1a26291
commit 0c7414101a
23 changed files with 962 additions and 355 deletions

View File

@@ -10,7 +10,6 @@ DB_USER=expense_app
DB_PASSWORD=expense_app
DB_SYNC=true
DB_LOGGING=false
APP_NAME=Expense Control
DEFAULT_CURRENCY=PLN
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=ChangeMe123!

39
api/package-lock.json generated
View File

@@ -604,9 +604,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -621,9 +618,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -638,9 +632,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -655,9 +646,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -672,9 +660,6 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -689,9 +674,6 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -706,9 +688,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -723,9 +702,6 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -740,9 +716,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -757,9 +730,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -774,9 +744,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -791,9 +758,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -808,9 +772,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [

View File

@@ -23,7 +23,6 @@ export const env = {
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!',

View File

@@ -23,6 +23,7 @@ const loginSchema = z.object({
password: z.string().min(8).max(100)
});
const DEFAULT_APP_NAME = 'Expense Control';
export const publicConfig = async (_req: Request, res: Response) => {
const settings = await AppDataSource.getRepository(AppSetting).find({
@@ -31,7 +32,7 @@ export const publicConfig = async (_req: Request, res: Response) => {
});
return res.json({
appName: settings[0]?.appName ?? 'Expense Control',
appName: settings[0]?.appName ?? DEFAULT_APP_NAME,
registrationEnabled: settings[0]?.registrationEnabled ?? true
});
};

View File

@@ -36,7 +36,7 @@ export const listMerchants = async (req: AuthenticatedRequest, res: Response) =>
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 });
return res.status(400).json({ message: 'Invalid merchant payload', issues: parsed.error.issues });
}
const user = await userRepo().findOneOrFail({ where: { id: req.user!.id } });
@@ -47,13 +47,13 @@ export const createMerchant = async (req: AuthenticatedRequest, res: Response) =
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 });
return res.status(400).json({ message: 'Invalid merchant 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' });
if (!item) return res.status(404).json({ message: 'Merchant not found' });
item.name = parsed.data.name;
item.kind = parsed.data.kind;
@@ -68,7 +68,7 @@ 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' });
if (!item) return res.status(404).json({ message: 'Merchant not found' });
await merchantRepo().remove(item);
return res.status(204).send();

View File

@@ -29,16 +29,23 @@ const defaultPrefs = (email: string) => ({
categoryIds: [] as string[]
});
const formatLocalDate = (date: Date) => {
const year = date.getFullYear();
const month = `${date.getMonth() + 1}`.padStart(2, '0');
const day = `${date.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
};
const periodRange = (frequency: 'monthly' | 'yearly' | 'threshold') => {
const now = new Date();
const endDate = now.toISOString().slice(0, 10);
const endDate = formatLocalDate(now);
if (frequency === 'yearly') {
return { startDate: `${now.getUTCFullYear()}-01-01`, endDate, bucket: 'month' as const, label: 'Year to date' };
return { startDate: `${now.getFullYear()}-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 start = new Date(now.getFullYear(), now.getMonth(), 1);
return { startDate: formatLocalDate(start), endDate, bucket: 'month' as const, label: 'Current month' };
};
const buildReportHtml = (title: string, summary: Awaited<ReturnType<typeof getStatistics>>) => {

View File

@@ -4,15 +4,15 @@ 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' });
if (!header?.startsWith('Bearer ')) return res.status(401).json({ message: 'Authorization token is missing' });
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' });
return res.status(401).json({ message: 'Invalid 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' });
if (!req.user || req.user.role !== 'ADMIN') return res.status(403).json({ message: 'Administrator access is required' });
return next();
};

View File

@@ -39,7 +39,7 @@ export const createUser = async (input: {
defaultCurrency?: string;
}) => {
const existing = await repo().findOne({ where: { email: input.email.toLowerCase() } });
if (existing) throw new Error('Email already exists');
if (existing) throw new Error('Email address is already in use');
const user = repo().create({
fullName: input.fullName,

View File

@@ -4,6 +4,8 @@ import { AppSetting } from '../entities/AppSetting.js';
import { Category } from '../entities/Category.js';
import { createUser, findUserByEmail } from './auth.service.js';
const DEFAULT_APP_NAME = 'Expense Control';
const systemCategories = [
{ name: 'Rachunki', color: '#b91c1c' },
{ name: 'Zakupy', color: '#2563eb' },
@@ -41,7 +43,7 @@ export const bootstrapData = async () => {
if (!settings) {
await settingsRepo.save(
settingsRepo.create({
appName: env.APP_NAME,
appName: DEFAULT_APP_NAME,
defaultCurrency: env.DEFAULT_CURRENCY,
registrationEnabled: true,
allowedProofTypes: ['RECEIPT', 'INVOICE', 'NOTE', 'BANK_STATEMENT', 'OTHER'],
@@ -52,7 +54,7 @@ export const bootstrapData = async () => {
smtpSecure: false,
smtpUser: null,
smtpPassword: null,
smtpFromName: env.APP_NAME,
smtpFromName: DEFAULT_APP_NAME,
smtpFromEmail: env.ADMIN_EMAIL
})
);