first commit

This commit is contained in:
Mateusz Gruszczyński
2026-04-05 13:40:27 +02:00
commit 9a6e77a5fc
89 changed files with 18276 additions and 0 deletions

View 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 });
};