From ffb340b9ea5b77474da8408f23590cdf25831db2 Mon Sep 17 00:00:00 2001 From: Niek Rabelink Date: Tue, 8 Sep 2026 20:43:38 +0200 Subject: [PATCH 1/2] feat(notifications): add browser push notifications for calendar events with personal profile settings --- api/notifications.php | 127 +++++++++++ assets/js/notifications.js | 417 +++++++++++++++++++++++++++++++++++++ config/schema.sql | 1 + includes/auth.php | 75 +++++++ includes/footer.php | 3 + includes/header.php | 144 ++++++++++++- sw.js | 45 ++++ 7 files changed, 810 insertions(+), 2 deletions(-) create mode 100644 api/notifications.php create mode 100644 assets/js/notifications.js create mode 100644 sw.js diff --git a/api/notifications.php b/api/notifications.php new file mode 100644 index 0000000..58409d7 --- /dev/null +++ b/api/notifications.php @@ -0,0 +1,127 @@ + true, + 'settings' => $settings, + ]); + exit; + } + + // 2. Instellingen opslaan + if ($action === 'save_settings') { + $input = json_decode(file_get_contents('php://input'), true) ?: $_POST; + $saved = update_user_notification_settings((int)$user['id'], $input); + $settings = get_user_notification_settings((int)$user['id']); + + echo json_encode([ + 'success' => $saved, + 'settings' => $settings, + 'message' => $saved ? 'Notificatievoorkeuren succesvol opgeslagen.' : 'Fout bij opslaan.', + ]); + exit; + } + + // 3. Actuele agenda-afspraken van vandaag ophalen voor notificatie-evaluatie + if ($action === 'get_events') { + $userId = (int)$user['id']; + $settings = get_user_notification_settings($userId); + + // Controleer of Outlook gekoppeld is + $stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1"); + $stmtInt->execute([$userId]); + $outlookInt = $stmtInt->fetch(); + + $isLiveOutlook = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']); + $events = []; + + if ($isLiveOutlook) { + $events = microsoft_fetch_today_events($userId, $today); + } + + // Als er geen live Outlook events zijn, haal ook taken met tijdblokken op uit Fociz dagplanning + $stmtTasks = $db->prepare(" + SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.source, t.external_url + FROM tasks t + LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id + WHERE t.user_id = ? AND (dp.plan_date = ? OR t.time_block_start IS NOT NULL) AND t.is_completed = 0 + ORDER BY t.time_block_start ASC + "); + $stmtTasks->execute([$userId, $today]); + $tasks = $stmtTasks->fetchAll(); + + foreach ($tasks as $t) { + if (!empty($t['time_block_start'])) { + $events[] = [ + 'id' => 'task_' . $t['id'], + 'subject' => $t['title'], + 'start' => $t['time_block_start'], + 'end' => $t['time_block_end'] ?: date('H:i', strtotime($t['time_block_start'] . ' +30 minutes')), + 'start_raw' => "{$today}T{$t['time_block_start']}:00", + 'location' => 'Fociz Dagplanning', + 'meeting_url' => $t['external_url'] ?? '/planner.php', + 'is_all_day' => false, + 'source' => 'FOCIZ_TASK', + ]; + } + } + + // Als er nog steeds geen afspraken zijn (en demo mode actief), lever sandbox items zodat er altijd getest kan worden + if (empty($events) && (!empty($outlookInt['is_demo_mode']) || !$isLiveOutlook)) { + $currentHour = (int)date('H'); + $nextHour = str_pad((string)(($currentHour + 1) % 24), 2, '0', STR_PAD_LEFT); + $events = [ + [ + 'id' => 'sandbox_meeting_1', + 'subject' => 'Focus Sync & Dagstart', + 'start' => "{$nextHour}:00", + 'end' => "{$nextHour}:30", + 'start_raw' => "{$today}T{$nextHour}:00:00", + 'location' => 'Microsoft Teams', + 'meeting_url' => 'https://teams.microsoft.com', + 'is_all_day' => false, + 'source' => 'SANDBOX', + ] + ]; + } + + echo json_encode([ + 'success' => true, + 'server_time' => date('H:i:s'), + 'server_datetime' => date('Y-m-d H:i:s'), + 'server_timestamp'=> time(), + 'settings' => $settings, + 'events' => $events, + 'count' => count($events), + ]); + exit; + } + + echo json_encode(['error' => 'Ongeldige actie']); + exit; + +} catch (Throwable $e) { + http_response_code(500); + echo json_encode([ + 'success' => false, + 'error' => $e->getMessage(), + ]); + exit; +} diff --git a/assets/js/notifications.js b/assets/js/notifications.js new file mode 100644 index 0000000..d3b1f19 --- /dev/null +++ b/assets/js/notifications.js @@ -0,0 +1,417 @@ +/** + * Fociz Browser Push Notificaties Engine + * Monitort actuele agenda-afspraken en stuurt browser notificaties op basis van persoonlijk profiel. + */ + +(function () { + // 1. Globale state + window.focizNotifSettings = { + enabled: 1, + lead_time_minutes: 5, + repeat_count: 2, + repeat_interval_minutes: 2, + sound_enabled: 1 + }; + + let cachedEvents = []; + let isFetchingEvents = false; + + // 2. Service Worker Registratie + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js', { scope: '/' }) + .then(reg => { + // Service worker geregistreerd + }) + .catch(err => { + // Stille fallback indien niet op HTTPS of lokaal + }); + } + + // 3. Web Audio API Chime Synthesizer + window.playNotificationChime = function () { + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + const ctx = new AudioContext(); + const now = ctx.currentTime; + + // Noot 1: 587.33 Hz (D5) + const osc1 = ctx.createOscillator(); + const gain1 = ctx.createGain(); + osc1.type = 'sine'; + osc1.frequency.setValueAtTime(587.33, now); + gain1.gain.setValueAtTime(0.2, now); + gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.35); + osc1.connect(gain1); + gain1.connect(ctx.destination); + osc1.start(now); + osc1.stop(now + 0.35); + + // Noot 2: 880 Hz (A5) + const osc2 = ctx.createOscillator(); + const gain2 = ctx.createGain(); + osc2.type = 'sine'; + osc2.frequency.setValueAtTime(880.0, now + 0.12); + gain2.gain.setValueAtTime(0.25, now + 0.12); + gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.55); + osc2.connect(gain2); + gain2.connect(ctx.destination); + osc2.start(now + 0.12); + osc2.stop(now + 0.55); + } catch (e) { + console.warn('Audio chime kon niet afspelen:', e); + } + }; + + // 4. In-App Toast Banner + function showInAppToast(title, body, meetingUrl) { + let container = document.getElementById('focizToastContainer'); + if (!container) { + container = document.createElement('div'); + container.id = 'focizToastContainer'; + container.className = 'fixed bottom-5 right-5 z-[9999] flex flex-col space-y-3 max-w-sm pointer-events-none'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.className = 'pointer-events-auto bg-slate-900/95 border border-blue-500/40 text-white p-4 rounded-2xl shadow-2xl backdrop-blur-md flex items-start space-x-3 transform translate-y-4 opacity-0 transition duration-300 ease-out'; + + const iconDiv = document.createElement('div'); + iconDiv.className = 'w-9 h-9 rounded-xl bg-blue-500/20 text-blue-400 flex items-center justify-center flex-shrink-0 mt-0.5 border border-blue-500/30'; + iconDiv.innerHTML = ''; + + const contentDiv = document.createElement('div'); + contentDiv.className = 'flex-1 min-w-0'; + + const titleEl = document.createElement('div'); + titleEl.className = 'text-xs font-bold text-white truncate'; + titleEl.textContent = title; + + const bodyEl = document.createElement('div'); + bodyEl.className = 'text-[11px] text-slate-300 mt-0.5 leading-snug'; + bodyEl.textContent = body; + + contentDiv.appendChild(titleEl); + contentDiv.appendChild(bodyEl); + + if (meetingUrl) { + const btn = document.createElement('a'); + btn.href = meetingUrl; + btn.target = '_blank'; + btn.className = 'inline-flex items-center space-x-1 mt-2 px-2.5 py-1 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-bold transition'; + btn.innerHTML = 'Direct Deelnemen →'; + contentDiv.appendChild(btn); + } + + const closeBtn = document.createElement('button'); + closeBtn.className = 'text-slate-500 hover:text-white transition -mr-1 -mt-1 p-1'; + closeBtn.innerHTML = ''; + closeBtn.onclick = () => { + toast.classList.add('opacity-0', 'translate-y-2'); + setTimeout(() => toast.remove(), 300); + }; + + toast.appendChild(iconDiv); + toast.appendChild(contentDiv); + toast.appendChild(closeBtn); + container.appendChild(toast); + + // Animaties + requestAnimationFrame(() => { + toast.classList.remove('opacity-0', 'translate-y-4'); + }); + + // Automatisch verdwijnen na 10 seconden + setTimeout(() => { + if (toast.parentElement) { + toast.classList.add('opacity-0', 'translate-y-2'); + setTimeout(() => toast.remove(), 300); + } + }, 10000); + } + + // 5. Browser Notificatie Verzenden + window.sendBrowserNotification = async function (title, body, meetingUrl, tag) { + if (!window.focizNotifSettings.enabled) return; + + // Geluidssignaal + if (window.focizNotifSettings.sound_enabled) { + playNotificationChime(); + } + + // In-app toast banner + showInAppToast(title, body, meetingUrl); + + // HTML5 Browser Push Notificatie + if ('Notification' in window && Notification.permission === 'granted') { + try { + if ('serviceWorker' in navigator && navigator.serviceWorker.controller) { + const reg = await navigator.serviceWorker.ready; + if (reg && reg.showNotification) { + reg.showNotification(title, { + body: body, + icon: '/favicon.ico', + badge: '/favicon.ico', + tag: tag || 'fociz-' + Date.now(), + data: { url: meetingUrl || '/planner.php' }, + requireInteraction: false + }); + return; + } + } + } catch (e) { + // Fallback + } + + try { + const n = new Notification(title, { + body: body, + icon: '/favicon.ico', + tag: tag || 'fociz-' + Date.now() + }); + n.onclick = function () { + window.focus(); + if (meetingUrl) { + window.open(meetingUrl, '_blank'); + } + n.close(); + }; + } catch (err) { + console.warn('Desktop notificatie kon niet worden getoond:', err); + } + } + }; + + // 6. Evaluatie van agenda-afspraken + function evaluateEvents() { + if (!window.focizNotifSettings.enabled) return; + if (!cachedEvents || cachedEvents.length === 0) return; + + const now = Date.now(); + const leadMs = (parseInt(window.focizNotifSettings.lead_time_minutes) || 5) * 60 * 1000; + const repeatCount = Math.max(1, parseInt(window.focizNotifSettings.repeat_count) || 2); + const intervalMs = (parseInt(window.focizNotifSettings.repeat_interval_minutes) || 2) * 60 * 1000; + + cachedEvents.forEach(evt => { + if (!evt.start_raw && !evt.start) return; + + // Bepaal starttijd in milliseconds + let startMs = 0; + if (evt.start_raw) { + startMs = new Date(evt.start_raw).getTime(); + } else if (evt.start) { + const parts = evt.start.split(':'); + const today = new Date(); + today.setHours(parseInt(parts[0], 10), parseInt(parts[1], 10), 0, 0); + startMs = today.getTime(); + } + + if (!startMs || isNaN(startMs)) return; + + // Check herhalingen + for (let i = 0; i < repeatCount; i++) { + // Eerste notificatie: start - leadMs + // Volgende notificaties: T_0 + i * intervalMs (tot maximaal starttijd) + let triggerMs = startMs - leadMs + (i * intervalMs); + if (triggerMs > startMs) { + triggerMs = startMs; + } + + // Venster: notificatie is nu actueel (tussen triggerMs en 4 minuten na de start) + const isDue = (now >= triggerMs) && (now <= startMs + (4 * 60 * 1000)); + if (isDue) { + const storageKey = `fociz_notif_${evt.id}_rep_${i}`; + const alreadySent = localStorage.getItem(storageKey); + + if (!alreadySent) { + localStorage.setItem(storageKey, String(now)); + + const minutesLeft = Math.max(0, Math.round((startMs - now) / 60000)); + let notifTitle = ''; + let notifBody = ''; + + if (now >= startMs) { + notifTitle = `🚀 Nu gestart: ${evt.subject}`; + notifBody = `Afspraak is begonnen (${evt.start}) • ${evt.location || 'Teams'}`; + } else if (minutesLeft === 0) { + notifTitle = `âš¡ Begint zo: ${evt.subject}`; + notifBody = `Start om ${evt.start} • ${evt.location || 'Teams'}`; + } else { + notifTitle = `📅 Over ${minutesLeft} min: ${evt.subject}`; + notifBody = `Aanvang ${evt.start} - ${evt.end} • ${evt.location || 'Teams'}`; + } + + sendBrowserNotification(notifTitle, notifBody, evt.meeting_url, `fociz-event-${evt.id}-${i}`); + } + } + } + }); + } + + // 7. Data ophalen van backend + async function refreshEvents() { + if (isFetchingEvents) return; + isFetchingEvents = true; + try { + const res = await fetch('/api/notifications.php?action=get_events', { cache: 'no-store' }); + if (res.ok) { + const data = await res.json(); + if (data && data.success) { + cachedEvents = data.events || []; + if (data.settings) { + window.focizNotifSettings = data.settings; + updateDotStatus(); + } + evaluateEvents(); + } + } + } catch (e) { + // Netwerkfout stil opvangen + } finally { + isFetchingEvents = false; + } + } + + // 8. Dot indicator bijwerken op de bel in de header + function updateDotStatus() { + const dot = document.getElementById('focizNotificationDot'); + if (!dot) return; + if ('Notification' in window && Notification.permission === 'granted' && window.focizNotifSettings.enabled) { + dot.classList.remove('hidden'); + } else { + dot.classList.add('hidden'); + } + } + + // 9. Modal helpers + window.openNotificationModal = function () { + const modal = document.getElementById('focizNotificationModal'); + if (!modal) return; + + // Vul actuele waarden in formulier + const s = window.focizNotifSettings; + const enabledCheckbox = document.getElementById('notifEnabledInput'); + const leadTimeSelect = document.getElementById('notifLeadTimeSelect'); + const repeatCountSelect = document.getElementById('notifRepeatCountSelect'); + const intervalSelect = document.getElementById('notifIntervalSelect'); + const soundCheckbox = document.getElementById('notifSoundInput'); + + if (enabledCheckbox) enabledCheckbox.checked = !!parseInt(s.enabled); + if (leadTimeSelect) leadTimeSelect.value = String(s.lead_time_minutes); + if (repeatCountSelect) repeatCountSelect.value = String(s.repeat_count); + if (intervalSelect) intervalSelect.value = String(s.repeat_interval_minutes); + if (soundCheckbox) soundCheckbox.checked = !!parseInt(s.sound_enabled); + + updatePermissionBanner(); + + modal.classList.remove('hidden'); + modal.classList.add('flex'); + }; + + window.closeNotificationModal = function () { + const modal = document.getElementById('focizNotificationModal'); + if (!modal) return; + modal.classList.add('hidden'); + modal.classList.remove('flex'); + }; + + function updatePermissionBanner() { + const grantedDiv = document.getElementById('notifPermGranted'); + const requestDiv = document.getElementById('notifPermRequest'); + const deniedDiv = document.getElementById('notifPermDenied'); + + if (!('Notification' in window)) { + if (grantedDiv) grantedDiv.classList.add('hidden'); + if (requestDiv) requestDiv.classList.add('hidden'); + if (deniedDiv) deniedDiv.classList.remove('hidden'); + return; + } + + const perm = Notification.permission; + if (perm === 'granted') { + if (grantedDiv) grantedDiv.classList.remove('hidden'); + if (requestDiv) requestDiv.classList.add('hidden'); + if (deniedDiv) deniedDiv.classList.add('hidden'); + } else if (perm === 'denied') { + if (grantedDiv) grantedDiv.classList.add('hidden'); + if (requestDiv) requestDiv.classList.add('hidden'); + if (deniedDiv) deniedDiv.classList.remove('hidden'); + } else { + if (grantedDiv) grantedDiv.classList.add('hidden'); + if (requestDiv) requestDiv.classList.remove('hidden'); + if (deniedDiv) deniedDiv.classList.add('hidden'); + } + } + + window.focizRequestPermission = async function () { + if (!('Notification' in window)) { + alert('Uw browser ondersteunt helaas geen HTML5 Web Notificaties.'); + return; + } + try { + const res = await Notification.requestPermission(); + updatePermissionBanner(); + updateDotStatus(); + if (res === 'granted') { + sendBrowserNotification('✅ Notificaties Ingeschakeld!', 'Fociz stuurt u nu pushberichten bij actuele afspraken.', null, 'fociz-test-perm'); + } + } catch (e) { + console.error(e); + } + }; + + window.focizTestNotification = function () { + sendBrowserNotification('🔔 Test Notificatie Fociz', 'Dit is hoe uw agenda-herinnering eruit ziet. Het geluid en de melding werken perfect!', '/planner.php', 'fociz-test-manual'); + }; + + window.saveNotificationPreferences = async function (e) { + if (e) e.preventDefault(); + const btn = document.getElementById('saveNotifBtn'); + if (btn) btn.disabled = true; + + const payload = { + enabled: document.getElementById('notifEnabledInput').checked ? 1 : 0, + lead_time_minutes: parseInt(document.getElementById('notifLeadTimeSelect').value, 10), + repeat_count: parseInt(document.getElementById('notifRepeatCountSelect').value, 10), + repeat_interval_minutes: parseInt(document.getElementById('notifIntervalSelect').value, 10), + sound_enabled: document.getElementById('notifSoundInput').checked ? 1 : 0 + }; + + try { + const res = await fetch('/api/notifications.php?action=save_settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + if (res.ok) { + const data = await res.json(); + if (data && data.success) { + window.focizNotifSettings = data.settings; + updateDotStatus(); + const statusMsg = document.getElementById('notifSaveStatus'); + if (statusMsg) { + statusMsg.textContent = 'Voorkeuren succesvol opgeslagen!'; + statusMsg.classList.remove('hidden'); + setTimeout(() => statusMsg.classList.add('hidden'), 3000); + } + setTimeout(() => closeNotificationModal(), 1000); + } + } + } catch (err) { + alert('Fout bij opslaan van voorkeuren: ' + err.message); + } finally { + if (btn) btn.disabled = false; + } + }; + + // 10. Initialisatie + document.addEventListener('DOMContentLoaded', () => { + refreshEvents(); + // Polling interval elke 60 seconden + setInterval(refreshEvents, 60000); + // Evaluatie interval elke 15 seconden + setInterval(evaluateEvents, 15000); + updateDotStatus(); + }); + +})(); diff --git a/config/schema.sql b/config/schema.sql index d3b0d94..6bbb337 100644 --- a/config/schema.sql +++ b/config/schema.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS `users` ( `backup_codes` TEXT NULL, `remember_token` VARCHAR(64) NULL, `remember_token_expires_at` DATETIME NULL, + `notification_settings` TEXT NULL, `avatar_url` VARCHAR(255) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP diff --git a/includes/auth.php b/includes/auth.php index f12cf44..dd05667 100644 --- a/includes/auth.php +++ b/includes/auth.php @@ -80,6 +80,24 @@ function ensure_remember_token_columns(PDO $db): void { } } +/** + * Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB + */ +function ensure_notification_columns(PDO $db): void { + static $notifColumnsChecked = false; + if ($notifColumnsChecked) return; + $notifColumnsChecked = true; + + try { + $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'notification_settings'"); + if (!$result || $result->rowCount() === 0) { + $db->exec("ALTER TABLE `users` ADD COLUMN `notification_settings` TEXT NULL AFTER `remember_token_expires_at`"); + } + } catch (Throwable $e) { + // Stil negeren indien al aanwezig + } +} + /** * Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie */ @@ -324,4 +342,61 @@ function verify_oauth_state(string $state): ?int { return (int)$payload['uid']; } +/** + * Haalt de persoonlijke notificatie-instellingen van een gebruiker op + */ +function get_user_notification_settings(int $userId): array { + $db = get_db_connection(); + ensure_notification_columns($db); + + $defaults = [ + 'enabled' => 1, + 'lead_time_minutes' => 5, // 0, 2, 5, 10, 15, 30 + 'repeat_count' => 2, // 1, 2, 3, 5 + 'repeat_interval_minutes' => 2, // 1, 2, 3, 5 + 'sound_enabled' => 1, // 0 of 1 + ]; + + try { + $stmt = $db->prepare("SELECT notification_settings FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $json = $stmt->fetchColumn(); + if (!empty($json)) { + $saved = json_decode($json, true); + if (is_array($saved)) { + return array_merge($defaults, $saved); + } + } + } catch (Throwable $e) { + error_log("get_user_notification_settings error: " . $e->getMessage()); + } + + return $defaults; +} + +/** + * Slaat de persoonlijke notificatie-instellingen van een gebruiker op + */ +function update_user_notification_settings(int $userId, array $settings): bool { + $db = get_db_connection(); + ensure_notification_columns($db); + + $current = get_user_notification_settings($userId); + $merged = [ + 'enabled' => !empty($settings['enabled']) ? 1 : 0, + 'lead_time_minutes' => max(0, min(60, (int)($settings['lead_time_minutes'] ?? $current['lead_time_minutes']))), + 'repeat_count' => max(1, min(10, (int)($settings['repeat_count'] ?? $current['repeat_count']))), + 'repeat_interval_minutes' => max(1, min(30, (int)($settings['repeat_interval_minutes'] ?? $current['repeat_interval_minutes']))), + 'sound_enabled' => !empty($settings['sound_enabled']) ? 1 : 0, + ]; + + try { + $stmt = $db->prepare("UPDATE users SET notification_settings = ? WHERE id = ?"); + return $stmt->execute([json_encode($merged), $userId]); + } catch (Throwable $e) { + error_log("update_user_notification_settings error: " . $e->getMessage()); + return false; + } +} + diff --git a/includes/footer.php b/includes/footer.php index d2afcbb..65cb76f 100644 --- a/includes/footer.php +++ b/includes/footer.php @@ -13,6 +13,9 @@ + + +