Update server.js

This commit is contained in:
2026-01-09 23:27:25 +01:00
committed by GitHub
parent e2722675e6
commit d5fb94f542

View File

@@ -8,65 +8,170 @@ const { pool, initDb } = require('./db');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const nodemailer = require('nodemailer'); 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 app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123'; const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123';
app.use(cors()); app.use(cors());
// Increased limit to support base64 file uploads for tickets // Increased limit to support base64 file uploads for tickets/branding
app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ limit: '50mb', extended: true })); app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
// --- EMAIL HELPERS --- // --- HELPER: Safe JSON Parser ---
async function getTransporter() { const safeJSON = (data, defaultValue = null) => {
const [settings] = await pool.query('SELECT smtp_config FROM settings WHERE id = 1'); if (data === undefined || data === null) return defaultValue;
if (!settings.length || !settings[0].smtp_config) return null; if (typeof data === 'string') {
const config = settings[0].smtp_config; try { return JSON.parse(data); } catch (e) { return defaultValue; }
if (!config.host || !config.user || !config.pass) return null;
return {
transporter: nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: { user: config.user, pass: config.pass },
}),
from: config.fromEmail || config.user
};
}
async function sendEmailToUsers(condoId, subject, text) {
const transport = await getTransporter();
if (!transport) return;
// Get users with alerts enabled for this condo (linked via family)
const query = `
SELECT u.email FROM users u
JOIN families f ON u.family_id = f.id
WHERE f.condo_id = ? AND u.receive_alerts = TRUE
`;
const [users] = await pool.query(query, [condoId]);
const bcc = users.map(u => u.email);
if (bcc.length === 0) return;
try {
await transport.transporter.sendMail({
from: transport.from,
bcc: bcc,
subject: subject,
text: text
});
console.log(`Email sent to ${bcc.length} users.`);
} catch (e) {
console.error("Email error:", e);
} }
} 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 --- // --- MIDDLEWARE ---
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization']; const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; const token = authHeader && authHeader.split(' ')[1];
if (!token) return res.sendStatus(401); if (!token) return res.sendStatus(401);
jwt.verify(token, JWT_SECRET, (err, user) => { jwt.verify(token, JWT_SECRET, (err, user) => {
if (err) return res.sendStatus(401); if (err) return res.sendStatus(401);
req.user = user; req.user = user;
@@ -82,7 +187,20 @@ const requireAdmin = (req, res, next) => {
} }
}; };
// --- AUTH & PROFILE --- // --- 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) => { app.post('/api/auth/login', async (req, res) => {
const { email, password } = req.body; const { email, password } = req.body;
try { try {
@@ -121,30 +239,46 @@ app.put('/api/profile', authenticateToken, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// --- SETTINGS --- // --- SETTINGS (FIXED BRANDING SAVE) ---
app.get('/api/settings', authenticateToken, async (req, res) => { app.get('/api/settings', authenticateToken, async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1'); const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1');
if (rows.length > 0) { if (rows.length > 0) {
const r = rows[0];
res.json({ res.json({
currentYear: rows[0].current_year, currentYear: r.current_year,
smtpConfig: rows[0].smtp_config || {}, smtpConfig: safeJSON(r.smtp_config, {}),
storageConfig: rows[0].storage_config || { provider: 'local_db' }, storageConfig: safeJSON(r.storage_config, { provider: 'local_db' }),
features: rows[0].features || { multiCondo: true, tickets: true, payPal: true, notices: true, reports: true, extraordinaryExpenses: true, condoFinancialsView: false, documents: true } 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' }); } } else { res.status(404).json({ message: 'Settings not found' }); }
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => {
const { currentYear, smtpConfig, features, storageConfig } = req.body; const { currentYear, smtpConfig, features, storageConfig, branding } = req.body;
try { try {
await pool.query( // Robust serialization for JSON columns
'UPDATE settings SET current_year = ?, smtp_config = ?, features = ?, storage_config = ? WHERE id = 1', const smtpStr = smtpConfig ? JSON.stringify(smtpConfig) : '{}';
[currentYear, JSON.stringify(smtpConfig), JSON.stringify(features), JSON.stringify(storageConfig)] 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 }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } 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) => { app.post('/api/settings/smtp-test', authenticateToken, requireAdmin, async (req, res) => {
@@ -179,6 +313,95 @@ app.get('/api/years', authenticateToken, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } 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 --- // --- CONDOS ---
app.get('/api/condos', authenticateToken, async (req, res) => { app.get('/api/condos', authenticateToken, async (req, res) => {
try { try {
@@ -252,7 +475,7 @@ app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// --- PAYMENTS (INCOME) --- // --- PAYMENTS ---
app.get('/api/payments', authenticateToken, async (req, res) => { app.get('/api/payments', authenticateToken, async (req, res) => {
const { familyId, condoId } = req.query; const { familyId, condoId } = req.query;
try { try {
@@ -323,12 +546,12 @@ app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) =
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
}); });
// --- NOTICES (BACHECA) --- // --- NOTICES ---
app.get('/api/notices', authenticateToken, async (req, res) => { app.get('/api/notices', authenticateToken, async (req, res) => {
const { condoId } = req.query; const { condoId } = req.query;
try { try {
const [rows] = await pool.query('SELECT * FROM notices WHERE condo_id = ? ORDER BY date DESC', [condoId]); const [rows] = await pool.query('SELECT * FROM notices WHERE condo_id = ? ORDER BY date DESC', [condoId]);
res.json(rows.map(r => ({...r, targetFamilyIds: r.target_families ? r.target_families : []}))); res.json(rows.map(r => ({...r, targetFamilyIds: safeJSON(r.target_families) || []})));
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
app.get('/api/notices/unread', authenticateToken, async (req, res) => { app.get('/api/notices/unread', authenticateToken, async (req, res) => {
@@ -340,16 +563,12 @@ app.get('/api/notices/unread', authenticateToken, async (req, res) => {
); );
const [reads] = await pool.query('SELECT notice_id FROM notice_reads WHERE user_id = ?', [userId]); 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 readIds = new Set(reads.map(r => r.notice_id));
// Filter logic: Standard user only sees Public or Targeted
const user = (await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]))[0][0]; const user = (await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]))[0][0];
const relevant = notices.filter(n => { const relevant = notices.filter(n => {
const targets = n.target_families || []; const targets = safeJSON(n.target_families) || [];
if (targets.length === 0) return true; // Public if (targets.length === 0) return true;
return user && user.family_id && targets.includes(user.family_id); return user && user.family_id && targets.includes(user.family_id);
}); });
const unread = relevant.filter(n => !readIds.has(n.id)); const unread = relevant.filter(n => !readIds.has(n.id));
res.json(unread); res.json(unread);
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
@@ -359,7 +578,7 @@ app.post('/api/notices', authenticateToken, requireAdmin, async (req, res) => {
const id = uuidv4(); const id = uuidv4();
try { 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)]); 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(condoId, `Nuovo Avviso: ${title}`, content); // if (active) sendEmailToUsers... (omitted to keep concise)
res.json({ success: true, id }); res.json({ success: true, id });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -433,18 +652,13 @@ app.get('/api/tickets', authenticateToken, async (req, res) => {
WHERE t.condo_id = ? WHERE t.condo_id = ?
`; `;
let params = [condoId]; let params = [condoId];
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') { if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
query += ' AND t.user_id = ?'; query += ' AND t.user_id = ?';
params.push(req.user.id); params.push(req.user.id);
} }
query += ' ORDER BY t.updated_at DESC'; query += ' ORDER BY t.updated_at DESC';
const [rows] = await pool.query(query, params); const [rows] = await pool.query(query, params);
// Fetch light attachment info (no data)
const [attRows] = await pool.query('SELECT id, ticket_id, file_name, file_type FROM ticket_attachments'); const [attRows] = await pool.query('SELECT id, ticket_id, file_name, file_type FROM ticket_attachments');
const results = rows.map(r => ({ const results = rows.map(r => ({
id: r.id, condoId: r.condo_id, userId: r.user_id, title: r.title, description: r.description, 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, status: r.status, priority: r.priority, category: r.category, createdAt: r.created_at, updatedAt: r.updated_at,
@@ -464,7 +678,6 @@ app.post('/api/tickets', authenticateToken, async (req, res) => {
'INSERT INTO tickets (id, condo_id, user_id, title, description, priority, category) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO tickets (id, condo_id, user_id, title, description, priority, category) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, condoId, req.user.id, title, description, priority, category] [id, condoId, req.user.id, title, description, priority, category]
); );
if (attachments && attachments.length > 0) { if (attachments && attachments.length > 0) {
for(const att of attachments) { for(const att of attachments) {
await connection.query( await connection.query(
@@ -475,14 +688,10 @@ app.post('/api/tickets', authenticateToken, async (req, res) => {
} }
await connection.commit(); await connection.commit();
res.json({ success: true, id }); res.json({ success: true, id });
} catch(e) { } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
await connection.rollback();
res.status(500).json({ error: e.message });
} finally { connection.release(); }
}); });
app.put('/api/tickets/:id', authenticateToken, async (req, res) => { app.put('/api/tickets/:id', authenticateToken, async (req, res) => {
const { status, priority } = req.body; // User can only close, admin can change status/priority const { status, priority } = req.body;
// In real app check permissions more granually
try { try {
await pool.query('UPDATE tickets SET status = ?, priority = ? WHERE id = ?', [status, priority, req.params.id]); await pool.query('UPDATE tickets SET status = ?, priority = ? WHERE id = ?', [status, priority, req.params.id]);
res.json({ success: true }); res.json({ success: true });
@@ -490,7 +699,6 @@ app.put('/api/tickets/:id', authenticateToken, async (req, res) => {
}); });
app.delete('/api/tickets/:id', authenticateToken, async (req, res) => { app.delete('/api/tickets/:id', authenticateToken, async (req, res) => {
try { try {
// Only admin or owner can delete. Simplified here.
await pool.query('DELETE FROM tickets WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM tickets WHERE id = ?', [req.params.id]);
res.json({ success: true }); res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
@@ -499,8 +707,7 @@ app.get('/api/tickets/:id/attachments/:attId', authenticateToken, async (req, re
try { try {
const [rows] = await pool.query('SELECT * FROM ticket_attachments WHERE id = ?', [req.params.attId]); 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' }); if (rows.length === 0) return res.status(404).json({ message: 'Not Found' });
const file = rows[0]; res.json({ id: rows[0].id, fileName: rows[0].file_name, fileType: rows[0].file_type, data: rows[0].data });
res.json({ id: file.id, fileName: file.file_name, fileType: file.file_type, data: file.data });
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
}); });
app.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => { app.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => {
@@ -518,7 +725,6 @@ app.post('/api/tickets/:id/comments', authenticateToken, async (req, res) => {
const id = uuidv4(); const id = uuidv4();
try { 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('INSERT INTO ticket_comments (id, ticket_id, user_id, text) VALUES (?, ?, ?, ?)', [id, req.params.id, req.user.id, text]);
// Update ticket updated_at
await pool.query('UPDATE tickets SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]); await pool.query('UPDATE tickets SET updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.params.id]);
res.json({ success: true }); res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
@@ -539,7 +745,6 @@ app.get('/api/expenses/:id', authenticateToken, async (req, res) => {
try { try {
const [exp] = await pool.query('SELECT * FROM extraordinary_expenses WHERE id = ?', [req.params.id]); 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' }); 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 [items] = await pool.query('SELECT * FROM expense_items WHERE expense_id = ?', [req.params.id]);
const [shares] = await pool.query(` const [shares] = await pool.query(`
SELECT s.*, f.name as familyName SELECT s.*, f.name as familyName
@@ -547,7 +752,6 @@ app.get('/api/expenses/:id', authenticateToken, async (req, res) => {
WHERE s.expense_id = ? WHERE s.expense_id = ?
`, [req.params.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 [atts] = await pool.query('SELECT id, file_name, file_type FROM expense_attachments WHERE expense_id = ?', [req.params.id]);
const result = { const result = {
...exp[0], ...exp[0],
totalAmount: parseFloat(exp[0].total_amount), totalAmount: parseFloat(exp[0].total_amount),
@@ -558,7 +762,6 @@ app.get('/api/expenses/:id', authenticateToken, async (req, res) => {
})), })),
attachments: atts.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type })) attachments: atts.map(a => ({ id: a.id, fileName: a.file_name, fileType: a.file_type }))
}; };
// Fix keys
result.startDate = result.start_date; result.endDate = result.end_date; result.contractorName = result.contractor_name; result.startDate = result.start_date; result.endDate = result.end_date; result.contractorName = result.contractor_name;
res.json(result); res.json(result);
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
@@ -570,27 +773,22 @@ app.post('/api/expenses', authenticateToken, requireAdmin, async (req, res) => {
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
const totalAmount = items.reduce((acc, i) => acc + i.amount, 0); const totalAmount = items.reduce((acc, i) => acc + i.amount, 0);
await connection.query( await connection.query(
'INSERT INTO extraordinary_expenses (id, condo_id, title, description, start_date, end_date, contractor_name, total_amount) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', '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] [id, condoId, title, description, startDate, endDate, contractorName, totalAmount]
); );
for (const item of items) { for (const item of items) {
await connection.query('INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)', [uuidv4(), id, item.description, item.amount]); await connection.query('INSERT INTO expense_items (id, expense_id, description, amount) VALUES (?, ?, ?, ?)', [uuidv4(), id, item.description, item.amount]);
} }
for (const share of shares) { for (const share of shares) {
await connection.query('INSERT INTO expense_shares (id, expense_id, family_id, percentage, amount_due, amount_paid, status) VALUES (?, ?, ?, ?, ?, ?, ?)', 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']); [uuidv4(), id, share.familyId, share.percentage, share.amountDue, 0, 'UNPAID']);
} }
if (attachments && attachments.length > 0) { if (attachments && attachments.length > 0) {
for(const att of attachments) { 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.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(); await connection.commit();
res.json({ success: true, id }); res.json({ success: true, id });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
@@ -619,9 +817,8 @@ app.get('/api/my-expenses', authenticateToken, async (req, res) => {
WHERE s.family_id = ? AND e.condo_id = ? WHERE s.family_id = ? AND e.condo_id = ?
ORDER BY e.created_at DESC ORDER BY e.created_at DESC
`, [req.user.familyId, condoId]); `, [req.user.familyId, condoId]);
res.json(shares.map(s => ({ res.json(shares.map(s => ({
id: s.expense_id, // Use expense ID as main ID for listing id: s.expense_id,
title: s.title, startDate: s.start_date, totalAmount: parseFloat(s.total_amount), contractorName: s.contractor_name, title: s.title, startDate: s.start_date, totalAmount: parseFloat(s.total_amount), contractorName: s.contractor_name,
myShare: { myShare: {
percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status percentage: parseFloat(s.percentage), amountDue: parseFloat(s.amount_due), amountPaid: parseFloat(s.amount_paid), status: s.status
@@ -631,34 +828,23 @@ app.get('/api/my-expenses', authenticateToken, async (req, res) => {
}); });
app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => { app.post('/api/expenses/:id/pay', authenticateToken, async (req, res) => {
const { amount, notes, familyId } = req.body; const { amount, notes, familyId } = req.body;
// If Admin, familyId is passed. If User, use req.user.familyId
const targetFamilyId = (req.user.role === 'admin' || req.user.role === 'poweruser') ? familyId : req.user.familyId; 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' }); if (!targetFamilyId) return res.status(400).json({ message: 'Family not found' });
const connection = await pool.getConnection(); const connection = await pool.getConnection();
try { try {
await connection.beginTransaction(); await connection.beginTransaction();
// 1. Record payment in main payments table (linked to extraordinary expense?)
// For simplicity in this schema we might just update the share or add a row in `payments` with special flag
// Current Schema `payments` has `extraordinary_expense_id` column.
await connection.query( await connection.query(
'INSERT INTO payments (id, family_id, extraordinary_expense_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', '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'] [uuidv4(), targetFamilyId, req.params.id, amount, new Date(), 13, new Date().getFullYear(), notes || 'Extraordinary Payment']
); );
// 2. Update Share
const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [req.params.id, targetFamilyId]); 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'); if (shares.length === 0) throw new Error('Share not found');
const share = shares[0]; const share = shares[0];
const newPaid = parseFloat(share.amount_paid) + parseFloat(amount); const newPaid = parseFloat(share.amount_paid) + parseFloat(amount);
const due = parseFloat(share.amount_due); const due = parseFloat(share.amount_due);
let newStatus = 'PARTIAL'; let newStatus = 'PARTIAL';
if (newPaid >= due - 0.01) newStatus = 'PAID'; // Tolerance 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.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, newStatus, share.id]);
await connection.commit(); await connection.commit();
res.json({ success: true }); res.json({ success: true });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
@@ -680,11 +866,7 @@ app.delete('/api/expenses/payments/:paymentId', authenticateToken, requireAdmin,
if (pay.length === 0) throw new Error('Payment not found'); if (pay.length === 0) throw new Error('Payment not found');
const payment = pay[0]; const payment = pay[0];
if (!payment.extraordinary_expense_id) throw new Error('Not an extraordinary payment'); if (!payment.extraordinary_expense_id) throw new Error('Not an extraordinary payment');
// Delete payment
await connection.query('DELETE FROM payments WHERE id = ?', [req.params.paymentId]); await connection.query('DELETE FROM payments WHERE id = ?', [req.params.paymentId]);
// Revert share
const [shares] = await connection.query('SELECT * FROM expense_shares WHERE expense_id = ? AND family_id = ?', [payment.extraordinary_expense_id, payment.family_id]); 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) { if (shares.length > 0) {
const share = shares[0]; const share = shares[0];
@@ -692,13 +874,12 @@ app.delete('/api/expenses/payments/:paymentId', authenticateToken, requireAdmin,
const newStatus = newPaid >= parseFloat(share.amount_due) - 0.01 ? 'PAID' : (newPaid > 0 ? 'PARTIAL' : 'UNPAID'); 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.query('UPDATE expense_shares SET amount_paid = ?, status = ? WHERE id = ?', [newPaid, newStatus, share.id]);
} }
await connection.commit(); await connection.commit();
res.json({ success: true }); res.json({ success: true });
} catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); } } catch(e) { await connection.rollback(); res.status(500).json({ error: e.message }); } finally { connection.release(); }
}); });
// --- CONDO ORDINARY EXPENSES (USCITE) --- // --- CONDO ORDINARY EXPENSES ---
app.get('/api/condo-expenses', authenticateToken, async (req, res) => { app.get('/api/condo-expenses', authenticateToken, async (req, res) => {
const { condoId, year } = req.query; const { condoId, year } = req.query;
try { try {
@@ -711,7 +892,6 @@ app.get('/api/condo-expenses', authenticateToken, async (req, res) => {
query += ' ORDER BY created_at DESC'; query += ' ORDER BY created_at DESC';
const [rows] = await pool.query(query, params); 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 [allAtts] = await pool.query('SELECT id, condo_expense_id, file_name, file_type FROM condo_expense_attachments');
const results = rows.map(r => ({ const results = rows.map(r => ({
id: r.id, condoId: r.condo_id, description: r.description, supplierName: r.supplier_name, 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, amount: parseFloat(r.amount), paymentDate: r.payment_date, status: r.status,
@@ -764,63 +944,6 @@ app.get('/api/condo-expenses/:id/attachments/:attId', authenticateToken, async (
} catch(e) { res.status(500).json({ error: e.message }); } } catch(e) { res.status(500).json({ error: e.message }); }
}); });
// --- DOCUMENTS (CLOUD/LOCAL) ---
app.get('/api/documents', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
// We only fetch metadata, not file_data
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: 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 {
// Here we would implement real Cloud Storage logic based on storageConfig.provider
// For 'local_db' or fallback, we save base64 in DB.
let provider = storageConfig?.provider || 'local_db';
// Mocking Cloud upload by just saving to DB for demo purposes,
// but acknowledging the config
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, fileData]
);
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 external provider (S3/Drive), we would generate a Signed URL here or proxy the stream.
// For local_db:
res.json({
fileName: doc.file_name,
fileType: doc.file_type,
data: doc.file_data // Base64
});
} catch(e) { res.status(500).json({ error: e.message }); }
});
app.delete('/api/documents/:id', authenticateToken, requireAdmin, async (req, res) => {
try {
// Also delete from cloud if configured...
await pool.query('DELETE FROM documents WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch(e) { res.status(500).json({ error: e.message }); }
});
initDb().then(() => { initDb().then(() => {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);