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: `
This message was sent from the admin panel test action.