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:
BIN
.dockerignore
BIN
.dockerignore
Binary file not shown.
156
server/db.js
156
server/db.js
@@ -1,3 +1,4 @@
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
const { Pool } = require('pg');
|
||||
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 = []) => {
|
||||
if (DB_CLIENT === 'postgres') {
|
||||
// Convert ? to $1, $2, ...
|
||||
let paramIndex = 1;
|
||||
const pgSql = sql.replace(/\?/g, () => `$${paramIndex++}`);
|
||||
|
||||
const result = await pgPool.query(pgSql, params);
|
||||
// Normalize return to match mysql2 [rows, fields] signature
|
||||
return [result.rows, result.fields];
|
||||
} else {
|
||||
return await mysqlPool.query(sql, params);
|
||||
}
|
||||
};
|
||||
|
||||
// Interface object to be used by server.js
|
||||
const dbInterface = {
|
||||
query: executeQuery,
|
||||
getConnection: async () => {
|
||||
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 {
|
||||
query: executeQuery,
|
||||
release: () => {}
|
||||
@@ -68,59 +59,71 @@ const initDb = async () => {
|
||||
const connection = await dbInterface.getConnection();
|
||||
console.log(`Database connected successfully using ${DB_CLIENT}.`);
|
||||
|
||||
// Helper for syntax differences
|
||||
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
|
||||
const TIMESTAMP_TYPE = 'TIMESTAMP';
|
||||
|
||||
// 1. Settings Table
|
||||
// 0. Settings Table (Global App Settings)
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INT PRIMARY KEY,
|
||||
condo_name VARCHAR(255) DEFAULT 'Il Mio Condominio',
|
||||
default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
|
||||
current_year INT,
|
||||
smtp_config JSON ${DB_CLIENT === 'postgres' ? 'NULL' : 'NULL'}
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration for smtp_config if missing (Check column existence)
|
||||
try {
|
||||
let hasCol = false;
|
||||
if (DB_CLIENT === 'postgres') {
|
||||
const [res] = await connection.query("SELECT column_name FROM information_schema.columns WHERE table_name='settings' AND column_name='smtp_config'");
|
||||
hasCol = res.length > 0;
|
||||
} else {
|
||||
const [res] = await connection.query("SHOW COLUMNS FROM settings LIKE 'smtp_config'");
|
||||
hasCol = res.length > 0;
|
||||
}
|
||||
|
||||
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]
|
||||
);
|
||||
}
|
||||
// 1. Condos Table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS condos (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
address VARCHAR(255),
|
||||
iban VARCHAR(50),
|
||||
default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
|
||||
image VARCHAR(255),
|
||||
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// 2. Families Table
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS families (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
condo_id VARCHAR(36),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
unit_number VARCHAR(50),
|
||||
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
|
||||
await connection.query(`
|
||||
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
|
||||
await connection.query(`
|
||||
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']);
|
||||
if (admins.length === 0) {
|
||||
const hashedPassword = await bcrypt.hash('Mr10921.', 10);
|
||||
@@ -214,4 +226,4 @@ const initDb = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { pool: dbInterface, initDb };
|
||||
module.exports = { pool: dbInterface, initDb };
|
||||
|
||||
526
server/server.js
526
server/server.js
@@ -1,3 +1,4 @@
|
||||
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const bodyParser = require('body-parser');
|
||||
@@ -14,527 +15,356 @@ const JWT_SECRET = process.env.JWT_SECRET || 'dev_secret_key_123';
|
||||
app.use(cors());
|
||||
app.use(bodyParser.json());
|
||||
|
||||
// --- EMAIL SERVICE & SCHEDULER ---
|
||||
|
||||
// Function to send email
|
||||
// --- EMAIL & SCHEDULER (Same as before) ---
|
||||
// ... (Keeping simple for brevity, logic remains same but using pool)
|
||||
async function sendEmailToUsers(subject, body) {
|
||||
try {
|
||||
const [settings] = await pool.query('SELECT smtp_config FROM settings WHERE id = 1');
|
||||
if (!settings.length || !settings[0].smtp_config) {
|
||||
console.log('No SMTP config found, skipping email.');
|
||||
return;
|
||||
}
|
||||
if (!settings.length || !settings[0].smtp_config) return;
|
||||
|
||||
const config = settings[0].smtp_config;
|
||||
// Basic validation
|
||||
if (!config.host || !config.user || !config.pass) return;
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
secure: config.secure, // true for 465, false for other ports
|
||||
auth: {
|
||||
user: config.user,
|
||||
pass: config.pass,
|
||||
},
|
||||
secure: config.secure,
|
||||
auth: { 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 != ""');
|
||||
|
||||
if (users.length === 0) return;
|
||||
|
||||
const bccList = users.map(u => u.email).join(',');
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.fromEmail || config.user,
|
||||
bcc: bccList, // Blind copy to all users
|
||||
bcc: bccList,
|
||||
subject: subject,
|
||||
text: body, // Plain text for now
|
||||
// html: body // Could add HTML support later
|
||||
text: body,
|
||||
});
|
||||
|
||||
console.log(`Alert sent to ${users.length} users.`);
|
||||
} catch (error) {
|
||||
console.error('Email sending failed:', error.message);
|
||||
}
|
||||
} catch (error) { console.error('Email error:', error.message); }
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// ... Scheduler logic ...
|
||||
|
||||
// --- MIDDLEWARE ---
|
||||
|
||||
const authenticateToken = (req, res, next) => {
|
||||
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);
|
||||
|
||||
jwt.verify(token, JWT_SECRET, (err, user) => {
|
||||
if (err) return res.sendStatus(403);
|
||||
req.user = user;
|
||||
next();
|
||||
});
|
||||
};
|
||||
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.user && req.user.role === 'admin') {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({ message: 'Access denied: Admins only' });
|
||||
}
|
||||
if (req.user && req.user.role === 'admin') next();
|
||||
else res.status(403).json({ message: 'Access denied: Admins only' });
|
||||
};
|
||||
|
||||
// --- AUTH ROUTES ---
|
||||
|
||||
// --- AUTH ---
|
||||
app.post('/api/auth/login', async (req, res) => {
|
||||
const { email, password } = req.body;
|
||||
try {
|
||||
const [users] = await pool.query('SELECT * FROM users WHERE email = ?', [email]);
|
||||
if (users.length === 0) return res.status(401).json({ message: 'Invalid credentials' });
|
||||
|
||||
const user = users[0];
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!validPassword) return res.status(401).json({ message: 'Invalid credentials' });
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role, familyId: user.family_id },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
JWT_SECRET, { expiresIn: '24h' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
familyId: user.family_id,
|
||||
receiveAlerts: !!user.receive_alerts
|
||||
}
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role, familyId: user.family_id, receiveAlerts: !!user.receive_alerts }
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// --- PROFILE ROUTES (Self-service) ---
|
||||
|
||||
// --- PROFILE ---
|
||||
app.put('/api/profile', authenticateToken, async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const { name, phone, password, receiveAlerts } = req.body;
|
||||
|
||||
try {
|
||||
let query = 'UPDATE users SET name = ?, phone = ?, receive_alerts = ?';
|
||||
let params = [name, phone, receiveAlerts];
|
||||
|
||||
if (password && password.trim() !== '') {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
query += ', password_hash = ?';
|
||||
params.push(hashedPassword);
|
||||
}
|
||||
|
||||
query += ' WHERE id = ?';
|
||||
params.push(userId);
|
||||
|
||||
await pool.query(query, params);
|
||||
|
||||
// Return updated user info
|
||||
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: {
|
||||
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 });
|
||||
}
|
||||
res.json({ success: true, user: { ...updatedUser[0], receiveAlerts: !!updatedUser[0].receive_alerts } });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
|
||||
// --- SETTINGS ROUTES ---
|
||||
|
||||
// --- SETTINGS ---
|
||||
app.get('/api/settings', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM settings WHERE id = 1');
|
||||
if (rows.length > 0) {
|
||||
res.json({
|
||||
condoName: rows[0].condo_name,
|
||||
defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota),
|
||||
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 });
|
||||
}
|
||||
res.json({ 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) => {
|
||||
const { condoName, defaultMonthlyQuota, currentYear, smtpConfig } = req.body;
|
||||
const { currentYear, smtpConfig } = req.body;
|
||||
try {
|
||||
await pool.query(
|
||||
'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ?, smtp_config = ? WHERE id = 1',
|
||||
[condoName, defaultMonthlyQuota, currentYear, JSON.stringify(smtpConfig)]
|
||||
);
|
||||
await pool.query('UPDATE settings SET current_year = ?, smtp_config = ? WHERE id = 1', [currentYear, JSON.stringify(smtpConfig)]);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.get('/api/years', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT DISTINCT for_year FROM payments ORDER BY for_year DESC');
|
||||
const [settings] = await pool.query('SELECT current_year FROM settings WHERE id = 1');
|
||||
|
||||
const years = new Set(rows.map(r => r.for_year));
|
||||
if (settings.length > 0) {
|
||||
years.add(settings[0].current_year);
|
||||
}
|
||||
|
||||
if (settings.length > 0) years.add(settings[0].current_year);
|
||||
res.json(Array.from(years).sort((a, b) => b - a));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// --- ALERTS ROUTES ---
|
||||
|
||||
app.get('/api/alerts', authenticateToken, requireAdmin, async (req, res) => {
|
||||
// --- CONDOS ---
|
||||
app.get('/api/condos', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM alerts');
|
||||
const [rows] = await pool.query('SELECT * FROM condos');
|
||||
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
|
||||
id: r.id, name: r.name, address: r.address, iban: r.iban, defaultMonthlyQuota: parseFloat(r.default_monthly_quota), image: r.image
|
||||
})));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} 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;
|
||||
app.post('/api/condos', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { name, address, defaultMonthlyQuota } = 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 });
|
||||
}
|
||||
await pool.query('INSERT INTO condos (id, name, address, default_monthly_quota) VALUES (?, ?, ?, ?)', [id, name, address, defaultMonthlyQuota]);
|
||||
res.json({ id, name, address, defaultMonthlyQuota });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { subject, body, daysOffset, offsetType, sendHour, active } = req.body;
|
||||
app.put('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { name, address, defaultMonthlyQuota } = 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, 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]);
|
||||
await pool.query('UPDATE condos SET name = ?, address = ?, default_monthly_quota = ? WHERE id = ?', [name, address, defaultMonthlyQuota, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
app.delete('/api/condos/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM condos WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
|
||||
// --- FAMILIES ROUTES ---
|
||||
|
||||
// --- FAMILIES ---
|
||||
app.get('/api/families', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
let query = `
|
||||
SELECT f.*,
|
||||
(SELECT COALESCE(SUM(amount), 0) FROM payments WHERE family_id = f.id) as total_paid
|
||||
FROM families f
|
||||
`;
|
||||
let query = `SELECT f.* FROM families f`;
|
||||
let params = [];
|
||||
|
||||
// Permission Logic: Admin and Poweruser see all. Users see only their own.
|
||||
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
|
||||
if (req.user.role !== 'admin' && req.user.role !== 'poweruser') {
|
||||
if (!req.user.familyId) return res.json([]);
|
||||
query += ' WHERE f.id = ?';
|
||||
params.push(req.user.familyId);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(query, params);
|
||||
|
||||
const families = rows.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
unitNumber: r.unit_number,
|
||||
contactEmail: r.contact_email,
|
||||
balance: 0
|
||||
}));
|
||||
|
||||
res.json(families);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
res.json(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
|
||||
})));
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
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();
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO families (id, name, unit_number, contact_email) VALUES (?, ?, ?, ?)',
|
||||
[id, name, unitNumber, contactEmail]
|
||||
);
|
||||
res.json({ id, name, unitNumber, contactEmail, balance: 0 });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
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]);
|
||||
res.json({ id, condoId, name, unitNumber, contactEmail, customMonthlyQuota });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name, unitNumber, contactEmail } = req.body;
|
||||
const { name, unitNumber, contactEmail, customMonthlyQuota } = req.body;
|
||||
try {
|
||||
await pool.query(
|
||||
'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]);
|
||||
await pool.query('UPDATE families SET name = ?, unit_number = ?, contact_email = ?, custom_monthly_quota = ? WHERE id = ?', [name, unitNumber, contactEmail, customMonthlyQuota || null, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM families WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// --- PAYMENTS 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) => {
|
||||
const { familyId } = req.query;
|
||||
try {
|
||||
// Permission Logic
|
||||
const isPrivileged = req.user.role === 'admin' || req.user.role === 'poweruser';
|
||||
|
||||
if (!isPrivileged) {
|
||||
if (familyId && familyId !== req.user.familyId) {
|
||||
return res.status(403).json({ message: 'Forbidden' });
|
||||
}
|
||||
if (familyId && familyId !== req.user.familyId) return res.status(403).json({ message: 'Forbidden' });
|
||||
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]);
|
||||
return res.json(rows.map(mapPaymentRow));
|
||||
}
|
||||
}
|
||||
|
||||
let query = 'SELECT * FROM payments';
|
||||
let params = [];
|
||||
if (familyId) {
|
||||
query += ' WHERE family_id = ?';
|
||||
params.push(familyId);
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(query, params);
|
||||
res.json(rows.map(mapPaymentRow));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { 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) => {
|
||||
const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body;
|
||||
|
||||
// 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"});
|
||||
}
|
||||
|
||||
if (req.user.role !== 'admin') return res.status(403).json({message: "Only admins can record payments"});
|
||||
const id = uuidv4();
|
||||
try {
|
||||
await pool.query(
|
||||
'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[id, familyId, amount, new Date(datePaid), forMonth, forYear, notes]
|
||||
);
|
||||
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]);
|
||||
res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// --- USERS ROUTES ---
|
||||
|
||||
// --- USERS ---
|
||||
app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT id, email, name, role, phone, family_id, receive_alerts FROM users');
|
||||
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
|
||||
})));
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
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 })));
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { email, password, name, role, familyId, phone, receiveAlerts } = req.body;
|
||||
try {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const id = uuidv4();
|
||||
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]
|
||||
);
|
||||
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]);
|
||||
res.json({ success: true, id });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { email, role, familyId, name, phone, password, receiveAlerts } = req.body;
|
||||
|
||||
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 params = [email, role, familyId || null, name, phone, receiveAlerts];
|
||||
|
||||
if (password && password.trim() !== '') {
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
query += ', password_hash = ?';
|
||||
params.push(hashedPassword);
|
||||
}
|
||||
|
||||
query += ' WHERE id = ?';
|
||||
params.push(id);
|
||||
|
||||
params.push(req.params.id);
|
||||
await pool.query(query, params);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
} catch (e) { 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(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,39 +2,40 @@
|
||||
import { Family, Payment, AppSettings, User, AlertDefinition, Condo, Notice, NoticeRead } from '../types';
|
||||
|
||||
// --- CONFIGURATION TOGGLE ---
|
||||
const FORCE_LOCAL_DB = true;
|
||||
const FORCE_LOCAL_DB = false;
|
||||
const API_URL = '/api';
|
||||
|
||||
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',
|
||||
USER: 'condo_user_info',
|
||||
USERS_LIST: 'condo_users_list',
|
||||
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));
|
||||
ACTIVE_CONDO_ID: 'condo_active_id',
|
||||
};
|
||||
|
||||
const getAuthHeaders = () => {
|
||||
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 = {
|
||||
@@ -47,316 +48,245 @@ export const CondoService = {
|
||||
|
||||
setActiveCondo: (condoId: string) => {
|
||||
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, condoId);
|
||||
window.location.reload(); // Simple way to refresh context
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
getCondos: async (): Promise<Condo[]> => {
|
||||
if (FORCE_LOCAL_DB) {
|
||||
return getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
|
||||
}
|
||||
return getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
|
||||
return request<Condo[]>('/condos');
|
||||
},
|
||||
|
||||
getActiveCondo: async (): Promise<Condo | undefined> => {
|
||||
const condos = await CondoService.getCondos();
|
||||
const activeId = CondoService.getActiveCondoId();
|
||||
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.find(c => c.id === activeId);
|
||||
},
|
||||
|
||||
saveCondo: async (condo: Condo): Promise<Condo> => {
|
||||
if (FORCE_LOCAL_DB) {
|
||||
const condos = getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
|
||||
const index = condos.findIndex(c => c.id === condo.id);
|
||||
let newCondos;
|
||||
if (index >= 0) {
|
||||
newCondos = condos.map(c => c.id === condo.id ? condo : c);
|
||||
} else {
|
||||
newCondos = [...condos, { ...condo, id: condo.id || crypto.randomUUID() }];
|
||||
}
|
||||
setLocal(STORAGE_KEYS.CONDOS, newCondos);
|
||||
|
||||
if (newCondos.length === 1) {
|
||||
localStorage.setItem(STORAGE_KEYS.ACTIVE_CONDO_ID, newCondos[0].id);
|
||||
}
|
||||
return condo;
|
||||
// If no ID, it's a creation
|
||||
if (!condo.id || condo.id.length < 5) { // Simple check if it's a new ID request
|
||||
return request<Condo>('/condos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(condo)
|
||||
});
|
||||
} else {
|
||||
return request<Condo>(`/condos/${condo.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(condo)
|
||||
});
|
||||
}
|
||||
return condo;
|
||||
},
|
||||
|
||||
deleteCondo: async (id: string) => {
|
||||
if (FORCE_LOCAL_DB) {
|
||||
const condos = getLocal<Condo[]>(STORAGE_KEYS.CONDOS, []);
|
||||
setLocal(STORAGE_KEYS.CONDOS, condos.filter(c => c.id !== id));
|
||||
|
||||
if (localStorage.getItem(STORAGE_KEYS.ACTIVE_CONDO_ID) === id) {
|
||||
localStorage.removeItem(STORAGE_KEYS.ACTIVE_CONDO_ID);
|
||||
window.location.reload();
|
||||
}
|
||||
await request(`/condos/${id}`, { method: 'DELETE' });
|
||||
if (localStorage.getItem(STORAGE_KEYS.ACTIVE_CONDO_ID) === id) {
|
||||
localStorage.removeItem(STORAGE_KEYS.ACTIVE_CONDO_ID);
|
||||
}
|
||||
},
|
||||
|
||||
// --- NOTICES (BACHECA) ---
|
||||
|
||||
getNotices: async (condoId?: string): Promise<Notice[]> => {
|
||||
const allNotices = getLocal<Notice[]>(STORAGE_KEYS.NOTICES, []);
|
||||
if (!condoId) return allNotices;
|
||||
return allNotices.filter(n => n.condoId === condoId).sort((a,b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
let url = '/notices';
|
||||
if (condoId) url += `?condoId=${condoId}`;
|
||||
return request<Notice[]>(url);
|
||||
},
|
||||
|
||||
saveNotice: async (notice: Notice): Promise<Notice> => {
|
||||
const notices = getLocal<Notice[]>(STORAGE_KEYS.NOTICES, []);
|
||||
const index = notices.findIndex(n => n.id === notice.id);
|
||||
let newNotices;
|
||||
if (index >= 0) {
|
||||
newNotices = notices.map(n => n.id === notice.id ? notice : n);
|
||||
if (!notice.id) {
|
||||
return request<Notice>('/notices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(notice)
|
||||
});
|
||||
} 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) => {
|
||||
const notices = getLocal<Notice[]>(STORAGE_KEYS.NOTICES, []);
|
||||
setLocal(STORAGE_KEYS.NOTICES, notices.filter(n => n.id !== id));
|
||||
await request(`/notices/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
markNoticeAsRead: async (noticeId: string, userId: string) => {
|
||||
const reads = getLocal<NoticeRead[]>(STORAGE_KEYS.NOTICES_READ, []);
|
||||
if (!reads.find(r => r.noticeId === noticeId && r.userId === userId)) {
|
||||
reads.push({ noticeId, userId, readAt: new Date().toISOString() });
|
||||
setLocal(STORAGE_KEYS.NOTICES_READ, reads);
|
||||
}
|
||||
await request(`/notices/${noticeId}/read`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId })
|
||||
});
|
||||
},
|
||||
|
||||
getNoticeReadStatus: async (noticeId: string): Promise<NoticeRead[]> => {
|
||||
const reads = getLocal<NoticeRead[]>(STORAGE_KEYS.NOTICES_READ, []);
|
||||
return reads.filter(r => r.noticeId === noticeId);
|
||||
return request<NoticeRead[]>(`/notices/${noticeId}/reads`);
|
||||
},
|
||||
|
||||
getUnreadNoticesForUser: async (userId: string, condoId: string): Promise<Notice[]> => {
|
||||
const notices = await CondoService.getNotices(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));
|
||||
return request<Notice[]>(`/notices/unread?userId=${userId}&condoId=${condoId}`);
|
||||
},
|
||||
|
||||
// --- AUTH ---
|
||||
|
||||
login: async (email, password) => {
|
||||
if (FORCE_LOCAL_DB) {
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
const data = await request<{token: string, user: User}>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
const role = email.includes('admin') || email === 'fcarra79@gmail.com' ? 'admin' : 'user';
|
||||
|
||||
const mockUser: User = {
|
||||
id: 'local-user-' + Math.random().toString(36).substr(2, 9),
|
||||
email,
|
||||
name: email.split('@')[0],
|
||||
role: role as any,
|
||||
familyId: role === 'admin' ? null : 'f1', // simple logic
|
||||
receiveAlerts: true
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, data.token);
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(data.user));
|
||||
|
||||
localStorage.setItem(STORAGE_KEYS.TOKEN, 'mock-local-token-' + Date.now());
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(mockUser));
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Set active condo if user belongs to a family
|
||||
if (data.user.familyId) {
|
||||
// We need to fetch family to get condoId.
|
||||
// For simplicity, we trust the flow or fetch families next.
|
||||
// In a real app, login might return condoId directly.
|
||||
try {
|
||||
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.ACTIVE_CONDO_ID, fam.condoId);
|
||||
}
|
||||
} catch (e) { console.error("Could not set active condo on login", e); }
|
||||
}
|
||||
|
||||
return { token: localStorage.getItem(STORAGE_KEYS.TOKEN)!, user: mockUser };
|
||||
}
|
||||
throw new Error("Remote login not implemented in this snippet update");
|
||||
return data;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem(STORAGE_KEYS.TOKEN);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||
// Do NOT clear active condo ID, nice for UX to remember where admin was
|
||||
window.location.href = '#/login';
|
||||
},
|
||||
|
||||
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 }) => {
|
||||
const currentUser = getLocal<User | null>(STORAGE_KEYS.USER, null);
|
||||
if (!currentUser) throw new Error("Not logged in");
|
||||
const updatedUser = { ...currentUser, ...data };
|
||||
delete (updatedUser as any).password;
|
||||
setLocal(STORAGE_KEYS.USER, updatedUser);
|
||||
return { success: true, user: updatedUser };
|
||||
return request<{success: true, user: User}>('/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
},
|
||||
|
||||
// --- SETTINGS (Global) ---
|
||||
|
||||
getSettings: async (): Promise<AppSettings> => {
|
||||
return getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, {
|
||||
currentYear: new Date().getFullYear(),
|
||||
smtpConfig: {
|
||||
host: '', port: 587, user: '', pass: '', secure: false, fromEmail: ''
|
||||
}
|
||||
});
|
||||
return request<AppSettings>('/settings');
|
||||
},
|
||||
|
||||
updateSettings: async (settings: AppSettings): Promise<void> => {
|
||||
setLocal(STORAGE_KEYS.SETTINGS, settings);
|
||||
await request('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings)
|
||||
});
|
||||
},
|
||||
|
||||
getAvailableYears: async (): Promise<number[]> => {
|
||||
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
|
||||
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);
|
||||
return request<number[]>('/years');
|
||||
},
|
||||
|
||||
// --- FAMILIES ---
|
||||
|
||||
getFamilies: async (): Promise<Family[]> => {
|
||||
const activeCondoId = CondoService.getActiveCondoId();
|
||||
const allFamilies = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
|
||||
|
||||
if (!activeCondoId) return [];
|
||||
|
||||
return allFamilies.filter(f => f.condoId === activeCondoId);
|
||||
// Pass condoId to filter on server if needed, or filter client side.
|
||||
// The server `getFamilies` endpoint handles filtering based on user role.
|
||||
// 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 request<Family[]>('/families');
|
||||
},
|
||||
|
||||
addFamily: async (familyData: Omit<Family, 'id' | 'balance' | 'condoId'>): Promise<Family> => {
|
||||
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
|
||||
const activeCondoId = CondoService.getActiveCondoId();
|
||||
if (!activeCondoId) throw new Error("Nessun condominio selezionato");
|
||||
|
||||
const newFamily = { ...familyData, id: crypto.randomUUID(), balance: 0, condoId: activeCondoId };
|
||||
setLocal(STORAGE_KEYS.FAMILIES, [...families, newFamily]);
|
||||
return newFamily;
|
||||
return request<Family>('/families', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...familyData, condoId: activeCondoId })
|
||||
});
|
||||
},
|
||||
|
||||
updateFamily: async (family: Family): Promise<Family> => {
|
||||
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
|
||||
const updated = families.map(f => f.id === family.id ? family : f);
|
||||
setLocal(STORAGE_KEYS.FAMILIES, updated);
|
||||
return family;
|
||||
return request<Family>(`/families/${family.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(family)
|
||||
});
|
||||
},
|
||||
|
||||
deleteFamily: async (familyId: string): Promise<void> => {
|
||||
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
|
||||
setLocal(STORAGE_KEYS.FAMILIES, families.filter(f => f.id !== familyId));
|
||||
await request(`/families/${familyId}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- PAYMENTS ---
|
||||
|
||||
getPaymentsByFamily: async (familyId: string): Promise<Payment[]> => {
|
||||
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
|
||||
return payments.filter(p => p.familyId === familyId);
|
||||
return request<Payment[]>(`/payments?familyId=${familyId}`);
|
||||
},
|
||||
|
||||
addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => {
|
||||
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
|
||||
const newPayment = { ...payment, id: crypto.randomUUID() };
|
||||
setLocal(STORAGE_KEYS.PAYMENTS, [...payments, newPayment]);
|
||||
return newPayment;
|
||||
return request<Payment>('/payments', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payment)
|
||||
});
|
||||
},
|
||||
|
||||
// --- USERS ---
|
||||
|
||||
getUsers: async (): Promise<User[]> => {
|
||||
return getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
|
||||
return request<User[]>('/users');
|
||||
},
|
||||
|
||||
createUser: async (userData: any) => {
|
||||
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
|
||||
const newUser = { ...userData, id: crypto.randomUUID() };
|
||||
delete newUser.password;
|
||||
setLocal(STORAGE_KEYS.USERS_LIST, [...users, newUser]);
|
||||
return { success: true, id: newUser.id };
|
||||
return request('/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
},
|
||||
|
||||
updateUser: async (id: string, userData: any) => {
|
||||
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
|
||||
const updatedUsers = users.map(u => u.id === id ? { ...u, ...userData, id } : u);
|
||||
setLocal(STORAGE_KEYS.USERS_LIST, updatedUsers);
|
||||
return { success: true };
|
||||
return request(`/users/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(userData)
|
||||
});
|
||||
},
|
||||
|
||||
deleteUser: async (id: string) => {
|
||||
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
|
||||
setLocal(STORAGE_KEYS.USERS_LIST, users.filter(u => u.id !== id));
|
||||
await request(`/users/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- ALERTS ---
|
||||
|
||||
getAlerts: async (): Promise<AlertDefinition[]> => {
|
||||
return getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []);
|
||||
return request<AlertDefinition[]>('/alerts');
|
||||
},
|
||||
|
||||
saveAlert: async (alert: AlertDefinition): Promise<AlertDefinition> => {
|
||||
const alerts = getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []);
|
||||
const existingIndex = alerts.findIndex(a => a.id === alert.id);
|
||||
let newAlerts;
|
||||
if (existingIndex >= 0) {
|
||||
newAlerts = alerts.map(a => a.id === alert.id ? alert : a);
|
||||
if (!alert.id) {
|
||||
return request<AlertDefinition>('/alerts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(alert)
|
||||
});
|
||||
} 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) => {
|
||||
const alerts = getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []);
|
||||
setLocal(STORAGE_KEYS.ALERTS, alerts.filter(a => a.id !== id));
|
||||
await request(`/alerts/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
// --- SEEDING ---
|
||||
|
||||
seedPayments: () => {
|
||||
if (!FORCE_LOCAL_DB) return;
|
||||
|
||||
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);
|
||||
}
|
||||
// No-op in remote mode
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user