Files
Condopay/server/server.js
2026-01-09 23:27:25 +01:00

952 lines
49 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const bodyParser = require('body-parser');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { pool, initDb } = require('./db');
const { v4: uuidv4 } = require('uuid');
const nodemailer = require('nodemailer');
// Cloud Storage Libs
const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const { google } = require('googleapis');
const { Dropbox } = require('dropbox');
require('isomorphic-fetch'); // Polyfill for Dropbox/Graph if needed on older Node
const app = express();
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123';
app.use(cors());
// Increased limit to support base64 file uploads for tickets/branding
app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
// --- HELPER: Safe JSON Parser ---
const safeJSON = (data, defaultValue = null) => {
if (data === undefined || data === null) return defaultValue;
if (typeof data === 'string') {
try { return JSON.parse(data); } catch (e) { return defaultValue; }
}
return data;
};
// --- HELPER: Cloud Storage Logic ---
const getStorageConfig = async () => {
const [rows] = await pool.query('SELECT storage_config FROM settings WHERE id = 1');
return rows.length > 0 ? safeJSON(rows[0].storage_config, { provider: 'local_db' }) : { provider: 'local_db' };
};
const uploadToCloud = async (fileDataBase64, fileName, fileType, config) => {
const buffer = Buffer.from(fileDataBase64.replace(/^data:.*;base64,/, ""), 'base64');
if (config.provider === 's3') {
const client = new S3Client({
region: config.region,
credentials: { accessKeyId: config.apiKey, secretAccessKey: config.apiSecret }
});
const key = `documents/${uuidv4()}-${fileName}`;
await client.send(new PutObjectCommand({
Bucket: config.bucket, Key: key, Body: buffer, ContentType: fileType
}));
return key; // Store Key in DB
}
else if (config.provider === 'google_drive') {
// Expects: apiKey = client_email, apiSecret = private_key (from Service Account JSON)
const auth = new google.auth.JWT(
config.apiKey, null, config.apiSecret.replace(/\\n/g, '\n'), ['https://www.googleapis.com/auth/drive']
);
const drive = google.drive({ version: 'v3', auth });
// Convert buffer to stream for Google API
const { Readable } = require('stream');
const stream = Readable.from(buffer);
const response = await drive.files.create({
requestBody: {
name: fileName,
parents: config.bucket ? [config.bucket] : undefined // Bucket field used as Folder ID
},
media: { mimeType: fileType, body: stream }
});
return response.data.id; // Store File ID
}
else if (config.provider === 'dropbox') {
const dbx = new Dropbox({ accessToken: config.apiKey });
const response = await dbx.filesUpload({
path: `/${fileName}`, // Simple root path
contents: buffer
});
return response.result.path_lower; // Store Path
}
else if (config.provider === 'onedrive') {
// Simple REST implementation for OneDrive Personal/Business using Access Token
// Expects: apiKey = Access Token
const url = `https://graph.microsoft.com/v1.0/me/drive/root:/${fileName}:/content`;
const response = await fetch(url, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': fileType
},
body: buffer
});
if (!response.ok) throw new Error('OneDrive upload failed');
const data = await response.json();
return data.id; // Store ID
}
return null; // Should not happen if provider matches
};
const getFromCloud = async (storedId, fileName, config) => {
if (config.provider === 's3') {
const client = new S3Client({
region: config.region,
credentials: { accessKeyId: config.apiKey, secretAccessKey: config.apiSecret }
});
// Generate Signed URL for frontend to download directly (safer/faster)
const command = new GetObjectCommand({ Bucket: config.bucket, Key: storedId });
const url = await getSignedUrl(client, command, { expiresIn: 3600 });
return { type: 'url', data: url };
}
else if (config.provider === 'google_drive') {
const auth = new google.auth.JWT(
config.apiKey, null, config.apiSecret.replace(/\\n/g, '\n'), ['https://www.googleapis.com/auth/drive']
);
const drive = google.drive({ version: 'v3', auth });
const response = await drive.files.get({ fileId: storedId, alt: 'media' }, { responseType: 'arraybuffer' });
const base64 = Buffer.from(response.data).toString('base64');
return { type: 'base64', data: base64 };
}
else if (config.provider === 'dropbox') {
const dbx = new Dropbox({ accessToken: config.apiKey });
const response = await dbx.filesDownload({ path: storedId });
const base64 = Buffer.from(response.result.fileBinary).toString('base64');
return { type: 'base64', data: base64 };
}
else if (config.provider === 'onedrive') {
const response = await fetch(`https://graph.microsoft.com/v1.0/me/drive/items/${storedId}/content`, {
headers: { 'Authorization': `Bearer ${config.apiKey}` }
});
const buffer = await response.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
return { type: 'base64', data: base64 };
}
return { type: 'error' };
};
const deleteFromCloud = async (storedId, config) => {
try {
if (config.provider === 's3') {
const client = new S3Client({
region: config.region,
credentials: { accessKeyId: config.apiKey, secretAccessKey: config.apiSecret }
});
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: storedId }));
}
else if (config.provider === 'google_drive') {
const auth = new google.auth.JWT(config.apiKey, null, config.apiSecret.replace(/\\n/g, '\n'), ['https://www.googleapis.com/auth/drive']);
const drive = google.drive({ version: 'v3', auth });
await drive.files.delete({ fileId: storedId });
}
else if (config.provider === 'dropbox') {
const dbx = new Dropbox({ accessToken: config.apiKey });
await dbx.filesDeleteV2({ path: storedId });
}
else if (config.provider === 'onedrive') {
await fetch(`https://graph.microsoft.com/v1.0/me/drive/items/${storedId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${config.apiKey}` }
});
}
} catch (e) {
console.error("Delete cloud error (ignoring):", e.message);
}
};
// --- MIDDLEWARE ---
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(401);
req.user = user;
next();
});
};
const requireAdmin = (req, res, next) => {
if (req.user && (req.user.role === 'admin' || req.user.role === 'poweruser')) {
next();
} else {
res.status(403).json({ message: 'Access denied: Privileged users only' });
}
};
// --- PUBLIC ENDPOINTS ---
app.get('/api/public/branding', async (req, res) => {
try {
const [rows] = await pool.query('SELECT branding FROM settings WHERE id = 1');
if (rows.length > 0) {
const branding = safeJSON(rows[0].branding, { appName: 'CondoPay', primaryColor: 'blue' });
res.json(branding);
} else {
res.json({ appName: 'CondoPay', primaryColor: 'blue' });
}
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- AUTH ---
app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body;
try {
const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' });
const user = users[0];
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' });
const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, familyId: user.family_id },
JWT_SECRET, { expiresIn: '24h' }
);
res.json({
token,
user: { id: user.id, email: user.email, name: user.name, role: user.role, familyId: user.family_id, receiveAlerts: !!user.receive_alerts }
});
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/profile', authenticateToken, async (req, res) => {
const userId = req.user.id;
const { name, phone, password, receiveAlerts } = req.body;
try {
let query = 'UPDATE users SET name = ?, phone = ?, receive_alerts = ?';
let params = [name, phone, receiveAlerts];
if (password && password.trim() !== '') {
const hashedPassword = await bcrypt.hash(password, 10);
query += ', password_hash = ?';
params.push(hashedPassword);
}
query += ' WHERE id = ?';
params.push(userId);
await pool.query(query, params);
const [updatedUser] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users WHERE id = ?', [userId]);
res.json({ success: true, user: { ...updatedUser[0], receiveAlerts: !!updatedUser[0].receive_alerts } });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- SETTINGS (FIXED BRANDING SAVE) ---
app.get('/api/settings', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1');
if (rows.length > 0) {
const r = rows[0];
res.json({
currentYear: r.current_year,
smtpConfig: safeJSON(r.smtp_config, {}),
storageConfig: safeJSON(r.storage_config, { provider: 'local_db' }),
features: safeJSON(r.features, { multiCondo: true, tickets: true, payPal: true, notices: true, reports: true, extraordinaryExpenses: true, condoFinancialsView: false, documents: true }),
branding: safeJSON(r.branding, { appName: 'CondoPay', primaryColor: 'blue', logoUrl: '', loginBackgroundUrl: '' })
});
} else { res.status(404).json({ message: 'Settings not found' }); }
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => {
const { currentYear, smtpConfig, features, storageConfig, branding } = req.body;
try {
// Robust serialization for JSON columns
const smtpStr = smtpConfig ? JSON.stringify(smtpConfig) : '{}';
const featuresStr = features ? JSON.stringify(features) : '{}';
const storageStr = storageConfig ? JSON.stringify(storageConfig) : '{}';
const brandingStr = branding ? JSON.stringify(branding) : JSON.stringify({ appName: 'CondoPay', primaryColor: 'blue' });
// Explicit query update
const query = `
UPDATE settings
SET current_year = ?, smtp_config = ?, features = ?, storage_config = ?, branding = ?
WHERE id = 1
`;
await pool.query(query, [currentYear, smtpStr, featuresStr, storageStr, brandingStr]);
res.json({ success: true });
} catch (e) {
console.error("Settings Update Error:", e);
res.status(500).json({ error: e.message });
}
});
app.post('/api/settings/smtp-test', authenticateToken, requireAdmin, async (req, res) => {
const config = req.body;
const userEmail = req.user.email;
try {
if (!config.host || !config.user || !config.pass) return res.status(400).json({ message: 'Parametri SMTP incompleti' });
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: { user: config.user, pass: config.pass },
});
await transporter.verify();
await transporter.sendMail({
from: config.fromEmail || config.user,
to: userEmail,
subject: 'CondoPay - Test Configurazione SMTP',
text: 'Se leggi questo messaggio, la configurazione SMTP è corretta.',
});
res.json({ success: true });
} catch (e) { res.status(400).json({ message: e.message }); }
});
app.get('/api/years', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT DISTINCT for_year FROM payments ORDER BY for_year DESC');
const [settings] = await pool.query('SELECT current_year FROM settings WHERE id = 1');
const years = new Set(rows.map(r => r.for_year));
if (settings.length > 0) years.add(settings[0].current_year);
res.json(Array.from(years).sort((a, b) => b - a));
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- DOCUMENTS (CLOUD IMPLEMENTATION) ---
app.get('/api/documents', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
const [rows] = await pool.query('SELECT id, condo_id, title, description, file_name, file_type, file_size, tags, storage_provider, upload_date FROM documents WHERE condo_id = ? ORDER BY upload_date DESC', [condoId]);
res.json(rows.map(r => ({
id: r.id, condoId: r.condo_id, title: r.title, description: r.description,
fileName: r.file_name, fileType: r.file_type, fileSize: r.file_size,
tags: safeJSON(r.tags) || [], storageProvider: r.storage_provider, uploadDate: r.upload_date
})));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/documents', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, title, description, fileName, fileType, fileSize, tags, fileData, storageConfig } = req.body;
const id = uuidv4();
try {
let provider = storageConfig?.provider || 'local_db';
let storageData = null; // Will hold base64 for local, or Key/ID for cloud
if (provider === 'local_db') {
storageData = fileData;
} else {
// Upload to Cloud Provider
try {
storageData = await uploadToCloud(fileData, fileName, fileType, storageConfig);
} catch(cloudError) {
console.error("Cloud Upload Failed:", cloudError);
return res.status(500).json({ error: `Cloud upload failed: ${cloudError.message}` });
}
}
await pool.query(
'INSERT INTO documents (id, condo_id, title, description, file_name, file_type, file_size, tags, storage_provider, file_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, condoId, title, description, fileName, fileType, fileSize, JSON.stringify(tags), provider, storageData]
);
res.json({ success: true, id });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/documents/:id/download', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT file_name, file_type, file_data, storage_provider FROM documents WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ message: 'Not Found' });
const doc = rows[0];
if (doc.storage_provider === 'local_db') {
res.json({ fileName: doc.file_name, fileType: doc.file_type, data: doc.file_data });
} else {
// Fetch from Cloud
const storageConfig = await getStorageConfig();
if (storageConfig.provider !== doc.storage_provider) {
// Config changed, warn user but try to use config if it matches partially? No, assume config matches provider type
// Actually, if I changed provider in settings, I can't access old files if keys changed.
// We assume config is current.
}
try {
const result = await getFromCloud(doc.file_data, doc.file_name, storageConfig);
if (result.type === 'url') {
// Redirect or return URL
res.json({ fileName: doc.file_name, fileType: doc.file_type, data: result.data, isUrl: true });
} else {
res.json({ fileName: doc.file_name, fileType: doc.file_type, data: `data:${doc.file_type};base64,${result.data}` });
}
} catch (cloudErr) {
console.error("Cloud Download Error:", cloudErr);
res.status(500).json({ error: 'Errore download da storage cloud' });
}
}
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/documents/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
const [rows] = await pool.query('SELECT file_data, storage_provider FROM documents WHERE id = ?', [req.params.id]);
if (rows.length > 0) {
const doc = rows[0];
if (doc.storage_provider !== 'local_db') {
const storageConfig = await getStorageConfig();
await deleteFromCloud(doc.file_data, storageConfig);
}
}
await pool.query('DELETE FROM documents WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// ... (Other endpoints for Condos, Families, Payments, Users, Tickets, etc. remain unchanged from previous version) ...
// Re-adding essential CRUD for completeness of the single file
// --- CONDOS ---
app.get('/api/condos', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM condos');
res.json(rows.map(r => ({
id: r.id, name: r.name, address: r.address, streetNumber: r.street_number, city: r.city, province: r.province, zipCode: r.zip_code, notes: r.notes, iban: r.iban, paypalClientId: r.paypal_client_id, defaultMonthlyQuota: parseFloat(r.default_monthly_quota), image: r.image, dueDay: r.due_day || 10
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
const { name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO condos (id, name, address, street_number, city, province, zip_code, notes, default_monthly_quota, paypal_client_id, due_day) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay || 10]);
res.json({ id, name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay: dueDay || 10 });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
const { name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay } = req.body;
try {
await pool.query('UPDATE condos SET name = ?, address = ?, street_number = ?, city = ?, province = ?, zip_code = ?, notes = ?, default_monthly_quota = ?, paypal_client_id = ?, due_day = ? WHERE id = ?', [name, address, streetNumber, city, province, zipCode, notes, defaultMonthlyQuota, paypalClientId, dueDay || 10, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM condos WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- FAMILIES ---
app.get('/api/families', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
let query = `SELECT f.* FROM families f`;
let params = [];
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
if (!req.user.familyId) return res.json([]);
query += ' WHERE f.id = ?';
params.push(req.user.familyId);
} else if (condoId) {
query += ' WHERE f.condo_id = ?';
params.push(condoId);
}
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({
id: r.id, condoId: r.condo_id, name: r.name, unitNumber: r.unit_number, stair: r.stair, floor: r.floor, notes: r.notes, contactEmail: r.contact_email, customMonthlyQuota: r.custom_monthly_quota ? parseFloat(r.custom_monthly_quota) : undefined, balance: 0
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, stair, floor, notes, contactEmail, condoId, customMonthlyQuota } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO families (id, condo_id, name, unit_number, stair, floor, notes, contact_email, custom_monthly_quota) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota || null]);
res.json({ id, condoId, name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota } = req.body;
try {
await pool.query('UPDATE families SET name = ?, unit_number = ?, stair = ?, floor = ?, notes = ?, contact_email = ?, custom_monthly_quota = ? WHERE id = ?', [name, unitNumber, stair, floor, notes, contactEmail, customMonthlyQuota || null, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM families WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- PAYMENTS ---
app.get('/api/payments', authenticateToken, async (req, res) => {
const { familyId, condoId } = req.query;
try {
let query = 'SELECT p.* FROM payments p JOIN families f ON p.family_id = f.id';
let params = [];
let conditions = [];
if (familyId) { conditions.push('p.family_id = ?'); params.push(familyId); }
if (condoId) { conditions.push('f.condo_id = ?'); params.push(condoId); }
if (conditions.length > 0) { query += ' WHERE ' + conditions.join(' AND '); }
query += ' ORDER BY p.date_paid DESC';
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, familyId: r.family_id, amount: parseFloat(r.amount), datePaid: r.date_paid, forMonth: r.for_month, forYear: r.for_year, notes: r.notes })));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/payments', authenticateToken, async (req, res) => {
const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body;
if (req.user.role !== 'admin' && req.user.role !== 'poweruser' && req.user.familyId !== familyId) return res.status(403).json({ message: 'Unauthorized' });
const id = uuidv4();
try {
await pool.query('INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, familyId, amount, datePaid, forMonth, forYear, notes]);
res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// --- USERS ---
app.get('/api/users', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
let query = 'SELECT id, email, name, role, phone, family_id, receive_alerts, created_at FROM users';
let params = [];
if (condoId) {
query = `SELECT u.id, u.email, u.name, u.role, u.phone, u.family_id, u.receive_alerts, u.created_at FROM users u LEFT JOIN families f ON u.family_id = f.id WHERE f.condo_id = ? OR u.role IN ('admin', 'poweruser')`;
params = [condoId];
}
const [rows] = await pool.query(query, params);
res.json(rows.map(u => ({ id: u.id, email: u.email, name: u.name, role: u.role, phone: u.phone, familyId: u.family_id, receiveAlerts: !!u.receive_alerts })));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
const { email, password, name, role, phone, familyId, receiveAlerts } = req.body;
const id = uuidv4();
try {
const hashedPassword = await bcrypt.hash(password || 'password', 10);
await pool.query('INSERT INTO users (id, email, password_hash, name, role, phone, family_id, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, email, hashedPassword, name, role, phone, familyId || null, receiveAlerts]);
res.json({ success: true, id });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
const { email, name, role, phone, familyId, receiveAlerts, password } = req.body;
try {
let query = 'UPDATE users SET email = ?, name = ?, role = ?, phone = ?, family_id = ?, receive_alerts = ?';
let params = [email, name, role, phone, familyId || null, receiveAlerts];
if (password) {
const hashedPassword = await bcrypt.hash(password, 10);
query += ', password_hash = ?';
params.push(hashedPassword);
}
query += ' WHERE id = ?';
params.push(req.params.id);
await pool.query(query, params);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// --- NOTICES ---
app.get('/api/notices', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
const [rows] = await pool.query('SELECT * FROM notices WHERE condo_id = ? ORDER BY date DESC', [condoId]);
res.json(rows.map(r => ({...r, targetFamilyIds: safeJSON(r.target_families) || []})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/unread', authenticateToken, async (req, res) => {
const { userId, condoId } = req.query;
try {
const [notices] = await pool.query(
'SELECT * FROM notices WHERE condo_id = ? AND active = TRUE ORDER BY date DESC',
[condoId]
);
const [reads] = await pool.query('SELECT notice_id FROM notice_reads WHERE user_id = ?', [userId]);
const readIds = new Set(reads.map(r => r.notice_id));
const user = (await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]))[0][0];
const relevant = notices.filter(n => {
const targets = safeJSON(n.target_families) || [];
if (targets.length === 0) return true;
return user && user.family_id && targets.includes(user.family_id);
});
const unread = relevant.filter(n => !readIds.has(n.id));
res.json(unread);
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/notices', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, title, content, type, link, active, targetFamilyIds } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO notices (id, condo_id, title, content, type, link, active, target_families) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, title, content, type, link, active, JSON.stringify(targetFamilyIds)]);
// if (active) sendEmailToUsers... (omitted to keep concise)
res.json({ success: true, id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/notices/:id', authenticateToken, requireAdmin, async (req, res) => {
const { title, content, type, link, active, targetFamilyIds } = req.body;
try {
await pool.query('UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ?, target_families = ? WHERE id = ?', [title, content, type, link, active, JSON.stringify(targetFamilyIds), req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/notices/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM notices WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/:id/read-status', authenticateToken, requireAdmin, async (req, res) => {
try {
const [rows] = await pool.query('SELECT user_id, read_at FROM notice_reads WHERE notice_id = ?', [req.params.id]);
res.json(rows.map(r => ({ userId: r.user_id, readAt: r.read_at })));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/notices/:id/read', authenticateToken, async (req, res) => {
const { userId } = req.body;
try {
await pool.query('INSERT IGNORE INTO notice_reads (user_id, notice_id) VALUES (?, ?)', [userId, req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- ALERTS ---
app.get('/api/alerts', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
const [rows] = await pool.query('SELECT * FROM alerts WHERE condo_id = ?', [condoId]);
res.json(rows.map(r => ({
id: r.id, subject: r.subject, body: r.body, daysOffset: r.days_offset, offsetType: r.offset_type, sendHour: r.send_hour, active: !!r.active, lastSent: r.last_sent
})));
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, subject, body, daysOffset, offsetType, sendHour, active } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO alerts (id, condo_id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, condoId, subject, body, daysOffset, offsetType, sendHour, active]);
res.json({ id, subject, body, daysOffset, offsetType, sendHour, active });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => {
const { subject, body, daysOffset, offsetType, sendHour, active } = req.body;
try {
await pool.query('UPDATE alerts SET subject = ?, body = ?, days_offset = ?, offset_type = ?, send_hour = ?, active = ? WHERE id = ?', [subject, body, daysOffset, offsetType, sendHour, active, req.params.id]);
res.json({ success: true, id: req.params.id });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM alerts WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- TICKETS ---
app.get('/api/tickets', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
let query = `
SELECT t.*, u.name as userName, u.email as userEmail
FROM tickets t
JOIN users u ON t.user_id = u.id
WHERE t.condo_id = ?
`;
let params = [condoId];
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
query += ' AND t.user_id = ?';
params.push(req.user.id);
}
query += ' ORDER BY t.updated_at DESC';
const [rows] = await pool.query(query, params);
const [attRows] = await pool.query('SELECT id, ticket_id, file_name, file_type FROM ticket_attachments');
const results = rows.map(r => ({
id: r.id, condoId: r.condo_id, userId: r.user_id, title: r.title, description: r.description,
status: r.status, priority: r.priority, category: r.category, createdAt: r.created_at, updatedAt: r.updated_at,
userName: r.userName, userEmail: r.userEmail,
attachments: attRows.filter(a => a.ticket_id === r.id).map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type }))
}));
res.json(results);
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/tickets', authenticateToken, async (req, res) => {
const { condoId, title, description, priority, category, attachments } = req.body;
const id = uuidv4();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query(
'INSERT INTO tickets (id, condo_id, user_id, title, description, priority, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, condoId, req.user.id, title, description, priority, category]
);
if (attachments && attachments.length > 0) {
for(const att of attachments) {
await connection.query(
'INSERT INTO ticket_attachments (id, ticket_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)',
[uuidv4(), id, att.fileName, att.fileType, att.data]
);
}
}
await connection.commit();
res.json({ success: true, id });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
});
app.put('/api/tickets/:id', authenticateToken, async (req, res) => {
const { status, priority } = req.body;
try {
await pool.query('UPDATE tickets SET status = ?, priority = ? WHERE id = ?', [status, priority, req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/tickets/:id', authenticateToken, async (req, res) => {
try {
await pool.query('DELETE FROM tickets WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/tickets/:id/attachments/:attId', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM ticket_attachments WHERE id = ?', [req.params.attId]);
if (rows.length === 0) return res.status(404).json({ message: 'Not Found' });
res.json({ id: rows[0].id, fileName: rows[0].file_name, fileType: rows[0].file_type, data: rows[0].data });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT c.*, u.name as userName
FROM ticket_comments c JOIN users u ON c.user_id = u.id
WHERE c.ticket_id = ? ORDER BY c.created_at ASC
`, [req.params.id]);
res.json(rows.map(r => ({ id: r.id, ticketId: r.ticket_id, userId: r.user_id, userName: r.userName, text: r.text, createdAt: r.created_at })));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/tickets/:id/comments', authenticateToken, async (req, res) => {
const { text } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO ticket_comments (id, ticket_id, user_id, text) VALUES (?, ?, ?, ?)', [id, req.params.id, req.user.id, text]);
await pool.query('UPDATE tickets SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
// --- EXTRAORDINARY EXPENSES ---
app.get('/api/expenses', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
const [rows] = await pool.query('SELECT * FROM extraordinary_expenses WHERE condo_id = ? ORDER BY created_at DESC', [condoId]);
res.json(rows.map(r => ({
id: r.id, condoId: r.condo_id, title: r.title, description: r.description,
startDate: r.start_date, endDate: r.end_date, contractorName: r.contractor_name, totalAmount: parseFloat(r.total_amount)
})));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/expenses/:id', authenticateToken, async (req, res) => {
try {
const [exp] = await pool.query('SELECT * FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
if(exp.length === 0) return res.status(404).json({ message: 'Not Found' });
const [items] = await pool.query('SELECT * FROM expense_items WHERE expense_id = ?', [req.params.id]);
const [shares] = await pool.query(`
SELECT s.*, f.name as familyName
FROM expense_shares s JOIN families f ON s.family_id = f.id
WHERE s.expense_id = ?
`, [req.params.id]);
const [atts] = await pool.query('SELECT id, file_name, file_type FROM expense_attachments WHERE expense_id = ?', [req.params.id]);
const result = {
...exp[0],
totalAmount: parseFloat(exp[0].total_amount),
items: items.map(i => ({ description: i.description, amount: parseFloat(i.amount) })),
shares: shares.map(s => ({
id: s.id, familyId: s.family_id, familyName: s.familyName,
percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status
})),
attachments: atts.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type }))
};
result.startDate = result.start_date; result.endDate = result.end_date; result.contractorName = result.contractor_name;
res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, title, description, startDate, endDate, contractorName, items, shares, attachments } = req.body;
const id = uuidv4();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const totalAmount = items.reduce((acc, i) => acc + i.amount, 0);
await connection.query(
'INSERT INTO extraordinary_expenses (id, condo_id, title, description, start_date, end_date, contractor_name, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, condoId, title, description, startDate, endDate, contractorName, totalAmount]
);
for (const item of items) {
await connection.query('INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)', [uuidv4(), id, item.description, item.amount]);
}
for (const share of shares) {
await connection.query('INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, amount_paid, status) VALUES (?, ?, ?, ?, ?, ?, ?)',
[uuidv4(), id, share.familyId, share.percentage, share.amountDue, 0, 'UNPAID']);
}
if (attachments && attachments.length > 0) {
for(const att of attachments) {
await connection.query('INSERT INTO expense_attachments (id, expense_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)', [uuidv4(), id, att.fileName, att.fileType, att.data]);
}
}
await connection.commit();
res.json({ success: true, id });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
});
app.delete('/api/expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM extraordinary_expenses WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/expenses/:id/attachments/:attId', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM expense_attachments WHERE id = ?', [req.params.attId]);
if (rows.length === 0) return res.status(404).json({ message: 'File not found' });
res.json({ id: rows[0].id, fileName: rows[0].file_name, fileType: rows[0].file_type, data: rows[0].data });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/my-expenses', authenticateToken, async (req, res) => {
const { condoId } = req.query;
if (!req.user.familyId) return res.json([]);
try {
const [shares] = await pool.query(`
SELECT s.*, e.title, e.start_date, e.total_amount, e.contractor_name
FROM expense_shares s
JOIN extraordinary_expenses e ON s.expense_id = e.id
WHERE s.family_id = ? AND e.condo_id = ?
ORDER BY e.created_at DESC
`, [req.user.familyId, condoId]);
res.json(shares.map(s => ({
id: s.expense_id,
title: s.title, startDate: s.start_date, totalAmount: parseFloat(s.total_amount), contractorName: s.contractor_name,
myShare: {
percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status
}
})));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => {
const { amount, notes, familyId } = req.body;
const targetFamilyId = (req.user.role === 'admin' || req.user.role === 'poweruser') ? familyId : req.user.familyId;
if (!targetFamilyId) return res.status(400).json({ message: 'Family not found' });
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query(
'INSERT INTO payments (id, family_id, extraordinary_expense_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[uuidv4(), targetFamilyId, req.params.id, amount, new Date(), 13, new Date().getFullYear(), notes || 'Extraordinary Payment']
);
const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [req.params.id, targetFamilyId]);
if (shares.length === 0) throw new Error('Share not found');
const share = shares[0];
const newPaid = parseFloat(share.amount_paid) + parseFloat(amount);
const due = parseFloat(share.amount_due);
let newStatus = 'PARTIAL';
if (newPaid >= due - 0.01) newStatus = 'PAID';
await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, newStatus, share.id]);
await connection.commit();
res.json({ success: true });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
});
app.get('/api/expenses/:id/shares/:familyId/payments', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM payments WHERE extraordinary_expense_id = ? AND family_id = ? ORDER BY date_paid DESC',
[req.params.id, req.params.familyId]
);
res.json(rows.map(r => ({ id: r.id, amount: parseFloat(r.amount), datePaid: r.date_paid, notes: r.notes })));
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/expenses/payments/:paymentId', authenticateToken, requireAdmin, async (req, res) => {
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [pay] = await connection.query('SELECT * FROM payments WHERE id = ?', [req.params.paymentId]);
if (pay.length === 0) throw new Error('Payment not found');
const payment = pay[0];
if (!payment.extraordinary_expense_id) throw new Error('Not an extraordinary payment');
await connection.query('DELETE FROM payments WHERE id = ?', [req.params.paymentId]);
const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [payment.extraordinary_expense_id, payment.family_id]);
if (shares.length > 0) {
const share = shares[0];
const newPaid = Math.max(0, parseFloat(share.amount_paid) - parseFloat(payment.amount));
const newStatus = newPaid >= parseFloat(share.amount_due) - 0.01 ? 'PAID' : (newPaid > 0 ? 'PARTIAL' : 'UNPAID');
await connection.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, newStatus, share.id]);
}
await connection.commit();
res.json({ success: true });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
});
// --- CONDO ORDINARY EXPENSES ---
app.get('/api/condo-expenses', authenticateToken, async (req, res) => {
const { condoId, year } = req.query;
try {
let query = 'SELECT * FROM condo_expenses WHERE condo_id = ?';
let params = [condoId];
if (year) {
query += ' AND (YEAR(created_at) = ? OR YEAR(payment_date) = ?)';
params.push(year, year);
}
query += ' ORDER BY created_at DESC';
const [rows] = await pool.query(query, params);
const [allAtts] = await pool.query('SELECT id, condo_expense_id, file_name, file_type FROM condo_expense_attachments');
const results = rows.map(r => ({
id: r.id, condoId: r.condo_id, description: r.description, supplierName: r.supplier_name,
amount: parseFloat(r.amount), paymentDate: r.payment_date, status: r.status,
paymentMethod: r.payment_method, invoiceNumber: r.invoice_number, notes: r.notes, createdAt: r.created_at,
attachments: allAtts.filter(a => a.condo_expense_id === r.id).map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type }))
}));
res.json(results);
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.post('/api/condo-expenses', authenticateToken, requireAdmin, async (req, res) => {
const { condoId, description, supplierName, amount, paymentDate, status, paymentMethod, invoiceNumber, notes, attachments } = req.body;
const id = uuidv4();
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query(
'INSERT INTO condo_expenses (id, condo_id, description, supplier_name, amount, payment_date, status, payment_method, invoice_number, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, condoId, description, supplierName, amount, paymentDate || null, status, paymentMethod, invoiceNumber, notes]
);
if (attachments && attachments.length > 0) {
for(const att of attachments) {
await connection.query('INSERT INTO condo_expense_attachments (id, condo_expense_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)', [uuidv4(), id, att.fileName, att.fileType, att.data]);
}
}
await connection.commit();
res.json({ success: true, id });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
});
app.put('/api/condo-expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
const { description, supplierName, amount, paymentDate, status, paymentMethod, invoiceNumber, notes } = req.body;
try {
await pool.query(
'UPDATE condo_expenses SET description=?, supplier_name=?, amount=?, payment_date=?, status=?, payment_method=?, invoice_number=?, notes=? WHERE id=?',
[description, supplierName, amount, paymentDate || null, status, paymentMethod, invoiceNumber, notes, req.params.id]
);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/condo-expenses/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
await pool.query('DELETE FROM condo_expenses WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/condo-expenses/:id/attachments/:attId', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM condo_expense_attachments WHERE id = ?', [req.params.attId]);
if (rows.length === 0) return res.status(404).json({ message: 'File not found' });
res.json({ id: rows[0].id, fileName: rows[0].file_name, fileType: rows[0].file_type, data: rows[0].data });
} catch(e) { res.status(500).json({ error: e.message }); }
});
initDb().then(() => {
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
});