diff --git a/server/server.js b/server/server.js index 839b230..04dc37c 100644 --- a/server/server.js +++ b/server/server.js @@ -322,20 +322,429 @@ app.delete('/api/families/:id', authenticateToken, requireAdmin, async (req, res }); // --- NOTICES --- -// ... (Notices API skipped for brevity, unchanged) ... -// (Assume existing Notices API is here as per previous code) +app.get('/api/notices', authenticateToken, async (req, res) => { + const { condoId } = req.query; // Usually filtered by condo + 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, + targetFamilyIds: r.target_families || [] + }))); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.get('/api/notices/unread', authenticateToken, async (req, res) => { + const { userId, condoId } = req.query; + try { + const [notices] = await pool.query('SELECT * FROM notices WHERE condo_id = ? AND active = TRUE ORDER BY date DESC', [condoId]); + const [reads] = await pool.query('SELECT notice_id FROM notice_reads WHERE user_id = ?', [userId]); + const readIds = new Set(reads.map(r => r.notice_id)); + + const [u] = await pool.query('SELECT family_id FROM users WHERE id = ?', [userId]); + const familyId = u[0]?.family_id; + + const unread = notices.filter(n => { + if (readIds.has(n.id)) return false; + // Target Check + if (n.target_families && n.target_families.length > 0) { + if (!familyId) return false; + // n.target_families is parsed by mysql2 driver if column type is JSON + // However, pg might need manual parsing if not automatic. + // Let's assume it's array. + const targets = (typeof n.target_families === 'string') ? JSON.parse(n.target_families) : n.target_families; + return Array.isArray(targets) && targets.includes(familyId); + } + return true; // Public + }); + + res.json(unread.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, + targetFamilyIds: r.target_families + }))); + + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.get('/api/notices/:id/read-status', 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.post('/api/notices/:id/read', authenticateToken, async (req, res) => { + const { userId } = req.body; + try { + await pool.query('INSERT IGNORE INTO notice_reads (user_id, notice_id) VALUES (?, ?)', [userId, req.params.id]); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/notices', authenticateToken, requireAdmin, async (req, res) => { + const { condoId, title, content, type, link, active, targetFamilyIds } = req.body; + const id = uuidv4(); + try { + await pool.query( + 'INSERT INTO notices (id, condo_id, title, content, type, link, active, target_families) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, condoId, title, content, type, link, active, JSON.stringify(targetFamilyIds)] + ); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.put('/api/notices/:id', authenticateToken, requireAdmin, async (req, res) => { + const { title, content, type, link, active, targetFamilyIds } = req.body; + try { + await pool.query( + 'UPDATE notices SET title = ?, content = ?, type = ?, link = ?, active = ?, target_families = ? WHERE id = ?', + [title, content, type, link, active, JSON.stringify(targetFamilyIds), req.params.id] + ); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.delete('/api/notices/:id', authenticateToken, requireAdmin, async (req, res) => { + try { + await pool.query('DELETE FROM notices WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); // --- PAYMENTS --- -// ... (Payments API skipped for brevity, unchanged) ... +app.get('/api/payments', authenticateToken, async (req, res) => { + const { familyId, condoId } = req.query; + try { + let query = 'SELECT p.* FROM payments p JOIN families f ON p.family_id = f.id'; + let params = []; + let conditions = []; + + if (familyId) { + conditions.push('p.family_id = ?'); + params.push(familyId); + } + if (condoId) { + conditions.push('f.condo_id = ?'); + params.push(condoId); + } + + if (conditions.length > 0) { + query += ' WHERE ' + conditions.join(' AND '); + } + + query += ' ORDER BY p.date_paid DESC'; + + const [rows] = await pool.query(query, params); + res.json(rows.map(r => ({ + id: r.id, + familyId: r.family_id, + amount: parseFloat(r.amount), + datePaid: r.date_paid, + forMonth: r.for_month, + forYear: r.for_year, + notes: r.notes + }))); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/payments', authenticateToken, async (req, res) => { + const { familyId, amount, datePaid, forMonth, forYear, notes } = req.body; + // Allow users to pay for themselves or admin to pay for anyone + if (req.user.role !== 'admin' && req.user.role !== 'poweruser' && req.user.familyId !== familyId) { + return res.status(403).json({ message: 'Unauthorized' }); + } + + const id = uuidv4(); + try { + await pool.query( + 'INSERT INTO payments (id, family_id, amount, date_paid, for_month, for_year, notes) VALUES (?, ?, ?, ?, ?, ?, ?)', + [id, familyId, amount, datePaid, forMonth, forYear, notes] + ); + res.json({ id, familyId, amount, datePaid, forMonth, forYear, notes }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); // --- USERS --- -// ... (Users API skipped for brevity, unchanged) ... +app.get('/api/users', authenticateToken, async (req, res) => { + const { condoId } = req.query; + try { + let query = 'SELECT id, email, name, role, phone, family_id, receive_alerts, created_at FROM users'; + let params = []; + // If condoId provided, we filter users belonging to families in that condo + if (condoId) { + query = ` + SELECT u.id, u.email, u.name, u.role, u.phone, u.family_id, u.receive_alerts, u.created_at + FROM users u + LEFT JOIN families f ON u.family_id = f.id + WHERE f.condo_id = ? OR u.role IN ('admin', 'poweruser') + `; + params = [condoId]; + } + + const [rows] = await pool.query(query, params); + res.json(rows.map(u => ({ + id: u.id, + email: u.email, + name: u.name, + role: u.role, + phone: u.phone, + familyId: u.family_id, + receiveAlerts: !!u.receive_alerts + }))); + } catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => { + const { email, password, name, role, phone, familyId, receiveAlerts } = req.body; + const id = uuidv4(); + try { + const hashedPassword = await bcrypt.hash(password || 'password', 10); + await pool.query( + 'INSERT INTO users (id, email, password_hash, name, role, phone, family_id, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, email, hashedPassword, name, role, phone, familyId || null, receiveAlerts] + ); + res.json({ success: true, id }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.put('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { + const { email, name, role, phone, familyId, receiveAlerts, password } = req.body; + try { + let query = 'UPDATE users SET email = ?, name = ?, role = ?, phone = ?, family_id = ?, receive_alerts = ?'; + let params = [email, name, role, phone, familyId || null, receiveAlerts]; + + if (password) { + const hashedPassword = await bcrypt.hash(password, 10); + query += ', password_hash = ?'; + params.push(hashedPassword); + } + + query += ' WHERE id = ?'; + params.push(req.params.id); + + await pool.query(query, params); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.delete('/api/users/:id', authenticateToken, requireAdmin, async (req, res) => { + try { + await pool.query('DELETE FROM users WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); // --- ALERTS --- -// ... (Alerts API skipped for brevity, unchanged) ... +app.get('/api/alerts', authenticateToken, async (req, res) => { + const { condoId } = req.query; + try { + let query = 'SELECT * FROM alerts'; + let params = []; + if (condoId) { + query += ' WHERE condo_id = ?'; + params.push(condoId); + } + const [rows] = await pool.query(query, params); + res.json(rows.map(r => ({ + id: r.id, + condoId: r.condo_id, + subject: r.subject, + body: r.body, + daysOffset: r.days_offset, + offsetType: r.offset_type, + sendHour: r.send_hour, + active: !!r.active, + lastSent: r.last_sent + }))); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/alerts', authenticateToken, requireAdmin, async (req, res) => { + const { condoId, subject, body, daysOffset, offsetType, sendHour, active } = req.body; + const id = uuidv4(); + try { + await pool.query( + 'INSERT INTO alerts (id, condo_id, subject, body, days_offset, offset_type, send_hour, active) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [id, condoId, subject, body, daysOffset, offsetType, sendHour, active] + ); + res.json({ id, condoId, subject, body, daysOffset, offsetType, sendHour, active }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.put('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => { + const { subject, body, daysOffset, offsetType, sendHour, active } = req.body; + try { + await pool.query( + 'UPDATE alerts SET subject = ?, body = ?, days_offset = ?, offset_type = ?, send_hour = ?, active = ? WHERE id = ?', + [subject, body, daysOffset, offsetType, sendHour, active, req.params.id] + ); + res.json({ success: true, id: req.params.id }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.delete('/api/alerts/:id', authenticateToken, requireAdmin, async (req, res) => { + try { + await pool.query('DELETE FROM alerts WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); // --- TICKETS --- -// ... (Tickets API skipped for brevity, unchanged) ... +app.get('/api/tickets', authenticateToken, async (req, res) => { + const { condoId } = req.query; + try { + let query = ` + SELECT t.*, u.name as user_name, u.email as user_email + FROM tickets t + JOIN users u ON t.user_id = u.id + `; + let params = []; + + if (req.user.role !== 'admin' && req.user.role !== 'poweruser') { + query += ' WHERE t.user_id = ?'; + params.push(req.user.id); + } else if (condoId) { + query += ' WHERE t.condo_id = ?'; + params.push(condoId); + } + + query += ' ORDER BY t.created_at DESC'; + + const [rows] = await pool.query(query, params); + + const ticketIds = rows.map(r => r.id); + let attachments = []; + if (ticketIds.length > 0) { + const [atts] = await pool.query(`SELECT id, ticket_id FROM ticket_attachments`); + attachments = atts; + } + + const result = rows.map(r => ({ + id: r.id, + condoId: r.condo_id, + userId: r.user_id, + title: r.title, + description: r.description, + status: r.status, + priority: r.priority, + category: r.category, + createdAt: r.created_at, + updatedAt: r.updated_at, + userName: r.user_name, + userEmail: r.user_email, + attachments: attachments.filter(a => a.ticket_id === r.id) + })); + + res.json(result); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/tickets', authenticateToken, async (req, res) => { + const { condoId, title, description, category, priority, attachments } = req.body; + const id = uuidv4(); + const connection = await pool.getConnection(); + + try { + await connection.beginTransaction(); + await connection.query( + 'INSERT INTO tickets (id, condo_id, user_id, title, description, category, priority) VALUES (?, ?, ?, ?, ?, ?, ?)', + [id, condoId, req.user.id, title, description, category, priority] + ); + + if (attachments && attachments.length > 0) { + for(const att of attachments) { + await connection.query( + 'INSERT INTO ticket_attachments (id, ticket_id, file_name, file_type, data) VALUES (?, ?, ?, ?, ?)', + [uuidv4(), id, att.fileName, att.fileType, att.data] + ); + } + } + await connection.commit(); + res.json({ success: true }); + } catch(e) { + await connection.rollback(); + res.status(500).json({ error: e.message }); + } finally { + connection.release(); + } +}); + +app.put('/api/tickets/:id', authenticateToken, async (req, res) => { + const { status, priority } = req.body; + try { + await pool.query('UPDATE tickets SET status = ?, priority = ? WHERE id = ?', [status, priority, req.params.id]); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.delete('/api/tickets/:id', authenticateToken, async (req, res) => { + try { + await pool.query('DELETE FROM tickets WHERE id = ?', [req.params.id]); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.get('/api/tickets/:id/comments', authenticateToken, async (req, res) => { + try { + const [rows] = await pool.query(` + SELECT c.*, u.name as user_name + FROM ticket_comments c + JOIN users u ON c.user_id = u.id + WHERE c.ticket_id = ? + ORDER BY c.created_at ASC + `, [req.params.id]); + + res.json(rows.map(r => ({ + id: r.id, + ticketId: r.ticket_id, + userId: r.user_id, + userName: r.user_name, + text: r.text, + createdAt: r.created_at + }))); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/tickets/:id/comments', authenticateToken, async (req, res) => { + const { text } = req.body; + const id = uuidv4(); + try { + await pool.query( + 'INSERT INTO ticket_comments (id, ticket_id, user_id, text) VALUES (?, ?, ?, ?)', + [id, req.params.id, req.user.id, text] + ); + res.json({ success: true }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); + +app.get('/api/tickets/:id/attachments/:attId', authenticateToken, async (req, res) => { + try { + const [rows] = await pool.query('SELECT * FROM ticket_attachments WHERE id = ?', [req.params.attId]); + if (rows.length === 0) return res.status(404).json({ message: 'File not found' }); + const file = rows[0]; + res.json({ id: file.id, fileName: file.file_name, fileType: file.file_type, data: file.data }); + } catch(e) { res.status(500).json({ error: e.message }); } +}); // --- EXTRAORDINARY EXPENSES ---