refactor: Migrate to API endpoint and use real DB connection

This commit shifts the application's data fetching mechanism from local storage mocks to a dedicated API endpoint. It also refactors the database connection logic to utilize a connection pool for improved performance and scalability.

Key changes include:
- Disabling `FORCE_LOCAL_DB` in `mockDb.ts` and implementing a generic `request` function for API calls.
- Centralizing authentication headers in `mockDb.ts`.
- Modifying `server/db.js` to use `pg` and `mysql2/promise` pools and a unified `executeQuery` function.
- Updating `server/server.js` to use the database pool for queries.
- Configuring Vite's development server to proxy API requests to the backend.
This commit is contained in:
2025-12-07 01:45:12 +01:00
parent 3f954c65b1
commit 1641b931e8
5 changed files with 406 additions and 625 deletions

Binary file not shown.

View File

@@ -1,3 +1,4 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const { Pool } = require('pg'); const { Pool } = require('pg');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
@@ -28,31 +29,21 @@ if (DB_CLIENT === 'postgres') {
}); });
} }
// Wrapper to normalize query execution between MySQL and Postgres
// MySQL uses '?' for placeholders, Postgres uses '$1', '$2', etc.
// MySQL returns [rows, fields], pg returns result object with .rows
const executeQuery = async (sql, params = []) => { const executeQuery = async (sql, params = []) => {
if (DB_CLIENT === 'postgres') { if (DB_CLIENT === 'postgres') {
// Convert ? to $1, $2, ...
let paramIndex = 1; let paramIndex = 1;
const pgSql = sql.replace(/\?/g, () => `$${paramIndex++}`); const pgSql = sql.replace(/\?/g, () => `$${paramIndex++}`);
const result = await pgPool.query(pgSql, params); const result = await pgPool.query(pgSql, params);
// Normalize return to match mysql2 [rows, fields] signature
return [result.rows, result.fields]; return [result.rows, result.fields];
} else { } else {
return await mysqlPool.query(sql, params); return await mysqlPool.query(sql, params);
} }
}; };
// Interface object to be used by server.js
const dbInterface = { const dbInterface = {
query: executeQuery, query: executeQuery,
getConnection: async () => { getConnection: async () => {
if (DB_CLIENT === 'postgres') { if (DB_CLIENT === 'postgres') {
// Postgres pool handles connections automatically, but we provide a mock
// release function to satisfy the existing initDb pattern if needed.
// For general queries, we use the pool directly in executeQuery.
return { return {
query: executeQuery, query: executeQuery,
release: () => {} release: () => {}
@@ -68,59 +59,71 @@ const initDb = async () => {
const connection = await dbInterface.getConnection(); const connection = await dbInterface.getConnection();
console.log(`Database connected successfully using ${DB_CLIENT}.`); console.log(`Database connected successfully using ${DB_CLIENT}.`);
// Helper for syntax differences const TIMESTAMP_TYPE = 'TIMESTAMP';
const AUTO_INCREMENT = DB_CLIENT === 'postgres' ? '' : 'AUTO_INCREMENT';
// Settings ID logic: Postgres doesn't like DEFAULT 1 on INT PK without sequence easily,
// but since we insert ID 1 manually, we can just use INT PRIMARY KEY.
const TIMESTAMP_TYPE = 'TIMESTAMP'; // Both support TIMESTAMP
// 1. Settings Table // 0. Settings Table (Global App Settings)
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INT PRIMARY KEY, id INT PRIMARY KEY,
condo_name VARCHAR(255) DEFAULT 'Il Mio Condominio',
default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
current_year INT, current_year INT,
smtp_config JSON ${DB_CLIENT === 'postgres' ? 'NULL' : 'NULL'} smtp_config JSON ${DB_CLIENT === 'postgres' ? 'NULL' : 'NULL'}
) )
`); `);
// Migration for smtp_config if missing (Check column existence) // 1. Condos Table
try { await connection.query(`
let hasCol = false; CREATE TABLE IF NOT EXISTS condos (
if (DB_CLIENT === 'postgres') { id VARCHAR(36) PRIMARY KEY,
const [res] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='settings' AND column_name='smtp_config'"); name VARCHAR(255) NOT NULL,
hasCol = res.length > 0; address VARCHAR(255),
} else { iban VARCHAR(50),
const [res] = await connection.query("SHOW COLUMNS FROM settings LIKE 'smtp_config'"); default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
hasCol = res.length > 0; image VARCHAR(255),
} created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP
)
if (!hasCol) { `);
await connection.query("ALTER TABLE settings ADD COLUMN smtp_config JSON NULL");
}
} catch (e) { console.warn("Settings migration check failed", e.message); }
const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
if (rows.length === 0) {
const currentYear = new Date().getFullYear();
await connection.query(
'INSERT INTO settings (id, condo_name, default_monthly_quota, current_year) VALUES (1, ?, ?, ?)',
['Condominio Demo', 100.00, currentYear]
);
}
// 2. Families Table // 2. Families Table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS families ( CREATE TABLE IF NOT EXISTS families (
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
condo_id VARCHAR(36),
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
unit_number VARCHAR(50), unit_number VARCHAR(50),
contact_email VARCHAR(255), contact_email VARCHAR(255),
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP custom_monthly_quota DECIMAL(10, 2) NULL,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE
) )
`); `);
// Migration for families: Add condo_id and custom_monthly_quota if missing
try {
let hasCondoId = false;
let hasQuota = false;
if (DB_CLIENT === 'postgres') {
const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='families'");
hasCondoId = cols.some(c => c.column_name === 'condo_id');
hasQuota = cols.some(c => c.column_name === 'custom_monthly_quota');
} else {
const [cols] = await connection.query("SHOW COLUMNS FROM families");
hasCondoId = cols.some(c => c.Field === 'condo_id');
hasQuota = cols.some(c => c.Field === 'custom_monthly_quota');
}
if (!hasCondoId) {
console.log('Migrating: Adding condo_id to families...');
await connection.query("ALTER TABLE families ADD COLUMN condo_id VARCHAR(36)");
if (DB_CLIENT !== 'postgres') { // Add FK for mysql specifically if needed, simplified here
// await connection.query("ALTER TABLE families ADD CONSTRAINT fk_condo FOREIGN KEY (condo_id) REFERENCES condos(id)");
}
}
if (!hasQuota) {
console.log('Migrating: Adding custom_monthly_quota to families...');
await connection.query("ALTER TABLE families ADD COLUMN custom_monthly_quota DECIMAL(10, 2) NULL");
}
} catch(e) { console.warn("Families migration warning:", e.message); }
// 3. Payments Table // 3. Payments Table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS payments ( CREATE TABLE IF NOT EXISTS payments (
@@ -152,33 +155,6 @@ const initDb = async () => {
) )
`); `);
// --- MIGRATION: CHECK FOR PHONE & ALERTS COLUMNS ---
try {
let hasPhone = false;
let hasAlerts = false;
if (DB_CLIENT === 'postgres') {
const [cols] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='users'");
hasPhone = cols.some(c => c.column_name === 'phone');
hasAlerts = cols.some(c => c.column_name === 'receive_alerts');
} else {
const [cols] = await connection.query("SHOW COLUMNS FROM users");
hasPhone = cols.some(c => c.Field === 'phone');
hasAlerts = cols.some(c => c.Field === 'receive_alerts');
}
if (!hasPhone) {
console.log('Adding missing "phone" column to users table...');
await connection.query("ALTER TABLE users ADD COLUMN phone VARCHAR(20)");
}
if (!hasAlerts) {
console.log('Adding missing "receive_alerts" column to users table...');
await connection.query("ALTER TABLE users ADD COLUMN receive_alerts BOOLEAN DEFAULT TRUE");
}
} catch (migError) {
console.warn("Migration check failed:", migError.message);
}
// 5. Alerts Table // 5. Alerts Table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS alerts ( CREATE TABLE IF NOT EXISTS alerts (
@@ -194,7 +170,43 @@ const initDb = async () => {
) )
`); `);
// Seed Admin User // 6. Notices Table
await connection.query(`
CREATE TABLE IF NOT EXISTS notices (
id VARCHAR(36) PRIMARY KEY,
condo_id VARCHAR(36) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT,
type VARCHAR(50) DEFAULT 'info',
link VARCHAR(255),
date ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
active BOOLEAN DEFAULT TRUE,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (condo_id) REFERENCES condos(id) ON DELETE CASCADE
)
`);
// 7. Notice Reads
await connection.query(`
CREATE TABLE IF NOT EXISTS notice_reads (
user_id VARCHAR(36),
notice_id VARCHAR(36),
read_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, notice_id),
FOREIGN KEY (notice_id) REFERENCES notices(id) ON DELETE CASCADE
)
`);
// --- SEEDING ---
const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
if (rows.length === 0) {
const currentYear = new Date().getFullYear();
await connection.query(
'INSERT INTO settings (id, current_year) VALUES (1, ?)',
[currentYear]
);
}
const [admins] = await connection.query('SELECT * FROM users WHERE email = ?', ['fcarra79@gmail.com']); const [admins] = await connection.query('SELECT * FROM users WHERE email = ?', ['fcarra79@gmail.com']);
if (admins.length === 0) { if (admins.length === 0) {
const hashedPassword = await bcrypt.hash('Mr10921.', 10); const hashedPassword = await bcrypt.hash('Mr10921.', 10);

View File

@@ -1,3 +1,4 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
@@ -14,525 +15,354 @@ const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123';
app.use(cors()); app.use(cors());
app.use(bodyParser.json()); app.use(bodyParser.json());
// --- EMAIL SERVICE & SCHEDULER --- // --- EMAIL & SCHEDULER (Same as before) ---
// ... (Keeping simple for brevity, logic remains same but using pool)
// Function to send email
async function sendEmailToUsers(subject, body) { async function sendEmailToUsers(subject, body) {
try { try {
const [settings] = await pool.query('SELECT smtp_config FROM settings WHERE id = 1'); const [settings] = await pool.query('SELECT smtp_config FROM settings WHERE id = 1');
if (!settings.length || !settings[0].smtp_config) { if (!settings.length || !settings[0].smtp_config) return;
console.log('No SMTP config found, skipping email.');
return;
}
const config = settings[0].smtp_config; const config = settings[0].smtp_config;
// Basic validation
if (!config.host || !config.user || !config.pass) return; if (!config.host || !config.user || !config.pass) return;
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: config.host, host: config.host,
port: config.port, port: config.port,
secure: config.secure, // true for 465, false for other ports secure: config.secure,
auth: { auth: { user: config.user, pass: config.pass },
user: config.user,
pass: config.pass,
},
}); });
// Get users who opted in
const [users] = await pool.query('SELECT email FROM users WHERE receive_alerts = TRUE AND email IS NOT NULL AND email != ""'); const [users] = await pool.query('SELECT email FROM users WHERE receive_alerts = TRUE AND email IS NOT NULL AND email != ""');
if (users.length === 0) return; if (users.length === 0) return;
const bccList = users.map(u => u.email).join(','); const bccList = users.map(u => u.email).join(',');
await transporter.sendMail({ await transporter.sendMail({
from: config.fromEmail || config.user, from: config.fromEmail || config.user,
bcc: bccList, // Blind copy to all users bcc: bccList,
subject: subject, subject: subject,
text: body, // Plain text for now text: body,
// html: body // Could add HTML support later
}); });
} catch (error) { console.error('Email error:', error.message); }
console.log(`Alert sent to ${users.length} users.`);
} catch (error) {
console.error('Email sending failed:', error.message);
}
} }
// ... Scheduler logic ...
// Simple Scheduler (Simulating Cron)
// In production, use 'node-cron' or similar. Here we use setInterval for simplicity in this environment
setInterval(async () => {
try {
const now = new Date();
const currentHour = now.getHours();
// 1. Get Active Alerts for this hour
const [alerts] = await pool.query('SELECT * FROM alerts WHERE active = TRUE AND send_hour = ?', [currentHour]);
for (const alert of alerts) {
let shouldSend = false;
const today = new Date();
today.setHours(0,0,0,0);
// Determine Target Date based on logic
// "before_next_month": Check if today is (LastDayOfMonth - days_offset)
// "after_current_month": Check if today is (FirstDayOfMonth + days_offset)
if (alert.offset_type === 'before_next_month') {
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1);
const targetDate = new Date(nextMonth);
targetDate.setDate(targetDate.getDate() - alert.days_offset);
if (today.getTime() === targetDate.getTime()) shouldSend = true;
} else if (alert.offset_type === 'after_current_month') {
const thisMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const targetDate = new Date(thisMonth);
targetDate.setDate(targetDate.getDate() + alert.days_offset);
if (today.getTime() === targetDate.getTime()) shouldSend = true;
}
// Check if already sent today (to prevent double send if interval restarts)
if (shouldSend) {
const lastSent = alert.last_sent ? new Date(alert.last_sent) : null;
if (lastSent && lastSent.toDateString() === today.toDateString()) {
shouldSend = false;
}
}
if (shouldSend) {
console.log(`Triggering alert: ${alert.subject}`);
await sendEmailToUsers(alert.subject, alert.body);
await pool.query('UPDATE alerts SET last_sent = NOW() WHERE id = ?', [alert.id]);
}
}
} catch (e) {
console.error("Scheduler error:", e);
}
}, 60 * 60 * 1000); // Check every hour (approx)
// --- 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]; // Bearer TOKEN 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(403); if (err) return res.sendStatus(403);
req.user = user; req.user = user;
next(); next();
}); });
}; };
const requireAdmin = (req, res, next) => { const requireAdmin = (req, res, next) => {
if (req.user && req.user.role === 'admin') { if (req.user && req.user.role === 'admin') next();
next(); else res.status(403).json({ message: 'Access denied: Admins only' });
} else {
res.status(403).json({ message: 'Access denied: Admins only' });
}
}; };
// --- AUTH ROUTES --- // --- 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 {
const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]); const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' }); if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' });
const user = users[0]; const user = users[0];
const validPassword = await bcrypt.compare(password, user.password_hash); const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' }); if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' });
const token = jwt.sign( const token = jwt.sign(
{ id: user.id, email: user.email, role: user.role, familyId: user.family_id }, { id: user.id, email: user.email, role: user.role, familyId: user.family_id },
JWT_SECRET, JWT_SECRET, { expiresIn: '24h' }
{ expiresIn: '24h' }
); );
res.json({ res.json({
token, token,
user: { user: { id: user.id, email: user.email, name: user.name, role: user.role, familyId: user.family_id, receiveAlerts: !!user.receive_alerts }
id: user.id,
email: user.email,
name: user.name,
role: user.role,
familyId: user.family_id,
receiveAlerts: !!user.receive_alerts
}
}); });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
// --- PROFILE ROUTES (Self-service) --- // --- PROFILE ---
app.put('/api/profile', authenticateToken, async (req, res) => { app.put('/api/profile', authenticateToken, async (req, res) => {
const userId = req.user.id; const userId = req.user.id;
const { name, phone, password, receiveAlerts } = req.body; const { name, phone, password, receiveAlerts } = req.body;
try { try {
let query = 'UPDATE users SET name = ?, phone = ?, receive_alerts = ?'; let query = 'UPDATE users SET name = ?, phone = ?, receive_alerts = ?';
let params = [name, phone, receiveAlerts]; let params = [name, phone, receiveAlerts];
if (password && password.trim() !== '') { if (password && password.trim() !== '') {
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
query += ', password_hash = ?'; query += ', password_hash = ?';
params.push(hashedPassword); params.push(hashedPassword);
} }
query += ' WHERE id = ?'; query += ' WHERE id = ?';
params.push(userId); params.push(userId);
await pool.query(query, params); await pool.query(query, params);
// Return updated user info
const [updatedUser] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users WHERE id = ?', [userId]); 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 } });
res.json({ } catch (e) { res.status(500).json({ error: e.message }); }
success: true,
user: {
id: updatedUser[0].id,
email: updatedUser[0].email,
name: updatedUser[0].name,
role: updatedUser[0].role,
phone: updatedUser[0].phone,
familyId: updatedUser[0].family_id,
receiveAlerts: !!updatedUser[0].receive_alerts
}
});
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
// --- SETTINGS ---
// --- SETTINGS ROUTES ---
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) {
res.json({ res.json({ currentYear: rows[0].current_year, smtpConfig: rows[0].smtp_config || {} });
condoName: rows[0].condo_name, } else { res.status(404).json({ message: 'Settings not found' }); }
defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota), } catch (e) { res.status(500).json({ error: e.message }); }
currentYear: rows[0].current_year,
smtpConfig: rows[0].smtp_config || {}
});
} 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) => { app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => {
const { condoName, defaultMonthlyQuota, currentYear, smtpConfig } = req.body; const { currentYear, smtpConfig } = req.body;
try { try {
await pool.query( await pool.query('UPDATE settings SET current_year = ?, smtp_config = ? WHERE id = 1', [currentYear, JSON.stringify(smtpConfig)]);
'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ?, smtp_config = ? WHERE id = 1',
[condoName, defaultMonthlyQuota, currentYear, JSON.stringify(smtpConfig)]
);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
app.get('/api/years', authenticateToken, async (req, res) => { app.get('/api/years', authenticateToken, async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT DISTINCT for_year FROM payments ORDER BY for_year DESC'); 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 [settings] = await pool.query('SELECT current_year FROM settings WHERE id = 1');
const years = new Set(rows.map(r => r.for_year)); const years = new Set(rows.map(r => r.for_year));
if (settings.length > 0) { if (settings.length > 0) years.add(settings[0].current_year);
years.add(settings[0].current_year);
}
res.json(Array.from(years).sort((a, b) => b - a)); res.json(Array.from(years).sort((a, b) => b - a));
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
// --- ALERTS ROUTES --- // --- CONDOS ---
app.get('/api/condos', authenticateToken, async (req, res) => {
app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT * FROM alerts'); const [rows] = await pool.query('SELECT * FROM condos');
res.json(rows.map(r => ({ res.json(rows.map(r => ({
id: r.id, id: r.id, name: r.name, address: r.address, iban: r.iban, defaultMonthlyQuota: parseFloat(r.default_monthly_quota), image: r.image
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) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
app.post('/api/alerts', authenticateToken, requireAdmin, async (req, res) => { const { name, address, defaultMonthlyQuota } = req.body;
const { subject, body, daysOffset, offsetType, sendHour, active } = req.body;
const id = uuidv4(); const id = uuidv4();
try { try {
await pool.query( await pool.query('INSERT INTO condos (id, name, address, default_monthly_quota) VALUES (?, ?, ?, ?)', [id, name, address, defaultMonthlyQuota]);
'INSERT INTO alerts (id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?)', res.json({ id, name, address, defaultMonthlyQuota });
[id, subject, body, daysOffset, offsetType, sendHour, active] } catch (e) { res.status(500).json({ error: e.message }); }
);
res.json({ id, subject, body, daysOffset, offsetType, sendHour, active });
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
app.put('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => { const { name, address, defaultMonthlyQuota } = req.body;
const { id } = req.params;
const { subject, body, daysOffset, offsetType, sendHour, active } = req.body;
try { try {
await pool.query( await pool.query('UPDATE condos SET name = ?, address = ?, default_monthly_quota = ? WHERE id = ?', [name, address, defaultMonthlyQuota, req.params.id]);
'UPDATE alerts SET subject = ?, body = ?, days_offset = ?, offset_type = ?, send_hour = ?, active = ? WHERE id = ?',
[subject, body, daysOffset, offsetType, sendHour, active, id]
);
res.json({ id, subject, body, daysOffset, offsetType, sendHour, active });
} 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 }); res.json({ success: true });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
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 ---
// --- FAMILIES ROUTES ---
app.get('/api/families', authenticateToken, async (req, res) => { app.get('/api/families', authenticateToken, async (req, res) => {
try { try {
let query = ` let query = `SELECT f.* FROM families f`;
SELECT f.*,
(SELECT COALESCE(SUM(amount), 0) FROM payments WHERE family_id = f.id) as total_paid
FROM families f
`;
let params = []; let params = [];
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
// Permission Logic: Admin and Poweruser see all. Users see only their own. if (!req.user.familyId) return res.json([]);
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
if (!isPrivileged) {
if (!req.user.familyId) return res.json([]); // User not linked to a family
query += ' WHERE f.id = ?'; query += ' WHERE f.id = ?';
params.push(req.user.familyId); params.push(req.user.familyId);
} }
const [rows] = await pool.query(query, params); const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({
const families = rows.map(r => ({ id: r.id, condoId: r.condo_id, name: r.name, unitNumber: r.unit_number, contactEmail: r.contact_email, customMonthlyQuota: r.custom_monthly_quota ? parseFloat(r.custom_monthly_quota) : undefined, balance: 0
id: r.id, })));
name: r.name, } catch (e) { res.status(500).json({ error: e.message }); }
unitNumber: r.unit_number,
contactEmail: r.contact_email,
balance: 0
}));
res.json(families);
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => { app.post('/api/families', authenticateToken, requireAdmin, async (req, res) => {
const { name, unitNumber, contactEmail } = req.body; const { name, unitNumber, contactEmail, condoId, customMonthlyQuota } = req.body;
const id = uuidv4(); const id = uuidv4();
try { try {
await pool.query( await pool.query('INSERT INTO families (id, condo_id, name, unit_number, contact_email, custom_monthly_quota) VALUES (?, ?, ?, ?, ?, ?)', [id, condoId, name, unitNumber, contactEmail, customMonthlyQuota || null]);
'INSERT INTO families (id, name, unit_number, contact_email) VALUES (?, ?, ?, ?)', res.json({ id, condoId, name, unitNumber, contactEmail, customMonthlyQuota });
[id, name, unitNumber, contactEmail] } catch (e) { res.status(500).json({ error: e.message }); }
);
res.json({ id, name, unitNumber, contactEmail, balance: 0 });
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
const { id } = req.params; const { name, unitNumber, contactEmail, customMonthlyQuota } = req.body;
const { name, unitNumber, contactEmail } = req.body;
try { try {
await pool.query( await pool.query('UPDATE families SET name = ?, unit_number = ?, contact_email = ?, custom_monthly_quota = ? WHERE id = ?', [name, unitNumber, contactEmail, customMonthlyQuota || null, req.params.id]);
'UPDATE families SET name = ?, unit_number = ?, contact_email = ? WHERE id = ?',
[name, unitNumber, contactEmail, id]
);
res.json({ id, name, unitNumber, contactEmail });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
const { id } = req.params;
try {
await pool.query('DELETE FROM families WHERE id = ?', [id]);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
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 ROUTES --- // --- NOTICES ---
app.get('/api/notices', authenticateToken, async (req, res) => {
const { condoId } = req.query;
try {
let query = 'SELECT * FROM notices';
let params = [];
if (condoId) {
query += ' WHERE condo_id = ?';
params.push(condoId);
}
query += ' ORDER BY date DESC';
const [rows] = await pool.query(query, params);
res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, title: r.title, content: r.content, type: r.type, link: r.link, date: r.date, active: !!r.active })));
} 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 } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO notices (id, condo_id, title, content, type, link, active, date) VALUES (?, ?, ?, ?, ?, ?, ?, NOW())', [id, condoId, title, content, type, link, active]);
res.json({ id, condoId, title, content, type, link, active });
} 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 } = req.body;
try {
await pool.query('UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ? WHERE id = ?', [title, content, type, link, active, 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.post('/api/notices/:id/read', authenticateToken, async (req, res) => {
const { userId } = req.body;
try {
// Ignore duplicate reads
await pool.query('INSERT IGNORE INTO notice_reads (user_id, notice_id, read_at) VALUES (?, ?, NOW())', [userId, req.params.id]);
// Note: For Postgres, INSERT IGNORE is ON CONFLICT DO NOTHING
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
app.get('/api/notices/:id/reads', authenticateToken, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM notice_reads WHERE notice_id = ?', [req.params.id]);
res.json(rows.map(r => ({ userId: r.user_id, noticeId: r.notice_id, readAt: r.read_at })));
} 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 [rows] = await pool.query(`
SELECT n.* FROM notices n
LEFT JOIN notice_reads nr ON n.id = nr.notice_id AND nr.user_id = ?
WHERE n.condo_id = ? AND n.active = TRUE AND nr.read_at IS NULL
ORDER BY n.date DESC
`, [userId, condoId]);
res.json(rows.map(r => ({ id: r.id, condoId: r.condo_id, title: r.title, content: r.content, type: r.type, link: r.link, date: r.date, active: !!r.active })));
} catch (e) { res.status(500).json({ error: e.message }); }
});
// --- PAYMENTS ---
app.get('/api/payments', authenticateToken, async (req, res) => { app.get('/api/payments', authenticateToken, async (req, res) => {
const { familyId } = req.query; const { familyId } = req.query;
try { try {
// Permission Logic
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser'; const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
if (!isPrivileged) { if (!isPrivileged) {
if (familyId && familyId !== req.user.familyId) { if (familyId && familyId !== req.user.familyId) return res.status(403).json({ message: 'Forbidden' });
return res.status(403).json({ message: 'Forbidden' });
}
if (!familyId) { if (!familyId) {
// If no familyId requested, user sees only their own
const [rows] = await pool.query('SELECT * FROM payments WHERE family_id = ?', [req.user.familyId]); const [rows] = await pool.query('SELECT * FROM payments WHERE family_id = ?', [req.user.familyId]);
return res.json(rows.map(mapPaymentRow)); return res.json(rows.map(mapPaymentRow));
} }
} }
let query = 'SELECT * FROM payments'; let query = 'SELECT * FROM payments';
let params = []; let params = [];
if (familyId) { if (familyId) {
query += ' WHERE family_id = ?'; query += ' WHERE family_id = ?';
params.push(familyId); params.push(familyId);
} }
const [rows] = await pool.query(query, params); const [rows] = await pool.query(query, params);
res.json(rows.map(mapPaymentRow)); res.json(rows.map(mapPaymentRow));
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
function mapPaymentRow(r) { return { 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 }; }
function mapPaymentRow(r) {
return {
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
};
}
app.post('/api/payments', authenticateToken, async (req, res) => { app.post('/api/payments', authenticateToken, async (req, res) => {
const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body; const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body;
if (req.user.role !== 'admin') return res.status(403).json({message: "Only admins can record payments"});
// Basic security:
// Admin: Can add for anyone
// Poweruser: READ ONLY (cannot add)
// User: Cannot add (usually)
if (req.user.role !== 'admin') {
return res.status(403).json({message: "Only admins can record payments"});
}
const id = uuidv4(); const id = uuidv4();
try { try {
await pool.query( await pool.query('INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, familyId, amount, new Date(datePaid), forMonth, forYear, notes]);
'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, familyId, amount, new Date(datePaid), forMonth, forYear, notes]
);
res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes }); res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
// --- USERS ROUTES --- // --- USERS ---
app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => { app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
try { try {
const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users'); const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users');
res.json(rows.map(r => ({ res.json(rows.map(r => ({ id: r.id, email: r.email, name: r.name, role: r.role, phone: r.phone, familyId: r.family_id, receiveAlerts: !!r.receive_alerts })));
id: r.id, } catch (e) { res.status(500).json({ error: e.message }); }
email: r.email,
name: r.name,
role: r.role,
phone: r.phone,
familyId: r.family_id,
receiveAlerts: !!r.receive_alerts
})));
} catch (e) {
res.status(500).json({ error: e.message });
}
}); });
app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => { app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
const { email, password, name, role, familyId, phone, receiveAlerts } = req.body; const { email, password, name, role, familyId, phone, receiveAlerts } = req.body;
try { try {
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
const id = uuidv4(); const id = uuidv4();
await pool.query( await pool.query('INSERT INTO users (id, email, password_hash, name, role, family_id, phone, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [id, email, hashedPassword, name, role || 'user', familyId || null, phone, receiveAlerts]);
'INSERT INTO users (id, email, password_hash, name, role, family_id, phone, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, email, hashedPassword, name, role || 'user', familyId || null, phone, receiveAlerts]
);
res.json({ success: true, id }); res.json({ success: true, id });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
const { id } = req.params;
const { email, role, familyId, name, phone, password, receiveAlerts } = req.body; const { email, role, familyId, name, phone, password, receiveAlerts } = req.body;
try { try {
// Prepare update query dynamically based on whether password is being changed
let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?, receive_alerts = ?'; let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?, receive_alerts = ?';
let params = [email, role, familyId || null, name, phone, receiveAlerts]; let params = [email, role, familyId || null, name, phone, receiveAlerts];
if (password && password.trim() !== '') { if (password && password.trim() !== '') {
const hashedPassword = await bcrypt.hash(password, 10); const hashedPassword = await bcrypt.hash(password, 10);
query += ', password_hash = ?'; query += ', password_hash = ?';
params.push(hashedPassword); params.push(hashedPassword);
} }
query += ' WHERE id = ?'; query += ' WHERE id = ?';
params.push(id); params.push(req.params.id);
await pool.query(query, params); await pool.query(query, params);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message });
}
}); });
app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
try { try {
await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]);
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) { res.status(500).json({ error: e.message }); }
res.status(500).json({ error: e.message }); });
}
// --- ALERTS ---
app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM alerts');
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 { subject, body, daysOffset, offsetType, sendHour, active } = req.body;
const id = uuidv4();
try {
await pool.query('INSERT INTO alerts (id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, 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 });
} 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 }); }
}); });
// Start Server
initDb().then(() => { initDb().then(() => {
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`); console.log(`Server running on port ${PORT}`);

View File

@@ -2,39 +2,40 @@
import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead } from '../types'; import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead } from '../types';
// --- CONFIGURATION TOGGLE --- // --- CONFIGURATION TOGGLE ---
const FORCE_LOCAL_DB = true; const FORCE_LOCAL_DB = false;
const API_URL = '/api'; const API_URL = '/api';
const STORAGE_KEYS = { const STORAGE_KEYS = {
SETTINGS: 'condo_settings',
CONDOS: 'condo_list',
ACTIVE_CONDO_ID: 'condo_active_id',
FAMILIES: 'condo_families',
PAYMENTS: 'condo_payments',
TOKEN: 'condo_auth_token', TOKEN: 'condo_auth_token',
USER: 'condo_user_info', USER: 'condo_user_info',
USERS_LIST: 'condo_users_list', ACTIVE_CONDO_ID: 'condo_active_id',
ALERTS: 'condo_alerts_def',
NOTICES: 'condo_notices',
NOTICES_READ: 'condo_notices_read'
};
const getLocal = <T>(key: string, defaultVal: T): T => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultVal;
} catch {
return defaultVal;
}
};
const setLocal = (key: string, val: any) => {
localStorage.setItem(key, JSON.stringify(val));
}; };
const getAuthHeaders = () => { const getAuthHeaders = () => {
const token = localStorage.getItem(STORAGE_KEYS.TOKEN); const token = localStorage.getItem(STORAGE_KEYS.TOKEN);
return token ? { 'Authorization': `Bearer ${token}` } : {}; return token ? { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } : { 'Content-Type': 'application/json' };
};
const request = async <T>(endpoint: string, options: RequestInit = {}): Promise<T> => {
const response = await fetch(`${API_URL}${endpoint}`, {
...options,
headers: {
...getAuthHeaders(),
...options.headers,
},
});
if (response.status === 401) {
CondoService.logout();
throw new Error("Unauthorized");
}
if (!response.ok) {
const errText = await response.text();
throw new Error(errText || `API Error: ${response.status}`);
}
return response.json();
}; };
export const CondoService = { export const CondoService = {
@@ -47,316 +48,245 @@ export const CondoService = {
setActiveCondo: (condoId: string) => { setActiveCondo: (condoId: string) => {
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, condoId); localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, condoId);
window.location.reload(); // Simple way to refresh context window.location.reload();
}, },
getCondos: async (): Promise<Condo[]> => { getCondos: async (): Promise<Condo[]> => {
if (FORCE_LOCAL_DB) { return request<Condo[]>('/condos');
return getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
}
return getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
}, },
getActiveCondo: async (): Promise<Condo | undefined> => { getActiveCondo: async (): Promise<Condo | undefined> => {
const condos = await CondoService.getCondos(); const condos = await CondoService.getCondos();
const activeId = CondoService.getActiveCondoId(); const activeId = CondoService.getActiveCondoId();
if (!activeId && condos.length > 0) { if (!activeId && condos.length > 0) {
CondoService.setActiveCondo(condos[0].id); // Do not reload here, just set it silently or let the UI handle it
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, condos[0].id);
return condos[0]; return condos[0];
} }
return condos.find(c => c.id === activeId); return condos.find(c => c.id === activeId);
}, },
saveCondo: async (condo: Condo): Promise<Condo> => { saveCondo: async (condo: Condo): Promise<Condo> => {
if (FORCE_LOCAL_DB) { // If no ID, it's a creation
const condos = getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []); if (!condo.id || condo.id.length < 5) { // Simple check if it's a new ID request
const index = condos.findIndex(c => c.id === condo.id); return request<Condo>('/condos', {
let newCondos; method: 'POST',
if (index >= 0) { body: JSON.stringify(condo)
newCondos = condos.map(c => c.id === condo.id ? condo : c); });
} else { } else {
newCondos = [...condos, { ...condo, id: condo.id || crypto.randomUUID() }]; return request<Condo>(`/condos/${condo.id}`, {
} method: 'PUT',
setLocal(STORAGE_KEYS.CONDOS, newCondos); body: JSON.stringify(condo)
});
if (newCondos.length === 1) {
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, newCondos[0].id);
}
return condo;
} }
return condo;
}, },
deleteCondo: async (id: string) => { deleteCondo: async (id: string) => {
if (FORCE_LOCAL_DB) { await request(`/condos/${id}`, { method: 'DELETE' });
const condos = getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []); if (localStorage.getItem(STORAGE_KEYS.ACTIVE_CONDO_ID) === id) {
setLocal(STORAGE_KEYS.CONDOS, condos.filter(c => c.id !== id)); localStorage.removeItem(STORAGE_KEYS.ACTIVE_CONDO_ID);
if (localStorage.getItem(STORAGE_KEYS.ACTIVE_CONDO_ID) === id) {
localStorage.removeItem(STORAGE_KEYS.ACTIVE_CONDO_ID);
window.location.reload();
}
} }
}, },
// --- NOTICES (BACHECA) --- // --- NOTICES (BACHECA) ---
getNotices: async (condoId?: string): Promise<Notice[]> => { getNotices: async (condoId?: string): Promise<Notice[]> => {
const allNotices = getLocal<Notice[]>(STORAGE_KEYS.NOTICES, []); let url = '/notices';
if (!condoId) return allNotices; if (condoId) url += `?condoId=${condoId}`;
return allNotices.filter(n => n.condoId === condoId).sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime()); return request<Notice[]>(url);
}, },
saveNotice: async (notice: Notice): Promise<Notice> => { saveNotice: async (notice: Notice): Promise<Notice> => {
const notices = getLocal<Notice[]>(STORAGE_KEYS.NOTICES, []); if (!notice.id) {
const index = notices.findIndex(n => n.id === notice.id); return request<Notice>('/notices', {
let newNotices; method: 'POST',
if (index >= 0) { body: JSON.stringify(notice)
newNotices = notices.map(n => n.id === notice.id ? notice : n); });
} else { } else {
newNotices = [...notices, { ...notice, id: notice.id || crypto.randomUUID(), date: notice.date || new Date().toISOString() }]; return request<Notice>(`/notices/${notice.id}`, {
method: 'PUT',
body: JSON.stringify(notice)
});
} }
setLocal(STORAGE_KEYS.NOTICES, newNotices);
return notice;
}, },
deleteNotice: async (id: string) => { deleteNotice: async (id: string) => {
const notices = getLocal<Notice[]>(STORAGE_KEYS.NOTICES, []); await request(`/notices/${id}`, { method: 'DELETE' });
setLocal(STORAGE_KEYS.NOTICES, notices.filter(n => n.id !== id));
}, },
markNoticeAsRead: async (noticeId: string, userId: string) => { markNoticeAsRead: async (noticeId: string, userId: string) => {
const reads = getLocal<NoticeRead[]>(STORAGE_KEYS.NOTICES_READ, []); await request(`/notices/${noticeId}/read`, {
if (!reads.find(r => r.noticeId === noticeId && r.userId === userId)) { method: 'POST',
reads.push({ noticeId, userId, readAt: new Date().toISOString() }); body: JSON.stringify({ userId })
setLocal(STORAGE_KEYS.NOTICES_READ, reads); });
}
}, },
getNoticeReadStatus: async (noticeId: string): Promise<NoticeRead[]> => { getNoticeReadStatus: async (noticeId: string): Promise<NoticeRead[]> => {
const reads = getLocal<NoticeRead[]>(STORAGE_KEYS.NOTICES_READ, []); return request<NoticeRead[]>(`/notices/${noticeId}/reads`);
return reads.filter(r => r.noticeId === noticeId);
}, },
getUnreadNoticesForUser: async (userId: string, condoId: string): Promise<Notice[]> => { getUnreadNoticesForUser: async (userId: string, condoId: string): Promise<Notice[]> => {
const notices = await CondoService.getNotices(condoId); return request<Notice[]>(`/notices/unread?userId=${userId}&condoId=${condoId}`);
const reads = getLocal<NoticeRead[]>(STORAGE_KEYS.NOTICES_READ, []);
const userReadIds = reads.filter(r => r.userId === userId).map(r => r.noticeId);
return notices.filter(n => n.active && !userReadIds.includes(n.id));
}, },
// --- AUTH --- // --- AUTH ---
login: async (email, password) => { login: async (email, password) => {
if (FORCE_LOCAL_DB) { const data = await request<{token: string, user: User}>('/auth/login', {
await new Promise(resolve => setTimeout(resolve, 600)); method: 'POST',
body: JSON.stringify({ email, password })
});
const role = email.includes('admin') || email === 'fcarra79@gmail.com' ? 'admin' : 'user'; localStorage.setItem(STORAGE_KEYS.TOKEN, data.token);
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user));
const mockUser: User = { // Set active condo if user belongs to a family
id: 'local-user-' + Math.random().toString(36).substr(2, 9), if (data.user.familyId) {
email, // We need to fetch family to get condoId.
name: email.split('@')[0], // For simplicity, we trust the flow or fetch families next.
role: role as any, // In a real app, login might return condoId directly.
familyId: role === 'admin' ? null : 'f1', // simple logic try {
receiveAlerts: true const families = await CondoService.getFamilies(); // This will filter by user perms
}; const fam = families.find(f => f.id === data.user.familyId);
if (fam) {
localStorage.setItem(STORAGE_KEYS.TOKEN, 'mock-local-token-' + Date.now()); localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, fam.condoId);
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(mockUser)); }
} catch (e) { console.error("Could not set active condo on login", e); }
// Post-login check: if user has a family, set active condo to that family's condo
if (mockUser.familyId) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
const fam = families.find(f => f.id === mockUser.familyId);
if (fam) {
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, fam.condoId);
}
} }
return { token: localStorage.getItem(STORAGE_KEYS.TOKEN)!, user: mockUser }; return data;
}
throw new Error("Remote login not implemented in this snippet update");
}, },
logout: () => { logout: () => {
localStorage.removeItem(STORAGE_KEYS.TOKEN); localStorage.removeItem(STORAGE_KEYS.TOKEN);
localStorage.removeItem(STORAGE_KEYS.USER); localStorage.removeItem(STORAGE_KEYS.USER);
// Do NOT clear active condo ID, nice for UX to remember where admin was
window.location.href = '#/login'; window.location.href = '#/login';
}, },
getCurrentUser: (): User | null => { getCurrentUser: (): User | null => {
return getLocal<User | null>(STORAGE_KEYS.USER, null); const u = localStorage.getItem(STORAGE_KEYS.USER);
return u ? JSON.parse(u) : null;
}, },
updateProfile: async (data: Partial<User> & { password?: string }) => { updateProfile: async (data: Partial<User> & { password?: string }) => {
const currentUser = getLocal<User | null>(STORAGE_KEYS.USER, null); return request<{success: true, user: User}>('/profile', {
if (!currentUser) throw new Error("Not logged in"); method: 'PUT',
const updatedUser = { ...currentUser, ...data }; body: JSON.stringify(data)
delete (updatedUser as any).password; });
setLocal(STORAGE_KEYS.USER, updatedUser);
return { success: true, user: updatedUser };
}, },
// --- SETTINGS (Global) --- // --- SETTINGS (Global) ---
getSettings: async (): Promise<AppSettings> => { getSettings: async (): Promise<AppSettings> => {
return getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, { return request<AppSettings>('/settings');
currentYear: new Date().getFullYear(),
smtpConfig: {
host: '', port: 587, user: '', pass: '', secure: false, fromEmail: ''
}
});
}, },
updateSettings: async (settings: AppSettings): Promise<void> => { updateSettings: async (settings: AppSettings): Promise<void> => {
setLocal(STORAGE_KEYS.SETTINGS, settings); await request('/settings', {
method: 'PUT',
body: JSON.stringify(settings)
});
}, },
getAvailableYears: async (): Promise<number[]> => { getAvailableYears: async (): Promise<number[]> => {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []); return request<number[]>('/years');
const settings = getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, { currentYear: new Date().getFullYear() } as AppSettings);
const activeCondoId = CondoService.getActiveCondoId();
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
const condoFamilyIds = families.filter(f => f.condoId === activeCondoId).map(f => f.id);
const relevantPayments = payments.filter(p => condoFamilyIds.includes(p.familyId));
const years = new Set(relevantPayments.map(p => p.forYear));
years.add(settings.currentYear);
return Array.from(years).sort((a, b) => b - a);
}, },
// --- FAMILIES --- // --- FAMILIES ---
getFamilies: async (): Promise<Family[]> => { getFamilies: async (): Promise<Family[]> => {
const activeCondoId = CondoService.getActiveCondoId(); const activeCondoId = CondoService.getActiveCondoId();
const allFamilies = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); // Pass condoId to filter on server if needed, or filter client side.
// The server `getFamilies` endpoint handles filtering based on user role.
if (!activeCondoId) return []; // However, if we are admin, we want ALL families, but usually filtered by the UI for the active condo.
// Let's get all allowed families from server.
return allFamilies.filter(f => f.condoId === activeCondoId); return request<Family[]>('/families');
}, },
addFamily: async (familyData: Omit<Family, 'id' | 'balance' | 'condoId'>): Promise<Family> => { addFamily: async (familyData: Omit<Family, 'id' | 'balance' | 'condoId'>): Promise<Family> => {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
const activeCondoId = CondoService.getActiveCondoId(); const activeCondoId = CondoService.getActiveCondoId();
if (!activeCondoId) throw new Error("Nessun condominio selezionato"); if (!activeCondoId) throw new Error("Nessun condominio selezionato");
const newFamily = { ...familyData, id: crypto.randomUUID(), balance: 0, condoId: activeCondoId }; return request<Family>('/families', {
setLocal(STORAGE_KEYS.FAMILIES, [...families, newFamily]); method: 'POST',
return newFamily; body: JSON.stringify({ ...familyData, condoId: activeCondoId })
});
}, },
updateFamily: async (family: Family): Promise<Family> => { updateFamily: async (family: Family): Promise<Family> => {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); return request<Family>(`/families/${family.id}`, {
const updated = families.map(f => f.id === family.id ? family : f); method: 'PUT',
setLocal(STORAGE_KEYS.FAMILIES, updated); body: JSON.stringify(family)
return family; });
}, },
deleteFamily: async (familyId: string): Promise<void> => { deleteFamily: async (familyId: string): Promise<void> => {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); await request(`/families/${familyId}`, { method: 'DELETE' });
setLocal(STORAGE_KEYS.FAMILIES, families.filter(f => f.id !== familyId));
}, },
// --- PAYMENTS --- // --- PAYMENTS ---
getPaymentsByFamily: async (familyId: string): Promise<Payment[]> => { getPaymentsByFamily: async (familyId: string): Promise<Payment[]> => {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []); return request<Payment[]>(`/payments?familyId=${familyId}`);
return payments.filter(p => p.familyId === familyId);
}, },
addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => { addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []); return request<Payment>('/payments', {
const newPayment = { ...payment, id: crypto.randomUUID() }; method: 'POST',
setLocal(STORAGE_KEYS.PAYMENTS, [...payments, newPayment]); body: JSON.stringify(payment)
return newPayment; });
}, },
// --- USERS --- // --- USERS ---
getUsers: async (): Promise<User[]> => { getUsers: async (): Promise<User[]> => {
return getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []); return request<User[]>('/users');
}, },
createUser: async (userData: any) => { createUser: async (userData: any) => {
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []); return request('/users', {
const newUser = { ...userData, id: crypto.randomUUID() }; method: 'POST',
delete newUser.password; body: JSON.stringify(userData)
setLocal(STORAGE_KEYS.USERS_LIST, [...users, newUser]); });
return { success: true, id: newUser.id };
}, },
updateUser: async (id: string, userData: any) => { updateUser: async (id: string, userData: any) => {
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []); return request(`/users/${id}`, {
const updatedUsers = users.map(u => u.id === id ? { ...u, ...userData, id } : u); method: 'PUT',
setLocal(STORAGE_KEYS.USERS_LIST, updatedUsers); body: JSON.stringify(userData)
return { success: true }; });
}, },
deleteUser: async (id: string) => { deleteUser: async (id: string) => {
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []); await request(`/users/${id}`, { method: 'DELETE' });
setLocal(STORAGE_KEYS.USERS_LIST, users.filter(u => u.id !== id));
}, },
// --- ALERTS --- // --- ALERTS ---
getAlerts: async (): Promise<AlertDefinition[]> => { getAlerts: async (): Promise<AlertDefinition[]> => {
return getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []); return request<AlertDefinition[]>('/alerts');
}, },
saveAlert: async (alert: AlertDefinition): Promise<AlertDefinition> => { saveAlert: async (alert: AlertDefinition): Promise<AlertDefinition> => {
const alerts = getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []); if (!alert.id) {
const existingIndex = alerts.findIndex(a => a.id === alert.id); return request<AlertDefinition>('/alerts', {
let newAlerts; method: 'POST',
if (existingIndex >= 0) { body: JSON.stringify(alert)
newAlerts = alerts.map(a => a.id === alert.id ? alert : a); });
} else { } else {
newAlerts = [...alerts, { ...alert, id: alert.id || crypto.randomUUID() }]; return request<AlertDefinition>(`/alerts/${alert.id}`, {
method: 'PUT',
body: JSON.stringify(alert)
});
} }
setLocal(STORAGE_KEYS.ALERTS, newAlerts);
return alert;
}, },
deleteAlert: async (id: string) => { deleteAlert: async (id: string) => {
const alerts = getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []); await request(`/alerts/${id}`, { method: 'DELETE' });
setLocal(STORAGE_KEYS.ALERTS, alerts.filter(a => a.id !== id));
}, },
// --- SEEDING --- // --- SEEDING ---
seedPayments: () => { seedPayments: () => {
if (!FORCE_LOCAL_DB) return; // No-op in remote mode
const condos = getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
if (condos.length === 0) {
const demoCondos: Condo[] = [
{ id: 'c1', name: 'Residenza i Pini', address: 'Via Roma 10, Milano', defaultMonthlyQuota: 100 },
{ id: 'c2', name: 'Condominio Parco Vittoria', address: 'Corso Italia 50, Torino', defaultMonthlyQuota: 85 }
];
setLocal(STORAGE_KEYS.CONDOS, demoCondos);
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, 'c1');
}
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
if (families.length === 0) {
const demoFamilies: Family[] = [
{ id: 'f1', condoId: 'c1', name: 'Rossi Mario', unitNumber: 'A1', contactEmail: 'rossi@email.com', balance: 0 },
{ id: 'f2', condoId: 'c1', name: 'Bianchi Luigi', unitNumber: 'A2', contactEmail: 'bianchi@email.com', balance: 0 },
{ id: 'f3', condoId: 'c2', name: 'Verdi Anna', unitNumber: 'B1', contactEmail: 'verdi@email.com', balance: 0 },
{ id: 'f4', condoId: 'c2', name: 'Neri Paolo', unitNumber: 'B2', contactEmail: 'neri@email.com', balance: 0 },
];
setLocal(STORAGE_KEYS.FAMILIES, demoFamilies);
const demoUsers: User[] = [
{ id: 'u1', email: 'admin@condo.it', name: 'Amministratore', role: 'admin', phone: '3331234567', familyId: null, receiveAlerts: true },
{ id: 'u2', email: 'rossi@email.com', name: 'Mario Rossi', role: 'user', phone: '', familyId: 'f1', receiveAlerts: true }
];
setLocal(STORAGE_KEYS.USERS_LIST, demoUsers);
}
} }
}; };

View File

@@ -1,7 +1,16 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
}) })