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,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 USE_MOCK_FALLBACK = true;
// --- MOCK / OFFLINE UTILS ---
const STORAGE_KEYS = {
SETTINGS: 'condo_settings',
FAMILIES: 'condo_families',
PAYMENTS: 'condo_payments',
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 => {
@@ -42,8 +40,29 @@ const getAuthHeaders = () => {
// --- SERVICE IMPLEMENTATION ---
export const CondoService = {
// ... (Auth methods remain the same)
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 {
const res = await fetch(`${API_URL}/auth/login`, {
method: 'POST',
@@ -71,15 +90,57 @@ export const CondoService = {
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> => {
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 {
const res = await fetch(`${API_URL}/settings`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error');
return res.json();
} catch (e) {
console.warn("Backend unavailable, using LocalStorage");
return getLocal<AppSettings>(STORAGE_KEYS.SETTINGS, {
defaultMonthlyQuota: 100,
condoName: 'Condominio (Offline)',
@@ -89,6 +150,11 @@ export const CondoService = {
},
updateSettings: async (settings: AppSettings): Promise<void> => {
if (FORCE_LOCAL_DB) {
setLocal(STORAGE_KEYS.SETTINGS, settings);
return;
}
try {
const res = await fetch(`${API_URL}/settings`, {
method: 'PUT',
@@ -102,6 +168,15 @@ export const CondoService = {
},
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 {
const res = await fetch(`${API_URL}/years`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error');
@@ -116,6 +191,10 @@ export const CondoService = {
},
getFamilies: async (): Promise<Family[]> => {
if (FORCE_LOCAL_DB) {
return getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
}
try {
const res = await fetch(`${API_URL}/families`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error');
@@ -126,6 +205,13 @@ export const CondoService = {
},
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 {
const res = await fetch(`${API_URL}/families`, {
method: 'POST',
@@ -135,14 +221,18 @@ export const CondoService = {
if (!res.ok) throw new Error('API Error');
return res.json();
} catch (e) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
const newFamily = { ...familyData, id: crypto.randomUUID(), balance: 0 };
setLocal(STORAGE_KEYS.FAMILIES, [...families, newFamily]);
return newFamily;
throw e;
}
},
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 {
const res = await fetch(`${API_URL}/families/${family.id}`, {
method: 'PUT',
@@ -152,14 +242,17 @@ export const CondoService = {
if (!res.ok) throw new Error('API Error');
return res.json();
} catch (e) {
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;
throw e;
}
},
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 {
const res = await fetch(`${API_URL}/families/${familyId}`, {
method: 'DELETE',
@@ -167,12 +260,16 @@ export const CondoService = {
});
if (!res.ok) throw new Error('API Error');
} catch (e) {
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
setLocal(STORAGE_KEYS.FAMILIES, families.filter(f => f.id !== familyId));
throw e;
}
},
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 {
const res = await fetch(`${API_URL}/payments?familyId=${familyId}`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error');
@@ -184,6 +281,13 @@ export const CondoService = {
},
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 {
const res = await fetch(`${API_URL}/payments`, {
method: 'POST',
@@ -193,14 +297,15 @@ export const CondoService = {
if (!res.ok) throw new Error('API Error');
return res.json();
} catch (e) {
const payments = getLocal<Payment[]>(STORAGE_KEYS.PAYMENTS, []);
const newPayment = { ...payment, id: crypto.randomUUID() };
setLocal(STORAGE_KEYS.PAYMENTS, [...payments, newPayment]);
return newPayment;
throw e;
}
},
getUsers: async (): Promise<User[]> => {
if (FORCE_LOCAL_DB) {
return getLocal<User[]>(STORAGE_KEYS.USERS_LIST, []);
}
try {
const res = await fetch(`${API_URL}/users`, { headers: getAuthHeaders() });
if (!res.ok) throw new Error('API Error');
@@ -211,6 +316,16 @@ export const CondoService = {
},
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
@@ -221,6 +336,13 @@ export const CondoService = {
},
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}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
@@ -231,6 +353,12 @@ export const CondoService = {
},
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}`, {
method: 'DELETE',
headers: getAuthHeaders()
@@ -238,10 +366,78 @@ export const CondoService = {
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: () => {
if (!FORCE_LOCAL_DB) return; // Don't seed if connected to DB
const families = getLocal<Family[]>(STORAGE_KEYS.FAMILIES, []);
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);
}
}
};