304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
import type { Response } from 'express';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { z } from 'zod';
|
|
import { AppDataSource } from '../config/data-source.js';
|
|
import { env } from '../config/env.js';
|
|
import { AppSetting } from '../entities/AppSetting.js';
|
|
import { Budget } from '../entities/Budget.js';
|
|
import { Category } from '../entities/Category.js';
|
|
import { Expense } from '../entities/Expense.js';
|
|
import { Merchant } from '../entities/Merchant.js';
|
|
import { RecurringExpense } from '../entities/RecurringExpense.js';
|
|
import { User } from '../entities/User.js';
|
|
import { createUser, hashPassword, sanitizeUser } from '../services/auth.service.js';
|
|
import { createSmtpTransport, describeSmtpMode, formatFromAddress } from '../services/mail.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 userCreateSchema = z.object({
|
|
fullName: z.string().min(2).max(120),
|
|
email: z.email(),
|
|
password: z.string().min(8).max(100),
|
|
role: z.enum(['ADMIN', 'USER']).optional(),
|
|
defaultCurrency: z.string().min(3).max(8).optional(),
|
|
isActive: z.boolean().optional(),
|
|
integrationsEnabled: z.boolean().optional()
|
|
});
|
|
|
|
const userUpdateSchema = z.object({
|
|
fullName: z.string().min(2).max(120).optional(),
|
|
email: z.email().optional(),
|
|
password: z.string().min(8).max(100).optional(),
|
|
role: z.enum(['ADMIN', 'USER']).optional(),
|
|
isActive: z.boolean().optional(),
|
|
defaultCurrency: z.string().min(3).max(8).optional(),
|
|
integrationsEnabled: z.boolean().optional()
|
|
});
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
type PackageLike = { version?: string };
|
|
|
|
function readPackageJsonSafe(filePath: string): PackageLike | null {
|
|
try {
|
|
if (!fs.existsSync(filePath)) return null;
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as PackageLike;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function findApiPackage(): PackageLike | null {
|
|
return (
|
|
readPackageJsonSafe(path.resolve(__dirname, '../../package.json')) ??
|
|
readPackageJsonSafe(path.resolve(__dirname, '../package.json'))
|
|
);
|
|
}
|
|
|
|
function findRootPackage(): PackageLike | null {
|
|
const candidates = [
|
|
path.resolve(__dirname, '../../../package.json'),
|
|
path.resolve(__dirname, '../../package.json'),
|
|
path.resolve(__dirname, '../package.json')
|
|
];
|
|
for (const candidate of candidates) {
|
|
const pkg = readPackageJsonSafe(candidate);
|
|
if (!pkg) continue;
|
|
if (candidate.endsWith('/api/package.json') || candidate.endsWith('\\api\\package.json')) continue;
|
|
return pkg;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findWebPackage(): PackageLike | null {
|
|
return (
|
|
readPackageJsonSafe(path.resolve(__dirname, '../../../web/package.json')) ??
|
|
readPackageJsonSafe(path.resolve(__dirname, '../../web/package.json'))
|
|
);
|
|
}
|
|
|
|
const rootPackage = findRootPackage();
|
|
const apiPackage = findApiPackage();
|
|
const webPackage = findWebPackage();
|
|
|
|
const settingsRepo = () => AppDataSource.getRepository(AppSetting);
|
|
const userRepo = () => AppDataSource.getRepository(User);
|
|
const expenseRepo = () => AppDataSource.getRepository(Expense);
|
|
const categoryRepo = () => AppDataSource.getRepository(Category);
|
|
const merchantRepo = () => AppDataSource.getRepository(Merchant);
|
|
const budgetRepo = () => AppDataSource.getRepository(Budget);
|
|
const recurringRepo = () => AppDataSource.getRepository(RecurringExpense);
|
|
|
|
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 createAdminUser = async (req: AuthenticatedRequest, res: Response) => {
|
|
const parsed = userCreateSchema.safeParse(req.body);
|
|
if (!parsed.success) {
|
|
return res.status(400).json({ message: 'Invalid user payload', issues: parsed.error.issues });
|
|
}
|
|
|
|
try {
|
|
const user = await createUser({
|
|
fullName: parsed.data.fullName,
|
|
email: parsed.data.email,
|
|
password: parsed.data.password,
|
|
role: parsed.data.role,
|
|
defaultCurrency: parsed.data.defaultCurrency
|
|
});
|
|
|
|
if (typeof parsed.data.isActive === 'boolean') user.isActive = parsed.data.isActive;
|
|
if (typeof parsed.data.integrationsEnabled === 'boolean') user.integrationsEnabled = parsed.data.integrationsEnabled;
|
|
|
|
await userRepo().save(user);
|
|
return res.status(201).json({ item: sanitizeUser(user) });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : 'Failed to create user';
|
|
const status = message === 'Email address is already in use' ? 409 : 500;
|
|
return res.status(status).json({ message });
|
|
}
|
|
};
|
|
|
|
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.email) {
|
|
const normalizedEmail = parsed.data.email.toLowerCase();
|
|
if (normalizedEmail !== item.email) {
|
|
const existing = await userRepo().findOne({ where: { email: normalizedEmail } });
|
|
if (existing && existing.id !== item.id) {
|
|
return res.status(409).json({ message: 'Email address is already in use' });
|
|
}
|
|
const previousEmail = item.email;
|
|
item.email = normalizedEmail;
|
|
if (item.reportPreferences?.sendToEmail === previousEmail) {
|
|
item.reportPreferences = { ...item.reportPreferences, sendToEmail: normalizedEmail };
|
|
}
|
|
}
|
|
}
|
|
|
|
if (parsed.data.fullName) item.fullName = parsed.data.fullName;
|
|
if (parsed.data.password) item.passwordHash = await hashPassword(parsed.data.password);
|
|
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;
|
|
if (typeof parsed.data.integrationsEnabled === 'boolean') item.integrationsEnabled = parsed.data.integrationsEnabled;
|
|
|
|
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' });
|
|
}
|
|
|
|
try {
|
|
const transport = createSmtpTransport(settings);
|
|
await transport.verify();
|
|
await transport.sendMail({
|
|
from: formatFromAddress(settings),
|
|
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', mode: describeSmtpMode(settings) });
|
|
} catch (error) {
|
|
const rawMessage = error instanceof Error ? error.message : 'SMTP connection failed';
|
|
const message = rawMessage.includes('wrong version number')
|
|
? `SMTP handshake failed. For port ${settings.smtpPort} the application now uses ${describeSmtpMode(settings)}.`
|
|
: rawMessage;
|
|
return res.status(500).json({ message, mode: describeSmtpMode(settings) });
|
|
}
|
|
};
|
|
|
|
export const getSystemInfo = async (_req: AuthenticatedRequest, res: Response) => {
|
|
const settings = await getSettingsEntity();
|
|
const [users, expenses, categories, merchants, budgets, recurring, usersWithIntegrations] = await Promise.all([
|
|
userRepo().count(),
|
|
expenseRepo().count(),
|
|
categoryRepo().count(),
|
|
merchantRepo().count(),
|
|
budgetRepo().count(),
|
|
recurringRepo().count(),
|
|
userRepo().find({ select: { id: true, shoppingListIntegration: true } })
|
|
]);
|
|
const shoppingIntegrations = usersWithIntegrations.filter((user) => Boolean(user.shoppingListIntegration?.enabled)).length;
|
|
|
|
return res.json({
|
|
item: {
|
|
appName: settings?.appName ?? 'Expense Control',
|
|
suiteVersion: rootPackage?.version ?? apiPackage?.version ?? '0.0.0',
|
|
apiVersion: apiPackage?.version ?? '0.0.0',
|
|
webVersion: webPackage?.version ?? '0.0.0',
|
|
nodeVersion: process.version,
|
|
environment: process.env.NODE_ENV ?? 'development',
|
|
database: env.DB_TYPE,
|
|
uploadDir: env.UPLOAD_DIR,
|
|
registrationEnabled: settings?.registrationEnabled ?? true,
|
|
smtpConfigured: Boolean(settings?.smtpEnabled && settings?.smtpHost && settings?.smtpFromEmail),
|
|
counters: { users, expenses, categories, merchants, budgets, recurring, shoppingIntegrations },
|
|
sources: {
|
|
appRepository: 'https://git.linuxiarz.pl/gru/expense-control',
|
|
shoppingListRepository: 'https://git.linuxiarz.pl/gru/lista_zakupowa_live',
|
|
apiBasePath: '/api'
|
|
},
|
|
checkedAt: new Date().toISOString()
|
|
}
|
|
});
|
|
};
|