/** * TrasportiNotifications.js * * Servizio notifiche centralizzato per Trasporti Solidali. * USA il telegrambot.js esistente per Telegram, AGGIUNGE Email e Push. * * NON MODIFICA telegrambot.js - lo importa e usa i suoi metodi. */ const nodemailer = require('nodemailer'); const webpush = require('web-push'); // Importa il tuo telegrambot esistente const MyTelegramBot = require('../../telegram/telegrambot'); // ============================================================================= // CONFIGURAZIONE // ============================================================================= const config = { // Email SMTP smtp: { host: process.env.SMTP_HOST || 'smtp.gmail.com', port: parseInt(process.env.SMTP_PORT) || 465, secure: true, auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } }, emailFrom: process.env.SMTP_FROM || 'noreply@trasporti.app', // Push VAPID vapidPublicKey: process.env.VAPID_PUBLIC_KEY, vapidPrivateKey: process.env.VAPID_PRIVATE_KEY, vapidEmail: process.env.VAPID_EMAIL || 'admin@trasporti.app', // App appName: process.env.APP_NAME || 'Trasporti Solidali', appUrl: process.env.APP_URL || 'https://trasporti.app' }; // Configura web-push se le chiavi sono presenti if (config.vapidPublicKey && config.vapidPrivateKey) { webpush.setVapidDetails( `mailto:${config.vapidEmail}`, config.vapidPublicKey, config.vapidPrivateKey ); } // Crea transporter email let emailTransporter = null; if (config.smtp.auth.user && config.smtp.auth.pass) { emailTransporter = nodemailer.createTransport(config.smtp); } // ============================================================================= // TIPI DI NOTIFICA // ============================================================================= const NotificationType = { // Viaggi NEW_RIDE_REQUEST: 'new_ride_request', REQUEST_ACCEPTED: 'request_accepted', REQUEST_REJECTED: 'request_rejected', RIDE_REMINDER_24H: 'ride_reminder_24h', RIDE_REMINDER_2H: 'ride_reminder_2h', RIDE_CANCELLED: 'ride_cancelled', RIDE_MODIFIED: 'ride_modified', // Messaggi NEW_MESSAGE: 'new_message', // Community NEW_COMMUNITY_RIDE: 'new_community_ride', // Sistema WEEKLY_DIGEST: 'weekly_digest', TEST: 'test', WELCOME: 'welcome' }; // ============================================================================= // EMOJI PER NOTIFICHE // ============================================================================= const emo = { CAR: 'πŸš—', PASSENGER: 'πŸ§‘β€πŸ€β€πŸ§‘', CHECK: 'βœ…', CROSS: '❌', BELL: 'πŸ””', CLOCK: '⏰', CALENDAR: 'πŸ“…', PIN: 'πŸ“', ARROW: '➑️', MESSAGE: 'πŸ’¬', STAR: '⭐', WARNING: '⚠️', INFO: 'ℹ️', WAVE: 'πŸ‘‹', HEART: '❀️' }; // ============================================================================= // TRADUZIONI NOTIFICHE // ============================================================================= const translations = { it: { // Richieste NEW_RIDE_REQUEST_TITLE: 'Nuova richiesta di passaggio', NEW_RIDE_REQUEST_BODY: '{{passengerName}} chiede un passaggio per il viaggio {{departure}} β†’ {{destination}} del {{date}}', NEW_RIDE_REQUEST_ACTION: 'Visualizza richiesta', // Accettazione REQUEST_ACCEPTED_TITLE: 'Richiesta accettata!', REQUEST_ACCEPTED_BODY: '{{driverName}} ha accettato la tua richiesta per {{departure}} β†’ {{destination}} del {{date}}', REQUEST_ACCEPTED_ACTION: 'Visualizza viaggio', // Rifiuto REQUEST_REJECTED_TITLE: 'Richiesta non accettata', REQUEST_REJECTED_BODY: '{{driverName}} non ha potuto accettare la tua richiesta per {{departure}} β†’ {{destination}}', // Promemoria RIDE_REMINDER_24H_TITLE: 'Viaggio domani!', RIDE_REMINDER_24H_BODY: 'Promemoria: domani hai un viaggio {{departure}} β†’ {{destination}} alle {{time}}', RIDE_REMINDER_2H_TITLE: 'Viaggio tra 2 ore!', RIDE_REMINDER_2H_BODY: 'Il tuo viaggio {{departure}} β†’ {{destination}} parte tra 2 ore alle {{time}}', // Cancellazione RIDE_CANCELLED_TITLE: 'Viaggio cancellato', RIDE_CANCELLED_BODY: 'Il viaggio {{departure}} β†’ {{destination}} del {{date}} Γ¨ stato cancellato', RIDE_CANCELLED_REASON: 'Motivo: {{reason}}', // Modifica RIDE_MODIFIED_TITLE: 'Viaggio modificato', RIDE_MODIFIED_BODY: 'Il viaggio {{departure}} β†’ {{destination}} Γ¨ stato modificato. Verifica i nuovi dettagli.', // Messaggi NEW_MESSAGE_TITLE: 'Nuovo messaggio', NEW_MESSAGE_BODY: '{{senderName}}: {{preview}}', // Community NEW_COMMUNITY_RIDE_TITLE: 'Nuovo viaggio nella tua zona', NEW_COMMUNITY_RIDE_BODY: 'Nuovo viaggio disponibile: {{departure}} β†’ {{destination}} il {{date}}', // Test TEST_TITLE: 'Notifica di test', TEST_BODY: 'Questa Γ¨ una notifica di test da Trasporti Solidali. Se la vedi, tutto funziona!', // Welcome WELCOME_TITLE: 'Benvenuto su Trasporti Solidali!', WELCOME_BODY: 'Le notifiche sono state attivate correttamente. Riceverai aggiornamenti sui tuoi viaggi.', // Common VIEW_DETAILS: 'Visualizza dettagli', REPLY: 'Rispondi' }, en: { NEW_RIDE_REQUEST_TITLE: 'New ride request', NEW_RIDE_REQUEST_BODY: '{{passengerName}} requests a ride for {{departure}} β†’ {{destination}} on {{date}}', NEW_RIDE_REQUEST_ACTION: 'View request', REQUEST_ACCEPTED_TITLE: 'Request accepted!', REQUEST_ACCEPTED_BODY: '{{driverName}} accepted your request for {{departure}} β†’ {{destination}} on {{date}}', REQUEST_ACCEPTED_ACTION: 'View ride', REQUEST_REJECTED_TITLE: 'Request not accepted', REQUEST_REJECTED_BODY: '{{driverName}} could not accept your request for {{departure}} β†’ {{destination}}', RIDE_REMINDER_24H_TITLE: 'Ride tomorrow!', RIDE_REMINDER_24H_BODY: 'Reminder: tomorrow you have a ride {{departure}} β†’ {{destination}} at {{time}}', RIDE_REMINDER_2H_TITLE: 'Ride in 2 hours!', RIDE_REMINDER_2H_BODY: 'Your ride {{departure}} β†’ {{destination}} leaves in 2 hours at {{time}}', RIDE_CANCELLED_TITLE: 'Ride cancelled', RIDE_CANCELLED_BODY: 'The ride {{departure}} β†’ {{destination}} on {{date}} has been cancelled', RIDE_CANCELLED_REASON: 'Reason: {{reason}}', RIDE_MODIFIED_TITLE: 'Ride modified', RIDE_MODIFIED_BODY: 'The ride {{departure}} β†’ {{destination}} has been modified. Check the new details.', NEW_MESSAGE_TITLE: 'New message', NEW_MESSAGE_BODY: '{{senderName}}: {{preview}}', NEW_COMMUNITY_RIDE_TITLE: 'New ride in your area', NEW_COMMUNITY_RIDE_BODY: 'New ride available: {{departure}} β†’ {{destination}} on {{date}}', TEST_TITLE: 'Test notification', TEST_BODY: 'This is a test notification from Trasporti Solidali. If you see this, everything works!', WELCOME_TITLE: 'Welcome to Trasporti Solidali!', WELCOME_BODY: 'Notifications have been enabled successfully. You will receive updates about your rides.', VIEW_DETAILS: 'View details', REPLY: 'Reply' } }; // ============================================================================= // HELPER FUNCTIONS // ============================================================================= /** * Ottiene traduzione con sostituzione variabili */ function getTranslation(lang, key, data = {}) { const langTranslations = translations[lang] || translations['it']; let text = langTranslations[key] || translations['it'][key] || key; // Sostituisci {{variabile}} Object.keys(data).forEach(varName => { const regex = new RegExp(`\\{\\{${varName}\\}\\}`, 'g'); text = text.replace(regex, data[varName] || ''); }); return text; } /** * Mappa tipo notifica a chiave preferenze */ function getPreferenceKey(type) { const map = { [NotificationType.NEW_RIDE_REQUEST]: 'newRideRequest', [NotificationType.REQUEST_ACCEPTED]: 'requestAccepted', [NotificationType.REQUEST_REJECTED]: 'requestRejected', [NotificationType.RIDE_REMINDER_24H]: 'rideReminder24h', [NotificationType.RIDE_REMINDER_2H]: 'rideReminder2h', [NotificationType.RIDE_CANCELLED]: 'rideCancelled', [NotificationType.RIDE_MODIFIED]: 'rideCancelled', [NotificationType.NEW_MESSAGE]: 'newMessage', [NotificationType.NEW_COMMUNITY_RIDE]: 'newCommunityRide', [NotificationType.WEEKLY_DIGEST]: 'weeklyDigest', [NotificationType.TEST]: null, // Sempre inviato [NotificationType.WELCOME]: null // Sempre inviato }; return map[type]; } /** * Verifica se inviare notifica su un canale */ function shouldSend(prefs, channel, type) { if (!prefs) return false; const channelPrefs = prefs[channel]; if (!channelPrefs || !channelPrefs.enabled) return false; // Test e Welcome sempre inviati se canale abilitato if (type === NotificationType.TEST || type === NotificationType.WELCOME) { return true; } const prefKey = getPreferenceKey(type); if (!prefKey) return true; // Se non mappato, invia return channelPrefs[prefKey] !== false; // Default true } /** * Tronca testo */ function truncate(text, maxLength = 100) { if (!text || text.length <= maxLength) return text; return text.substring(0, maxLength - 3) + '...'; } // ============================================================================= // EMAIL TEMPLATES // ============================================================================= function buildEmailHtml(type, data, lang = 'it') { const t = (key) => getTranslation(lang, key, data); const title = t(`${type.toUpperCase()}_TITLE`); const body = t(`${type.toUpperCase()}_BODY`); // Colori per tipo const colors = { [NotificationType.NEW_RIDE_REQUEST]: '#667eea', [NotificationType.REQUEST_ACCEPTED]: '#21ba45', [NotificationType.REQUEST_REJECTED]: '#c10015', [NotificationType.RIDE_REMINDER_24H]: '#f2711c', [NotificationType.RIDE_REMINDER_2H]: '#db2828', [NotificationType.RIDE_CANCELLED]: '#c10015', [NotificationType.NEW_MESSAGE]: '#2185d0', [NotificationType.NEW_COMMUNITY_RIDE]: '#a333c8', [NotificationType.TEST]: '#667eea', [NotificationType.WELCOME]: '#21ba45' }; const color = colors[type] || '#667eea'; // Emoji per tipo const emojis = { [NotificationType.NEW_RIDE_REQUEST]: emo.PASSENGER, [NotificationType.REQUEST_ACCEPTED]: emo.CHECK, [NotificationType.REQUEST_REJECTED]: emo.CROSS, [NotificationType.RIDE_REMINDER_24H]: emo.CALENDAR, [NotificationType.RIDE_REMINDER_2H]: emo.CLOCK, [NotificationType.RIDE_CANCELLED]: emo.WARNING, [NotificationType.NEW_MESSAGE]: emo.MESSAGE, [NotificationType.NEW_COMMUNITY_RIDE]: emo.CAR, [NotificationType.TEST]: emo.BELL, [NotificationType.WELCOME]: emo.WAVE }; const emoji = emojis[type] || emo.BELL; // CTA button let ctaHtml = ''; if (data.actionUrl) { const actionText = data.actionText || t('VIEW_DETAILS'); ctaHtml = `
${actionText}
`; } // Info viaggio let rideInfoHtml = ''; if (data.departure && data.destination) { rideInfoHtml = `
${emo.PIN}
Partenza
${data.departure}
${emo.ARROW}
${emo.PIN}
Destinazione
${data.destination}
${data.date ? `
${emo.CALENDAR} ${data.date} ${data.time ? `${emo.CLOCK} ${data.time}` : ''}
` : ''}
`; } return `
${emoji}

