first commit
This commit is contained in:
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));
|
||||
};
|
||||
Reference in New Issue
Block a user