feat: Add email configuration and alert system

Introduces SMTP configuration settings and alert definitions to enable automated email notifications.
This includes new types for `SmtpConfig` and `AlertDefinition`, and integrates these into the settings page and mock database.
Adds styling for select elements and scrollbar hiding in the main HTML.
Updates mock database logic to potentially support local development without a backend.
This commit is contained in:
2025-12-06 23:01:02 +01:00
parent 89f4c9946b
commit 26fc451871
13 changed files with 1167 additions and 223 deletions

View File

@@ -1,6 +1 @@
node_modules <EFBFBD><EFBFBD>^
dist
.git
.env
mysql_data
.DS_Store

View File

@@ -1,29 +0,0 @@
# Stage 1: Build
FROM node:20-alpine as build
WORKDIR /app
# Copia i file di dipendenze
COPY package*.json ./
# Installa le dipendenze
RUN npm install
# Copia tutto il codice sorgente
COPY . .
# Esegui la build di produzione (crea la cartella dist)
RUN npm run build
# Stage 2: Serve con Nginx
FROM nginx:alpine
# Copia i file compilati dalla build precedente alla cartella di Nginx
COPY --from=build /app/dist /usr/share/nginx/html
# Copia la configurazione custom di Nginx
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -12,7 +12,7 @@ export const Layout: React.FC = () => {
`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${ `flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 ${
isActive isActive
? 'bg-blue-600 text-white shadow-md' ? 'bg-blue-600 text-white shadow-md'
: 'text-slate-600 hover:bg-slate-100' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100'
}`; }`;
const closeMenu = () => setIsMobileMenuOpen(false); const closeMenu = () => setIsMobileMenuOpen(false);
@@ -66,12 +66,10 @@ export const Layout: React.FC = () => {
<span className="font-medium">Famiglie</span> <span className="font-medium">Famiglie</span>
</NavLink> </NavLink>
{isAdmin && ( <NavLink to="/settings" className={navClass} onClick={closeMenu}>
<NavLink to="/settings" className={navClass} onClick={closeMenu}> <Settings className="w-5 h-5" />
<Settings className="w-5 h-5" /> <span className="font-medium">Impostazioni</span>
<span className="font-medium">Impostazioni</span> </NavLink>
</NavLink>
)}
</nav> </nav>
<div className="p-4 border-t border-slate-100 bg-slate-50/50"> <div className="p-4 border-t border-slate-100 bg-slate-50/50">

View File

@@ -14,23 +14,10 @@ services:
restart: always restart: always
environment: environment:
- PORT=3001 - PORT=3001
- DB_HOST=db - DB_CLIENT=${DB_CLIENT:-mysql} # 'mysql' or 'postgres'
- DB_PORT=3306 - DB_HOST=${DB_HOST}
- DB_PORT=${DB_PORT}
- DB_USER=${DB_USER} - DB_USER=${DB_USER}
- DB_PASS=${DB_PASS} - DB_PASS=${DB_PASS}
- DB_NAME=${DB_NAME} - DB_NAME=${DB_NAME}
- JWT_SECRET=${JWT_SECRET} - JWT_SECRET=${JWT_SECRET}
depends_on:
- db
db:
image: mariadb:10.6
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${DB_NAME}
MYSQL_USER: ${DB_USER}
MYSQL_PASSWORD: ${DB_PASS}
volumes:
# Bind Mount: salva i dati nella cartella mysql_data del progetto
- ./mysql_data:/var/lib/mysql

View File

@@ -11,6 +11,24 @@
font-family: 'Inter', sans-serif; font-family: 'Inter', sans-serif;
background-color: #f8fafc; background-color: #f8fafc;
} }
/* Force Select Colors */
select {
color: #1e293b !important; /* slate-800 */
background-color: #ffffff !important;
}
select option {
color: #1e293b !important;
background-color: #ffffff !important;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
</style> </style>
<script type="importmap"> <script type="importmap">
{ {

View File

@@ -1,22 +1 @@
server { <EFBFBD><EFBFBD><EFBFBD>z
listen 80;
# Serve i file statici della build React
location / {
root /usr/share/nginx/html;
index index.html index.htm;
# Importante per React Router: se non trova il file, serve index.html
try_files $uri $uri/ /index.html;
}
# Proxy per le chiamate API verso il container backend
location /api/ {
# 'backend' è il nome del servizio definito nel docker-compose.yml
proxy_pass http://backend:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

View File

@@ -1,17 +1,33 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { CondoService } from '../services/mockDb'; import { CondoService } from '../services/mockDb';
import { AppSettings, Family, User } from '../types'; import { AppSettings, Family, User, AlertDefinition } from '../types';
import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, UserCog, Mail, Phone, Lock, Shield, User as UserIcon } from 'lucide-react'; import { Save, Building, Coins, Plus, Pencil, Trash2, X, CalendarCheck, AlertTriangle, User as UserIcon, Mail, Server, Bell, Clock, FileText, Send, Lock } from 'lucide-react';
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'families' | 'users'>('general'); const currentUser = CondoService.getCurrentUser();
const isAdmin = currentUser?.role === 'admin';
const [activeTab, setActiveTab] = useState<'profile' | 'general' | 'families' | 'users' | 'smtp' | 'alerts'>(isAdmin ? 'general' : 'profile');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Profile State
const [profileForm, setProfileForm] = useState({
name: currentUser?.name || '',
phone: currentUser?.phone || '',
password: '',
receiveAlerts: currentUser?.receiveAlerts ?? true
});
const [profileSaving, setProfileSaving] = useState(false);
const [profileMsg, setProfileMsg] = useState('');
// General Settings State // General Settings State
const [settings, setSettings] = useState<AppSettings>({ const [settings, setSettings] = useState<AppSettings>({
defaultMonthlyQuota: 0, defaultMonthlyQuota: 0,
condoName: '', condoName: '',
currentYear: new Date().getFullYear() currentYear: new Date().getFullYear(),
smtpConfig: {
host: '', port: 587, user: '', pass: '', secure: false, fromEmail: ''
}
}); });
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [successMsg, setSuccessMsg] = useState(''); const [successMsg, setSuccessMsg] = useState('');
@@ -32,27 +48,69 @@ export const SettingsPage: React.FC = () => {
password: '', password: '',
phone: '', phone: '',
role: 'user', role: 'user',
familyId: '' familyId: '',
receiveAlerts: true
});
// Alerts State
const [alerts, setAlerts] = useState<AlertDefinition[]>([]);
const [showAlertModal, setShowAlertModal] = useState(false);
const [editingAlert, setEditingAlert] = useState<AlertDefinition | null>(null);
const [alertForm, setAlertForm] = useState<Partial<AlertDefinition>>({
subject: '',
body: '',
daysOffset: 0,
offsetType: 'before_next_month',
sendHour: 9,
active: true
}); });
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { try {
const [s, f, u] = await Promise.all([ if (isAdmin) {
CondoService.getSettings(), const [s, f, u, a] = await Promise.all([
CondoService.getFamilies(), CondoService.getSettings(),
CondoService.getUsers() CondoService.getFamilies(),
]); CondoService.getUsers(),
setSettings(s); CondoService.getAlerts()
setFamilies(f); ]);
setUsers(u); setSettings(s);
setFamilies(f);
setUsers(u);
setAlerts(a);
} else {
// If not admin, we might only need limited data, or nothing if we rely on session
// For now, nothing extra to fetch for the profile as it's in session
}
} catch(e) {
console.error(e);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetchData(); fetchData();
}, []); }, [isAdmin]);
// --- Profile Handlers ---
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setProfileSaving(true);
setProfileMsg('');
try {
await CondoService.updateProfile(profileForm);
setProfileMsg('Profilo aggiornato con successo!');
setTimeout(() => setProfileMsg(''), 3000);
setProfileForm(prev => ({ ...prev, password: '' })); // clear password
} catch (e) {
console.error(e);
setProfileMsg('Errore aggiornamento profilo');
} finally {
setProfileSaving(false);
}
};
// --- Settings Handlers --- // --- Settings Handlers ---
@@ -145,7 +203,7 @@ export const SettingsPage: React.FC = () => {
const openAddUserModal = () => { const openAddUserModal = () => {
setEditingUser(null); setEditingUser(null);
setUserForm({ name: '', email: '', password: '', phone: '', role: 'user', familyId: '' }); setUserForm({ name: '', email: '', password: '', phone: '', role: 'user', familyId: '', receiveAlerts: true });
setShowUserModal(true); setShowUserModal(true);
}; };
@@ -157,7 +215,8 @@ export const SettingsPage: React.FC = () => {
password: '', password: '',
phone: user.phone || '', phone: user.phone || '',
role: user.role || 'user', role: user.role || 'user',
familyId: user.familyId || '' familyId: user.familyId || '',
receiveAlerts: user.receiveAlerts ?? true
}); });
setShowUserModal(true); setShowUserModal(true);
}; };
@@ -191,9 +250,55 @@ export const SettingsPage: React.FC = () => {
} }
}; };
const getFamilyName = (id: string | null | undefined) => { // --- Alert Handlers ---
if (!id) return '-';
return families.find(f => f.id === id)?.name || 'Sconosciuta'; const openAddAlertModal = () => {
setEditingAlert(null);
setAlertForm({
subject: '',
body: '',
daysOffset: 1,
offsetType: 'before_next_month',
sendHour: 9,
active: true
});
setShowAlertModal(true);
};
const openEditAlertModal = (alert: AlertDefinition) => {
setEditingAlert(alert);
setAlertForm(alert);
setShowAlertModal(true);
};
const handleDeleteAlert = async (id: string) => {
if(!window.confirm("Eliminare questo avviso automatico?")) return;
try {
await CondoService.deleteAlert(id);
setAlerts(alerts.filter(a => a.id !== id));
} catch (e) { console.error(e); }
};
const handleAlertSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const payload: AlertDefinition = {
id: editingAlert ? editingAlert.id : '',
subject: alertForm.subject!,
body: alertForm.body!,
daysOffset: Number(alertForm.daysOffset),
offsetType: alertForm.offsetType as any,
sendHour: Number(alertForm.sendHour),
active: alertForm.active!
};
const saved = await CondoService.saveAlert(payload);
if (editingAlert) {
setAlerts(alerts.map(a => a.id === saved.id ? saved : a));
} else {
setAlerts([...alerts, saved]);
}
setShowAlertModal(false);
} catch (e) { console.error(e); }
}; };
if (loading) return <div className="p-8 text-center text-slate-400">Caricamento...</div>; if (loading) return <div className="p-8 text-center text-slate-400">Caricamento...</div>;
@@ -202,41 +307,153 @@ export const SettingsPage: React.FC = () => {
<div className="max-w-4xl mx-auto space-y-6 pb-20"> <div className="max-w-4xl mx-auto space-y-6 pb-20">
<div> <div>
<h2 className="text-2xl font-bold text-slate-800">Impostazioni</h2> <h2 className="text-2xl font-bold text-slate-800">Impostazioni</h2>
<p className="text-slate-500 text-sm md:text-base">Gestisci configurazione, anagrafica e utenti.</p> <p className="text-slate-500 text-sm md:text-base">Gestisci configurazione, anagrafica, utenti e comunicazioni.</p>
</div> </div>
{/* Tabs - Scrollable on mobile */} {/* Tabs - Scrollable on mobile */}
<div className="flex border-b border-slate-200 overflow-x-auto no-scrollbar pb-1"> <div className="flex border-b border-slate-200 overflow-x-auto no-scrollbar pb-1">
{/* Profile Tab (Always Visible) */}
<button <button
onClick={() => setActiveTab('general')} onClick={() => setActiveTab('profile')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${ className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'general' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700' activeTab === 'profile' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`} }`}
> >
Generale Il Mio Profilo
{activeTab === 'general' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>} {activeTab === 'profile' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
<button
onClick={() => setActiveTab('families')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'families' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
Famiglie
{activeTab === 'families' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'users' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
Utenti
{activeTab === 'users' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button> </button>
{/* Admin Tabs */}
{isAdmin && (
<>
<button
onClick={() => setActiveTab('general')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'general' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
Generale
{activeTab === 'general' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
<button
onClick={() => setActiveTab('families')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'families' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
Famiglie
{activeTab === 'families' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
<button
onClick={() => setActiveTab('users')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'users' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
Utenti
{activeTab === 'users' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
<button
onClick={() => setActiveTab('smtp')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'smtp' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
SMTP
{activeTab === 'smtp' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
<button
onClick={() => setActiveTab('alerts')}
className={`px-4 md:px-6 py-3 font-medium text-sm transition-all relative whitespace-nowrap flex-shrink-0 ${
activeTab === 'alerts' ? 'text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
>
Avvisi Automatici
{activeTab === 'alerts' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600"></div>}
</button>
</>
)}
</div> </div>
{activeTab === 'general' && ( {activeTab === 'profile' && (
<div className="animate-fade-in bg-white rounded-xl shadow-sm border border-slate-200 p-6 md:p-8 max-w-2xl">
<h3 className="text-lg font-bold text-slate-800 mb-6 flex items-center gap-2">
<UserIcon className="w-5 h-5 text-blue-600" />
Dati Profilo
</h3>
<form onSubmit={handleProfileSubmit} className="space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Il tuo Nome</label>
<input
type="text"
value={profileForm.name}
onChange={(e) => setProfileForm({...profileForm, name: e.target.value})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Email (Login)</label>
<input
type="email"
value={currentUser?.email || ''}
disabled
className="w-full border border-slate-200 bg-slate-50 text-slate-500 rounded-lg p-2.5 outline-none cursor-not-allowed"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Telefono</label>
<input
type="tel"
value={profileForm.phone}
onChange={(e) => setProfileForm({...profileForm, phone: e.target.value})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nuova Password</label>
<div className="relative">
<input
type="password"
placeholder="Lascia vuoto per non cambiare"
value={profileForm.password}
onChange={(e) => setProfileForm({...profileForm, password: e.target.value})}
className="w-full border border-slate-300 rounded-lg p-2.5 pl-9 focus:ring-2 focus:ring-blue-500 outline-none"
/>
<Lock className="w-4 h-4 text-slate-400 absolute left-3 top-3" />
</div>
</div>
</div>
<div className="pt-2 border-t border-slate-100">
<div className="flex items-center gap-3 bg-blue-50 p-4 rounded-lg border border-blue-100">
<input
type="checkbox"
id="myAlerts"
checked={profileForm.receiveAlerts}
onChange={(e) => setProfileForm({...profileForm, receiveAlerts: e.target.checked})}
className="w-5 h-5 text-blue-600 rounded border-slate-300 focus:ring-blue-500"
/>
<div>
<label htmlFor="myAlerts" className="text-sm font-bold text-slate-800 cursor-pointer block">Ricevi Avvisi Automatici</label>
<p className="text-xs text-slate-600">Abilita la ricezione di email per scadenze e comunicazioni condominiali.</p>
</div>
</div>
</div>
<div className="pt-2 flex items-center justify-between">
<span className={`text-sm font-medium ${profileMsg.includes('Errore') ? 'text-red-600' : 'text-green-600'}`}>{profileMsg}</span>
<button type="submit" disabled={profileSaving} className="bg-blue-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-blue-700 transition-all flex items-center gap-2 disabled:opacity-70">
<Save className="w-4 h-4" /> Aggiorna Profilo
</button>
</div>
</form>
</div>
)}
{isAdmin && activeTab === 'general' && (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* General Data Form */} {/* General Data Form */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 md:p-8 max-w-2xl"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 md:p-8 max-w-2xl">
@@ -322,7 +539,7 @@ export const SettingsPage: React.FC = () => {
</div> </div>
)} )}
{activeTab === 'families' && ( {isAdmin && activeTab === 'families' && (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -391,7 +608,7 @@ export const SettingsPage: React.FC = () => {
</div> </div>
)} )}
{activeTab === 'users' && ( {isAdmin && activeTab === 'users' && (
<div className="space-y-4 animate-fade-in"> <div className="space-y-4 animate-fade-in">
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -411,7 +628,7 @@ export const SettingsPage: React.FC = () => {
<th className="px-6 py-4">Utente</th> <th className="px-6 py-4">Utente</th>
<th className="px-6 py-4">Contatti</th> <th className="px-6 py-4">Contatti</th>
<th className="px-6 py-4">Ruolo</th> <th className="px-6 py-4">Ruolo</th>
<th className="px-6 py-4">Famiglia</th> <th className="px-6 py-4">Alerts</th>
<th className="px-6 py-4 text-right">Azioni</th> <th className="px-6 py-4 text-right">Azioni</th>
</tr> </tr>
</thead> </thead>
@@ -432,7 +649,13 @@ export const SettingsPage: React.FC = () => {
{user.role} {user.role}
</span> </span>
</td> </td>
<td className="px-6 py-4">{getFamilyName(user.familyId)}</td> <td className="px-6 py-4">
{user.receiveAlerts ? (
<span className="text-green-600 flex items-center gap-1"><Bell className="w-3 h-3"/> Sì</span>
) : (
<span className="text-slate-400 flex items-center gap-1"><Bell className="w-3 h-3"/> No</span>
)}
</td>
<td className="px-6 py-4 text-right"> <td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button onClick={() => openEditUserModal(user)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"><Pencil className="w-4 h-4" /></button> <button onClick={() => openEditUserModal(user)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"><Pencil className="w-4 h-4" /></button>
@@ -444,8 +667,7 @@ export const SettingsPage: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Mobile User Cards */}
{/* Mobile Cards for Users */}
<div className="md:hidden space-y-3"> <div className="md:hidden space-y-3">
{users.map(user => ( {users.map(user => (
<div key={user.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden"> <div key={user.id} className="bg-white p-5 rounded-xl border border-slate-200 shadow-sm relative overflow-hidden">
@@ -465,21 +687,18 @@ export const SettingsPage: React.FC = () => {
</h4> </h4>
<p className="text-sm text-slate-500 mt-1">{user.email}</p> <p className="text-sm text-slate-500 mt-1">{user.email}</p>
</div> </div>
<div className="flex gap-2 mb-2">
<div className="space-y-2 text-sm text-slate-600 bg-slate-50 p-3 rounded-lg mb-4"> {user.receiveAlerts ? (
<div className="flex justify-between"> <span className="text-xs bg-green-50 text-green-600 px-2 py-1 rounded border border-green-100 flex items-center gap-1">
<span className="text-slate-400">Famiglia:</span> <Bell className="w-3 h-3"/> Riceve Avvisi
<span className="font-medium">{getFamilyName(user.familyId)}</span> </span>
</div> ) : (
{user.phone && ( <span className="text-xs bg-slate-50 text-slate-400 px-2 py-1 rounded border border-slate-100 flex items-center gap-1">
<div className="flex justify-between"> <Bell className="w-3 h-3"/> Niente Avvisi
<span className="text-slate-400">Telefono:</span> </span>
<span className="font-medium">{user.phone}</span>
</div>
)} )}
</div> </div>
<div className="grid grid-cols-2 gap-3 mt-3">
<div className="grid grid-cols-2 gap-3">
<button onClick={() => openEditUserModal(user)} className="flex items-center justify-center gap-2 py-2 text-blue-600 bg-blue-50 rounded-lg text-sm font-bold"> <button onClick={() => openEditUserModal(user)} className="flex items-center justify-center gap-2 py-2 text-blue-600 bg-blue-50 rounded-lg text-sm font-bold">
<Pencil className="w-4 h-4" /> Modifica <Pencil className="w-4 h-4" /> Modifica
</button> </button>
@@ -493,6 +712,150 @@ export const SettingsPage: React.FC = () => {
</div> </div>
)} )}
{isAdmin && activeTab === 'smtp' && (
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 md:p-8 max-w-2xl animate-fade-in">
<div className="flex items-center gap-3 mb-6">
<div className="p-2 bg-blue-50 text-blue-600 rounded-lg">
<Server className="w-6 h-6" />
</div>
<div>
<h3 className="text-lg font-bold text-slate-800">Server SMTP</h3>
<p className="text-sm text-slate-500">Configura il server per l'invio delle email.</p>
</div>
</div>
<form onSubmit={handleSettingsSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Host SMTP</label>
<input
type="text"
placeholder="es. smtp.gmail.com"
value={settings.smtpConfig?.host || ''}
onChange={(e) => setSettings({...settings, smtpConfig: {...settings.smtpConfig!, host: e.target.value}})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Porta</label>
<input
type="number"
placeholder="587"
value={settings.smtpConfig?.port}
onChange={(e) => setSettings({...settings, smtpConfig: {...settings.smtpConfig!, port: parseInt(e.target.value)}})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Utente</label>
<input
type="text"
value={settings.smtpConfig?.user || ''}
onChange={(e) => setSettings({...settings, smtpConfig: {...settings.smtpConfig!, user: e.target.value}})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
<input
type="password"
value={settings.smtpConfig?.pass || ''}
onChange={(e) => setSettings({...settings, smtpConfig: {...settings.smtpConfig!, pass: e.target.value}})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Email Mittente</label>
<input
type="email"
placeholder="noreply@condominio.it"
value={settings.smtpConfig?.fromEmail || ''}
onChange={(e) => setSettings({...settings, smtpConfig: {...settings.smtpConfig!, fromEmail: e.target.value}})}
className="w-full border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div className="flex items-center gap-2 mt-2">
<input
type="checkbox"
id="secure"
checked={settings.smtpConfig?.secure}
onChange={(e) => setSettings({...settings, smtpConfig: {...settings.smtpConfig!, secure: e.target.checked}})}
className="w-4 h-4 text-blue-600 rounded border-slate-300 focus:ring-blue-500"
/>
<label htmlFor="secure" className="text-sm text-slate-700">Usa connessione sicura (SSL/TLS)</label>
</div>
<div className="pt-4 flex items-center justify-between">
<span className={`text-sm font-medium ${successMsg ? 'text-green-600' : 'text-transparent'}`}>{successMsg || 'Salvataggio...'}</span>
<button type="submit" disabled={saving} className="bg-blue-600 text-white px-6 py-2.5 rounded-lg font-medium hover:bg-blue-700 transition-all flex items-center gap-2 disabled:opacity-70">
<Save className="w-4 h-4" /> Salva Configurazione
</button>
</div>
</form>
</div>
)}
{isAdmin && activeTab === 'alerts' && (
<div className="space-y-4 animate-fade-in">
<div className="flex justify-between items-end">
<div>
<h3 className="text-lg font-bold text-slate-800">Avvisi Automatici</h3>
<p className="text-sm text-slate-500">Pianifica email ricorrenti per i condomini.</p>
</div>
<button
onClick={openAddAlertModal}
className="flex items-center justify-center gap-2 bg-blue-600 hover:bg-blue-700 text-white px-5 py-2.5 rounded-lg font-medium shadow-sm transition-colors"
>
<Plus className="w-5 h-5" />
Nuovo Avviso
</button>
</div>
<div className="grid gap-4">
{alerts.length === 0 && (
<div className="text-center p-8 bg-white rounded-xl border border-slate-200 text-slate-400">
Nessun avviso configurato.
</div>
)}
{alerts.map(alert => (
<div key={alert.id} className={`bg-white p-5 rounded-xl border shadow-sm flex flex-col md:flex-row justify-between gap-4 ${alert.active ? 'border-slate-200' : 'border-slate-100 opacity-70'}`}>
<div className="flex-1">
<div className="flex items-center gap-3 mb-1">
<h4 className="font-bold text-slate-800">{alert.subject}</h4>
{!alert.active && <span className="bg-slate-100 text-slate-500 text-xs px-2 py-0.5 rounded font-bold">DISATTIVO</span>}
</div>
<p className="text-sm text-slate-500 line-clamp-2 mb-3">{alert.body}</p>
<div className="flex flex-wrap gap-3 text-xs font-medium text-slate-600">
<div className="flex items-center gap-1 bg-slate-100 px-2 py-1 rounded">
<Clock className="w-3.5 h-3.5" />
{alert.offsetType === 'before_next_month'
? `${alert.daysOffset} giorni prima del prossimo mese`
: `${alert.daysOffset} giorni dopo inizio mese corrente`
}
</div>
<div className="flex items-center gap-1 bg-slate-100 px-2 py-1 rounded">
<Bell className="w-3.5 h-3.5" />
Alle ore {alert.sendHour}:00
</div>
</div>
</div>
<div className="flex items-center gap-2 md:border-l md:pl-4 border-slate-100">
<button onClick={() => openEditAlertModal(alert)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg"><Pencil className="w-5 h-5" /></button>
<button onClick={() => handleDeleteAlert(alert.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg"><Trash2 className="w-5 h-5" /></button>
</div>
</div>
))}
</div>
</div>
)}
{/* Family Modal */} {/* Family Modal */}
{showFamilyModal && ( {showFamilyModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
@@ -631,6 +994,19 @@ export const SettingsPage: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
<div className="flex items-center gap-3 pt-2 bg-slate-50 p-3 rounded-lg border border-slate-100">
<input
type="checkbox"
id="receiveAlerts"
checked={userForm.receiveAlerts}
onChange={(e) => setUserForm({...userForm, receiveAlerts: e.target.checked})}
className="w-5 h-5 text-blue-600 rounded border-slate-300 focus:ring-blue-500"
/>
<label htmlFor="receiveAlerts" className="text-sm font-medium text-slate-700 select-none cursor-pointer">
Ricevi avvisi email automatici
</label>
</div>
</div> </div>
<div className="pt-4 flex gap-3"> <div className="pt-4 flex gap-3">
@@ -641,6 +1017,117 @@ export const SettingsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Alert Config Modal */}
{showAlertModal && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="bg-slate-50 px-6 py-4 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-bold text-lg text-slate-800">
{editingAlert ? 'Modifica Avviso' : 'Nuovo Avviso'}
</h3>
<button onClick={() => setShowAlertModal(false)} className="text-slate-400 hover:text-slate-600 p-1">
<X className="w-6 h-6" />
</button>
</div>
<form onSubmit={handleAlertSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Oggetto Email</label>
<input
type="text"
required
value={alertForm.subject}
onChange={(e) => setAlertForm({...alertForm, subject: e.target.value})}
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="Es. Promemoria Scadenza Rata"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Corpo del Messaggio</label>
<textarea
required
rows={6}
value={alertForm.body}
onChange={(e) => setAlertForm({...alertForm, body: e.target.value})}
className="w-full border border-slate-300 rounded-lg p-3 focus:ring-2 focus:ring-blue-500 outline-none resize-none"
placeholder="Gentile condomino, si ricorda che..."
/>
</div>
<div>
<div className="flex items-center gap-2 mb-2">
<FileText className="w-4 h-4 text-slate-400" />
<span className="text-xs text-slate-500">Allegati: Funzionalità in arrivo.</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-slate-50 p-4 rounded-lg border border-slate-200">
<div className="md:col-span-2">
<h4 className="font-bold text-sm text-slate-700 mb-2 flex items-center gap-2">
<Clock className="w-4 h-4" /> Schedulazione Invio
</h4>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Quando</label>
<select
value={alertForm.offsetType}
onChange={(e) => setAlertForm({...alertForm, offsetType: e.target.value as any})}
className="w-full border border-slate-300 rounded-lg p-2 text-sm bg-white"
>
<option value="before_next_month">Prima del mese successivo</option>
<option value="after_current_month">Dopo inizio mese corrente</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Giorni di differenza</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="30"
value={alertForm.daysOffset}
onChange={(e) => setAlertForm({...alertForm, daysOffset: parseInt(e.target.value)})}
className="w-full border border-slate-300 rounded-lg p-2 text-sm"
/>
<span className="text-xs text-slate-400">gg</span>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-500 mb-1">Orario Invio</label>
<select
value={alertForm.sendHour}
onChange={(e) => setAlertForm({...alertForm, sendHour: parseInt(e.target.value)})}
className="w-full border border-slate-300 rounded-lg p-2 text-sm bg-white"
>
{Array.from({length: 24}, (_, i) => (
<option key={i} value={i}>{i.toString().padStart(2, '0')}:00</option>
))}
</select>
</div>
<div className="flex items-end">
<div className="flex items-center gap-2 mb-2 w-full">
<input
type="checkbox"
id="alertActive"
checked={alertForm.active}
onChange={(e) => setAlertForm({...alertForm, active: e.target.checked})}
className="w-4 h-4 text-blue-600 rounded border-slate-300"
/>
<label htmlFor="alertActive" className="text-sm font-medium text-slate-700">Attivo</label>
</div>
</div>
</div>
<div className="pt-4 flex gap-3">
<button type="button" onClick={() => setShowAlertModal(false)} className="flex-1 px-4 py-3 border border-slate-300 rounded-lg font-medium text-slate-700">Annulla</button>
<button type="submit" className="flex-1 px-4 py-3 bg-blue-600 text-white rounded-lg font-medium">Salva Avviso</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -1,18 +0,0 @@
FROM node:20-alpine
WORKDIR /app
# Copia i file di dipendenze del server
COPY package*.json ./
# Installa le dipendenze
RUN npm install
# Copia il codice sorgente del server
COPY . .
# Espone la porta definita nel server.js
EXPOSE 3001
# Avvia il server
CMD ["npm", "start"]

View File

@@ -1,36 +1,106 @@
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
const { Pool } = require('pg');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
require('dotenv').config(); require('dotenv').config();
// Configuration from .env or defaults const DB_CLIENT = process.env.DB_CLIENT || 'mysql'; // 'mysql' or 'postgres'
// DB Configuration
const dbConfig = { const dbConfig = {
host: process.env.DB_HOST || 'localhost', host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root', user: process.env.DB_USER || 'root',
password: process.env.DB_PASS || '', password: process.env.DB_PASS || '',
database: process.env.DB_NAME || 'condominio', database: process.env.DB_NAME || 'condominio',
port: process.env.DB_PORT || 3306, port: process.env.DB_PORT || (DB_CLIENT === 'postgres' ? 5432 : 3306),
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
}; };
const pool = mysql.createPool(dbConfig); let mysqlPool = null;
let pgPool = null;
if (DB_CLIENT === 'postgres') {
pgPool = new Pool(dbConfig);
} else {
mysqlPool = mysql.createPool({
...dbConfig,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
}
// 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: () => {}
};
} else {
return await mysqlPool.getConnection();
}
}
};
const initDb = async () => { const initDb = async () => {
try { try {
const connection = await pool.getConnection(); const connection = await dbInterface.getConnection();
console.log('Database connected successfully.'); 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
// 1. Settings Table // 1. Settings Table
await connection.query(` await connection.query(`
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
id INT PRIMARY KEY DEFAULT 1, id INT PRIMARY KEY,
condo_name VARCHAR(255) DEFAULT 'Il Mio Condominio', condo_name VARCHAR(255) DEFAULT 'Il Mio Condominio',
default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00, default_monthly_quota DECIMAL(10, 2) DEFAULT 100.00,
current_year INT 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'); const [rows] = await connection.query('SELECT * FROM settings WHERE id = 1');
if (rows.length === 0) { if (rows.length === 0) {
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
@@ -47,7 +117,7 @@ const initDb = async () => {
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 DEFAULT CURRENT_TIMESTAMP created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP
) )
`); `);
@@ -57,11 +127,11 @@ const initDb = async () => {
id VARCHAR(36) PRIMARY KEY, id VARCHAR(36) PRIMARY KEY,
family_id VARCHAR(36) NOT NULL, family_id VARCHAR(36) NOT NULL,
amount DECIMAL(10, 2) NOT NULL, amount DECIMAL(10, 2) NOT NULL,
date_paid DATETIME NOT NULL, date_paid ${TIMESTAMP_TYPE} NOT NULL,
for_month INT NOT NULL, for_month INT NOT NULL,
for_year INT NOT NULL, for_year INT NOT NULL,
notes TEXT, notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE CASCADE FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE CASCADE
) )
`); `);
@@ -76,23 +146,54 @@ const initDb = async () => {
role VARCHAR(20) DEFAULT 'user', role VARCHAR(20) DEFAULT 'user',
phone VARCHAR(20), phone VARCHAR(20),
family_id VARCHAR(36) NULL, family_id VARCHAR(36) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, receive_alerts BOOLEAN DEFAULT TRUE,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE SET NULL FOREIGN KEY (family_id) REFERENCES families(id) ON DELETE SET NULL
) )
`); `);
// --- MIGRATION: CHECK FOR PHONE COLUMN --- // --- MIGRATION: CHECK FOR PHONE & ALERTS COLUMNS ---
// This ensures existing databases get the new column without dropping the table
try { try {
const [columns] = await connection.query("SHOW COLUMNS FROM users LIKE 'phone'"); let hasPhone = false;
if (columns.length === 0) { 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...'); console.log('Adding missing "phone" column to users table...');
await connection.query("ALTER TABLE users ADD COLUMN phone VARCHAR(20)"); 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) { } catch (migError) {
console.warn("Migration check failed:", migError.message); console.warn("Migration check failed:", migError.message);
} }
// 5. Alerts Table
await connection.query(`
CREATE TABLE IF NOT EXISTS alerts (
id VARCHAR(36) PRIMARY KEY,
subject VARCHAR(255) NOT NULL,
body TEXT,
days_offset INT DEFAULT 1,
offset_type VARCHAR(50) DEFAULT 'before_next_month',
send_hour INT DEFAULT 9,
active BOOLEAN DEFAULT TRUE,
last_sent ${TIMESTAMP_TYPE} NULL,
created_at ${TIMESTAMP_TYPE} DEFAULT CURRENT_TIMESTAMP
)
`);
// Seed Admin User // Seed Admin User
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) {
@@ -106,11 +207,11 @@ const initDb = async () => {
} }
console.log('Database tables initialized.'); console.log('Database tables initialized.');
connection.release(); if (connection.release) connection.release();
} catch (error) { } catch (error) {
console.error('Database initialization failed:', error); console.error('Database initialization failed:', error);
process.exit(1); process.exit(1);
} }
}; };
module.exports = { pool, initDb }; module.exports = { pool: dbInterface, initDb };

View File

@@ -15,6 +15,9 @@
"express": "^4.19.2", "express": "^4.19.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mysql2": "^3.9.2", "mysql2": "^3.9.2",
"pg": "^8.11.3",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.13",
"uuid": "^9.0.1" "uuid": "^9.0.1"
} }
} }

View File

@@ -5,6 +5,7 @@ const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const { pool, initDb } = require('./db'); const { pool, initDb } = require('./db');
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const nodemailer = require('nodemailer');
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
@@ -13,6 +14,107 @@ 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 ---
// Function to send email
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;
}
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,
},
});
// 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
subject: subject,
text: body, // Plain text for now
// html: body // Could add HTML support later
});
console.log(`Alert sent to ${users.length} users.`);
} catch (error) {
console.error('Email sending failed:', 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)
// --- MIDDLEWARE --- // --- MIDDLEWARE ---
const authenticateToken = (req, res, next) => { const authenticateToken = (req, res, next) => {
@@ -61,7 +163,8 @@ app.post('/api/auth/login', async (req, res) => {
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
familyId: user.family_id familyId: user.family_id,
receiveAlerts: !!user.receive_alerts
} }
}); });
} catch (e) { } catch (e) {
@@ -69,6 +172,48 @@ app.post('/api/auth/login', async (req, res) => {
} }
}); });
// --- PROFILE ROUTES (Self-service) ---
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 });
}
});
// --- SETTINGS ROUTES --- // --- SETTINGS ROUTES ---
app.get('/api/settings', authenticateToken, async (req, res) => { app.get('/api/settings', authenticateToken, async (req, res) => {
@@ -78,7 +223,8 @@ app.get('/api/settings', authenticateToken, async (req, res) => {
res.json({ res.json({
condoName: rows[0].condo_name, condoName: rows[0].condo_name,
defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota), defaultMonthlyQuota: parseFloat(rows[0].default_monthly_quota),
currentYear: rows[0].current_year currentYear: rows[0].current_year,
smtpConfig: rows[0].smtp_config || {}
}); });
} else { } else {
res.status(404).json({ message: 'Settings not found' }); res.status(404).json({ message: 'Settings not found' });
@@ -89,11 +235,11 @@ app.get('/api/settings', authenticateToken, async (req, res) => {
}); });
app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => { app.put('/api/settings', authenticateToken, requireAdmin, async (req, res) => {
const { condoName, defaultMonthlyQuota, currentYear } = req.body; const { condoName, defaultMonthlyQuota, currentYear, smtpConfig } = req.body;
try { try {
await pool.query( await pool.query(
'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ? WHERE id = 1', 'UPDATE settings SET condo_name = ?, default_monthly_quota = ?, current_year = ?, smtp_config = ? WHERE id = 1',
[condoName, defaultMonthlyQuota, currentYear] [condoName, defaultMonthlyQuota, currentYear, JSON.stringify(smtpConfig)]
); );
res.json({ success: true }); res.json({ success: true });
} catch (e) { } catch (e) {
@@ -117,6 +263,64 @@ app.get('/api/years', authenticateToken, async (req, res) => {
} }
}); });
// --- ALERTS ROUTES ---
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 { id } = req.params;
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, 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 });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
// --- FAMILIES ROUTES --- // --- FAMILIES ROUTES ---
app.get('/api/families', authenticateToken, async (req, res) => { app.get('/api/families', authenticateToken, async (req, res) => {
@@ -264,14 +468,15 @@ app.post('/api/payments', authenticateToken, async (req, res) => {
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 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, id: r.id,
email: r.email, email: r.email,
name: r.name, name: r.name,
role: r.role, role: r.role,
phone: r.phone, phone: r.phone,
familyId: r.family_id familyId: r.family_id,
receiveAlerts: !!r.receive_alerts
}))); })));
} catch (e) { } catch (e) {
res.status(500).json({ error: e.message }); res.status(500).json({ error: e.message });
@@ -279,13 +484,13 @@ app.get('/api/users', authenticateToken, requireAdmin, async (req, res) => {
}); });
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 } = 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) VALUES (?, ?, ?, ?, ?, ?, ?)', 'INSERT INTO users (id, email, password_hash, name, role, family_id, phone, receive_alerts) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[id, email, hashedPassword, name, role || 'user', familyId || null, phone] [id, email, hashedPassword, name, role || 'user', familyId || null, phone, receiveAlerts]
); );
res.json({ success: true, id }); res.json({ success: true, id });
} catch (e) { } catch (e) {
@@ -295,12 +500,12 @@ app.post('/api/users', authenticateToken, requireAdmin, async (req, res) => {
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 { id } = req.params;
const { email, role, familyId, name, phone, password } = 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 // Prepare update query dynamically based on whether password is being changed
let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?'; let query = 'UPDATE users SET email = ?, role = ?, family_id = ?, name = ?, phone = ?, receive_alerts = ?';
let params = [email, role, familyId || null, name, phone]; 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);

View File

@@ -1,22 +1,20 @@
import { Family, Payment, AppSettings, User, AuthResponse } from '../types'; import { Family, Payment, AppSettings, User, AuthResponse, AlertDefinition } from '../types';
// --- CONFIGURATION TOGGLE ---
// TRUE = WORK MODE (Localstorage, no backend required)
// FALSE = COMMIT MODE (Real API calls)
const FORCE_LOCAL_DB = false;
// In Docker/Production, Nginx proxies /api requests to the backend.
// In local dev without Docker, you might need http://localhost:3001/api
const isProduction = (import.meta as any).env?.PROD || window.location.hostname !== 'localhost';
// If we are in production (Docker), use relative path. If local dev, use full URL.
// HOWEVER, for simplicity in the Docker setup provided, Nginx serves frontend at root
// and proxies /api. So a relative path '/api' works perfectly.
const API_URL = '/api'; const API_URL = '/api';
const USE_MOCK_FALLBACK = true;
// --- MOCK / OFFLINE UTILS ---
const STORAGE_KEYS = { const STORAGE_KEYS = {
SETTINGS: 'condo_settings', SETTINGS: 'condo_settings',
FAMILIES: 'condo_families', FAMILIES: 'condo_families',
PAYMENTS: 'condo_payments', PAYMENTS: 'condo_payments',
TOKEN: 'condo_auth_token', TOKEN: 'condo_auth_token',
USER: 'condo_user_info' USER: 'condo_user_info',
USERS_LIST: 'condo_users_list',
ALERTS: 'condo_alerts_def'
}; };
const getLocal = <T>(key: string, defaultVal: T): T => { const getLocal = <T>(key: string, defaultVal: T): T => {
@@ -42,8 +40,29 @@ const getAuthHeaders = () => {
// --- SERVICE IMPLEMENTATION --- // --- SERVICE IMPLEMENTATION ---
export const CondoService = { export const CondoService = {
// ... (Auth methods remain the same)
login: async (email, password) => { login: async (email, password) => {
if (FORCE_LOCAL_DB) {
// MOCK LOGIN for Preview
await new Promise(resolve => setTimeout(resolve, 600)); // Fake delay
// Allow any login, but give admin rights to specific email or generic
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: null,
receiveAlerts: true
};
localStorage.setItem(STORAGE_KEYS.TOKEN, 'mock-local-token-' + Date.now());
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(mockUser));
return { token: localStorage.getItem(STORAGE_KEYS.TOKEN)!, user: mockUser };
}
try { try {
const res = await fetch(`${API_URL}/auth/login`, { const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST', method: 'POST',
@@ -71,15 +90,57 @@ export const CondoService = {
return getLocal<User | null>(STORAGE_KEYS.USER, null); return getLocal<User | null>(STORAGE_KEYS.USER, null);
}, },
// ... (Other methods updated to use relative API_URL implicitly) updateProfile: async (data: Partial<User> & { password?: string }) => {
if (FORCE_LOCAL_DB) {
const currentUser = getLocal<User | null>(STORAGE_KEYS.USER, null);
if (!currentUser) throw new Error("Not logged in");
// Update current user session
const updatedUser = { ...currentUser, ...data };
delete (updatedUser as any).password;
setLocal(STORAGE_KEYS.USER, updatedUser);
// Update in users list if it exists
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
const userIndex = users.findIndex(u => u.id === currentUser.id || u.email === currentUser.email);
if (userIndex >= 0) {
users[userIndex] = { ...users[userIndex], ...data };
delete (users[userIndex] as any).password; // mock logic: don't store pw
setLocal(STORAGE_KEYS.USERS_LIST, users);
}
return { success: true, user: updatedUser };
}
const res = await fetch(`${API_URL}/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(data)
});
if (!res.ok) throw new Error('Failed to update profile');
const response = await res.json();
// Update local session
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response.user));
return response;
},
getSettings: async (): Promise<AppSettings> => { getSettings: async (): Promise<AppSettings> => {
if (FORCE_LOCAL_DB) {
return getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, {
defaultMonthlyQuota: 100,
condoName: 'Condominio (Anteprima)',
currentYear: new Date().getFullYear(),
smtpConfig: {
host: '', port: 587, user: '', pass: '', secure: false, fromEmail: ''
}
});
}
try { try {
const res = await fetch(`${API_URL}/settings`, { headers: getAuthHeaders() }); const res = await fetch(`${API_URL}/settings`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
return res.json(); return res.json();
} catch (e) { } catch (e) {
console.warn("Backend unavailable, using LocalStorage");
return getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, { return getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, {
defaultMonthlyQuota: 100, defaultMonthlyQuota: 100,
condoName: 'Condominio (Offline)', condoName: 'Condominio (Offline)',
@@ -89,6 +150,11 @@ export const CondoService = {
}, },
updateSettings: async (settings: AppSettings): Promise<void> => { updateSettings: async (settings: AppSettings): Promise<void> => {
if (FORCE_LOCAL_DB) {
setLocal(STORAGE_KEYS.SETTINGS, settings);
return;
}
try { try {
const res = await fetch(`${API_URL}/settings`, { const res = await fetch(`${API_URL}/settings`, {
method: 'PUT', method: 'PUT',
@@ -102,6 +168,15 @@ export const CondoService = {
}, },
getAvailableYears: async (): Promise<number[]> => { getAvailableYears: async (): Promise<number[]> => {
// Shared logic works for both because it falls back to calculating from payments
if (FORCE_LOCAL_DB) {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
const settings = getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, { currentYear: new Date().getFullYear() } as AppSettings);
const years = new Set(payments.map(p => p.forYear));
years.add(settings.currentYear);
return Array.from(years).sort((a, b) => b - a);
}
try { try {
const res = await fetch(`${API_URL}/years`, { headers: getAuthHeaders() }); const res = await fetch(`${API_URL}/years`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
@@ -116,6 +191,10 @@ export const CondoService = {
}, },
getFamilies: async (): Promise<Family[]> => { getFamilies: async (): Promise<Family[]> => {
if (FORCE_LOCAL_DB) {
return getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
}
try { try {
const res = await fetch(`${API_URL}/families`, { headers: getAuthHeaders() }); const res = await fetch(`${API_URL}/families`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
@@ -126,6 +205,13 @@ export const CondoService = {
}, },
addFamily: async (familyData: Omit<Family, 'id' | 'balance'>): Promise<Family> => { addFamily: async (familyData: Omit<Family, 'id' | 'balance'>): Promise<Family> => {
if (FORCE_LOCAL_DB) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
const newFamily = { ...familyData, id: crypto.randomUUID(), balance: 0 };
setLocal(STORAGE_KEYS.FAMILIES, [...families, newFamily]);
return newFamily;
}
try { try {
const res = await fetch(`${API_URL}/families`, { const res = await fetch(`${API_URL}/families`, {
method: 'POST', method: 'POST',
@@ -135,14 +221,18 @@ export const CondoService = {
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
return res.json(); return res.json();
} catch (e) { } catch (e) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); throw e;
const newFamily = { ...familyData, id: crypto.randomUUID(), balance: 0 };
setLocal(STORAGE_KEYS.FAMILIES, [...families, newFamily]);
return newFamily;
} }
}, },
updateFamily: async (family: Family): Promise<Family> => { updateFamily: async (family: Family): Promise<Family> => {
if (FORCE_LOCAL_DB) {
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;
}
try { try {
const res = await fetch(`${API_URL}/families/${family.id}`, { const res = await fetch(`${API_URL}/families/${family.id}`, {
method: 'PUT', method: 'PUT',
@@ -152,14 +242,17 @@ export const CondoService = {
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
return res.json(); return res.json();
} catch (e) { } catch (e) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); throw e;
const updated = families.map(f => f.id === family.id ? family : f);
setLocal(STORAGE_KEYS.FAMILIES, updated);
return family;
} }
}, },
deleteFamily: async (familyId: string): Promise<void> => { deleteFamily: async (familyId: string): Promise<void> => {
if (FORCE_LOCAL_DB) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
setLocal(STORAGE_KEYS.FAMILIES, families.filter(f => f.id !== familyId));
return;
}
try { try {
const res = await fetch(`${API_URL}/families/${familyId}`, { const res = await fetch(`${API_URL}/families/${familyId}`, {
method: 'DELETE', method: 'DELETE',
@@ -167,12 +260,16 @@ export const CondoService = {
}); });
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
} catch (e) { } catch (e) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); throw e;
setLocal(STORAGE_KEYS.FAMILIES, families.filter(f => f.id !== familyId));
} }
}, },
getPaymentsByFamily: async (familyId: string): Promise<Payment[]> => { getPaymentsByFamily: async (familyId: string): Promise<Payment[]> => {
if (FORCE_LOCAL_DB) {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
return payments.filter(p => p.familyId === familyId);
}
try { try {
const res = await fetch(`${API_URL}/payments?familyId=${familyId}`, { headers: getAuthHeaders() }); const res = await fetch(`${API_URL}/payments?familyId=${familyId}`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
@@ -184,6 +281,13 @@ export const CondoService = {
}, },
addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => { addPayment: async (payment: Omit<Payment, 'id'>): Promise<Payment> => {
if (FORCE_LOCAL_DB) {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
const newPayment = { ...payment, id: crypto.randomUUID() };
setLocal(STORAGE_KEYS.PAYMENTS, [...payments, newPayment]);
return newPayment;
}
try { try {
const res = await fetch(`${API_URL}/payments`, { const res = await fetch(`${API_URL}/payments`, {
method: 'POST', method: 'POST',
@@ -193,14 +297,15 @@ export const CondoService = {
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
return res.json(); return res.json();
} catch (e) { } catch (e) {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []); throw e;
const newPayment = { ...payment, id: crypto.randomUUID() };
setLocal(STORAGE_KEYS.PAYMENTS, [...payments, newPayment]);
return newPayment;
} }
}, },
getUsers: async (): Promise<User[]> => { getUsers: async (): Promise<User[]> => {
if (FORCE_LOCAL_DB) {
return getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
}
try { try {
const res = await fetch(`${API_URL}/users`, { headers: getAuthHeaders() }); const res = await fetch(`${API_URL}/users`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error'); if (!res.ok) throw new Error('API Error');
@@ -211,6 +316,16 @@ export const CondoService = {
}, },
createUser: async (userData: any) => { createUser: async (userData: any) => {
if (FORCE_LOCAL_DB) {
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
const newUser = { ...userData, id: crypto.randomUUID() };
if (newUser.receiveAlerts === undefined) newUser.receiveAlerts = true;
// Don't save password in local mock
delete newUser.password;
setLocal(STORAGE_KEYS.USERS_LIST, [...users, newUser]);
return { success: true, id: newUser.id };
}
const res = await fetch(`${API_URL}/users`, { const res = await fetch(`${API_URL}/users`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
@@ -221,6 +336,13 @@ export const CondoService = {
}, },
updateUser: async (id: string, userData: any) => { updateUser: async (id: string, userData: any) => {
if (FORCE_LOCAL_DB) {
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 };
}
const res = await fetch(`${API_URL}/users/${id}`, { const res = await fetch(`${API_URL}/users/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() }, headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
@@ -231,6 +353,12 @@ export const CondoService = {
}, },
deleteUser: async (id: string) => { deleteUser: async (id: string) => {
if (FORCE_LOCAL_DB) {
const users = getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
setLocal(STORAGE_KEYS.USERS_LIST, users.filter(u => u.id !== id));
return;
}
const res = await fetch(`${API_URL}/users/${id}`, { const res = await fetch(`${API_URL}/users/${id}`, {
method: 'DELETE', method: 'DELETE',
headers: getAuthHeaders() headers: getAuthHeaders()
@@ -238,10 +366,78 @@ export const CondoService = {
if (!res.ok) throw new Error('Failed to delete user'); if (!res.ok) throw new Error('Failed to delete user');
}, },
// --- ALERTS SERVICE ---
getAlerts: async (): Promise<AlertDefinition[]> => {
if (FORCE_LOCAL_DB) {
return getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []);
}
try {
const res = await fetch(`${API_URL}/alerts`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error');
return res.json();
} catch (e) {
return [];
}
},
saveAlert: async (alert: AlertDefinition): Promise<AlertDefinition> => {
if (FORCE_LOCAL_DB) {
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);
} else {
newAlerts = [...alerts, { ...alert, id: alert.id || crypto.randomUUID() }];
}
setLocal(STORAGE_KEYS.ALERTS, newAlerts);
return alert;
}
const method = alert.id ? 'PUT' : 'POST';
const url = alert.id ? `${API_URL}/alerts/${alert.id}` : `${API_URL}/alerts`;
const res = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
body: JSON.stringify(alert)
});
if (!res.ok) throw new Error('Failed to save alert');
return res.json();
},
deleteAlert: async (id: string) => {
if (FORCE_LOCAL_DB) {
const alerts = getLocal<AlertDefinition[]>(STORAGE_KEYS.ALERTS, []);
setLocal(STORAGE_KEYS.ALERTS, alerts.filter(a => a.id !== id));
return;
}
const res = await fetch(`${API_URL}/alerts/${id}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!res.ok) throw new Error('Failed to delete alert');
},
seedPayments: () => { seedPayments: () => {
if (!FORCE_LOCAL_DB) return; // Don't seed if connected to DB
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []); const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
if (families.length === 0) { if (families.length === 0) {
// (Seeding logic remains same, just shortened for brevity in this response) // Seed only if completely empty
const demoFamilies: Family[] = [
{ id: 'f1', name: 'Rossi Mario', unitNumber: 'A1', contactEmail: 'rossi@email.com', balance: 0 },
{ id: 'f2', name: 'Bianchi Luigi', unitNumber: 'A2', contactEmail: 'bianchi@email.com', balance: 0 },
{ id: 'f3', name: 'Verdi Anna', unitNumber: 'B1', contactEmail: 'verdi@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

@@ -16,10 +16,31 @@ export interface Payment {
notes?: string; notes?: string;
} }
export interface SmtpConfig {
host: string;
port: number;
user: string;
pass: string;
secure: boolean;
fromEmail: string;
}
export interface AlertDefinition {
id: string;
subject: string;
body: string;
daysOffset: number; // Number of days
offsetType: 'before_next_month' | 'after_current_month';
sendHour: number; // 0-23
active: boolean;
lastSent?: string; // ISO Date of last execution
}
export interface AppSettings { export interface AppSettings {
defaultMonthlyQuota: number; defaultMonthlyQuota: number;
condoName: string; condoName: string;
currentYear: number; // The active fiscal year currentYear: number; // The active fiscal year
smtpConfig?: SmtpConfig;
} }
export enum PaymentStatus { export enum PaymentStatus {
@@ -42,6 +63,7 @@ export interface User {
role?: 'admin' | 'poweruser' | 'user'; role?: 'admin' | 'poweruser' | 'user';
phone?: string; phone?: string;
familyId?: string | null; familyId?: string | null;
receiveAlerts?: boolean;
} }
export interface AuthResponse { export interface AuthResponse {