${title}

${body}

${rideInfoHtml} ${data.reason ? `

${t('RIDE_CANCELLED_REASON')}

` : ''} ${ctaHtml}

${config.appName}

Ricevi questa email perchΓ© hai attivato le notifiche. Gestisci preferenze

`; } // ============================================================================= // TELEGRAM MESSAGE BUILDER // ============================================================================= function buildTelegramMessage(type, data, lang = 'it') { const t = (key) => getTranslation(lang, key, data); const title = t(`${type.toUpperCase()}_TITLE`); const body = t(`${type.toUpperCase()}_BODY`); // Emoji per tipo const emojis = { [NotificationType.NEW_RIDE_REQUEST]: emo.PASSENGER, [NotificationType.REQUEST_ACCEPTED]: emo.CHECK, [NotificationType.REQUEST_REJECTED]: emo.CROSS, [NotificationType.RIDE_REMINDER_24H]: emo.CALENDAR, [NotificationType.RIDE_REMINDER_2H]: emo.CLOCK, [NotificationType.RIDE_CANCELLED]: emo.WARNING, [NotificationType.NEW_MESSAGE]: emo.MESSAGE, [NotificationType.NEW_COMMUNITY_RIDE]: emo.CAR, [NotificationType.TEST]: emo.BELL, [NotificationType.WELCOME]: emo.WAVE }; const emoji = emojis[type] || emo.BELL; let message = `${emoji} ${title}\n\n${body}`; // Aggiungi info viaggio if (data.departure && data.destination) { message += `\n\n${emo.PIN} Percorso:\n${data.departure} ${emo.ARROW} ${data.destination}`; if (data.date) { message += `\n${emo.CALENDAR} ${data.date}`; } if (data.time) { message += ` ${emo.CLOCK} ${data.time}`; } } // Motivo cancellazione if (data.reason) { message += `\n\n${t('RIDE_CANCELLED_REASON')}`; } return message; } // ============================================================================= // PUSH NOTIFICATION BUILDER // ============================================================================= function buildPushPayload(type, data, lang = 'it') { const t = (key) => getTranslation(lang, key, data); const title = t(`${type.toUpperCase()}_TITLE`); let body = t(`${type.toUpperCase()}_BODY`); // Tronca body per push body = truncate(body, 150); // Icone per tipo const icons = { [NotificationType.NEW_RIDE_REQUEST]: '/icons/request.png', [NotificationType.REQUEST_ACCEPTED]: '/icons/accepted.png', [NotificationType.REQUEST_REJECTED]: '/icons/rejected.png', [NotificationType.RIDE_REMINDER_24H]: '/icons/reminder.png', [NotificationType.RIDE_REMINDER_2H]: '/icons/urgent.png', [NotificationType.RIDE_CANCELLED]: '/icons/cancelled.png', [NotificationType.NEW_MESSAGE]: '/icons/message.png', [NotificationType.NEW_COMMUNITY_RIDE]: '/icons/community.png', [NotificationType.TEST]: '/icons/notification.png', [NotificationType.WELCOME]: '/icons/welcome.png' }; return { title, body, icon: icons[type] || '/icons/notification.png', badge: '/icons/badge.png', tag: type, data: { type, url: data.actionUrl || config.appUrl, ...data }, actions: data.actionUrl ? [ { action: 'open', title: t('VIEW_DETAILS') } ] : [] }; } // ============================================================================= // MAIN SERVICE // ============================================================================= const TrasportiNotifications = { // Esponi tipi e emoji NotificationType, emo, // Esponi config config, /** * Invia notifica su tutti i canali abilitati * * @param {Object} user - Utente destinatario (con notificationPreferences) * @param {string} type - Tipo notifica (da NotificationType) * @param {Object} data - Dati per template * @param {string} idapp - ID app (per Telegram) * @returns {Object} { success, results: { email, telegram, push } } */ async sendNotification(user, type, data, idapp) { const results = { email: null, telegram: null, push: null }; const prefs = user.notificationPreferences || {}; const lang = user.lang || 'it'; // Aggiungi URL azione se non presente if (!data.actionUrl && data.rideId) { data.actionUrl = `${config.appUrl}/trasporti/viaggio/${data.rideId}`; } if (!data.actionUrl && data.requestId) { data.actionUrl = `${config.appUrl}/trasporti/richieste/${data.requestId}`; } if (!data.actionUrl && data.chatId) { data.actionUrl = `${config.appUrl}/trasporti/chat/${data.chatId}`; } // EMAIL if (shouldSend(prefs, 'email', type) && user.email) { results.email = await this.sendEmail(user.email, type, data, lang); } // TELEGRAM (usa il tuo telegrambot.js esistente!) const telegId = user.profile?.teleg_id || prefs.telegram?.chatId; if (shouldSend(prefs, 'telegram', type) && telegId) { results.telegram = await this.sendTelegram(idapp, telegId, type, data, lang); } // PUSH const pushSub = prefs.push?.subscription; if (shouldSend(prefs, 'push', type) && pushSub) { results.push = await this.sendPush(pushSub, type, data, lang); } return { success: Object.values(results).some(r => r?.success), results }; }, /** * Invia notifica a multipli utenti */ async sendNotificationToMany(users, type, data, idapp) { const results = []; for (const user of users) { try { const result = await this.sendNotification(user, type, data, idapp); results.push({ userId: user._id, ...result }); // Delay per evitare rate limiting await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { results.push({ userId: user._id, success: false, error: error.message }); } } return results; }, // =========================================================================== // EMAIL // =========================================================================== async sendEmail(to, type, data, lang = 'it') { if (!emailTransporter) { return { success: false, error: 'Email not configured' }; } try { const t = (key) => getTranslation(lang, key, data); const subject = `${config.appName} - ${t(`${type.toUpperCase()}_TITLE`)}`; const html = buildEmailHtml(type, data, lang); const info = await emailTransporter.sendMail({ from: `"${config.appName}" <${config.emailFrom}>`, to, subject, html }); return { success: true, messageId: info.messageId }; } catch (error) { console.error('Email send error:', error); return { success: false, error: error.message }; } }, // =========================================================================== // TELEGRAM (usa il tuo MyTelegramBot!) // =========================================================================== async sendTelegram(idapp, chatId, type, data, lang = 'it') { try { const message = buildTelegramMessage(type, data, lang); // USA IL TUO METODO ESISTENTE! const result = await MyTelegramBot.local_sendMsgTelegramByIdTelegram( idapp, chatId, message, null, // message_id null, // chat_id reply false, // ripr_menuPrec null, // MyForm (bottoni) '' // img ); return { success: true, messageId: result?.message_id }; } catch (error) { console.error('Telegram send error:', error); return { success: false, error: error.message }; } }, /** * Invia notifica Telegram con bottoni inline */ async sendTelegramWithButtons(idapp, chatId, type, data, buttons, lang = 'it') { try { const message = buildTelegramMessage(type, data, lang); // Crea inline keyboard const cl = MyTelegramBot.getclTelegByidapp(idapp); if (!cl) { return { success: false, error: 'Telegram client not found' }; } const keyboard = cl.getInlineKeyboard(lang, buttons); const result = await MyTelegramBot.local_sendMsgTelegramByIdTelegram( idapp, chatId, message, null, null, false, keyboard, '' ); return { success: true, messageId: result?.message_id }; } catch (error) { console.error('Telegram send error:', error); return { success: false, error: error.message }; } }, // =========================================================================== // PUSH // =========================================================================== async sendPush(subscription, type, data, lang = 'it') { if (!config.vapidPublicKey || !config.vapidPrivateKey) { return { success: false, error: 'Push not configured' }; } try { const payload = JSON.stringify(buildPushPayload(type, data, lang)); await webpush.sendNotification(subscription, payload); return { success: true }; } catch (error) { console.error('Push send error:', error); // Subscription scaduta if (error.statusCode === 410 || error.statusCode === 404) { return { success: false, error: 'Subscription expired', expired: true }; } return { success: false, error: error.message }; } }, // =========================================================================== // METODI SPECIFICI PER TRASPORTI // =========================================================================== /** * Notifica nuova richiesta passaggio al conducente */ async notifyNewRideRequest(driver, passenger, ride, request, idapp) { return this.sendNotification(driver, NotificationType.NEW_RIDE_REQUEST, { passengerName: `${passenger.name} ${passenger.surname}`, departure: ride.departure?.city || ride.departure?.address, destination: ride.destination?.city || ride.destination?.address, date: formatDate(ride.departureTime), time: formatTime(ride.departureTime), seats: request.seats || 1, rideId: ride._id, requestId: request._id, actionUrl: `${config.appUrl}/trasporti/richieste/${request._id}` }, idapp); }, /** * Notifica richiesta accettata al passeggero */ async notifyRequestAccepted(passenger, driver, ride, idapp) { return this.sendNotification(passenger, NotificationType.REQUEST_ACCEPTED, { driverName: `${driver.name} ${driver.surname}`, departure: ride.departure?.city || ride.departure?.address, destination: ride.destination?.city || ride.destination?.address, date: formatDate(ride.departureTime), time: formatTime(ride.departureTime), rideId: ride._id, actionUrl: `${config.appUrl}/trasporti/viaggio/${ride._id}` }, idapp); }, /** * Notifica richiesta rifiutata al passeggero */ async notifyRequestRejected(passenger, driver, ride, reason, idapp) { return this.sendNotification(passenger, NotificationType.REQUEST_REJECTED, { driverName: `${driver.name} ${driver.surname}`, departure: ride.departure?.city || ride.departure?.address, destination: ride.destination?.city || ride.destination?.address, reason }, idapp); }, /** * Notifica promemoria viaggio */ async notifyRideReminder(user, ride, hoursBefor, idapp) { const type = hoursBefor === 24 ? NotificationType.RIDE_REMINDER_24H : NotificationType.RIDE_REMINDER_2H; return this.sendNotification(user, type, { departure: ride.departure?.city || ride.departure?.address, destination: ride.destination?.city || ride.destination?.address, date: formatDate(ride.departureTime), time: formatTime(ride.departureTime), rideId: ride._id, actionUrl: `${config.appUrl}/trasporti/viaggio/${ride._id}` }, idapp); }, /** * Notifica viaggio cancellato */ async notifyRideCancelled(user, ride, reason, idapp) { return this.sendNotification(user, NotificationType.RIDE_CANCELLED, { departure: ride.departure?.city || ride.departure?.address, destination: ride.destination?.city || ride.destination?.address, date: formatDate(ride.departureTime), reason, rideId: ride._id }, idapp); }, /** * Notifica nuovo messaggio */ async notifyNewMessage(recipient, sender, message, chatId, idapp) { return this.sendNotification(recipient, NotificationType.NEW_MESSAGE, { senderName: `${sender.name} ${sender.surname}`, preview: truncate(message.text, 100), chatId, actionUrl: `${config.appUrl}/trasporti/chat/${chatId}` }, idapp); }, /** * Invia notifica di test */ async sendTestNotification(user, channel, idapp) { const type = NotificationType.TEST; const data = { actionUrl: `${config.appUrl}/trasporti/impostazioni` }; const lang = user.lang || 'it'; if (channel === 'email' && user.email) { return this.sendEmail(user.email, type, data, lang); } const telegId = user.profile?.teleg_id || user.notificationPreferences?.telegram?.chatId; if (channel === 'telegram' && telegId) { return this.sendTelegram(idapp, telegId, type, data, lang); } const pushSub = user.notificationPreferences?.push?.subscription; if (channel === 'push' && pushSub) { return this.sendPush(pushSub, type, data, lang); } if (channel === 'all') { return this.sendNotification(user, type, data, idapp); } return { success: false, error: 'Invalid channel or not configured' }; } }; // ============================================================================= // HELPER DATE // ============================================================================= function formatDate(date) { if (!date) return ''; const d = new Date(date); return d.toLocaleDateString('it-IT', { weekday: 'short', day: 'numeric', month: 'short' }); } function formatTime(date) { if (!date) return ''; const d = new Date(date); return d.toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); } // ============================================================================= // EXPORT // ============================================================================= module.exports = TrasportiNotifications;