From 9262b09c4de64abf6fd83a0ec04e2b949b71e2dd Mon Sep 17 00:00:00 2001 From: Niek Rabelink Date: Wed, 9 Sep 2026 15:54:24 +0200 Subject: [PATCH 1/3] Add roadmap management, service worker, and user invitation features - Implemented roadmap.php for managing roadmap items with filtering options by quarter and status. - Added a service worker (sw.js) to handle push notifications and click events for calendar appointments. - Created users.php for admin to manage team members and invitations, including user creation and deletion functionalities. --- .gitattributes | 5 + .gitignore | 12 + Dockerfile | 10 + README.md | 105 ++ admin.php | 753 ++++++++++++ api/ai.php | 212 ++++ api/brainstorm.php | 146 +++ api/integrations.php | 114 ++ api/notifications.php | 128 ++ api/planner.php | 460 +++++++ api/roadmap.php | 93 ++ api/theme.php | 48 + assets/js/notifications.js | 418 +++++++ auth/callback_microsoft.php | 142 +++ auth/invite.php | 217 ++++ auth/login.php | 256 ++++ auth/logout.php | 44 + auth/oauth_microsoft.php | 50 + auth/setup.php | 242 ++++ brainstorm.php | 172 +++ brainstorm_canvas.php | 560 +++++++++ composer.json | 9 + config/database.php | 147 +++ config/schema.sql | 135 +++ includes/ai.php | 197 +++ includes/auth.php | 459 +++++++ includes/env_badge.php | 64 + includes/footer.php | 26 + includes/header.php | 365 ++++++ includes/http.php | 159 +++ includes/integrations/jira.php | 356 ++++++ includes/integrations/microsoft.php | 885 ++++++++++++++ includes/settings.php | 114 ++ includes/totp.php | 132 ++ index.php | 22 + integrations.php | 19 + planner.php | 1717 +++++++++++++++++++++++++++ profile.php | 944 +++++++++++++++ roadmap.php | 327 +++++ sw.js | 46 + users.php | 269 +++++ 41 files changed, 10579 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 admin.php create mode 100644 api/ai.php create mode 100644 api/brainstorm.php create mode 100644 api/integrations.php create mode 100644 api/notifications.php create mode 100644 api/planner.php create mode 100644 api/roadmap.php create mode 100644 api/theme.php create mode 100644 assets/js/notifications.js create mode 100644 auth/callback_microsoft.php create mode 100644 auth/invite.php create mode 100644 auth/login.php create mode 100644 auth/logout.php create mode 100644 auth/oauth_microsoft.php create mode 100644 auth/setup.php create mode 100644 brainstorm.php create mode 100644 brainstorm_canvas.php create mode 100644 composer.json create mode 100644 config/database.php create mode 100644 config/schema.sql create mode 100644 includes/ai.php create mode 100644 includes/auth.php create mode 100644 includes/env_badge.php create mode 100644 includes/footer.php create mode 100644 includes/header.php create mode 100644 includes/http.php create mode 100644 includes/integrations/jira.php create mode 100644 includes/integrations/microsoft.php create mode 100644 includes/settings.php create mode 100644 includes/totp.php create mode 100644 index.php create mode 100644 integrations.php create mode 100644 planner.php create mode 100644 profile.php create mode 100644 roadmap.php create mode 100644 sw.js create mode 100644 users.php diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8579a1d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Zorg dat omgevingsspecifieke bestanden nooit worden overschreven bij merges tussen dev en main +config/config.php merge=ours +includes/env_badge.php merge=ours +config/.env.example merge=ours + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e388cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Environment +.env +.env*.local + +# Systeem & Editor +.DS_Store +Thumbs.db +*.log +.vscode/ +.idea/ +config/.env.example +config/config.php \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5450d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM php:8.2-apache + +# Installeer MariaDB/MySQL drivers +RUN docker-php-ext-install pdo pdo_mysql mysqli + +# Activeer Apache mod_rewrite voor schone URL's +RUN a2enmod rewrite + +# Zorg dat Apache op poort 80 luistert +EXPOSE 80 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..558cd0d --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Fociz — Focus, Dagplanning, Brainstorm & Roadmap Systeem + +Fociz is een compleet, modern webplatform gebouwd in **PHP 8 & MariaDB** voor dagplanning, werkstructurering, interactieve brainstormsessies die met één klik omgezet kunnen worden in een roadmap, en koppelingen met Outlook, Teams en Jira. + +--- + +## 🔒 Beveiliging & Toegangsbeheer + +- **Strikt afgesloten**: Geen openbare registratie. Enkel toegankelijk na inloggen met tweetrapsauthenticatie (2FA). +- **First-run Admin Setup (`/auth/setup.php`)**: + - Bij het allereerste gebruik detecteert Fociz automatisch dat er nog geen beheerder bestaat en start de setup wizard. + - De beheerder voert naam, e-mail en wachtwoord in en scant de 2FA QR-code met bijv. **Google Authenticator**, **Microsoft Authenticator** of **1Password**. + - Na verificatie van de 6-cijferige code wordt de beheerder opgeslagen en wordt `/auth/setup.php` definitief vergrendeld. +- **Invite-only Registratie (`/auth/invite.php?token=...`)**: + - Nieuwe collega's kunnen enkel toetreden met een unieke uitnodigingslink aangemaakt door de beheerder (`/users.php`). + - Dwingt direct het instellen van een wachtwoord en 2FA-registratie af. +- **Inloggen (`/auth/login.php`)**: + - Stap 1: E-mailadres & wachtwoord (`password_hash` / `password_verify`). + - Stap 2: 6-cijferige TOTP verificatiecode (of herstelcode). + +--- + +## 🚦 Omgevingsindicator (Dev vs Live) + +In de linkerbovenhoek van elk scherm bevindt zich een opvallende flashy badge met live radar-ping animatie: +- **Development (Rood)**: Knippert rood met de tekst `DEVELOPMENT (DEV)` zodat u direct ziet dat u op de test/dev omgeving werkt. +- **Live (Groen)**: Knippert groen met de tekst `LIVE (MAIN)` voor de productie-omgeving. + +### Hoe in te stellen per omgeving: +In het bestand **`includes/env_badge.php`**: +- **Op DEV**: `$environment = 'development';` *(staat standaard al zo ingesteld)* +- **Op MAIN / LIVE**: `$environment = 'live';` *(of zet `APP_ENV="live"` in `.env`)* + +--- + +## 🚀 Kernmodules + +1. **Dagplanning & Focus (`/planner.php`)**: + - **Dagdoel**: Leg uw nummer 1 focuspunt van de dag vast met één klik. + - **Focus Modus**: Ingebouwde Pomodoro/Stopwatch timer (25 min Focus, 50 min Deep Work, 5 min Pauze) met visuele voortgang en taakselectie. + - **Dagtaken & Prioriteiten**: Taken indelen op prioriteit (P1 Brandend, P2 Belangrijk, P3 Regulier), snel toevoegen en afvinken. + - **Tijdlijn & Time-blocking**: Uuroverzicht (08:00 - 18:00) met time-blocking voor taken en gesynchroniseerde Outlook meetings. + - **Dagnotities**: Snel kladblok voor reflecties en memo's. + +2. **Brainstorm Canvas (`/brainstorm.php` & `/brainstorm_canvas.php`)**: + - Interactief whiteboard met sticky notes ingedeeld in 4 categorieën: + - 💡 **Ideeën & Concepten** + - ⚠️ **Knelpunten & Uitdagingen** + - 🚀 **Oplossingen & Features** + - ⚡ **Quick Wins** + - Stemmen (upvoting) op ideeën. + - **Zet om naar Roadmap**: Selecteer brainstormkaarten en zet ze direct om naar planbare roadmap-items met kwartaal, deadline en prioriteit. + +3. **Roadmap & Tijdlijn (`/roadmap.php`)**: + - Kwartaaloverzicht (Q1, Q2, Q3, Q4) en statussen (Gepland, In Uitvoering, In Review, Voltooid). + - Voortgangsbalken (0 - 100%) en deadlines. + - Gekoppeld aan de oorspronkelijke brainstormkaarten en optioneel Jira Issue Keys (bijv. `FOC-101`). + +4. **Mijn Profiel & Persoonlijke Koppelingen (`/profile.php`)**: + - **Microsoft Outlook & Teams**: Koppel met 1 klik uw persoonlijke werkagenda voor dagelijkse afsprakensynchronisatie en directe Teams meeting links. + - **Atlassian Jira**: Koppel uw persoonlijke Atlassian API token om aan u toegewezen tickets in te zien en in te plannen. + - **Browser Push Meldingen**: Configureer persoonlijke waarschuwingen (vooraankondigingstijd, herhalingsfrequentie, audio chime en live test). + - **Account & Beveiliging**: Wachtwoord wijzigen en 2FA beveiligingsstatus. + +5. **Systeembeheer & Integraties Portaal (`/admin.php` - Admin only)**: + - Centrale Microsoft 365 Azure App Registratie (Client ID, Client Secret, Tenant ID, Admin Consent verlenen). + - Standaard Jira bedrijfsdomein voor alle teamleden. + - Systeem & Team Monitor (statistieken over actieve leden, agenda koppelingen en API-status). + - Extensibiliteit Hub voor toekomstige modules (AI Co-Pilot, werkdag standaarden). + +6. **Team & Invites Beheer (`/users.php` - Admin only)**: + - Genereer beveiligde uitnodigingslinks voor collega's met kopieerknop. + - Overzicht van actieve gebruikers, rollen en 2FA-status. + +--- + +## 🛠️ Installatie op uw Server + +### 1. Bestanden uploaden +Push of upload alle bestanden naar de gewenste webdirectory op uw server (bijv. `/var/www/fociz` of `public_html`). + +### 2. MariaDB Database configureren +Pas de databasegegevens aan in `.env` (of direct in `config/config.php`): +```env +DB_HOST="127.0.0.1" +DB_PORT="3306" +DB_NAME="fociz" +DB_USER="uw_mariadb_gebruiker" +DB_PASS="uw_mariadb_wachtwoord" +APP_URL="https://fociz.nl" +``` + +### 3. Automatische Tabellen Aanmaak +U hoeft **niet** handmatig SQL-commando's uit te voeren: zodra u de website voor het eerst opent in uw browser, detecteert Fociz automatisch MariaDB en worden alle tabellen en relaties uit `config/schema.sql` vanzelf aangemaakt! + +*(Optioneel kunt u `config/schema.sql` ook zelf importeren via phpMyAdmin of de MariaDB CLI).* + +### 4. Eerste Beheerder Instellen +1. Open uw browser en ga naar `https://fociz.nl` (of `https://dev.fociz.nl`). +2. U wordt automatisch doorverwezen naar `/auth/setup.php`. +3. Vul uw naam, e-mailadres en gewenst wachtwoord in. +4. Scan de QR-code met Google Authenticator of Microsoft Authenticator. +5. Vul de 6-cijferige code in om te bevestigen. +6. **Klaar!** U kunt nu direct inloggen en collega's uitnodigen. + diff --git a/admin.php b/admin.php new file mode 100644 index 0000000..4457f5a --- /dev/null +++ b/admin.php @@ -0,0 +1,753 @@ +prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'"); + $stmtExisting->execute([$userId]); + $existing = $stmtExisting->fetch(); + $existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: []; + + if (($clientSecret === '' || $clientSecret === '••••••••••••••••') && !empty($existingSettings['client_secret']) && $existingSettings['client_secret'] !== '••••••••••••••••') { + $clientSecret = $existingSettings['client_secret']; + } + + $newSettings = array_merge($existingSettings, [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'tenant_id' => $tenantId, + ]); + + $stmt = $db->prepare(" + INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at) + VALUES (?, 'OUTLOOK', 1, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + is_demo_mode = VALUES(is_demo_mode), + settings = VALUES(settings), + last_sync_at = NOW() + "); + $stmt->execute([$userId, $isDemo, json_encode($newSettings)]); + + $message = "Centrale Microsoft 365 Azure App instellingen zijn succesvol opgeslagen."; + $messageType = 'success'; + } + + // 2. Centraal Jira Bedrijfsdomein opslaan + elseif ($action === 'save_jira_domain') { + $rawDomain = trim($_POST['jira_domain'] ?? ''); + $companyDomain = !empty($rawDomain) ? jira_normalize_domain($rawDomain) : ''; + + $stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'"); + $stmtExisting->execute([$userId]); + $existing = $stmtExisting->fetch(); + $existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: []; + + $existingSettings['jira_domain'] = $companyDomain; + + $stmt = $db->prepare(" + INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at) + VALUES (?, 'JIRA', 1, 0, ?, NOW()) + ON DUPLICATE KEY UPDATE + settings = VALUES(settings), + last_sync_at = NOW() + "); + $stmt->execute([$userId, json_encode($existingSettings)]); + + $message = "Standaard Atlassian Jira bedrijfsdomein is succesvol opgeslagen."; + $messageType = 'success'; + } + + // 3. Organisatie Werkdag Standaarden opslaan (Release v1.4) + elseif ($action === 'save_workday_settings') { + $workdayStart = trim($_POST['workday_start'] ?? '08:30'); + $workdayEnd = trim($_POST['workday_end'] ?? '17:30'); + $lunchStart = trim($_POST['lunch_start'] ?? '12:30'); + $lunchEnd = trim($_POST['lunch_end'] ?? '13:00'); + $pomoFocus = max(10, min(120, (int)($_POST['pomodoro_focus'] ?? 25))); + $pomoDeep = max(20, min(180, (int)($_POST['pomodoro_deep'] ?? 50))); + $pomoBreak = max(2, min(30, (int)($_POST['pomodoro_break'] ?? 5))); + $taskDuration = in_array((int)($_POST['default_task_duration'] ?? 30), [15, 30, 45, 60, 90, 120]) ? (int)$_POST['default_task_duration'] : 30; + + set_system_setting('workday_start', $workdayStart); + set_system_setting('workday_end', $workdayEnd); + set_system_setting('lunch_start', $lunchStart); + set_system_setting('lunch_end', $lunchEnd); + set_system_setting('pomodoro_focus', $pomoFocus); + set_system_setting('pomodoro_deep', $pomoDeep); + set_system_setting('pomodoro_break', $pomoBreak); + set_system_setting('default_task_duration', $taskDuration); + + $message = "Organisatie werkdagstandaarden zijn succesvol bijgewerkt."; + $messageType = 'success'; + } + + // 4. AI Co-Pilot instellingen opslaan (Release v1.5) + elseif ($action === 'save_ai_settings') { + $aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0; + $aiProvider = trim($_POST['ai_provider'] ?? 'openai'); + $aiModel = trim($_POST['ai_model'] ?? 'gpt-4o-mini'); + $apiKeyIn = trim($_POST['ai_api_key'] ?? ''); + + $currentAi = get_ai_settings(); + if ($apiKeyIn !== '' && $apiKeyIn !== '••••••••••••••••') { + set_system_setting('ai_api_key', $apiKeyIn); + } + + set_system_setting('ai_enabled', $aiEnabled); + set_system_setting('ai_provider', $aiProvider); + set_system_setting('ai_model', $aiModel); + + $message = "AI Co-Pilot instellingen zijn succesvol opgeslagen."; + $messageType = 'success'; + } +} + +// Haal actuele configuratie op +$appConfig = microsoft_get_app_config(); +$companyJiraDomain = jira_get_company_domain(); +$workdaySettings = get_workday_settings(); +$aiSettings = get_ai_settings(); + +// Redirect URI +$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php'; + +// Admin Consent URL +$tenantForConsent = !empty($appConfig['tenant_id']) ? $appConfig['tenant_id'] : 'common'; +$adminConsentUrl = "https://login.microsoftonline.com/" . urlencode($tenantForConsent) . "/adminconsent?client_id=" . urlencode($appConfig['client_id']) . "&redirect_uri=" . urlencode($redirectUri); + +// Statistieken ophalen voor Systeem & Team Monitor +$totalUsers = (int)$db->query("SELECT COUNT(*) FROM users")->fetchColumn(); +$totalAdmins = (int)$db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'")->fetchColumn(); +$totalMembers = $totalUsers - $totalAdmins; +$pendingInvites = (int)$db->query("SELECT COUNT(*) FROM invites WHERE used_at IS NULL AND expires_at > NOW()")->fetchColumn(); + +$outlookConnectedCount = (int)$db->query("SELECT COUNT(*) FROM integrations WHERE provider = 'OUTLOOK' AND access_token IS NOT NULL")->fetchColumn(); +$jiraConnectedCount = (int)$db->query("SELECT COUNT(*) FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_api_token%' AND settings NOT LIKE '%\"jira_api_token\":\"\"%'")->fetchColumn(); + +// Haal recente gebruikers op voor snelle inspectie +$recentUsers = $db->query(" + SELECT u.id, u.name, u.email, u.role, u.created_at, + (SELECT COUNT(*) FROM integrations i WHERE i.user_id = u.id AND i.provider = 'OUTLOOK' AND i.access_token IS NOT NULL) as has_outlook, + (SELECT COUNT(*) FROM integrations i WHERE i.user_id = u.id AND i.provider = 'JIRA' AND i.settings LIKE '%jira_api_token%' AND i.settings NOT LIKE '%\"jira_api_token\":\"\"%') as has_jira + FROM users u + ORDER BY u.created_at DESC + LIMIT 6 +")->fetchAll(); + +$pageTitle = 'Systeembeheer & Integraties — Fociz'; +require_once __DIR__ . '/includes/header.php'; +?> + +
+ +
+
+
+ +
+
+
+ + Beheerderspaneel • Alleen toegankelijk voor Beheer +
+

+ Systeembeheer & Integraties +

+

+ Beheer hier de centrale organisatiekoppelingen zoals Microsoft 365 Azure App Registratie en Jira, inspecteer de teamstatus en configureer toekomstige modules. +

+
+ + +
+
+ + + +
+ +
+
+ + + +
+
+
+ +
+
+ Totaal Teamleden + + Beheerders • Collega's +
+
+ +
+
+ +
+
+ Outlook Live Gekoppeld + + +
+
+ +
+
+ +
+
+ Jira Gekoppeld + + +
+
+ +
+
+ +
+
+ Openstaande Invites + + Beheer uitnodigingen → +
+
+
+ + +
+ + +
+ + +
+
+
+
+ +
+
+
+

Microsoft 365 & Azure App Registratie

+ + + Geconfigureerd + + + + Configuratie Vereist + + +
+

Centrale OAuth 2.0 instellingen voor de hele organisatie

+
+
+ + + + + Admin Consent Verlenen + + + +
+ +
+ + + +
+
+ + Kopieer dit exact naar de Azure Web Redirect URIs +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + + Vul uw domeinnaam in (planningsagenda.nl) of de Directory (tenant) ID uit Azure. Let op: Gebruik géén 'common' voor single-tenant apps. +
+
+ + +
+
+ + Wordt veilig en versleuteld opgeslagen +
+
+ + +
+
+ +
+
+ Zodra opgeslagen kunnen al uw collega's met 1 klik hun Outlook agenda koppelen. +
+ +
+
+ + +
+ + + +
+
+ + +
+
+
+ +
+
+
+

Atlassian Jira Organisatie-instellingen

+ + + Actief + + + + Optioneel + + +
+

Standaard Jira Cloud domein voor het hele team

+
+
+ +
+ + +
+ + +

+ Wanneer u dit instelt, wordt dit domein automatisch vooraf ingevuld op de profielpagina van alle collega's. Zij hoeven dan enkel nog hun eigen persoonlijke e-mailadres en Atlassian API-token in te voeren. +

+
+ +
+ +
+
+
+ + +
+
+
+
+ +
+
+

Werkdag & Dagplanning Standaarden

+

Centrale kantoortijden, lunchpauzes en Pomodoro presets voor het team

+
+
+ + Release v1.4 Actief + +
+ +
+ + +
+ +
+
+ + Kantooruren / Tijdlijn Bereik +
+
+
+ + +
+
+ + +
+
+

Bepaalt het standaard start- en einduur op de dagelijkse tijdlijn.

+
+ + +
+
+ + Automatisch Lunchpauze Blok +
+
+
+ + +
+
+ + +
+
+

Wordt visueel gemarkeerd als pauzeblok op de tijdlijn.

+
+ + +
+
+ + Focus & Pomodoro Presets (minuten) +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + Standaard Taakduur bij Aanmaken +
+
+ + +
+

Selectie bij het toevoegen van nieuwe dagtaken.

+
+
+ +
+ +
+
+
+ + +
+
+
+
+ +
+
+

AI & Brainstorm Co-Pilot

+

Intelligente assistentie voor post-it categorisatie, ideegeneratie en slimme planning

+
+
+ + + +
+ +
+ + +
+ +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+
+
+
+ + +
+ + +
+
+
+ +

Team & Integratiestatus

+
+ + Alle gebruikers → + +
+ +
+ +
+
+
+ +
+
+ + +
+
+ +
+ + + + + + +
+
+ +
+ + +
+ + +
+
+ + Systeem & Beveiliging +
+
+
+ Tweestapsverificatie (2FA) + + + Verplicht + +
+ +
+ Database Versie + MariaDB 10.x +
+ +
+ Push Notificaties + Actief +
+ +
+ Graph API Sync + Live v1.0 +
+
+
+ +
+
+
+ + + + diff --git a/api/ai.php b/api/ai.php new file mode 100644 index 0000000..3bd5237 --- /dev/null +++ b/api/ai.php @@ -0,0 +1,212 @@ + 'Sessie ID ontbreekt']); + exit; + } + + $stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?"); + $stmtSession->execute([$sessionId]); + $session = $stmtSession->fetch(); + + if (!$session) { + echo json_encode(['error' => 'Sessie niet gevonden']); + exit; + } + + $stmtCards = $db->prepare("SELECT id, content, category FROM brainstorm_cards WHERE session_id = ?"); + $stmtCards->execute([$sessionId]); + $cards = $stmtCards->fetchAll(); + + if (empty($cards)) { + echo json_encode(['error' => 'Er zijn nog geen kaartjes op het canvas om te categoriseren']); + exit; + } + + $aiRes = fociz_ai_categorize_cards($session['title'], $session['theme'] ?? 'Algemeen', $cards); + + if (!$aiRes['success']) { + echo json_encode(['error' => $aiRes['error']]); + exit; + } + + $validCategories = ['Ideeën', 'Knelpunten', 'Oplossingen', 'Quick Wins']; + $updateStmt = $db->prepare("UPDATE brainstorm_cards SET category = ? WHERE id = ? AND session_id = ?"); + $updatedCount = 0; + + foreach ($aiRes['categorized'] as $item) { + $cardId = (int)($item['id'] ?? 0); + $newCat = trim($item['category'] ?? ''); + if ($cardId > 0 && in_array($newCat, $validCategories)) { + $updateStmt->execute([$newCat, $cardId, $sessionId]); + $updatedCount++; + } + } + + echo json_encode(['success' => true, 'updated' => $updatedCount]); + exit; + } + + // 2. Automatisch ideeën genereren voor een sessie + if ($action === 'generate_ideas') { + $data = json_decode(file_get_contents('php://input'), true); + $sessionId = (int)($data['session_id'] ?? 0); + $count = min(6, max(2, (int)($data['count'] ?? 4))); + + if (!$sessionId) { + echo json_encode(['error' => 'Sessie ID ontbreekt']); + exit; + } + + $stmtSession = $db->prepare("SELECT * FROM brainstorm_sessions WHERE id = ?"); + $stmtSession->execute([$sessionId]); + $session = $stmtSession->fetch(); + + if (!$session) { + echo json_encode(['error' => 'Sessie niet gevonden']); + exit; + } + + $aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count); + + if (!$aiRes['success']) { + echo json_encode(['error' => $aiRes['error']]); + exit; + } + + $insertStmt = $db->prepare(" + INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes) + VALUES (?, ?, 'Ideeën', 'amber', ?, 1) + "); + + $createdCards = []; + $authorName = 'AI Co-Pilot'; + + foreach ($aiRes['ideas'] as $ideaText) { + $ideaText = trim($ideaText); + if (!empty($ideaText)) { + $insertStmt->execute([$sessionId, $ideaText, $authorName]); + $createdCards[] = [ + 'id' => (int)$db->lastInsertId(), + 'content' => $ideaText, + 'category' => 'Ideeën', + 'color' => 'amber', + 'author_name' => $authorName, + 'votes' => 1, + ]; + } + } + + echo json_encode(['success' => true, 'cards' => $createdCards]); + exit; + } + + // 3. Brainstorm notitie omzetten naar Roadmap Item + if ($action === 'summarize_to_roadmap') { + $data = json_decode(file_get_contents('php://input'), true); + $content = trim($data['content'] ?? ''); + $sessionTitle = trim($data['session_title'] ?? ''); + + if (empty($content)) { + echo json_encode(['error' => 'Geen inhoud opgegeven om samen te vatten']); + exit; + } + + $aiRes = fociz_ai_summarize_to_roadmap($content, $sessionTitle); + + if (!$aiRes['success']) { + echo json_encode(['error' => $aiRes['error']]); + exit; + } + + echo json_encode(['success' => true, 'item' => $aiRes['item']]); + exit; + } + + // 4. Slimme dagindeling berekenen voor taken rondom Outlook afspraken + if ($action === 'smart_schedule') { + $data = json_decode(file_get_contents('php://input'), true); + $date = !empty($data['date']) ? trim($data['date']) : date('Y-m-d'); + + // Haal daily plan op + $stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?"); + $stmtPlan->execute([$user['id'], $date]); + $plan = $stmtPlan->fetch(); + $planId = $plan['id'] ?? 0; + + // Haal openstaande taken op voor de dag (inclusief fallback voor ongebonden taken op de huidige dag) + $isToday = ($date === date('Y-m-d')); + $stmtTasks = $db->prepare(" + SELECT t.id, t.title, t.priority, t.duration_minutes + FROM tasks t + WHERE t.user_id = ? AND (t.daily_plan_id = ? " . ($isToday ? "OR t.daily_plan_id IS NULL" : "") . ") AND t.is_completed = 0 + ORDER BY t.priority ASC, t.created_at ASC + "); + $stmtTasks->execute([$user['id'], $planId]); + $tasks = $stmtTasks->fetchAll(); + + if (empty($tasks)) { + echo json_encode(['error' => 'Geen openstaande taken gevonden om in te delen']); + exit; + } + + require_once __DIR__ . '/../includes/integrations/microsoft.php'; + $events = microsoft_fetch_today_events($user['id'], $date); + $workdaySettings = get_workday_settings(); + + $aiRes = fociz_ai_smart_schedule($tasks, $events, $workdaySettings); + + if (!$aiRes['success']) { + echo json_encode(['error' => $aiRes['error']]); + exit; + } + + // Werk de tijdsblokken bij in MariaDB + $updateStmt = $db->prepare(" + UPDATE tasks + SET time_block_start = ?, + duration_minutes = COALESCE(?, duration_minutes) + WHERE id = ? AND user_id = ? + "); + + $scheduledCount = 0; + foreach ($aiRes['scheduled'] as $s) { + $taskId = (int)($s['id'] ?? 0); + $startTime = trim($s['time_block_start'] ?? ''); + $duration = !empty($s['duration_minutes']) ? (int)$s['duration_minutes'] : null; + + if ($taskId > 0 && preg_match('/^\d{1,2}:\d{2}$/', $startTime)) { + $updateStmt->execute([$startTime, $duration, $taskId, $user['id']]); + $scheduledCount++; + } + } + + echo json_encode(['success' => true, 'scheduled_count' => $scheduledCount]); + exit; + } + + echo json_encode(['error' => 'Onbekende actie']); +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} diff --git a/api/brainstorm.php b/api/brainstorm.php new file mode 100644 index 0000000..8c3cdc8 --- /dev/null +++ b/api/brainstorm.php @@ -0,0 +1,146 @@ + 'Inhoud is verplicht']); + exit; + } + + $stmt = $db->prepare(" + INSERT INTO brainstorm_cards (session_id, content, category, color, author_name, votes) + VALUES (?, ?, ?, ?, ?, 0) + "); + $stmt->execute([$sessionId, $content, $category, $color, $user['name']]); + $cardId = (int)$db->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'card' => [ + 'id' => $cardId, + 'content' => $content, + 'category' => $category, + 'color' => $color, + 'author_name' => $user['name'], + 'votes' => 0, + 'converted_to_roadmap_item_id' => null + ] + ]); + exit; + } + + // 2. Stemmen / Upvote op kaart + if ($action === 'vote_card') { + $data = json_decode(file_get_contents('php://input'), true); + $cardId = (int)($data['card_id'] ?? 0); + + $stmt = $db->prepare("UPDATE brainstorm_cards SET votes = votes + 1 WHERE id = ?"); + $stmt->execute([$cardId]); + + $stmtVotes = $db->prepare("SELECT votes FROM brainstorm_cards WHERE id = ?"); + $stmtVotes->execute([$cardId]); + $votes = (int)$stmtVotes->fetchColumn(); + + echo json_encode(['success' => true, 'votes' => $votes]); + exit; + } + + // 3. Kaart verwijderen + if ($action === 'delete_card') { + $data = json_decode(file_get_contents('php://input'), true); + $cardId = (int)($data['card_id'] ?? 0); + + $stmt = $db->prepare("DELETE FROM brainstorm_cards WHERE id = ?"); + $stmt->execute([$cardId]); + + echo json_encode(['success' => true]); + exit; + } + + // 4. Omzetten naar Roadmap! + if ($action === 'convert_to_roadmap') { + $data = json_decode(file_get_contents('php://input'), true); + $cardIds = $data['card_ids'] ?? []; + $quarter = $data['quarter'] ?? 'Q3'; + $type = $data['type'] ?? 'FEATURE'; + $priority = $data['priority'] ?? 'MEDIUM'; + $dueDate = !empty($data['due_date']) ? $data['due_date'] : null; + + if (empty($cardIds) || !is_array($cardIds)) { + echo json_encode(['error' => 'Selecteer ten minste één kaart om om te zetten.']); + exit; + } + + $db->beginTransaction(); + + $convertedCount = 0; + foreach ($cardIds as $cardId) { + $cardId = (int)$cardId; + $stmtCard = $db->prepare("SELECT * FROM brainstorm_cards WHERE id = ?"); + $stmtCard->execute([$cardId]); + $card = $stmtCard->fetch(); + + if ($card) { + // Maak titel (eerste zin of max 80 tekens) + $firstLine = explode("\n", trim($card['content']))[0]; + $title = mb_substr($firstLine, 0, 80); + + $stmtRoadmap = $db->prepare(" + INSERT INTO roadmap_items (title, description, type, status, quarter, priority, progress, due_date, origin_brainstorm_card_id, created_by_id) + VALUES (?, ?, ?, 'PLANNED', ?, ?, 0, ?, ?, ?) + "); + $stmtRoadmap->execute([ + $title, + $card['content'], + $type, + $quarter, + $priority, + $dueDate, + $cardId, + $user['id'] + ]); + $roadmapId = (int)$db->lastInsertId(); + + // Koppel terug aan brainstorm kaart + $stmtUpdateCard = $db->prepare("UPDATE brainstorm_cards SET converted_to_roadmap_item_id = ? WHERE id = ?"); + $stmtUpdateCard->execute([$roadmapId, $cardId]); + + $convertedCount++; + } + } + + $db->commit(); + + echo json_encode([ + 'success' => true, + 'message' => "{$convertedCount} brainstorm items succesvol omgezet naar de Roadmap!", + 'converted_count' => $convertedCount + ]); + exit; + } + + echo json_encode(['error' => 'Onbekende actie']); +} catch (Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + diff --git a/api/integrations.php b/api/integrations.php new file mode 100644 index 0000000..57aa15e --- /dev/null +++ b/api/integrations.php @@ -0,0 +1,114 @@ +prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'JIRA'"); + $stmt->execute([$user['id']]); + $saved = json_decode($stmt->fetchColumn() ?: '{}', true); + $apiToken = $saved['jira_api_token'] ?? ''; + if (empty($domain)) $domain = $saved['jira_domain'] ?? ''; + if (empty($email)) $email = $saved['jira_email'] ?? ''; + } + + $res = jira_test_connection($domain, $email, $apiToken); + + if ($res['success'] && $save) { + $jiraUser = $res['user']; + $settings = json_encode([ + 'jira_domain' => jira_normalize_domain($domain), + 'jira_email' => $email, + 'jira_api_token' => $apiToken, + 'account_name' => $jiraUser['displayName'] ?? '', + 'account_id' => $jiraUser['accountId'] ?? '', + ]); + + $stmt = $db->prepare(" + INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at) + VALUES (?, 'JIRA', 1, 0, ?, NOW()) + ON DUPLICATE KEY UPDATE + is_enabled = 1, + is_demo_mode = 0, + settings = VALUES(settings), + last_sync_at = NOW() + "); + $stmt->execute([$user['id'], $settings]); + + // Wis eventuele oude sessie cache + unset($_SESSION["jira_issues_user_{$user['id']}"]); + } + + echo json_encode($res); + exit; + } + + // 2. Jira Issues Ophalen (Toegewezen aan de gebruiker) + if ($action === 'get_jira_issues') { + $force = !empty($_GET['force']) || !empty($_POST['force']); + $res = jira_get_user_issues($user['id'], $force); + echo json_encode($res); + exit; + } + + // 3. Outlook Agenda Afspraken Ophalen + if ($action === 'get_outlook_events') { + $date = $_GET['date'] ?? date('Y-m-d'); + $events = microsoft_fetch_today_events($user['id'], $date); + echo json_encode([ + 'success' => true, + 'date' => $date, + 'events' => $events, + 'count' => count($events), + ]); + exit; + } + + // 4. Integratie Ontkoppelen + if ($action === 'disconnect') { + $input = json_decode(file_get_contents('php://input'), true) ?: $_POST; + $provider = strtoupper(trim($input['provider'] ?? '')); + + if ($provider === 'OUTLOOK') { + microsoft_disconnect($user['id']); + echo json_encode(['success' => true, 'message' => 'Microsoft Outlook ontkoppeld']); + exit; + } + + if ($provider === 'JIRA') { + jira_disconnect($user['id']); + echo json_encode(['success' => true, 'message' => 'Atlassian Jira ontkoppeld']); + exit; + } + + echo json_encode(['error' => 'Onbekende provider']); + exit; + } + + echo json_encode(['error' => 'Onbekende actie']); +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + diff --git a/api/notifications.php b/api/notifications.php new file mode 100644 index 0000000..9ff490f --- /dev/null +++ b/api/notifications.php @@ -0,0 +1,128 @@ + 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/api/planner.php b/api/planner.php new file mode 100644 index 0000000..be66cbc --- /dev/null +++ b/api/planner.php @@ -0,0 +1,460 @@ +prepare(" + INSERT INTO daily_plans (user_id, plan_date, focus_goal, notes) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE focus_goal = VALUES(focus_goal), notes = VALUES(notes) + "); + $stmt->execute([$user['id'], $date, $focusGoal, $notes]); + + echo json_encode(['success' => true]); + exit; + } + + // 2. Taak toevoegen + if ($action === 'add_task') { + $data = json_decode(file_get_contents('php://input'), true); + $title = trim($data['title'] ?? ''); + $priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2'; + $timeBlockStart = !empty($data['time_block_start']) ? trim($data['time_block_start']) : null; + $timeBlockEnd = !empty($data['time_block_end']) ? trim($data['time_block_end']) : null; + $durationMinutes = (int)($data['duration_minutes'] ?? 0); + $date = !empty($data['date']) ? trim($data['date']) : $today; + $syncOutlook = !empty($data['sync_to_outlook']); + + if (empty($title)) { + echo json_encode(['error' => 'Titel is verplicht']); + exit; + } + + // Als zowel start- als eindtijd zijn opgegeven, bereken exact de minuten + if (!empty($timeBlockStart) && !empty($timeBlockEnd)) { + $t1 = strtotime("2000-01-01 {$timeBlockStart}"); + $t2 = strtotime("2000-01-01 {$timeBlockEnd}"); + if ($t1 !== false && $t2 !== false && $t2 > $t1) { + $durationMinutes = (int)(($t2 - $t1) / 60); + } + } + + if ($durationMinutes <= 0) { + $durationMinutes = 30; + } + + // Als starttijd bekend is maar eindtijd nog niet, bereken eindtijd + if (!empty($timeBlockStart) && empty($timeBlockEnd)) { + try { + $dtEnd = new DateTime("{$date} {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam')); + $dtEnd->modify("+{$durationMinutes} minutes"); + $timeBlockEnd = $dtEnd->format('H:i'); + } catch (Throwable $e) { + // negeer + } + } + + // Haal of maak daily plan op + $stmtPlan = $db->prepare(" + INSERT INTO daily_plans (user_id, plan_date) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id) + "); + $stmtPlan->execute([$user['id'], $date]); + $planId = $db->lastInsertId(); + + $source = !empty($data['source']) ? substr(trim($data['source']), 0, 50) : 'FOCIZ'; + $externalId = !empty($data['external_id']) ? substr(trim($data['external_id']), 0, 255) : null; + $externalUrl = !empty($data['external_url']) ? trim($data['external_url']) : null; + $outlookSyncError = null; + + // Als synchronisatie naar Outlook gewenst is (of source is OUTLOOK) en nog geen extern ID heeft + if (($syncOutlook || $source === 'OUTLOOK') && empty($externalId)) { + // Als er geen starttijd is ingevuld maar wel sync gewenst is, gebruik 09:00 als veilige standaard + if (empty($timeBlockStart)) { + $timeBlockStart = '09:00'; + } + + $msResult = microsoft_create_event($user['id'], [ + 'subject' => $title, + 'description' => "Gepland via Fociz Dagplanning ({$priority})", + 'date' => $date, + 'start_time' => $timeBlockStart, + 'end_time' => $timeBlockEnd, + 'duration_minutes' => $durationMinutes, + ]); + + if (!empty($msResult['success']) && !empty($msResult['event_id'])) { + $source = 'OUTLOOK'; + $externalId = $msResult['event_id']; + $externalUrl = $msResult['web_link'] ?? 'https://outlook.office.com'; + $timeBlockStart = $msResult['start_time'] ?? $timeBlockStart; + $timeBlockEnd = $msResult['end_time'] ?? $timeBlockEnd; + } else { + $outlookSyncError = $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook'; + } + } + + $stmt = $db->prepare(" + INSERT INTO tasks (user_id, daily_plan_id, title, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url) + VALUES (?, ?, ?, ?, ?, ?, ?, 'TODO', ?, ?, ?) + "); + $stmt->execute([$user['id'], $planId, $title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $source, $externalId, $externalUrl]); + $taskId = (int)$db->lastInsertId(); + + echo json_encode([ + 'success' => true, + 'task' => [ + 'id' => $taskId, + 'title' => $title, + 'priority' => $priority, + 'time_block_start' => $timeBlockStart, + 'time_block_end' => $timeBlockEnd, + 'duration_minutes' => $durationMinutes, + 'source' => $source, + 'external_id' => $externalId, + 'external_url' => $externalUrl + ], + 'outlook_synced' => ($source === 'OUTLOOK' && !empty($externalId)), + 'outlook_error' => $outlookSyncError + ]); + exit; + } + + // 2b. Taak bewerken (titel, prioriteit, starttijd, eindtijd, duur) + if ($action === 'update_task') { + $data = json_decode(file_get_contents('php://input'), true); + $taskId = (int)($data['task_id'] ?? 0); + $title = trim($data['title'] ?? ''); + $priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2'; + $timeBlockStart = !empty($data['time_block_start']) ? trim($data['time_block_start']) : null; + $timeBlockEnd = !empty($data['time_block_end']) ? trim($data['time_block_end']) : null; + $durationMinutes = (int)($data['duration_minutes'] ?? 0); + + if (empty($title)) { + echo json_encode(['error' => 'Titel mag niet leeg zijn']); + exit; + } + + // Haal bestaande taak op + $stmtTask = $db->prepare("SELECT * FROM tasks WHERE id = ? AND user_id = ?"); + $stmtTask->execute([$taskId, $user['id']]); + $existingTask = $stmtTask->fetch(); + + if (!$existingTask) { + echo json_encode(['error' => 'Taak niet gevonden']); + exit; + } + + if (!empty($timeBlockStart) && !empty($timeBlockEnd)) { + $t1 = strtotime("2000-01-01 {$timeBlockStart}"); + $t2 = strtotime("2000-01-01 {$timeBlockEnd}"); + if ($t1 !== false && $t2 !== false && $t2 > $t1) { + $durationMinutes = (int)(($t2 - $t1) / 60); + } + } + + if ($durationMinutes <= 0) { + $durationMinutes = (int)($existingTask['duration_minutes'] ?: 30); + } + + if (!empty($timeBlockStart) && empty($timeBlockEnd)) { + try { + $dtEnd = new DateTime("2000-01-01 {$timeBlockStart}:00", new DateTimeZone('Europe/Amsterdam')); + $dtEnd->modify("+{$durationMinutes} minutes"); + $timeBlockEnd = $dtEnd->format('H:i'); + } catch (Throwable $e) {} + } + + // Indien gekoppeld aan Outlook, werk afspraak bij via Graph API + if (!empty($existingTask['external_id']) && $existingTask['source'] === 'OUTLOOK') { + try { + $stmtPlan = $db->prepare("SELECT plan_date FROM daily_plans WHERE id = ?"); + $stmtPlan->execute([$existingTask['daily_plan_id']]); + $planDate = $stmtPlan->fetchColumn() ?: date('Y-m-d'); + + microsoft_update_event($user['id'], $existingTask['external_id'], [ + 'subject' => $title, + 'date' => $planDate, + 'start_time' => $timeBlockStart ?: '09:00', + 'end_time' => $timeBlockEnd ?: '09:30', + ]); + } catch (Throwable $t) { + error_log("Fout bij bijwerken Outlook event: " . $t->getMessage()); + } + } + + $stmtUpdate = $db->prepare(" + UPDATE tasks + SET title = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ? + WHERE id = ? AND user_id = ? + "); + $stmtUpdate->execute([$title, $priority, $timeBlockStart, $timeBlockEnd, $durationMinutes, $taskId, $user['id']]); + + echo json_encode([ + 'success' => true, + 'time_block_start' => $timeBlockStart, + 'time_block_end' => $timeBlockEnd, + 'duration_minutes' => $durationMinutes + ]); + exit; + } + + // 3. Bestaande taak handmatig naar Microsoft Outlook synchroniseren + if ($action === 'sync_task_to_outlook') { + $data = json_decode(file_get_contents('php://input'), true); + $taskId = (int)($data['task_id'] ?? 0); + + if (!$taskId) { + echo json_encode(['error' => 'Geen taak-ID opgegeven']); + exit; + } + + // Haal taak inclusief plan datum op + $stmtTask = $db->prepare(" + SELECT t.*, dp.plan_date + FROM tasks t + LEFT JOIN daily_plans dp ON t.daily_plan_id = dp.id + WHERE t.id = ? AND t.user_id = ? + "); + $stmtTask->execute([$taskId, $user['id']]); + $task = $stmtTask->fetch(); + + if (!$task) { + echo json_encode(['error' => 'Taak niet gevonden']); + exit; + } + + // Als al gesynchroniseerd + if (!empty($task['external_id']) && $task['source'] === 'OUTLOOK') { + echo json_encode([ + 'success' => true, + 'already_synced' => true, + 'external_id' => $task['external_id'], + 'external_url' => $task['external_url'] + ]); + exit; + } + + $planDate = $task['plan_date'] ?: $today; + $startTime = !empty($task['time_block_start']) ? $task['time_block_start'] : ($data['time_block_start'] ?? '09:00'); + $endTime = !empty($task['time_block_end']) ? $task['time_block_end'] : null; + $duration = (int)($task['duration_minutes'] ?: 30); + + $msResult = microsoft_create_event($user['id'], [ + 'subject' => $task['title'], + 'description' => "Gepland via Fociz Dagplanning (" . $task['priority'] . ")", + 'date' => $planDate, + 'start_time' => $startTime, + 'end_time' => $endTime, + 'duration_minutes' => $duration, + ]); + + if (empty($msResult['success']) || empty($msResult['event_id'])) { + echo json_encode(['error' => $msResult['error'] ?? 'Kon afspraak niet aanmaken in Microsoft Outlook']); + exit; + } + + // Sla Outlook ID en bron op in de taak + $stmtUp = $db->prepare(" + UPDATE tasks + SET source = 'OUTLOOK', + external_id = ?, + external_url = ?, + time_block_start = COALESCE(time_block_start, ?), + time_block_end = ? + WHERE id = ? AND user_id = ? + "); + $stmtUp->execute([ + $msResult['event_id'], + $msResult['web_link'] ?? 'https://outlook.office.com', + $msResult['start_time'], + $msResult['end_time'], + $taskId, + $user['id'] + ]); + + echo json_encode([ + 'success' => true, + 'external_id' => $msResult['event_id'], + 'external_url' => $msResult['web_link'] ?? 'https://outlook.office.com', + 'start_time' => $msResult['start_time'], + 'end_time' => $msResult['end_time'], + ]); + exit; + } + + // 4. Taak status toggelen (voltooid / open) + if ($action === 'toggle_task') { + $data = json_decode(file_get_contents('php://input'), true); + $taskId = (int)($data['task_id'] ?? 0); + $isCompleted = !empty($data['is_completed']) ? 1 : 0; + $status = $isCompleted ? 'DONE' : 'TODO'; + + $stmt = $db->prepare(" + UPDATE tasks SET is_completed = ?, status = ? + WHERE id = ? AND user_id = ? + "); + $stmt->execute([$isCompleted, $status, $taskId, $user['id']]); + + echo json_encode(['success' => true]); + exit; + } + + // 5. Taak verwijderen (verwijder ook eventueel gekoppelde Outlook afspraak) + if ($action === 'delete_task') { + $data = json_decode(file_get_contents('php://input'), true); + $taskId = (int)($data['task_id'] ?? 0); + + // Haal eerst op of deze taak aan Outlook was gekoppeld + $stmtCheck = $db->prepare("SELECT external_id, source FROM tasks WHERE id = ? AND user_id = ?"); + $stmtCheck->execute([$taskId, $user['id']]); + $taskToDelete = $stmtCheck->fetch(); + + if ($taskToDelete && !empty($taskToDelete['external_id']) && $taskToDelete['source'] === 'OUTLOOK') { + try { + microsoft_delete_event($user['id'], $taskToDelete['external_id']); + } catch (Throwable $t) { + error_log("Fout bij verwijderen Outlook afspraak voor taak {$taskId}: " . $t->getMessage()); + } + } + + $stmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?"); + $stmt->execute([$taskId, $user['id']]); + + echo json_encode(['success' => true]); + exit; + } + + // 6. Realtime Outlook synchronisatie & controle op gewijzigde/gewiste items + if ($action === 'check_outlook_sync') { + $rawInput = file_get_contents('php://input'); + $data = !empty($rawInput) ? (json_decode($rawInput, true) ?: []) : []; + if (empty($data)) { + $data = $_GET; + } + + $planDate = !empty($data['plan_date']) ? trim($data['plan_date']) : $today; + $weekStart = !empty($data['week_start']) ? trim($data['week_start']) : date('Y-m-d', strtotime('monday this week', strtotime($planDate))); + $weekEnd = !empty($data['week_end']) ? trim($data['week_end']) : date('Y-m-d', strtotime('sunday this week', strtotime($planDate))); + $lastHash = !empty($data['last_hash']) ? trim($data['last_hash']) : ''; + + // Controleer of Outlook gekoppeld is + $stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1"); + $stmtInt->execute([$user['id']]); + $integration = $stmtInt->fetch(); + + if (!$integration || empty($integration['access_token']) || !empty($integration['is_demo_mode'])) { + echo json_encode([ + 'success' => true, + 'connected' => false, + 'has_changes' => false, + 'last_sync' => date('H:i:s'), + ]); + exit; + } + + // Haal live afspraken op voor de hele week + $weekLiveEvents = microsoft_fetch_events_range($user['id'], $weekStart, $weekEnd); + + // Voer reconciliatie uit met de Fociz tasks tabel + $reconcileRes = microsoft_reconcile_tasks_with_outlook($user['id'], $weekStart, $weekEnd, $weekLiveEvents); + + // Haal de geactualiseerde taken voor deze dag op + $stmtPlan = $db->prepare("SELECT id FROM daily_plans WHERE user_id = ? AND plan_date = ?"); + $stmtPlan->execute([$user['id'], $planDate]); + $planId = $stmtPlan->fetchColumn(); + + $tasks = []; + if ($planId) { + $stmtTasks = $db->prepare(" + SELECT id, title, priority, time_block_start, time_block_end, duration_minutes, is_completed, status, source, external_id + FROM tasks + WHERE user_id = ? AND (daily_plan_id = ? " . ($planDate === $today ? "OR daily_plan_id IS NULL" : "") . ") + ORDER BY is_completed ASC, priority ASC, time_block_start ASC, created_at DESC + "); + $stmtTasks->execute([$user['id'], $planId]); + $tasks = $stmtTasks->fetchAll(); + } + + // Filter events voor de geselecteerde dag die geen gesynchroniseerde Fociz taak zijn + $syncedIds = []; + foreach ($tasks as $t) { + if (!empty($t['external_id'])) { + $syncedIds[$t['external_id']] = true; + } + } + + $dayEvents = []; + foreach ($weekLiveEvents as $e) { + if (($e['date'] ?? '') === $planDate) { + if (!empty($e['id']) && isset($syncedIds[$e['id']])) { + continue; + } + $dayEvents[] = [ + 'id' => $e['id'], + 'start' => $e['start'], + 'end' => $e['end'], + 'subject' => $e['subject'], + ]; + } + } + + // Bereken status hash voor de dag + $hashData = [ + 'events' => $dayEvents, + 'tasks' => array_map(function($t) { + return [ + 'id' => $t['id'], + 'title' => $t['title'], + 'time_block_start' => $t['time_block_start'], + 'time_block_end' => $t['time_block_end'], + 'is_completed' => $t['is_completed'], + ]; + }, $tasks) + ]; + $currentHash = md5(json_encode($hashData)); + + $hasChanges = ($reconcileRes['deleted'] > 0 || $reconcileRes['updated'] > 0); + if (!empty($lastHash) && $lastHash !== $currentHash) { + $hasChanges = true; + } + + echo json_encode([ + 'success' => true, + 'connected' => true, + 'has_changes' => $hasChanges, + 'hash' => $currentHash, + 'deleted_count' => $reconcileRes['deleted'], + 'updated_count' => $reconcileRes['updated'], + 'events_count' => count($dayEvents), + 'tasks_count' => count($tasks), + 'last_sync' => date('H:i:s'), + ]); + exit; + } + + echo json_encode(['error' => 'Onbekende actie']); +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + diff --git a/api/roadmap.php b/api/roadmap.php new file mode 100644 index 0000000..d617236 --- /dev/null +++ b/api/roadmap.php @@ -0,0 +1,93 @@ + 'Titel is verplicht']); + exit; + } + + $stmt = $db->prepare(" + INSERT INTO roadmap_items (title, description, quarter, type, priority, status, progress, due_date, jira_issue_key, created_by_id) + VALUES (?, ?, ?, ?, ?, 'PLANNED', 0, ?, ?, ?) + "); + $stmt->execute([$title, $description, $quarter, $type, $priority, $dueDate, $jiraKey, $user['id']]); + + echo json_encode(['success' => true]); + exit; + } + + // 2. Status of voortgang bijwerken + if ($action === 'update_item') { + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + $status = $data['status'] ?? null; + $progress = isset($data['progress']) ? (int)$data['progress'] : null; + $jiraKey = isset($data['jira_key']) ? trim($data['jira_key']) : null; + + $fields = []; + $params = []; + + if ($status !== null) { + $fields[] = "status = ?"; + $params[] = $status; + } + if ($progress !== null) { + $fields[] = "progress = ?"; + $params[] = max(0, min(100, $progress)); + } + if ($jiraKey !== null) { + $fields[] = "jira_issue_key = ?"; + $params[] = $jiraKey; + } + + if (!empty($fields)) { + $params[] = $id; + $sql = "UPDATE roadmap_items SET " . implode(", ", $fields) . " WHERE id = ?"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + } + + echo json_encode(['success' => true]); + exit; + } + + // 3. Item verwijderen + if ($action === 'delete_item') { + $data = json_decode(file_get_contents('php://input'), true); + $id = (int)($data['id'] ?? 0); + + $stmt = $db->prepare("DELETE FROM roadmap_items WHERE id = ?"); + $stmt->execute([$id]); + + echo json_encode(['success' => true]); + exit; + } + + echo json_encode(['error' => 'Onbekende actie']); +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + diff --git a/api/theme.php b/api/theme.php new file mode 100644 index 0000000..68bcb5b --- /dev/null +++ b/api/theme.php @@ -0,0 +1,48 @@ + true, + 'theme' => $currentTheme, + ]); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $raw = file_get_contents('php://input'); + $data = json_decode($raw, true); + + $theme = $data['theme'] ?? ($_POST['theme'] ?? 'dark'); + if (!in_array($theme, ['dark', 'light', 'system'])) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Ongeldig thema. Kies dark, light of system.']); + exit; + } + + $ok = update_user_theme($userId, $theme); + if ($ok) { + echo json_encode([ + 'success' => true, + 'theme' => $theme, + 'message' => 'Themavoorkeur succesvol opgeslagen.', + ]); + } else { + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Kon thema niet opslaan in database.']); + } + exit; +} + +http_response_code(405); +echo json_encode(['success' => false, 'error' => 'Method not allowed']); + diff --git a/assets/js/notifications.js b/assets/js/notifications.js new file mode 100644 index 0000000..cb472a4 --- /dev/null +++ b/assets/js/notifications.js @@ -0,0 +1,418 @@ +/** + * 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/auth/callback_microsoft.php b/auth/callback_microsoft.php new file mode 100644 index 0000000..c1f00bd --- /dev/null +++ b/auth/callback_microsoft.php @@ -0,0 +1,142 @@ +prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?"); + $stmtUser->execute([$stateUserId]); + $recoveredUser = $stmtUser->fetch(); + if ($recoveredUser) { + $_SESSION['user_id'] = $recoveredUser['id']; + $_SESSION['user_name'] = $recoveredUser['name']; + $_SESSION['user_email'] = $recoveredUser['email']; + $_SESSION['user_role'] = $recoveredUser['role']; + $_SESSION['is_2fa_verified'] = true; + issue_remember_token((int)$recoveredUser['id']); + } + } catch (Throwable $e) { + // Foutafhandeling + } +} + +// 2. Controleer authenticatie +$user = get_logged_in_user(); +if (!$user) { + header('Location: /auth/login.php?error=' . urlencode('Sessie verlopen tijdens Microsoft koppeling. Log opnieuw in en probeer het nogmaals.')); + exit; +} + +$db = get_db_connection(); + +// 3. Foutafhandeling vanuit Microsoft (bijv. gebruiker weigerde machtiging) +if (!empty($_GET['error'])) { + $errorDescription = $_GET['error_description'] ?? $_GET['error']; + header('Location: /profile.php?error=' . urlencode("Microsoft melding: {$errorDescription}")); + exit; +} + +// 4. Valideer de state (via HMAC handtekening of fallback sessiestate) +$expectedState = $_SESSION['ms_oauth_state'] ?? ''; +unset($_SESSION['ms_oauth_state'], $_SESSION['ms_oauth_user_id']); + +if (!$stateUserId && (empty($receivedState) || empty($expectedState) || !hash_equals($expectedState, $receivedState))) { + header('Location: /profile.php?error=' . urlencode('Ongeldige sessie of CSRF-controle mislukt. Probeer opnieuw in te loggen via Microsoft.')); + exit; +} + +// Afhandeling van beheerderstoestemming (Admin Consent) +if (!empty($_GET['admin_consent'])) { + header('Location: /admin.php?success=' . urlencode('Beheerdersgoedkeuring voor uw organisatie is succesvol verleend! U en uw collega\'s kunnen nu direct hun Outlook agenda koppelen via Mijn Profiel.')); + exit; +} + +// 5. Autorisatiecode controleren +$code = $_GET['code'] ?? ''; +if (empty($code)) { + header('Location: /profile.php?error=' . urlencode('Geen autorisatiecode ontvangen van Microsoft.')); + exit; +} + +// 4. Centrale instellingen ophalen +$appConfig = microsoft_get_app_config(); + +$clientId = $appConfig['client_id']; +$clientSecret = $appConfig['client_secret']; +$tenantId = (!empty($appConfig['tenant_id']) && $appConfig['tenant_id'] !== 'common') ? $appConfig['tenant_id'] : 'planningsagenda.nl'; + +if (empty($clientId) || empty($clientSecret)) { + header('Location: /profile.php?error=' . urlencode('Microsoft 365 configuratie (Client ID of Secret) ontbreekt in Fociz. Neem contact op met de beheerder.')); + exit; +} + +$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php'; + +// 5. Code omwisselen voor access & refresh tokens +$tokenResult = microsoft_exchange_code($code, $clientId, $clientSecret, $redirectUri, $tenantId); + +if (!$tokenResult['success']) { + header('Location: /profile.php?error=' . urlencode('Token uitwisseling mislukt: ' . $tokenResult['error'])); + exit; +} + +$accessToken = $tokenResult['access_token']; +$refreshToken = $tokenResult['refresh_token']; +$expiresIn = $tokenResult['expires_in']; +$expiresAt = time() + $expiresIn; + +// 6. Microsoft gebruikersprofiel ophalen +$profile = microsoft_get_user_profile($accessToken); +$accountEmail = $profile['mail'] ?? ($profile['userPrincipalName'] ?? $user['email']); +$accountName = $profile['displayName'] ?? 'Microsoft 365 Account'; + +// 7. Opslaan in MariaDB +$stmtExisting = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'"); +$stmtExisting->execute([$user['id']]); +$existingRow = $stmtExisting->fetch(); +$existingSettings = json_decode($existingRow['settings'] ?? '{}', true) ?: []; + +$newSettings = array_merge($existingSettings, [ + 'expires_at' => $expiresAt, + 'account_email' => $accountEmail, + 'account_name' => $accountName, +]); + +// Als de ingelogde gebruiker Beheerder (ADMIN) is, bewaren we ook de centrale app credentials +if ($user['role'] === 'ADMIN') { + $newSettings['client_id'] = $clientId; + $newSettings['client_secret'] = $clientSecret; + $newSettings['tenant_id'] = $tenantId; +} + +$saveStmt = $db->prepare(" + INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, access_token, refresh_token, settings, last_sync_at) + VALUES (?, 'OUTLOOK', 1, 0, ?, ?, ?, NOW()) + ON DUPLICATE KEY UPDATE + is_enabled = 1, + is_demo_mode = 0, + access_token = VALUES(access_token), + refresh_token = VALUES(refresh_token), + settings = VALUES(settings), + last_sync_at = NOW() +"); +$saveStmt->execute([$user['id'], $accessToken, $refreshToken, json_encode($newSettings)]); + +// 8. Succesvol terugkeren naar de Persoonlijke Profiel Hub +header('Location: /profile.php?success=' . urlencode("Microsoft Outlook succesvol gekoppeld als {$accountEmail}!")); +exit; + diff --git a/auth/invite.php b/auth/invite.php new file mode 100644 index 0000000..1e4757c --- /dev/null +++ b/auth/invite.php @@ -0,0 +1,217 @@ + + + + +
+

Ongeldige Uitnodiging

+

Deze uitnodigingslink is ongeldig, verlopen of al eerder gebruikt.

+ Naar Inloggen +
+ + + "); +} + +$error = null; +if (empty($_SESSION['invite_totp_secret'])) { + $_SESSION['invite_totp_secret'] = TOTP::generateSecret(16); +} +$secret = $_SESSION['invite_totp_secret']; + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $name = trim($_POST['name'] ?? ''); + $password = $_POST['password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + $totpCode = trim($_POST['totp_code'] ?? ''); + + if (empty($name) || empty($password) || empty($totpCode)) { + $error = "Alle velden (inclusief 2FA verificatiecode) zijn verplicht."; + } elseif (strlen($password) < 8) { + $error = "Het wachtwoord moet minimaal 8 tekens lang zijn."; + } elseif ($password !== $confirmPassword) { + $error = "Wachtwoorden komen niet overeen."; + } elseif (!TOTP::verifyCode($secret, $totpCode)) { + $error = "Ongeldige 2FA verificatiecode. Controleer de tijd op uw smartphone."; + } else { + try { + $db = get_db_connection(); + $passwordHash = password_hash($password, PASSWORD_DEFAULT); + $backupCodes = TOTP::generateBackupCodes(8); + + $db->beginTransaction(); + + // 1. Maak gebruiker aan + $stmt = $db->prepare(" + INSERT INTO users (name, email, password_hash, role, two_factor_secret, two_factor_enabled, backup_codes) + VALUES (?, ?, ?, ?, ?, 1, ?) + "); + $stmt->execute([ + $name, + $invite['email'], + $passwordHash, + $invite['role'], + $secret, + json_encode($backupCodes) + ]); + + $newUserId = (int)$db->lastInsertId(); + + // 2. Markeer invite als gebruikt + $stmtInvite = $db->prepare("UPDATE invites SET used_at = NOW() WHERE id = ?"); + $stmtInvite->execute([$invite['id']]); + + $db->commit(); + + // Start sessie + $_SESSION['user_id'] = $newUserId; + $_SESSION['user_name'] = $name; + $_SESSION['user_email'] = $invite['email']; + $_SESSION['user_role'] = $invite['role']; + $_SESSION['is_2fa_verified'] = true; + issue_remember_token($newUserId); + unset($_SESSION['invite_totp_secret']); + + header('Location: /planner.php'); + exit; + } catch (Exception $e) { + if ($db->inTransaction()) $db->rollBack(); + $error = "Fout bij registreren: " . $e->getMessage(); + } + } +} + +$otpAuthUrl = TOTP::getOtpAuthUrl($invite['email'], $secret, 'Fociz'); +$qrCodeUrl = TOTP::getQrCodeUrl($otpAuthUrl, 180); +$pageTitle = "Fociz — Uitnodiging Accepteren"; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + + + + +
+
+
+ +
+

Welkom bij Fociz

+

+ Uitgenodigd door voor +

+
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+

+ Verplichte Tweestapsverificatie (2FA) +

+

+ Scan deze QR-code in uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password): +

+ +
+ 2FA QR Code +
+ +
+ Sleutel: +
+ +
+ + +
+
+ + +
+
+ + + + + diff --git a/auth/login.php b/auth/login.php new file mode 100644 index 0000000..169d050 --- /dev/null +++ b/auth/login.php @@ -0,0 +1,256 @@ +prepare("SELECT id, name, email, password_hash, two_factor_secret, two_factor_enabled FROM users WHERE email = ?"); + $stmt->execute([$email]); + $user = $stmt->fetch(); + + if ($user && password_verify($password, $user['password_hash'])) { + // Succesvol stap 1: zet tijdelijke pending sessie voor 2FA + $_SESSION['pending_2fa_user_id'] = $user['id']; + $_SESSION['pending_2fa_user_email'] = $user['email']; + $_SESSION['pending_2fa_user_name'] = $user['name']; + $step = 2; + } else { + $error = "Onjuist e-mailadres of wachtwoord."; + } + } + } + // STAP 2: 2FA TOTP Code verificatie + elseif ($step === 2) { + $userId = $_SESSION['pending_2fa_user_id'] ?? null; + $totpCode = trim($_POST['totp_code'] ?? ''); + + if (!$userId) { + header('Location: /auth/login.php'); + exit; + } + + if (empty($totpCode)) { + $error = "Vul de 6-cijferige 2FA code in uit uw authenticator app."; + } else { + $stmt = $db->prepare("SELECT * FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $user = $stmt->fetch(); + + $isValid = false; + + // Controleer reguliere TOTP code + if (!empty($user['two_factor_secret'])) { + $isValid = verify_totp($user['two_factor_secret'], $totpCode); + } + + // Fallback: controleer 8-karakter backup codes + if (!$isValid && !empty($user['backup_codes'])) { + $backupCodes = json_decode($user['backup_codes'], true) ?: []; + $codeUpper = strtoupper($totpCode); + $key = array_search($codeUpper, $backupCodes); + if ($key !== false) { + $isValid = true; + // Verbruik de backup code + unset($backupCodes[$key]); + $stmtUpdate = $db->prepare("UPDATE users SET backup_codes = ? WHERE id = ?"); + $stmtUpdate->execute([json_encode(array_values($backupCodes)), $userId]); + } + } + + if ($isValid) { + // Volledige login succesvol + $_SESSION['user_id'] = $user['id']; + $_SESSION['user_name'] = $user['name']; + $_SESSION['user_email'] = $user['email']; + $_SESSION['user_role'] = $user['role']; + $_SESSION['is_2fa_verified'] = true; + + // Activeer persistente remember token (30 dagen blijf ingelogd) + issue_remember_token((int)$user['id']); + + unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']); + + header('Location: /planner.php'); + exit; + } else { + $error = "Onjuiste 2FA code. Controleer uw authenticator app en probeer opnieuw."; + } + } + } +} + +$pageTitle = "Fociz — Inloggen"; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + + + + +
+ +
+
+ +
+

Fociz

+

+ Veilig inloggen met tweestapsverificatie (2FA) +

+
+ + +
+ + +
+ + + + +
+
+ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ + + + +
+
+
+ +
+

+ Voer uw 6-cijferige verificatiecode in +

+

+ Gekoppeld aan +

+
+ +
+ + + Tip: U kunt ook een 8-cijferige herstelcode invoeren. + +
+ +
+ + Terug + + +
+
+ + + +
+ Toegang tot Fociz is strikt op uitnodiging. Vraag uw beheerder om een uitnodigingslink. +
+
+ + + + diff --git a/auth/logout.php b/auth/logout.php new file mode 100644 index 0000000..c1ef092 --- /dev/null +++ b/auth/logout.php @@ -0,0 +1,44 @@ +prepare("UPDATE users SET remember_token = NULL, remember_token_expires_at = NULL WHERE id = ?"); + $stmt->execute([$_SESSION['user_id']]); + } catch (Throwable $e) { + // Stil negeren bij uitloggen + } +} + +// Verwijder remember cookie +clear_remember_cookie(); + +// Vernietig alle sessie variabelen +$_SESSION = []; + +if (ini_get("session.use_cookies")) { + $params = session_get_cookie_params(); + setcookie( + session_name(), + '', + time() - 42000, + $params["path"], + $params["domain"], + $params["secure"], + $params["httponly"] + ); +} + +session_destroy(); + +header('Location: /auth/login.php'); +exit; + diff --git a/auth/oauth_microsoft.php b/auth/oauth_microsoft.php new file mode 100644 index 0000000..2257126 --- /dev/null +++ b/auth/oauth_microsoft.php @@ -0,0 +1,50 @@ +prepare(" + INSERT INTO users (name, email, password_hash, role, two_factor_secret, two_factor_enabled, backup_codes) + VALUES (?, ?, ?, 'ADMIN', ?, 1, ?) + "); + $stmt->execute([ + $name, + $email, + $passwordHash, + $secret, + json_encode($backupCodes) + ]); + + $adminId = (int)$db->lastInsertId(); + + // Automatische sessie starten + $_SESSION['user_id'] = $adminId; + $_SESSION['user_name'] = $name; + $_SESSION['user_email'] = $email; + $_SESSION['user_role'] = 'ADMIN'; + $_SESSION['is_2fa_verified'] = true; + issue_remember_token($adminId); + unset($_SESSION['setup_totp_secret']); + + $success = true; + } catch (Exception $e) { + $error = "Fout bij aanmaken beheerder in MariaDB: " . $e->getMessage(); + } + } +} + +$otpAuthUrl = TOTP::getOtpAuthUrl(!empty($email) ? $email : 'admin@fociz.local', $secret, 'Fociz'); +$qrCodeUrl = TOTP::getQrCodeUrl($otpAuthUrl, 200); +$pageTitle = "Fociz — Eerste Initialisatie"; +?> + + + + + + <?= htmlspecialchars($pageTitle) ?> + + + + + + + + + + + + + + +
+ +
+
+ +
+

Fociz Initialisatie

+

+ Eenmalige configuratie van het hoofdbeheerdersaccount met verplichte 2FA +

+
+ + +
+ + +
+ + + + +
+
+ +
+
+

Beheerder Geïnstalleerd!

+

+ Uw account is aangemaakt en direct beveiligd met 2FA. Bewaar deze eenmalige herstelcodes veilig: +

+
+ +
+
HERSTELCODES (NOODGEBRUIK):
+
+ + + +
+
+ + + Start met Fociz Dagplanning + + +
+ + + +
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+

+ + Tweestapsverificatie (2FA) Koppelen +

+

+ Scan onderstaande QR-code met uw authenticator app (Google Authenticator, Microsoft Authenticator of 1Password): +

+ +
+ 2FA QR Code +
+ +
+ Sleutel: +
+ +
+ + +
+
+ + +
+ + +
+ + + + + diff --git a/brainstorm.php b/brainstorm.php new file mode 100644 index 0000000..bfc7496 --- /dev/null +++ b/brainstorm.php @@ -0,0 +1,172 @@ +prepare(" + INSERT INTO brainstorm_sessions (title, theme, description, created_by_id) + VALUES (?, ?, ?, ?) + "); + $stmt->execute([$title, $theme, $description, $currentUser['id']]); + $newId = $db->lastInsertId(); + header("Location: /brainstorm_canvas.php?id={$newId}"); + exit; + } else { + $error = "Titel van de brainstormsessie is verplicht."; + } +} + +// Haal alle sessies op +$stmtSessions = $db->query(" + SELECT s.*, u.name as creator_name, + (SELECT COUNT(*) FROM brainstorm_cards WHERE session_id = s.id) as cards_count + FROM brainstorm_sessions s + JOIN users u ON s.created_by_id = u.id + ORDER BY s.created_at DESC +"); +$sessions = $stmtSessions->fetchAll(); +require_once __DIR__ . '/includes/header.php'; +?> + +
+ +
+
+
+ + Creatie & Innovatie +
+

+ Brainstorm Sessies +

+

+ Brainstorm met collega's, verzamel ideeën en knelpunten, en transformeer ze direct tot een werkbare roadmap. +

+
+ + +
+ + +
+ +
+ + + +
+ +
+ +

Nog geen brainstormsessies gestart

+

+ Start uw eerste brainstormsessie om ideeën te verzamelen en ze met één klik om te vormen naar planningsitems. +

+ +
+ + + +
+
+ + + + + + kaarten + +
+ +

+ +

+ + +

+ +

+ +
+ +
+ Door +
+ Open canvas + +
+
+
+ + +
+
+ + + + + diff --git a/brainstorm_canvas.php b/brainstorm_canvas.php new file mode 100644 index 0000000..34d729f --- /dev/null +++ b/brainstorm_canvas.php @@ -0,0 +1,560 @@ +prepare(" + SELECT s.*, u.name as creator_name + FROM brainstorm_sessions s + JOIN users u ON s.created_by_id = u.id + WHERE s.id = ? +"); +$stmtSession->execute([$sessionId]); +$session = $stmtSession->fetch(); + +if (!$session) { + echo "
Brainstormsessie niet gevonden. Terug
"; + require_once __DIR__ . '/includes/footer.php'; + exit; +} + +// Haal alle kaarten op van deze sessie +$stmtCards = $db->prepare(" + SELECT * FROM brainstorm_cards + WHERE session_id = ? + ORDER BY votes DESC, created_at ASC +"); +$stmtCards->execute([$sessionId]); +$cards = $stmtCards->fetchAll(); + +$categories = [ + 'Ideeën' => [ + 'title' => 'Ideeën & Concepten', + 'icon' => 'lightbulb', + 'color' => 'amber', + 'border' => 'border-amber-200/80 dark:border-amber-800/40', + 'bg' => 'bg-amber-50/60 dark:bg-amber-950/20', + 'badge' => 'bg-amber-500/15 text-amber-800 dark:text-amber-300' + ], + 'Knelpunten' => [ + 'title' => 'Knelpunten & Uitdagingen', + 'icon' => 'alert-triangle', + 'color' => 'rose', + 'border' => 'border-rose-200/80 dark:border-rose-800/40', + 'bg' => 'bg-rose-50/60 dark:bg-rose-950/20', + 'badge' => 'bg-rose-500/15 text-rose-800 dark:text-rose-300' + ], + 'Oplossingen' => [ + 'title' => 'Oplossingen & Features', + 'icon' => 'rocket', + 'color' => 'teal', + 'border' => 'border-teal-200/80 dark:border-teal-800/40', + 'bg' => 'bg-teal-50/60 dark:bg-teal-950/20', + 'badge' => 'bg-teal-500/15 text-teal-800 dark:text-teal-300' + ], + 'Quick Wins' => [ + 'title' => 'Quick Wins (Direct)', + 'icon' => 'zap', + 'color' => 'indigo', + 'border' => 'border-indigo-200/80 dark:border-indigo-800/40', + 'bg' => 'bg-indigo-50/60 dark:bg-indigo-950/20', + 'badge' => 'bg-indigo-500/15 text-indigo-800 dark:text-indigo-300' + ], +]; +?> + +
+ +
+
+ + + +
+
+ + + + Gestart door +
+

+ +

+
+
+ +
+ + + + + + + + + + +
+
+ + +
+ $catConfig): + $catCards = array_filter($cards, fn($c) => $c['category'] === $catKey); + ?> +
+ +
+
+ +

+
+ + + +
+ + +
+ +
+ Nog geen kaarten in deze categorie. +
+ + +
+ + +
+ + + + + + Op Roadmap + + +
+ + +

+ +

+ + +
+ + +
+ + + + + +
+
+
+ + +
+ + + +
+ +
+
+ + + + + + + + + + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..92284f3 --- /dev/null +++ b/composer.json @@ -0,0 +1,9 @@ +{ + "require": { + "php": "^8.2", + "ext-pdo": "*", + "ext-pdo_mysql": "*", + "ext-mysqli": "*" + } +} + diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..7009f35 --- /dev/null +++ b/config/database.php @@ -0,0 +1,147 @@ + +
+

⚠️ PHP MySQL Driver Ontbreekt (pdo_mysql)

+

De extensie pdo_mysql is niet actief in deze PHP-omgeving.

+
+ + "); + } + + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + PDO::ATTR_TIMEOUT => 5, + ]; + if (defined('PDO::MYSQL_ATTR_MULTI_STATEMENTS')) { + $options[PDO::MYSQL_ATTR_MULTI_STATEMENTS] = true; + } + + $dsnWithDb = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + DB_HOST, + DB_PORT, + DB_NAME, + DB_CHARSET + ); + + // 2. CHECK: Bestaat de database al? + // We proberen eerst direct verbinding te maken met de database. + // Als de database al bestaat, lukt dit direct zonder database-aanmaakrechten te vereisen! + try { + $pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options); + + // De database bestaat al! Controleer enkel of de tabellen al aanwezig zijn + ensure_tables_exist($pdo); + return $pdo; + + } catch (PDOException $e) { + // Foutcode 1049 betekent: Database 'DB_NAME' bestaat nog NIET op de MariaDB server + if ($e->getCode() == 1049 || str_contains($e->getMessage(), 'Unknown database')) { + // Probeer de database eenmalig aan te maken indien de gebruiker CREATE rechten heeft + try { + $serverDsn = sprintf('mysql:host=%s;port=%s;charset=%s', DB_HOST, DB_PORT, DB_CHARSET); + $serverPdo = new PDO($serverDsn, DB_USER, DB_PASS, $options); + $serverPdo->exec(sprintf( + 'CREATE DATABASE IF NOT EXISTS `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci', + DB_NAME + )); + + // Nu opnieuw verbinden met de nieuw aangemaakte database + $pdo = new PDO($dsnWithDb, DB_USER, DB_PASS, $options); + ensure_tables_exist($pdo); + return $pdo; + + } catch (PDOException $createEx) { + die(" +
+
+

⚠️ Database '" . htmlspecialchars(DB_NAME) . "' bestaat nog niet

+

+ De database " . htmlspecialchars(DB_NAME) . " bestaat nog niet op MariaDB server " . htmlspecialchars(DB_HOST) . " en de gebruiker " . htmlspecialchars(DB_USER) . " heeft geen rechten om automatisch databases aan te maken. +

+

+ Maak de database " . htmlspecialchars(DB_NAME) . " eenmalig aan via phpMyAdmin, HeidiSQL of de MariaDB CLI: +

+ + CREATE DATABASE IF NOT EXISTS `" . htmlspecialchars(DB_NAME) . "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +
+
+ "); + } + } + + // Andere verbindingsfout (bijv. verkeerd wachtwoord, poort of host onbereikbaar) + die(" +
+
+

MariaDB Verbindingsfout (" . htmlspecialchars((string)$e->getCode()) . ")

+

" . htmlspecialchars($e->getMessage()) . "

+
+ Host: " . htmlspecialchars(DB_HOST) . "
+ Poort: " . htmlspecialchars(DB_PORT) . "
+ Database: " . htmlspecialchars(DB_NAME) . "
+ Gebruiker: " . htmlspecialchars(DB_USER) . " +
+
+
+ "); + } +} + +/** + * Zorgt ervoor dat alle benodigde tabellen uit schema.sql aanwezig zijn in MariaDB + * Voert niets uit als de tabellen al bestaan! + */ +function ensure_tables_exist(PDO $pdo): void { + try { + // Controleer of de hoofdtabel 'users' al bestaat + $result = $pdo->query("SHOW TABLES LIKE 'users'"); + if ($result && $result->rowCount() > 0) { + // Tabellen bestaan al. Voer eventuele niet-destructieve kolom-upgrades uit indien nodig + try { + $pdo->exec("ALTER TABLE tasks MODIFY external_id VARCHAR(255) NULL"); + } catch (Throwable $t) { + // Kolom bestaat wellicht al of heeft al de juiste lengte + } + return; + } + + // Tabellen ontbreken nog: voer schema.sql uit statement voor statement + $schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql'); + if (file_exists($schemaFile)) { + $sql = file_get_contents($schemaFile); + $statements = array_filter(array_map('trim', explode(';', $sql))); + foreach ($statements as $stmt) { + // Verwijder eventuele SQL commentaren aan het begin + $stmtClean = preg_replace('/^--.*$/m', '', $stmt); + $stmtClean = trim($stmtClean); + if (!empty($stmtClean)) { + $pdo->exec($stmtClean); + } + } + } + } catch (Exception $e) { + error_log("Fout bij aanmaken tabellen: " . $e->getMessage()); + } +} diff --git a/config/schema.sql b/config/schema.sql new file mode 100644 index 0000000..aed8b4b --- /dev/null +++ b/config/schema.sql @@ -0,0 +1,135 @@ +-- Fociz MariaDB Databaseschema +-- UTF-8 / utf8mb4 ondersteuning voor emoticons, sticky notes en internationale tekens + +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(120) NOT NULL, + `email` VARCHAR(191) NOT NULL UNIQUE, + `password_hash` VARCHAR(255) NOT NULL, + `role` ENUM('ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER', + `two_factor_secret` VARCHAR(120) NULL, + `two_factor_enabled` TINYINT(1) NOT NULL DEFAULT 0, + `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 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `invites` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `token` VARCHAR(64) NOT NULL UNIQUE, + `email` VARCHAR(191) NOT NULL, + `role` ENUM('ADMIN', 'MEMBER') NOT NULL DEFAULT 'MEMBER', + `expires_at` DATETIME NOT NULL, + `used_at` DATETIME NULL, + `created_by_id` INT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX (`token`), + CONSTRAINT `fk_invites_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `daily_plans` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL, + `plan_date` DATE NOT NULL, + `focus_goal` TEXT NULL, + `secondary_goals` TEXT NULL, + `notes` TEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uk_user_date` (`user_id`, `plan_date`), + CONSTRAINT `fk_dailyplans_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `tasks` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL, + `daily_plan_id` INT NULL, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `status` ENUM('TODO', 'IN_PROGRESS', 'DONE', 'CANCELLED') NOT NULL DEFAULT 'TODO', + `priority` ENUM('P1', 'P2', 'P3') NOT NULL DEFAULT 'P2', + `time_block_start` VARCHAR(10) NULL, + `time_block_end` VARCHAR(10) NULL, + `duration_minutes` INT NULL DEFAULT 30, + `is_completed` TINYINT(1) NOT NULL DEFAULT 0, + `source` VARCHAR(50) NOT NULL DEFAULT 'FOCIZ', + `external_id` VARCHAR(255) NULL, + `external_url` TEXT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (`user_id`), + CONSTRAINT `fk_tasks_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_tasks_plan` FOREIGN KEY (`daily_plan_id`) REFERENCES `daily_plans` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `brainstorm_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `theme` VARCHAR(100) NOT NULL DEFAULT 'Algemeen', + `created_by_id` INT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (`created_by_id`), + CONSTRAINT `fk_brainstorm_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `brainstorm_cards` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL, + `content` TEXT NOT NULL, + `category` VARCHAR(100) NOT NULL DEFAULT 'Ideeën', + `color` VARCHAR(20) NOT NULL DEFAULT 'amber', + `votes` INT NOT NULL DEFAULT 0, + `author_name` VARCHAR(120) NULL, + `converted_to_roadmap_item_id` INT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (`session_id`), + CONSTRAINT `fk_cards_session` FOREIGN KEY (`session_id`) REFERENCES `brainstorm_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `roadmap_items` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `title` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `type` VARCHAR(50) NOT NULL DEFAULT 'FEATURE', + `status` VARCHAR(50) NOT NULL DEFAULT 'PLANNED', + `quarter` VARCHAR(20) NOT NULL DEFAULT 'Q3', + `priority` VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', + `progress` INT NOT NULL DEFAULT 0, + `start_date` DATE NULL, + `due_date` DATE NULL, + `jira_issue_key` VARCHAR(50) NULL, + `origin_brainstorm_card_id` INT NULL, + `created_by_id` INT NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX (`created_by_id`), + CONSTRAINT `fk_roadmap_user` FOREIGN KEY (`created_by_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `integrations` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL, + `provider` ENUM('OUTLOOK', 'TEAMS', 'JIRA') NOT NULL, + `is_enabled` TINYINT(1) NOT NULL DEFAULT 1, + `is_demo_mode` TINYINT(1) NOT NULL DEFAULT 1, + `access_token` TEXT NULL, + `refresh_token` TEXT NULL, + `settings` TEXT NULL, + `last_sync_at` DATETIME NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `uk_user_provider` (`user_id`, `provider`), + CONSTRAINT `fk_integrations_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `system_settings` ( + `setting_key` VARCHAR(64) PRIMARY KEY, + `setting_value` TEXT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/includes/ai.php b/includes/ai.php new file mode 100644 index 0000000..46c0cb1 --- /dev/null +++ b/includes/ai.php @@ -0,0 +1,197 @@ + 'system'|'user'|'assistant', 'content' => string]] + * @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int] + * @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string] + */ +function fociz_ai_chat(array $messages, array $options = []): array { + $aiSettings = get_ai_settings(); + if (empty($aiSettings['ai_api_key'])) { + return [ + 'success' => false, + 'error' => 'Geen AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).', + ]; + } + + $apiKey = $aiSettings['ai_api_key']; + $model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini'; + $temperature = $options['temperature'] ?? 0.3; + $maxTokens = $options['max_tokens'] ?? 1500; + $jsonMode = !empty($options['json_mode']); + + $payload = [ + 'model' => $model, + 'messages' => $messages, + 'temperature' => $temperature, + 'max_tokens' => $maxTokens, + ]; + + if ($jsonMode) { + $payload['response_format'] = ['type' => 'json_object']; + } + + $response = fociz_http_request('https://api.openai.com/v1/chat/completions', [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => "Bearer {$apiKey}", + 'Content-Type' => 'application/json', + ], + 'body' => $payload, + 'timeout' => 35, + ]); + + if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) { + $errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})"); + error_log("fociz_ai_chat fout: {$errMsg}"); + return [ + 'success' => false, + 'error' => $errMsg, + ]; + } + + $rawContent = trim($response['json']['choices'][0]['message']['content']); + $parsedJson = null; + + if ($jsonMode) { + $parsedJson = json_decode($rawContent, true); + } + + return [ + 'success' => true, + 'content' => $rawContent, + 'json' => $parsedJson, + 'model' => $response['json']['model'] ?? $model, + ]; +} + +/** + * Categoriseert sticky notes automatisch in: + * - 'Ideeën' + * - 'Knelpunten' + * - 'Oplossingen' + * - 'Quick Wins' + */ +function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array { + if (empty($cards)) { + return ['success' => true, 'categorized' => []]; + } + + $cardItems = []; + foreach ($cards as $c) { + $cardItems[] = [ + 'id' => $c['id'], + 'content' => $c['content'], + 'category' => $c['category'] ?? 'Ideeën', + ]; + } + + $systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n" + . "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n" + . "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n" + . "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n" + . "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n" + . "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n" + . "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n" + . "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'."; + + $userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE); + + $aiRes = fociz_ai_chat([ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], ['json_mode' => true]); + + if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) { + return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren']; + } + + return ['success' => true, 'categorized' => $aiRes['json']['categorized']]; +} + +/** + * Genereert nieuwe relevante brainstorm ideeën voor een sessie + */ +function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): array { + $systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n" + . "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n" + . "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\n" + . "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is."; + + $aiRes = fociz_ai_chat([ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."], + ], ['json_mode' => true]); + + if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) { + return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren']; + } + + return ['success' => true, 'ideas' => $aiRes['json']['ideas']]; +} + +/** + * Zet brainstorm kaartjes om in een scherp Roadmap Item + */ +function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = ''): array { + $systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n" + . "Retourneer een JSON-object met:\n" + . "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n" + . "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n" + . "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n" + . "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n" + . "- 'quarter': 'Q3' of 'Q4'"; + + $aiRes = fociz_ai_chat([ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"], + ], ['json_mode' => true]); + + if (!$aiRes['success'] || empty($aiRes['json']['title'])) { + return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren']; + } + + return ['success' => true, 'item' => $aiRes['json']]; +} + +/** + * Berekent een slimme dagindeling voor open taken rondom bestaande afspraken + */ +function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySettings): array { + $startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0]; + $endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:30')[0]; + $lunchStart = $workdaySettings['lunch_start'] ?? '12:30'; + $lunchEnd = $workdaySettings['lunch_end'] ?? '13:00'; + + $systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n" + . "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n" + . "Regels:\n" + . "1. Plan GEEN taken tijdens bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n" + . "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n" + . "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n" + . "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}."; + + $userPrompt = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n" + . "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE); + + $aiRes = fociz_ai_chat([ + ['role' => 'system', 'content' => $systemPrompt], + ['role' => 'user', 'content' => $userPrompt], + ], ['json_mode' => true]); + + if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) { + return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen']; + } + + return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']]; +} + diff --git a/includes/auth.php b/includes/auth.php new file mode 100644 index 0000000..92aa78e --- /dev/null +++ b/includes/auth.php @@ -0,0 +1,459 @@ + 30 * 24 * 60 * 60, // 30 dagen + 'path' => '/', + 'domain' => '', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax' + ]); + session_start(); + } +} + +/** + * Controleert of er al een beheerder bestaat in MariaDB + */ +function has_admin_user(): bool { + try { + $db = get_db_connection(); + $stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'"); + return ((int)$stmt->fetchColumn()) > 0; + } catch (Throwable $e) { + return false; + } +} + +/** + * Garandeert dat de kolommen voor persistent sessiebeheer aanwezig zijn in MariaDB + */ +function ensure_remember_token_columns(PDO $db): void { + static $columnsChecked = false; + if ($columnsChecked) return; + $columnsChecked = true; + + try { + $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'"); + if (!$result || $result->rowCount() === 0) { + $db->exec("ALTER TABLE `users` ADD COLUMN `remember_token` VARCHAR(64) NULL AFTER `backup_codes`, ADD COLUMN `remember_token_expires_at` DATETIME NULL AFTER `remember_token`"); + } + } catch (Throwable $e) { + // Stil negeren indien al aanwezig of tijdens migraties + } +} + +/** + * 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 + } +} + +/** + * Garandeert dat de kolom voor weergavethema (dark, light, system) aanwezig is in MariaDB + */ +function ensure_theme_column(PDO $db): void { + static $themeChecked = false; + if ($themeChecked) return; + $themeChecked = true; + + try { + $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'theme'"); + if (!$result || $result->rowCount() === 0) { + $db->exec("ALTER TABLE `users` ADD COLUMN `theme` VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER `notification_settings`"); + } + } catch (Throwable $e) { + // Stil negeren indien al aanwezig + } +} + +/** + * Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie + */ +function issue_remember_token(int $userId): string { + $rawToken = bin2hex(random_bytes(32)); + $tokenHash = hash('sha256', $rawToken); + $expiresAt = date('Y-m-d H:i:s', time() + (30 * 86400)); // 30 dagen geldig + + $db = get_db_connection(); + ensure_remember_token_columns($db); + + $stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ? WHERE id = ?"); + $stmt->execute([$tokenHash, $expiresAt, $userId]); + + $isSecure = is_https_request(); + setcookie('fociz_remember', $rawToken, [ + 'expires' => time() + (30 * 86400), + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax' + ]); + + return $rawToken; +} + +/** + * Verwijdert de persistente remember cookie + */ +function clear_remember_cookie(): void { + $isSecure = is_https_request(); + setcookie('fociz_remember', '', [ + 'expires' => time() - 86400, + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => 'Lax' + ]); +} + +/** + * Haalt de huidige ingelogde gebruiker op uit de database. + * Herstelt automatisch de sessie als een geldige 'fociz_remember' cookie aanwezig is. + */ +function get_logged_in_user(): ?array { + start_secure_session(); + + $db = get_db_connection(); + ensure_remember_token_columns($db); + + // 1. Controleer actieve PHP sessie + if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) { + $stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?"); + $stmt->execute([$_SESSION['user_id']]); + $user = $stmt->fetch(); + if ($user) { + return $user; + } + // Gebruiker bestaat niet meer in database + $_SESSION = []; + } + + // 2. Controleer persistente 'remember me' cookie (voorkomt ongewenst uitloggen bij refresh of herstart) + if (!empty($_COOKIE['fociz_remember'])) { + $rawToken = trim($_COOKIE['fociz_remember']); + $tokenHash = hash('sha256', $rawToken); + + $stmt = $db->prepare(" + SELECT id, name, email, role, two_factor_enabled, created_at + FROM users + WHERE remember_token = ? AND remember_token_expires_at > NOW() + "); + $stmt->execute([$tokenHash]); + $user = $stmt->fetch(); + + if ($user) { + // Herstel volledige PHP sessie + $_SESSION['user_id'] = $user['id']; + $_SESSION['user_name'] = $user['name']; + $_SESSION['user_email'] = $user['email']; + $_SESSION['user_role'] = $user['role']; + $_SESSION['is_2fa_verified'] = true; + + // Verleng direct de geldigheid van de remember token voor nog eens 30 dagen + issue_remember_token((int)$user['id']); + + return $user; + } else { + clear_remember_cookie(); + } + } + + return null; +} + +/** + * Beschermt pagina's: dwingt login + 2FA af + */ +function require_auth(bool $json = false): array { + start_secure_session(); + + $isAjax = $json || + (isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) || + (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest'); + + // 1. Controleer of eerste setup nodig is + if (!has_admin_user()) { + if ($isAjax) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Setup vereist']); + exit; + } + header('Location: /auth/setup.php'); + exit; + } + + // 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is + $user = get_logged_in_user(); + if (!$user) { + if ($isAjax) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['success' => false, 'error' => 'Niet geauthenticeerd of sessie verlopen']); + exit; + } + header('Location: /auth/login.php'); + exit; + } + + return $user; +} + +/** + * Dwingt beheerder (ADMIN) rol af + */ +function require_admin(): array { + $user = require_auth(); + if ($user['role'] !== 'ADMIN') { + header('Location: /planner.php'); + exit; + } + return $user; +} + +/** + * Maakt een nieuwe uitnodiging aan voor een collega + */ +function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string { + $db = get_db_connection(); + $token = bin2hex(random_bytes(24)); + $expiresAt = date('Y-m-d H:i:s', strtotime('+7 days')); + + $stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)"); + $stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]); + + return $token; +} + +/** + * Valideert een invite token + */ +function validate_invite(string $token): ?array { + $db = get_db_connection(); + $stmt = $db->prepare(" + SELECT i.*, u.name as inviter_name, u.email as inviter_email + FROM invites i + JOIN users u ON i.created_by_id = u.id + WHERE i.token = ? + "); + $stmt->execute([$token]); + $invite = $stmt->fetch(); + + if (!$invite) return null; + if ($invite['used_at'] !== null) return null; + if (strtotime($invite['expires_at']) < time()) return null; + + return $invite; +} + +/** + * CSRF token helpers + */ +function get_csrf_token(): string { + start_secure_session(); + if (empty($_SESSION['csrf_token'])) { + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['csrf_token']; +} + +function verify_csrf_token(?string $token): bool { + start_secure_session(); + return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token); +} + +/** + * Genereert een cryptografisch ondertekende OAuth state parameter waarin de user_id en timestamp veilig zijn opgeslagen. + * Dit voorkomt CSRF en maakt sessieherstel mogelijk bij cross-site OAuth redirects. + */ +function generate_oauth_state(int $userId): string { + $secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026'); + $payload = [ + 'uid' => $userId, + 'ts' => time(), + 'rnd' => bin2hex(random_bytes(8)) + ]; + $json = json_encode($payload); + $b64 = rtrim(strtr(base64_encode($json), '+/', '-_'), '='); + $sig = hash_hmac('sha256', $b64, $secret); + return $b64 . '.' . $sig; +} + +/** + * Valideert een ontvangen OAuth state parameter en geeft bij succes het geverifieerde user_id terug. + * Geeft null terug bij ongeldige handtekening of verlopen state (> 30 minuten). + */ +function verify_oauth_state(string $state): ?int { + $secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026'); + $parts = explode('.', $state, 2); + if (count($parts) !== 2) { + return null; + } + [$b64, $sig] = $parts; + $expectedSig = hash_hmac('sha256', $b64, $secret); + if (!hash_equals($expectedSig, $sig)) { + return null; + } + $remainder = strlen($b64) % 4; + if ($remainder) { + $b64 .= str_repeat('=', 4 - $remainder); + } + $json = base64_decode(strtr($b64, '-_', '+/')); + $payload = json_decode($json, true); + if (!is_array($payload) || empty($payload['uid']) || empty($payload['ts'])) { + return null; + } + // Geldigheidsduur: maximaal 30 minuten (1800 seconden) + if (time() - (int)$payload['ts'] > 1800) { + return null; + } + 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; + } +} + +/** + * Haalt het voorkeursthema op voor een gebruiker ('dark', 'light' of 'system') + */ +function get_user_theme(int $userId): string { + $db = get_db_connection(); + ensure_theme_column($db); + + try { + $stmt = $db->prepare("SELECT theme FROM users WHERE id = ?"); + $stmt->execute([$userId]); + $theme = $stmt->fetchColumn(); + if ($theme && in_array($theme, ['dark', 'light', 'system'])) { + return $theme; + } + } catch (Throwable $e) { + error_log("get_user_theme error: " . $e->getMessage()); + } + + return 'dark'; +} + +/** + * Slaat het voorkeursthema van een gebruiker op ('dark', 'light' of 'system') + */ +function update_user_theme(int $userId, string $theme): bool { + $db = get_db_connection(); + ensure_theme_column($db); + + $theme = in_array($theme, ['dark', 'light', 'system']) ? $theme : 'dark'; + + try { + $stmt = $db->prepare("UPDATE users SET theme = ? WHERE id = ?"); + return $stmt->execute([$theme, $userId]); + } catch (Throwable $e) { + error_log("update_user_theme error: " . $e->getMessage()); + return false; + } +} + + diff --git a/includes/env_badge.php b/includes/env_badge.php new file mode 100644 index 0000000..522a096 --- /dev/null +++ b/includes/env_badge.php @@ -0,0 +1,64 @@ + + + +
+ + +
+ + + + + + + + Live + + + +
+ + +
+ + + + + + + + Development + + + +
+ +
diff --git a/includes/footer.php b/includes/footer.php new file mode 100644 index 0000000..8e1b645 --- /dev/null +++ b/includes/footer.php @@ -0,0 +1,26 @@ + + + + + + + + + + diff --git a/includes/header.php b/includes/header.php new file mode 100644 index 0000000..c4e2e79 --- /dev/null +++ b/includes/header.php @@ -0,0 +1,365 @@ + + + + + + + <?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Dagplanning & Roadmap') ?> + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
diff --git a/includes/http.php b/includes/http.php new file mode 100644 index 0000000..35d00c3 --- /dev/null +++ b/includes/http.php @@ -0,0 +1,159 @@ + 'GET'|'POST'|'PUT'|'DELETE', + * 'headers' => ['Header-Name: Value'] of ['Header-Name' => 'Value'], + * 'body' => string|array, + * 'timeout' => int (seconden, default 12), + * 'verify_ssl' => bool (default true) + * ] + * @return array ['status' => int, 'body' => string, 'json' => ?array, 'error' => ?string] + */ + function fociz_http_request(string $url, array $options = []): array { + $method = strtoupper($options['method'] ?? 'GET'); + $timeout = (int)($options['timeout'] ?? 12); + $verifySsl = $options['verify_ssl'] ?? true; + + // Normaliseer headers naar 'Name: Value' regels + $rawHeaders = $options['headers'] ?? []; + $headerLines = []; + foreach ($rawHeaders as $k => $v) { + if (is_numeric($k)) { + $headerLines[] = $v; + } else { + $headerLines[] = "{$k}: {$v}"; + } + } + + // Bereid payload voor + $body = $options['body'] ?? null; + if (is_array($body)) { + $hasJsonHeader = false; + foreach ($headerLines as $h) { + if (stripos($h, 'application/json') !== false) { + $hasJsonHeader = true; + break; + } + } + if ($hasJsonHeader) { + $body = json_encode($body); + } else { + $body = http_build_query($body); + $headerLines[] = 'Content-Type: application/x-www-form-urlencoded'; + } + } + + // Voeg standaard User-Agent toe als die er niet is + $hasUserAgent = false; + foreach ($headerLines as $h) { + if (stripos($h, 'User-Agent:') !== false) { + $hasUserAgent = true; + break; + } + } + if (!$hasUserAgent) { + $headerLines[] = 'User-Agent: Fociz-Integrations/1.0'; + } + + // 1. Probeer cURL indien beschikbaar + if (extension_loaded('curl')) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifySsl ? 2 : 0); + + if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) { + curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + } + + $responseBody = curl_exec($ch); + $curlError = curl_error($ch); + $statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($responseBody === false && !empty($curlError)) { + return [ + 'status' => $statusCode ?: 0, + 'body' => '', + 'json' => null, + 'error' => "cURL fout: {$curlError}" + ]; + } + + $parsedJson = json_decode((string)$responseBody, true); + + return [ + 'status' => $statusCode, + 'body' => (string)$responseBody, + 'json' => is_array($parsedJson) ? $parsedJson : null, + 'error' => null + ]; + } + + // 2. Fallback: PHP Streams (file_get_contents) + $contextOptions = [ + 'http' => [ + 'method' => $method, + 'header' => implode("\r\n", $headerLines), + 'timeout' => $timeout, + 'ignore_errors' => true // Zorgt dat we ook bij 4xx / 5xx responses de body en statuscode kunnen lezen + ], + 'ssl' => [ + 'verify_peer' => $verifySsl, + 'verify_peer_name' => $verifySsl + ] + ]; + + if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) { + $contextOptions['http']['content'] = $body; + } + + $context = stream_context_create($contextOptions); + $responseBody = @file_get_contents($url, false, $context); + + $statusCode = 0; + if (!empty($http_response_header)) { + // Zoek de laatste HTTP status regel (bij eventuele redirects) + foreach (array_reverse($http_response_header) as $hdr) { + if (preg_match('#HTTP/\d\.\d\s+(\d{3})#i', $hdr, $matches)) { + $statusCode = (int)$matches[1]; + break; + } + } + } + + if ($responseBody === false) { + $lastErr = error_get_last(); + return [ + 'status' => $statusCode ?: 0, + 'body' => '', + 'json' => null, + 'error' => 'Stream fout: ' . ($lastErr['message'] ?? 'Kan verbinding niet voltooien') + ]; + } + + $parsedJson = json_decode((string)$responseBody, true); + + return [ + 'status' => $statusCode, + 'body' => (string)$responseBody, + 'json' => is_array($parsedJson) ? $parsedJson : null, + 'error' => null + ]; + } +} + diff --git a/includes/integrations/jira.php b/includes/integrations/jira.php new file mode 100644 index 0000000..57b8240 --- /dev/null +++ b/includes/integrations/jira.php @@ -0,0 +1,356 @@ +query(" + SELECT i.settings + FROM integrations i + JOIN users u ON i.user_id = u.id + WHERE u.role = 'ADMIN' AND i.provider = 'JIRA' AND i.settings IS NOT NULL + ORDER BY i.updated_at DESC + LIMIT 1 + "); + $row = $stmt->fetch(); + if (!empty($row['settings'])) { + $settings = json_decode($row['settings'], true) ?: []; + if (!empty($settings['jira_domain'])) { + return jira_normalize_domain($settings['jira_domain']); + } + } + + // Fallback naar een willekeurige JIRA rij + $fallback = $db->query(" + SELECT settings FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_domain%' LIMIT 1 + ")->fetch(); + if (!empty($fallback['settings'])) { + $settings = json_decode($fallback['settings'], true) ?: []; + if (!empty($settings['jira_domain'])) { + return jira_normalize_domain($settings['jira_domain']); + } + } + } catch (Throwable $e) { + error_log("jira_get_company_domain error: " . $e->getMessage()); + } + + return ''; +} + +/** + * Genereert de Basic Authentication header voor Atlassian REST API + */ +function jira_get_auth_header(string $email, string $apiToken): string { + return 'Basic ' . base64_encode(trim($email) . ':' . trim($apiToken)); +} + +/** + * Test de verbinding met Atlassian Jira Cloud via /rest/api/3/myself + */ +function jira_test_connection(string $domain, string $email, string $apiToken): array { + $cleanDomain = jira_normalize_domain($domain); + if (empty($cleanDomain) || empty($email) || empty($apiToken)) { + return [ + 'success' => false, + 'error' => 'Vul een Jira domein, e-mailadres en Atlassian API token in.', + ]; + } + + $authHeader = jira_get_auth_header($email, $apiToken); + $url = $cleanDomain . '/rest/api/3/myself'; + + $response = fociz_http_request($url, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => $authHeader, + 'Accept' => 'application/json', + ], + 'timeout' => 10, + ]); + + // Fallback naar api v2 indien v3 niet beschikbaar is + if ($response['status'] === 404) { + $urlV2 = $cleanDomain . '/rest/api/2/myself'; + $response = fociz_http_request($urlV2, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => $authHeader, + 'Accept' => 'application/json', + ], + 'timeout' => 10, + ]); + } + + if ($response['status'] === 200 && is_array($response['json'])) { + $user = $response['json']; + return [ + 'success' => true, + 'user' => [ + 'displayName' => $user['displayName'] ?? 'Atlassian Gebruiker', + 'emailAddress' => $user['emailAddress'] ?? $email, + 'accountId' => $user['accountId'] ?? ($user['key'] ?? ''), + 'active' => $user['active'] ?? true, + 'timeZone' => $user['timeZone'] ?? 'Europe/Amsterdam', + ], + ]; + } + + $errMsg = 'Verbinding mislukt met Jira.'; + if ($response['status'] === 401) { + $errMsg = 'Authenticatie geweigerd (HTTP 401). Controleer uw e-mailadres en zorg dat u een geldig Atlassian API token gebruikt.'; + } elseif ($response['status'] === 403) { + $errMsg = 'Toegang geweigerd (HTTP 403). Uw account heeft onvoldoende rechten op dit Jira domein.'; + } elseif ($response['status'] === 404) { + $errMsg = 'Jira Cloud domein niet gevonden (HTTP 404). Controleer het ingevoerde domein (bijv. uwbedrijf.atlassian.net).'; + } elseif (!empty($response['error'])) { + $errMsg = "Netwerkfout: {$response['error']}"; + } + + return [ + 'success' => false, + 'error' => $errMsg, + 'status' => $response['status'], + ]; +} + +/** + * Haalt alle actieve issues op die zijn toegewezen aan de ingelogde Jira gebruiker + */ +function jira_fetch_assigned_issues(string $domain, string $email, string $apiToken, int $maxResults = 50): array { + $cleanDomain = jira_normalize_domain($domain); + $authHeader = jira_get_auth_header($email, $apiToken); + + // JQL: Alle openstaande taken toegewezen aan mij, gesorteerd op recent bijgewerkt + $jql = 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC'; + + $params = [ + 'jql' => $jql, + 'maxResults' => min($maxResults, 100), + 'fields' => 'summary,status,priority,issuetype,parent,project,duedate,updated', + ]; + + $url = $cleanDomain . '/rest/api/3/search?' . http_build_query($params); + + $response = fociz_http_request($url, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => $authHeader, + 'Accept' => 'application/json', + ], + 'timeout' => 12, + ]); + + // Fallback naar api v2 + if ($response['status'] === 404) { + $urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($params); + $response = fociz_http_request($urlV2, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => $authHeader, + 'Accept' => 'application/json', + ], + 'timeout' => 12, + ]); + } + + if ($response['status'] !== 200 || !isset($response['json']['issues'])) { + return [ + 'success' => false, + 'error' => $response['json']['errorMessages'][0] ?? ($response['error'] ?? 'Kan Jira tickets niet ophalen'), + 'issues' => [], + ]; + } + + $rawIssues = $response['json']['issues']; + $issues = []; + + foreach ($rawIssues as $item) { + $fields = $item['fields'] ?? []; + $key = $item['key'] ?? ''; + $summary = $fields['summary'] ?? '(Geen titel)'; + $statusName = $fields['status']['name'] ?? 'To Do'; + $priorityName = $fields['priority']['name'] ?? 'Medium'; + $issueTypeName = $fields['issuetype']['name'] ?? 'Taak'; + $projectName = $fields['project']['name'] ?? 'Project'; + $dueDate = $fields['duedate'] ?? null; + + $issues[] = [ + 'key' => $key, + 'summary' => $summary, + 'status' => $statusName, + 'priority' => $priorityName, + 'issue_type' => $issueTypeName, + 'project' => $projectName, + 'due_date' => $dueDate, + 'url' => "{$cleanDomain}/browse/{$key}", + 'source' => 'JIRA_LIVE', + ]; + } + + return [ + 'success' => true, + 'issues' => $issues, + 'total' => count($issues), + ]; +} + +/** + * Haalt de Jira tickets op voor een Fociz gebruiker + * Schakelt naadloos tussen live Jira tickets en sandbox demotickets + */ +function jira_get_user_issues(int $userId, bool $forceRefresh = false): array { + $db = get_db_connection(); + $stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA' AND is_enabled = 1"); + $stmt->execute([$userId]); + $integration = $stmt->fetch(); + + // Als de integratie niet is ingesteld of in demo mode staat, geef interactieve fallback issues terug + if (!$integration || !empty($integration['is_demo_mode'])) { + return [ + 'is_demo' => true, + 'success' => true, + 'issues' => [ + [ + 'key' => 'FOC-101', + 'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren', + 'status' => 'In Progress', + 'priority' => 'High', + 'issue_type' => 'Story', + 'project' => 'Fociz Platform', + 'due_date' => date('Y-m-d', strtotime('+2 days')), + 'url' => '#', + 'source' => 'SANDBOX', + ], + [ + 'key' => 'FOC-102', + 'summary' => 'Interactief whiteboard canvas voor brainstormsessies', + 'status' => 'To Do', + 'priority' => 'High', + 'issue_type' => 'Feature', + 'project' => 'Fociz Platform', + 'due_date' => date('Y-m-d', strtotime('+4 days')), + 'url' => '#', + 'source' => 'SANDBOX', + ], + [ + 'key' => 'FOC-103', + 'summary' => 'Koppeling met Microsoft Outlook Calendar & Teams Graph API', + 'status' => 'In Review', + 'priority' => 'Medium', + 'issue_type' => 'Task', + 'project' => 'Integrations Hub', + 'due_date' => date('Y-m-d'), + 'url' => '#', + 'source' => 'SANDBOX', + ], + [ + 'key' => 'FOC-104', + 'summary' => 'Roadmap kwartaalweergave en voortgangsindicatoren', + 'status' => 'To Do', + 'priority' => 'Medium', + 'issue_type' => 'Story', + 'project' => 'Roadmap Module', + 'due_date' => date('Y-m-d', strtotime('+7 days')), + 'url' => '#', + 'source' => 'SANDBOX', + ], + ], + ]; + } + + $settings = json_decode($integration['settings'] ?? '{}', true) ?: []; + $domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain(); + $email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', ''); + $apiToken = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', ''); + + if (empty($domain) || empty($email) || empty($apiToken)) { + return [ + 'is_demo' => false, + 'success' => false, + 'error' => 'Jira configuratie is onvolledig.', + 'issues' => [], + ]; + } + + // Sessiecache voor 90 seconden om onnodige Jira API calls bij snelle pagina refreshes te beperken + if (session_status() === PHP_SESSION_NONE) { + @session_start(); + } + $cacheKey = "jira_issues_user_{$userId}"; + $cached = $_SESSION[$cacheKey] ?? null; + + if (!$forceRefresh && $cached && is_array($cached) && ($cached['timestamp'] ?? 0) > (time() - 90)) { + return [ + 'is_demo' => false, + 'success' => true, + 'issues' => $cached['issues'], + 'from_cache' => true, + ]; + } + + $result = jira_fetch_assigned_issues($domain, $email, $apiToken, 50); + + if ($result['success']) { + $_SESSION[$cacheKey] = [ + 'timestamp' => time(), + 'issues' => $result['issues'], + ]; + + // Werk last_sync_at bij + $stmtUpdate = $db->prepare("UPDATE integrations SET last_sync_at = NOW() WHERE id = ?"); + $stmtUpdate->execute([$integration['id']]); + + return [ + 'is_demo' => false, + 'success' => true, + 'issues' => $result['issues'], + ]; + } + + return [ + 'is_demo' => false, + 'success' => false, + 'error' => $result['error'], + 'issues' => [], + ]; +} + +/** + * Ontkoppelt Jira integratie voor een gebruiker + */ +function jira_disconnect(int $userId): bool { + $db = get_db_connection(); + $stmt = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider = 'JIRA'"); + if (session_status() === PHP_SESSION_NONE) { + @session_start(); + } + unset($_SESSION["jira_issues_user_{$userId}"]); + return $stmt->execute([$userId]); +} + diff --git a/includes/integrations/microsoft.php b/includes/integrations/microsoft.php new file mode 100644 index 0000000..dd0859d --- /dev/null +++ b/includes/integrations/microsoft.php @@ -0,0 +1,885 @@ +query(" + SELECT i.settings, u.email as admin_email + FROM integrations i + JOIN users u ON i.user_id = u.id + WHERE u.role = 'ADMIN' AND i.provider = 'OUTLOOK' AND i.settings IS NOT NULL + ORDER BY i.updated_at DESC + LIMIT 5 + "); + $rows = $stmt->fetchAll(); + foreach ($rows as $row) { + if (!empty($row['admin_email']) && empty($adminOrgDomain)) { + $emailParts = explode('@', $row['admin_email']); + $domain = strtolower(trim(end($emailParts))); + if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) { + $adminOrgDomain = $domain; + } + } + if (!empty($row['settings'])) { + $saved = json_decode($row['settings'], true) ?: []; + if (empty($clientId) && !empty($saved['client_id'])) { + $clientId = trim($saved['client_id']); + } + if (empty($clientSecret) && !empty($saved['client_secret'])) { + $clientSecret = trim($saved['client_secret']); + } + if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') { + $tenantId = trim($saved['tenant_id']); + } + if (!empty($clientId) && !empty($clientSecret) && !empty($tenantId) && $tenantId !== 'common') { + break; + } + } + } + + // Fallback: zoek een willekeurige OUTLOOK rij met ingevulde client_id + if (empty($clientId) || empty($clientSecret) || empty($tenantId) || $tenantId === 'common') { + $stmtFallback = $db->query(" + SELECT settings + FROM integrations + WHERE provider = 'OUTLOOK' AND settings LIKE '%client_id%' + ORDER BY updated_at DESC + LIMIT 3 + "); + $fallbackRows = $stmtFallback->fetchAll(); + foreach ($fallbackRows as $fbRow) { + if (!empty($fbRow['settings'])) { + $saved = json_decode($fbRow['settings'], true) ?: []; + if (empty($clientId) && !empty($saved['client_id'])) { + $clientId = trim($saved['client_id']); + } + if (empty($clientSecret) && !empty($saved['client_secret'])) { + $clientSecret = trim($saved['client_secret']); + } + if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') { + $tenantId = trim($saved['tenant_id']); + } + } + } + } + + // Als tenantId nog leeg of 'common' is, leid het domein af van de beheerder + if (empty($tenantId) || $tenantId === 'common') { + if (empty($adminOrgDomain)) { + $firstAdminEmail = $db->query("SELECT email FROM users WHERE role = 'ADMIN' ORDER BY id ASC LIMIT 1")->fetchColumn(); + if ($firstAdminEmail) { + $parts = explode('@', $firstAdminEmail); + $domain = strtolower(trim(end($parts))); + if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) { + $adminOrgDomain = $domain; + } + } + } + if (!empty($adminOrgDomain)) { + $tenantId = $adminOrgDomain; + } + } + } catch (Throwable $e) { + error_log("microsoft_get_app_config error: " . $e->getMessage()); + } + + // Default tenantId voor de organisatie + if (empty($tenantId) || $tenantId === 'common') { + $tenantId = 'planningsagenda.nl'; + } + + return [ + 'client_id' => $clientId ?: '', + 'client_secret' => $clientSecret ?: '', + 'tenant_id' => $tenantId, + 'is_configured' => !empty($clientId) && !empty($clientSecret), + ]; +} + +/** + * Genereert de Microsoft OAuth2 login URL + */ +function microsoft_get_auth_url(string $clientId, string $redirectUri, string $tenantId = '', string $state = ''): string { + $tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl'; + $baseUrl = sprintf(MS_OAUTH_AUTHORIZE_URL, urlencode($tenant)); + + $params = [ + 'client_id' => trim($clientId), + 'response_type' => 'code', + 'redirect_uri' => trim($redirectUri), + 'response_mode' => 'query', + 'scope' => MS_DEFAULT_SCOPES, + 'state' => $state, + 'prompt' => 'select_account', + ]; + + return $baseUrl . '?' . http_build_query($params); +} + +/** + * Wisselt de ontvangen autorisatiecode om voor access- en refresh tokens + */ +function microsoft_exchange_code(string $code, string $clientId, string $clientSecret, string $redirectUri, string $tenantId = ''): array { + $tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl'; + $tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($tenant)); + + $payload = [ + 'client_id' => trim($clientId), + 'client_secret' => trim($clientSecret), + 'code' => trim($code), + 'redirect_uri' => trim($redirectUri), + 'grant_type' => 'authorization_code', + 'scope' => MS_DEFAULT_SCOPES, + ]; + + $response = fociz_http_request($tokenUrl, [ + 'method' => 'POST', + 'body' => $payload, + 'timeout' => 15, + ]); + + if ($response['status'] !== 200 || empty($response['json']['access_token'])) { + $errorMsg = $response['json']['error_description'] ?? ($response['error'] ?? 'Onbekende fout bij token uitwisseling'); + return [ + 'success' => false, + 'error' => $errorMsg, + ]; + } + + return [ + 'success' => true, + 'access_token' => $response['json']['access_token'], + 'refresh_token' => $response['json']['refresh_token'] ?? null, + 'expires_in' => (int)($response['json']['expires_in'] ?? 3600), + ]; +} + +/** + * Haalt het Microsoft gebruikersprofiel op via Graph API + */ +function microsoft_get_user_profile(string $accessToken): ?array { + $response = fociz_http_request(MS_GRAPH_BASE_URL . '/me', [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => "Bearer {$accessToken}", + 'Accept' => 'application/json', + ], + 'timeout' => 10, + ]); + + if ($response['status'] === 200 && is_array($response['json'])) { + return [ + 'displayName' => $response['json']['displayName'] ?? 'Microsoft 365 Gebruiker', + 'mail' => $response['json']['mail'] ?? ($response['json']['userPrincipalName'] ?? ''), + 'id' => $response['json']['id'] ?? '', + ]; + } + + return null; +} + +/** + * Haalt een geldige access_token op voor de gebruiker; vernieuwt automatisch indien verlopen + */ +function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?string { + $db = get_db_connection(); + $stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1"); + $stmt->execute([$userId]); + $integration = $stmt->fetch(); + + if (!$integration || empty($integration['access_token'])) { + return null; + } + + $settings = json_decode($integration['settings'] ?? '{}', true) ?: []; + $expiresAt = (int)($settings['expires_at'] ?? 0); + + // Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren + if (!$forceRefresh && $expiresAt > (time() + 240)) { + return $integration['access_token']; + } + + // Token is verlopen of forceRefresh gevraagd; refresh met refresh_token + $refreshToken = $integration['refresh_token'] ?? ''; + $appConfig = microsoft_get_app_config(); + $clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id']; + $clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret']; + $tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id']; + + if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) { + return !$forceRefresh ? ($integration['access_token'] ?? null) : null; + } + + $cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl'; + $tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($cleanTenant)); + $payload = [ + 'client_id' => $clientId, + 'client_secret' => $clientSecret, + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshToken, + 'scope' => MS_DEFAULT_SCOPES, + ]; + + $response = fociz_http_request($tokenUrl, [ + 'method' => 'POST', + 'body' => $payload, + 'timeout' => 15, + ]); + + if ($response['status'] === 200 && !empty($response['json']['access_token'])) { + $newAccessToken = $response['json']['access_token']; + $newRefreshToken = $response['json']['refresh_token'] ?? $refreshToken; + $expiresIn = (int)($response['json']['expires_in'] ?? 3600); + $settings['expires_at'] = time() + $expiresIn; + + $updateStmt = $db->prepare(" + UPDATE integrations + SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW() + WHERE id = ? + "); + $updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]); + + return $newAccessToken; + } + + error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json']))); + // Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken + if (!empty($integration['access_token'])) { + return $integration['access_token']; + } + return null; +} + +/** + * Haalt agenda-afspraken van Microsoft Outlook op voor een datumbereik (bijv. een hele week) + */ +function microsoft_fetch_events_range(int $userId, string $startDate, string $endDate): array { + $token = microsoft_get_valid_token($userId); + if (!$token) { + return []; + } + + try { + $tzLocal = new DateTimeZone('Europe/Amsterdam'); + $tzUtc = new DateTimeZone('UTC'); + + $dtStart = new DateTime("{$startDate} 00:00:00", $tzLocal); + $dtEnd = new DateTime("{$endDate} 23:59:59", $tzLocal); + + $startIso = $dtStart->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z'); + $endIso = $dtEnd->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z'); + } catch (Throwable $e) { + $startIso = "{$startDate}T00:00:00Z"; + $endIso = "{$endDate}T23:59:59Z"; + } + + $url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([ + 'startDateTime' => $startIso, + 'endDateTime' => $endIso, + '$select' => 'id,subject,bodyPreview,start,end,location,isAllDay,webLink,onlineMeeting,isCancelled', + '$orderby' => 'start/dateTime', + '$top' => '100', + ]); + + $response = fociz_http_request($url, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/json', + 'Prefer' => 'outlook.timezone="Europe/Amsterdam"', + ], + 'timeout' => 12, + ]); + + // Retry bij 401 Unauthorized + if ($response['status'] === 401) { + $freshToken = microsoft_get_valid_token($userId, true); + if ($freshToken && $freshToken !== $token) { + $token = $freshToken; + $response = fociz_http_request($url, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/json', + 'Prefer' => 'outlook.timezone="Europe/Amsterdam"', + ], + 'timeout' => 12, + ]); + } + } + + if ($response['status'] !== 200 || empty($response['json']['value'])) { + if ($response['status'] !== 200) { + error_log("microsoft_fetch_events_range error (HTTP {$response['status']}): " . ($response['error'] ?? json_encode($response['json']))); + } + return []; + } + + $rawEvents = $response['json']['value']; + $events = []; + + foreach ($rawEvents as $e) { + if (!empty($e['isCancelled'])) { + continue; + } + + $rawStart = $e['start']['dateTime'] ?? ''; + $rawEnd = $e['end']['dateTime'] ?? ''; + + $eventDate = ''; + $startTime = ''; + $endTime = ''; + + if (!empty($rawStart)) { + $eventDate = substr($rawStart, 0, 10); + if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) { + $startTime = $m[1]; + } else { + $startTime = date('H:i', strtotime($rawStart)); + } + } + if (!empty($rawEnd)) { + if (preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) { + $endTime = $m[1]; + } else { + $endTime = date('H:i', strtotime($rawEnd)); + } + } + + $meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com'); + if (empty($meetingUrl) && !empty($e['bodyPreview'])) { + if (preg_match('#https://teams\.microsoft\.com/l/meetup-join/[^\s"\'<>]+#i', $e['bodyPreview'], $m)) { + $meetingUrl = $m[0]; + } + } + + $locationName = $e['location']['displayName'] ?? ''; + if (empty($locationName) && !empty($e['onlineMeeting']['joinUrl'])) { + $locationName = 'Microsoft Teams'; + } + + $events[] = [ + 'id' => $e['id'] ?? uniqid('ms_'), + 'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)', + 'date' => $eventDate ?: $startDate, + 'start' => $startTime ?: '09:00', + 'end' => $endTime ?: '10:00', + 'start_raw' => $rawStart, + 'end_raw' => $rawEnd, + 'location' => $locationName ?: 'Geen locatie', + 'meeting_url' => $meetingUrl, + 'web_link' => $e['webLink'] ?? '', + 'is_all_day' => !empty($e['isAllDay']), + 'source' => 'OUTLOOK_LIVE', + ]; + } + + return $events; +} + +/** + * Haalt agenda-afspraken van Microsoft Outlook op voor de opgegeven datum + */ +function microsoft_fetch_today_events(int $userId, ?string $date = null): array { + $targetDate = $date ?: date('Y-m-d'); + return microsoft_fetch_events_range($userId, $targetDate, $targetDate); +} + +/** + * Maakt een nieuwe afspraak aan in de Microsoft Outlook agenda via Graph API + * + * @param int $userId + * @param array $eventData [ + * 'subject' => string, + * 'description' => ?string, + * 'date' => ?string (Y-m-d), + * 'start_time' => ?string (H:i), + * 'end_time' => ?string (H:i), + * 'duration_minutes' => ?int, + * ] + * @return array ['success' => bool, 'event_id' => ?string, 'web_link' => ?string, 'error' => ?string] + */ +function microsoft_create_event(int $userId, array $eventData): array { + $token = microsoft_get_valid_token($userId); + if (!$token) { + return ['success' => false, 'error' => 'Geen geldige Outlook 365 verbinding gevonden. Koppel uw account via Mijn Profiel.']; + } + + $subject = trim($eventData['subject'] ?? 'Fociz Dagplanning Taak'); + $description = trim($eventData['description'] ?? 'Gepland via Fociz Dagplanning & Structuur'); + + // Normaliseer datum naar strikt YYYY-MM-DD + $date = !empty($eventData['date']) ? trim($eventData['date']) : date('Y-m-d'); + if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + $date = date('Y-m-d', strtotime($date) ?: time()); + } + + // Normaliseer starttijd naar strikt HH:mm met 2-cijferig uur + $rawStart = !empty($eventData['start_time']) ? trim($eventData['start_time']) : '09:00'; + if (preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawStart, $m)) { + $startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]); + } elseif (preg_match('/^(\d{1,2})$/', $rawStart, $m)) { + $startTime = sprintf('%02d:00', (int)$m[1]); + } else { + $startTime = '09:00'; + } + + // Duur + $duration = (int)($eventData['duration_minutes'] ?? 30); + if ($duration <= 0) $duration = 30; + + // Normaliseer eindtijd naar strikt HH:mm + $rawEnd = !empty($eventData['end_time']) ? trim($eventData['end_time']) : ''; + if (!empty($rawEnd) && preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawEnd, $m)) { + $endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]); + } else { + try { + $dt = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam')); + $dt->modify("+{$duration} minutes"); + $endTime = $dt->format('H:i'); + } catch (Throwable $e) { + $endTime = '10:00'; + } + } + + $startDateTimeStr = "{$date}T{$startTime}:00"; + $endDateTimeStr = "{$date}T{$endTime}:00"; + + $payload = [ + 'subject' => $subject, + 'body' => [ + 'contentType' => 'HTML', + 'content' => nl2br(htmlspecialchars($description)) . '

Aangemaakt via Fociz Dagplanning', + ], + 'start' => [ + 'dateTime' => $startDateTimeStr, + 'timeZone' => 'Europe/Amsterdam', + ], + 'end' => [ + 'dateTime' => $endDateTimeStr, + 'timeZone' => 'Europe/Amsterdam', + ], + 'showAs' => 'busy', + 'isReminderOn' => true, + 'reminderMinutesBeforeStart' => 15, + ]; + + $response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => $payload, + 'timeout' => 15, + ]); + + // Retry bij 401 Unauthorized (token verlopen) + if ($response['status'] === 401) { + $freshToken = microsoft_get_valid_token($userId, true); + if ($freshToken && $freshToken !== $token) { + $token = $freshToken; + $response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => $payload, + 'timeout' => 15, + ]); + } + } + + if ($response['status'] === 201 && is_array($response['json'])) { + return [ + 'success' => true, + 'event_id' => $response['json']['id'] ?? null, + 'web_link' => $response['json']['webLink'] ?? null, + 'start_time'=> $startTime, + 'end_time' => $endTime, + ]; + } + + $errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Fout bij aanmaken Outlook afspraak (HTTP {$response['status']})"); + error_log("microsoft_create_event fout (user {$userId}): {$errMsg}"); + return [ + 'success' => false, + 'error' => $errMsg, + ]; +} + +/** + * Verwijdert een afspraak uit Microsoft Outlook via Graph API + */ +function microsoft_delete_event(int $userId, string $eventId): bool { + if (empty($eventId)) { + return false; + } + + $token = microsoft_get_valid_token($userId); + if (!$token) { + return false; + } + + $url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId); + $response = fociz_http_request($url, [ + 'method' => 'DELETE', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + ], + 'timeout' => 12, + ]); + + return in_array($response['status'], [200, 204, 404]); +} + +/** + * Werkt een bestaande afspraak bij in Microsoft Outlook via Graph API (PATCH) + */ +function microsoft_update_event(int $userId, string $eventId, array $eventData): bool { + if (empty($eventId)) { + return false; + } + + $token = microsoft_get_valid_token($userId); + if (!$token) { + return false; + } + + $payload = []; + if (!empty($eventData['subject'])) { + $payload['subject'] = $eventData['subject']; + } + + $date = !empty($eventData['date']) ? $eventData['date'] : date('Y-m-d'); + $startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00'; + $endTime = !empty($eventData['end_time']) ? $eventData['end_time'] : '09:30'; + + if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $startTime, $m)) { + $startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]); + } + if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $endTime, $m)) { + $endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]); + } + + $payload['start'] = [ + 'dateTime' => "{$date}T{$startTime}:00", + 'timeZone' => 'Europe/Amsterdam', + ]; + $payload['end'] = [ + 'dateTime' => "{$date}T{$endTime}:00", + 'timeZone' => 'Europe/Amsterdam', + ]; + + $url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId); + $response = fociz_http_request($url, [ + 'method' => 'PATCH', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => $payload, + 'timeout' => 12, + ]); + + if ($response['status'] === 401) { + $freshToken = microsoft_get_valid_token($userId, true); + if ($freshToken && $freshToken !== $token) { + $response = fociz_http_request($url, [ + 'method' => 'PATCH', + 'headers' => [ + 'Authorization' => "Bearer {$freshToken}", + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ], + 'body' => $payload, + 'timeout' => 12, + ]); + } + } + + return in_array($response['status'], [200, 204]); +} + +/** + * Ontkoppelt Microsoft integratie voor een gebruiker + * Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig. + */ +function microsoft_disconnect(int $userId): bool { + $db = get_db_connection(); + $stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'"); + $stmt->execute([$userId]); + $integration = $stmt->fetch(); + + if (!$integration) { + return true; + } + + $settings = json_decode($integration['settings'] ?? '{}', true) ?: []; + // Verwijder gebruikers-specifieke token gegevens + unset($settings['account_email'], $settings['account_name'], $settings['expires_at']); + + // Als er centrale app credentials in staan (bijv. van beheerder), behouden we deze netjes + if (!empty($settings['client_id']) || !empty($settings['client_secret'])) { + $updateStmt = $db->prepare(" + UPDATE integrations + SET access_token = NULL, refresh_token = NULL, settings = ?, last_sync_at = NULL, is_enabled = 1, is_demo_mode = 0 + WHERE id = ? + "); + return $updateStmt->execute([json_encode($settings), $integration['id']]); + } else { + $delStmt = $db->prepare("DELETE FROM integrations WHERE id = ?"); + return $delStmt->execute([$integration['id']]); + } +} + +/** + * Haalt één specifieke afspraak op uit Microsoft Outlook via Graph API. + * Retourneert: + * - array met event data indien gevonden (HTTP 200) + * - false indien definitief verwijderd / geannuleerd (HTTP 404 of isCancelled) + * - null indien er een netwerk/server error optreedt (beschermt taken tegen onterecht wissen) + */ +function microsoft_get_single_event(int $userId, string $eventId): false|array|null { + if (empty($eventId)) { + return false; + } + + $token = microsoft_get_valid_token($userId); + if (!$token) { + return null; + } + + $url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId) . '?' . http_build_query([ + '$select' => 'id,subject,start,end,isCancelled', + ]); + + $response = fociz_http_request($url, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/json', + 'Prefer' => 'outlook.timezone="Europe/Amsterdam"', + ], + 'timeout' => 8, + ]); + + if ($response['status'] === 401) { + $freshToken = microsoft_get_valid_token($userId, true); + if ($freshToken && $freshToken !== $token) { + $token = $freshToken; + $response = fociz_http_request($url, [ + 'method' => 'GET', + 'headers' => [ + 'Authorization' => "Bearer {$token}", + 'Accept' => 'application/json', + 'Prefer' => 'outlook.timezone="Europe/Amsterdam"', + ], + 'timeout' => 8, + ]); + } + } + + if ($response['status'] === 404) { + return false; // Definitief niet meer gevonden in Outlook + } + + if ($response['status'] === 200 && is_array($response['json'])) { + $e = $response['json']; + if (!empty($e['isCancelled'])) { + return false; // Geannuleerd + } + + $rawStart = $e['start']['dateTime'] ?? ''; + $rawEnd = $e['end']['dateTime'] ?? ''; + + $eventDate = !empty($rawStart) ? substr($rawStart, 0, 10) : ''; + $startTime = ''; + $endTime = ''; + + if (!empty($rawStart) && preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) { + $startTime = $m[1]; + } + if (!empty($rawEnd) && preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) { + $endTime = $m[1]; + } + + return [ + 'id' => $e['id'], + 'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)', + 'date' => $eventDate, + 'start' => $startTime ?: '09:00', + 'end' => $endTime ?: '10:00', + 'start_raw' => $rawStart, + 'end_raw' => $rawEnd, + ]; + } + + return null; +} + +/** + * Synchroniseert en reconciled Fociz taken met de live Microsoft Outlook agenda. + * + * - Als een gekoppelde afspraak in Outlook is verwijderd of geannuleerd, wordt de taak in Fociz gewist. + * - Als een afspraak in Outlook van tijd, datum of titel is gewijzigd, wordt de taak in Fociz bijgewerkt. + * + * @param int $userId + * @param string $startDate Datumbereik begin (Y-m-d) + * @param string $endDate Datumbereik einde (Y-m-d) + * @param array $liveEvents Reeds opgehaalde Outlook events via microsoft_fetch_events_range + * @return array ['deleted' => int, 'updated' => int] + */ +function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate, string $endDate, array $liveEvents): array { + $db = get_db_connection(); + $deletedCount = 0; + $updatedCount = 0; + + // 1. Indexeer live events op id + $liveEventMap = []; + foreach ($liveEvents as $evt) { + if (!empty($evt['id'])) { + $liveEventMap[$evt['id']] = $evt; + } + } + + // 2. Haal alle taken op voor deze gebruiker in dit datumbereik met een external_id (gekoppeld aan Outlook) + $stmt = $db->prepare(" + SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.duration_minutes, t.external_id, dp.plan_date, dp.id as daily_plan_id + FROM tasks t + JOIN daily_plans dp ON t.daily_plan_id = dp.id + WHERE t.user_id = ? + AND t.source = 'OUTLOOK' + AND t.external_id IS NOT NULL + AND t.external_id != '' + AND dp.plan_date BETWEEN ? AND ? + "); + $stmt->execute([$userId, $startDate, $endDate]); + $syncedTasks = $stmt->fetchAll(); + + foreach ($syncedTasks as $task) { + $extId = $task['external_id']; + + if (isset($liveEventMap[$extId])) { + // Het event bestaat in het huidige week/datumbereik! + $evt = $liveEventMap[$extId]; + + $dateChanged = (!empty($evt['date']) && $evt['date'] !== $task['plan_date']); + $titleChanged = (!empty($evt['subject']) && $evt['subject'] !== $task['title']); + $startChanged = (!empty($evt['start']) && $evt['start'] !== $task['time_block_start']); + $endChanged = (!empty($evt['end']) && $evt['end'] !== $task['time_block_end']); + + if ($dateChanged || $titleChanged || $startChanged || $endChanged) { + $targetPlanId = (int)$task['daily_plan_id']; + + // Als datum gewijzigd is, verplaats naar de daily_plan van de nieuwe datum + if ($dateChanged) { + $stmtPlan = $db->prepare(" + INSERT INTO daily_plans (user_id, plan_date) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id) + "); + $stmtPlan->execute([$userId, $evt['date']]); + $targetPlanId = (int)$db->lastInsertId(); + } + + // Bereken duur + $newStart = !empty($evt['start']) ? $evt['start'] : $task['time_block_start']; + $newEnd = !empty($evt['end']) ? $evt['end'] : $task['time_block_end']; + $newDuration = (int)$task['duration_minutes']; + if (!empty($newStart) && !empty($newEnd)) { + $durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60; + if ($durCalc > 0) { + $newDuration = (int)$durCalc; + } + } + + $newTitle = !empty($evt['subject']) ? $evt['subject'] : $task['title']; + + $updateStmt = $db->prepare(" + UPDATE tasks + SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ? + WHERE id = ? AND user_id = ? + "); + $updateStmt->execute([$targetPlanId, $newTitle, $newStart, $newEnd, $newDuration, $task['id'], $userId]); + $updatedCount++; + } + } else { + // Event niet gevonden in het weekbereik! + // Controleer specifiek of het event in Outlook gewist is of naar een andere week verplaatst + $singleEvent = microsoft_get_single_event($userId, $extId); + + if ($singleEvent === false) { + // Event is definitief verwijderd of geannuleerd in Outlook! + $delStmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?"); + $delStmt->execute([$task['id'], $userId]); + $deletedCount++; + } elseif (is_array($singleEvent)) { + // Event bestaat nog, maar staat op een andere datum (buiten huidig bereik) + $newDate = $singleEvent['date']; + if (!empty($newDate)) { + $stmtPlan = $db->prepare(" + INSERT INTO daily_plans (user_id, plan_date) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id) + "); + $stmtPlan->execute([$userId, $newDate]); + $newPlanId = (int)$db->lastInsertId(); + + $newStart = $singleEvent['start']; + $newEnd = $singleEvent['end']; + $newDuration = (int)$task['duration_minutes']; + if (!empty($newStart) && !empty($newEnd)) { + $durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60; + if ($durCalc > 0) $newDuration = (int)$durCalc; + } + + $updateStmt = $db->prepare(" + UPDATE tasks + SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ? + WHERE id = ? AND user_id = ? + "); + $updateStmt->execute([$newPlanId, $singleEvent['subject'], $newStart, $newEnd, $newDuration, $task['id'], $userId]); + $updatedCount++; + } + } + } + } + + return [ + 'deleted' => $deletedCount, + 'updated' => $updatedCount, + ]; +} + + diff --git a/includes/settings.php b/includes/settings.php new file mode 100644 index 0000000..43bbd66 --- /dev/null +++ b/includes/settings.php @@ -0,0 +1,114 @@ +exec(" + CREATE TABLE IF NOT EXISTS `system_settings` ( + `setting_key` VARCHAR(64) PRIMARY KEY, + `setting_value` TEXT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + "); + $ensured = true; + } catch (Throwable $e) { + error_log("ensure_system_settings_table error: " . $e->getMessage()); + } +} + +/** + * Haalt een enkele instelling op uit MariaDB + */ +function get_system_setting(string $key, $default = null) { + try { + $db = get_db_connection(); + ensure_system_settings_table($db); + + $stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?"); + $stmt->execute([$key]); + $val = $stmt->fetchColumn(); + + if ($val === false || $val === null) { + return $default; + } + + // Decodeer JSON indien van toepassing + $decoded = json_decode($val, true); + if (json_last_error() === JSON_ERROR_NONE) { + return $decoded; + } + + return $val; + } catch (Throwable $e) { + error_log("get_system_setting error for {$key}: " . $e->getMessage()); + return $default; + } +} + +/** + * Slaat een instelling op in MariaDB + */ +function set_system_setting(string $key, $value): bool { + try { + $db = get_db_connection(); + ensure_system_settings_table($db); + + $valToStore = is_array($value) || is_object($value) ? json_encode($value) : (string)$value; + + $stmt = $db->prepare(" + INSERT INTO system_settings (setting_key, setting_value) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW() + "); + return $stmt->execute([$key, $valToStore]); + } catch (Throwable $e) { + error_log("set_system_setting error for {$key}: " . $e->getMessage()); + return false; + } +} + +/** + * Haalt alle organisatie werkdag standaarden op (Release v1.4) + */ +function get_workday_settings(): array { + return [ + 'workday_start' => get_system_setting('workday_start', '08:30'), + 'workday_end' => get_system_setting('workday_end', '17:30'), + 'lunch_start' => get_system_setting('lunch_start', '12:30'), + 'lunch_end' => get_system_setting('lunch_end', '13:00'), + 'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25), + 'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50), + 'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5), + 'default_task_duration' => (int)get_system_setting('default_task_duration', 30), + ]; +} + +/** + * Haalt AI configuratie-instellingen op (Release v1.5) + */ +function get_ai_settings(): array { + $envKey = fociz_env('OPENAI_API_KEY', ''); + $savedKey = get_system_setting('ai_api_key', ''); + $apiKey = !empty($savedKey) ? $savedKey : $envKey; + + return [ + 'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)), + 'ai_provider' => get_system_setting('ai_provider', 'openai'), + 'ai_api_key' => $apiKey, + 'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'), + 'has_key' => !empty($apiKey), + ]; +} + diff --git a/includes/totp.php b/includes/totp.php new file mode 100644 index 0000000..93ec2d2 --- /dev/null +++ b/includes/totp.php @@ -0,0 +1,132 @@ += 8) { + $bufferBits -= 8; + $binary .= chr(($buffer >> $bufferBits) & 0xFF); + } + } + + return $binary; + } + + /** + * Berekent de 6-cijferige TOTP code voor een bepaald tijdsinterval + */ + public static function getCode(string $secret, ?int $timestamp = null): string { + if ($timestamp === null) { + $timestamp = time(); + } + + $timeCounter = floor($timestamp / 30); + $binaryKey = self::base32Decode($secret); + + // Converteer tijdsinterval naar 8-byte big-endian binaire string + $binaryTime = pack('N*', 0) . pack('N*', $timeCounter); + + $hash = hash_hmac('sha1', $binaryTime, $binaryKey, true); + $offset = ord($hash[strlen($hash) - 1]) & 0x0F; + + $binaryCode = ( + ((ord($hash[$offset]) & 0x7F) << 24) | + ((ord($hash[$offset + 1]) & 0xFF) << 16) | + ((ord($hash[$offset + 2]) & 0xFF) << 8) | + (ord($hash[$offset + 3]) & 0xFF) + ); + + $otp = $binaryCode % 1000000; + return str_pad((string)$otp, 6, '0', STR_PAD_LEFT); + } + + /** + * Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter) + */ + public static function verifyCode(string $secret, string $userInputCode, int $window = 1): bool { + $userInputCode = trim(str_replace(' ', '', $userInputCode)); + if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) { + return false; + } + + $currentTime = time(); + for ($i = -$window; $i <= $window; $i++) { + $calcCode = self::getCode($secret, $currentTime + ($i * 30)); + if (hash_equals($calcCode, $userInputCode)) { + return true; + } + } + + return false; + } + + /** + * Maakt de otpauth:// URL aan voor authenticator apps + */ + public static function getOtpAuthUrl(string $email, string $secret, string $issuer = 'Fociz'): string { + return sprintf( + 'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30', + rawurlencode($issuer), + rawurlencode($email), + $secret, + rawurlencode($issuer) + ); + } + + /** + * Genereert een QR code afbeelding URL (via QuickChart HTTPS) + */ + public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string { + return sprintf( + 'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M', + urlencode($otpAuthUrl), + $size + ); + } + + /** + * Genereert een lijst met eenmalige herstelcodes + */ + public static function generateBackupCodes(int $count = 8): array { + $codes = []; + for ($i = 0; $i < $count; $i++) { + $bytes = bin2hex(random_bytes(4)); + $codes[] = strtoupper(substr($bytes, 0, 4) . '-' . substr($bytes, 4, 4)); + } + return $codes; + } +} + diff --git a/index.php b/index.php new file mode 100644 index 0000000..d30943a --- /dev/null +++ b/index.php @@ -0,0 +1,22 @@ + 'Ma', 2 => 'Di', 3 => 'Wo', 4 => 'Do', 5 => 'Vr', 6 => 'Za', 7 => 'Zo']; +$dutchFullDays = [1 => 'Maandag', 2 => 'Dinsdag', 3 => 'Woensdag', 4 => 'Donderdag', 5 => 'Vrijdag', 6 => 'Zaterdag', 7 => 'Zondag']; +$dutchMonths = ['', 'januari', 'februari', 'maart', 'april', 'mei', 'juni', 'juli', 'augustus', 'september', 'oktober', 'november', 'december']; +$dutchShortMonths = ['', 'jan', 'feb', 'mrt', 'apr', 'mei', 'jun', 'jul', 'aug', 'sep', 'okt', 'nov', 'dec']; + +$formattedSelectedDate = $dutchFullDays[(int)date('N', $selectedTs)] . ' ' . date('j', $selectedTs) . ' ' . $dutchMonths[(int)date('n', $selectedTs)] . ' ' . date('Y', $selectedTs); +$formattedWeekRange = date('j', $mondayTs) . ' ' . $dutchShortMonths[(int)date('n', $mondayTs)] . ' – ' . date('j', $sundayTs) . ' ' . $dutchShortMonths[(int)date('n', $sundayTs)] . ' ' . date('Y', $sundayTs); + +// 1. Haal integratiestatussen op +$stmtInt = $db->prepare("SELECT * FROM integrations WHERE user_id = ?"); +$stmtInt->execute([$userId]); +$userIntegrations = []; +foreach ($stmtInt->fetchAll() as $row) { + $userIntegrations[$row['provider']] = $row; +} + +$isOutlookConnected = !empty($userIntegrations['OUTLOOK']['access_token']) && empty($userIntegrations['OUTLOOK']['is_demo_mode']); +$outlookIntegrationSettings = json_decode($userIntegrations['OUTLOOK']['settings'] ?? '{}', true) ?: []; +$syncIntervalSeconds = isset($outlookIntegrationSettings['sync_interval_seconds']) ? (int)$outlookIntegrationSettings['sync_interval_seconds'] : 30; + +$weekOutlookEvents = []; +$isLiveOutlook = false; + +if ($isOutlookConnected) { + // Haal afspraken op voor het volledige datumbereik van de week + $weekOutlookEvents = microsoft_fetch_events_range($userId, $weekStart, $weekEnd); + $isLiveOutlook = true; + + // Synchroniseer / reconcilieer Fociz taken direct met actuele Outlook data + microsoft_reconcile_tasks_with_outlook($userId, $weekStart, $weekEnd, $weekOutlookEvents); +} else { + // Demo data indien Microsoft Outlook nog niet gekoppeld is + $weekOutlookEvents = [ + [ + 'id' => 'evt-1', + 'subject' => 'Voorbeeld: Team Standup & Sync', + 'date' => $today, + 'start' => '09:00', + 'end' => '09:30', + 'location' => 'Microsoft Teams', + 'meeting_url' => 'https://teams.microsoft.com', + 'source' => 'SANDBOX', + ], + [ + 'id' => 'evt-2', + 'subject' => 'Voorbeeld: Roadmap Review met Stakeholders', + 'date' => $today, + 'start' => '11:00', + 'end' => '12:00', + 'location' => 'Vergaderruimte A & Teams', + 'meeting_url' => 'https://teams.microsoft.com', + 'source' => 'SANDBOX', + ], + [ + 'id' => 'evt-3', + 'subject' => 'Voorbeeld: Architectuur & DB Evaluatie', + 'date' => $today, + 'start' => '14:30', + 'end' => '15:30', + 'location' => 'Online Teams Call', + 'meeting_url' => 'https://teams.microsoft.com', + 'source' => 'SANDBOX', + ] + ]; +} + +// 2. Haal dagplanning op of maak aan voor $selectedDate +$stmtPlan = $db->prepare("SELECT * FROM daily_plans WHERE user_id = ? AND plan_date = ?"); +$stmtPlan->execute([$userId, $selectedDate]); +$dailyPlan = $stmtPlan->fetch(); + +if (!$dailyPlan) { + $defaultGoal = ($selectedDate === $today) ? 'Mijn belangrijkste doel van vandaag behalen' : 'Focusdoel voor deze dag bepalen'; + $stmtCreatePlan = $db->prepare(" + INSERT INTO daily_plans (user_id, plan_date, focus_goal) + VALUES (?, ?, ?) + "); + $stmtCreatePlan->execute([$userId, $selectedDate, $defaultGoal]); + $planId = (int)$db->lastInsertId(); + $dailyPlan = ['id' => $planId, 'plan_date' => $selectedDate, 'focus_goal' => $defaultGoal, 'notes' => '']; +} + +// 3. Haal taken op voor de geselecteerde dag (vers bijgewerkt na eventuele reconciliatie) +$stmtTasks = $db->prepare(" + SELECT * FROM tasks + WHERE user_id = ? AND (daily_plan_id = ? " . ($selectedDate === $today ? "OR daily_plan_id IS NULL" : "") . ") + ORDER BY is_completed ASC, priority ASC, time_block_start ASC, created_at DESC +"); +$stmtTasks->execute([$userId, $dailyPlan['id']]); +$tasks = $stmtTasks->fetchAll(); + +// 4. Haal weekstatistieken voor taken op (aantal per dag) +$stmtWeekTasks = $db->prepare(" + SELECT dp.plan_date, COUNT(t.id) as total_tasks, SUM(CASE WHEN t.is_completed = 1 THEN 1 ELSE 0 END) as completed_tasks + FROM daily_plans dp + LEFT JOIN tasks t ON t.daily_plan_id = dp.id AND t.user_id = ? + WHERE dp.user_id = ? AND dp.plan_date BETWEEN ? AND ? + GROUP BY dp.plan_date +"); +$stmtWeekTasks->execute([$userId, $userId, $weekStart, $weekEnd]); +$weekTaskStats = []; +foreach ($stmtWeekTasks->fetchAll() as $row) { + $weekTaskStats[$row['plan_date']] = [ + 'total' => (int)$row['total_tasks'], + 'completed' => (int)$row['completed_tasks'], + ]; +} + +// Groepeer Outlook afspraken per datum +$outlookEventsByDate = []; +foreach ($weekOutlookEvents as $evt) { + $evtDate = $evt['date'] ?? $selectedDate; + $outlookEventsByDate[$evtDate][] = $evt; +} + +// 7 Dagen van de week samenstellen voor de weekselector +$weekDays = []; +for ($i = 0; $i < 7; $i++) { + $dayTs = strtotime("+{$i} days", $mondayTs); + $dayDate = date('Y-m-d', $dayTs); + $dayN = (int)date('N', $dayTs); + $dayM = (int)date('n', $dayTs); + $tStat = $weekTaskStats[$dayDate] ?? ['total' => 0, 'completed' => 0]; + $mCount = count($outlookEventsByDate[$dayDate] ?? []); + + $weekDays[] = [ + 'date' => $dayDate, + 'day_num' => date('j', $dayTs), + 'short_name' => $dutchShortDays[$dayN], + 'full_name' => $dutchFullDays[$dayN], + 'month_short' => $dutchShortMonths[$dayM], + 'is_today' => ($dayDate === $today), + 'is_selected' => ($dayDate === $selectedDate), + 'task_count' => $tStat['total'], + 'completed_count' => $tStat['completed'], + 'meeting_count' => $mCount, + ]; +} + +// 5. Haal Jira issues op voor de snelle import widget +$jiraData = jira_get_user_issues($userId); +$jiraIssues = $jiraData['issues'] ?? []; +$isJiraLive = !empty($jiraData['success']) && empty($jiraData['is_demo']); + +// Filter Outlook events die afkomstig zijn van Fociz taken voor de geselecteerde dag om duplicaten op de tijdlijn te voorkomen +$dayOutlookEvents = $outlookEventsByDate[$selectedDate] ?? []; +$syncedTaskExternalIds = []; +foreach ($tasks as $t) { + if (!empty($t['external_id'])) { + $syncedTaskExternalIds[$t['external_id']] = true; + } +} +$displayedOutlookEvents = []; +foreach ($dayOutlookEvents as $evt) { + if (!empty($evt['id']) && isset($syncedTaskExternalIds[$evt['id']])) { + continue; + } + $displayedOutlookEvents[] = $evt; +} + +// Bereken initiële status-hash voor achtergrondsynchronisatie +$dayEventsForHash = []; +foreach ($displayedOutlookEvents as $e) { + $dayEventsForHash[] = [ + 'id' => $e['id'] ?? '', + 'start' => $e['start'] ?? '', + 'end' => $e['end'] ?? '', + 'subject' => $e['subject'] ?? '', + ]; +} +$tasksForHash = array_map(function($t) { + return [ + 'id' => $t['id'], + 'title' => $t['title'], + 'time_block_start' => $t['time_block_start'], + 'time_block_end' => $t['time_block_end'], + 'is_completed' => $t['is_completed'], + ]; +}, $tasks); +$initialSyncHash = md5(json_encode([ + 'events' => $dayEventsForHash, + 'tasks' => $tasksForHash +])); + +// 6. Tijdlijn uren & Werkdag Standaarden (Release v1.4) +$workdayStartStr = $workdaySettings['workday_start'] ?? '08:30'; +$workdayEndStr = $workdaySettings['workday_end'] ?? '17:30'; +$lunchStart = $workdaySettings['lunch_start'] ?? '12:30'; +$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00'; +$lunchHour = (int)explode(':', $lunchStart)[0]; + +$startHour = (int)explode(':', $workdayStartStr)[0]; +$endHourParts = explode(':', $workdayEndStr); +$endHour = (int)$endHourParts[0]; +if (!empty($endHourParts[1]) && (int)$endHourParts[1] > 0) { + $endHour++; +} +if ($startHour < 6) $startHour = 6; +if ($endHour > 23) $endHour = 23; + +foreach ($displayedOutlookEvents as $e) { + if (!empty($e['start'])) { + $h = (int)explode(':', $e['start'])[0]; + if ($h < $startHour && $h >= 6) $startHour = $h; + if ($h > $endHour && $h <= 23) $endHour = $h + 1; + } +} +foreach ($tasks as $t) { + if (!empty($t['time_block_start'])) { + $h = (int)explode(':', $t['time_block_start'])[0]; + if ($h < $startHour && $h >= 6) $startHour = $h; + if ($h > $endHour && $h <= 23) $endHour = $h + 1; + } +} +$hours = []; +for ($h = $startHour; $h <= $endHour; $h++) { + $hours[] = sprintf('%02d:00', $h); +} +?> + + +
+ +
+
+
+
+ + + + VANDAAG + + GEKOZEN DAG + +
+

+ Dagplanning & Structuur +

+
+ + +
+ +
+ + Outlook 365 Live + + +
+ + + + Koppel Outlook + + + + + + + Jira: tickets + + + + + Koppel Jira + + +
+
+ + +
+ +
+ + +
+
+
+ + + + + +
+ + +
+ + +
+
+
+
+ +
+
+

Focus Modus

+

Pomodoro & geconcentreerd werken

+
+
+ +
+ + + +
+
+ +
+
+
+ Actieve focus: + Kies een taak hieronder +
+ +
+
+
+
+ +
+ + +
+
+ + +
+
+
+

+ Dagtaken & Prioriteiten + () +

+
+ + +
+ + + + + +
+ +
+ Nog geen taken gepland voor . Klik op 'Nieuwe Taak' of importeer een Jira ticket hieronder. +
+ + +
+
+ + +
+
+ + + JIRA + + + + + OUTLOOK + + + + + +
+ +
+ + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + + + + + + + + +
+
+ + +
+
+ + +
+
+
+ +

+ Mijn Jira Tickets +

+ + + +
+ + + Beheren + + +
+ + +
+ Geen openstaande tickets gevonden in Jira. +
+ +
+ +
+
+
+ + + + + + +
+ + • + +
+ + +
+ +
+ +
+ + +
+
+ +

Dagnotities & Kladblok

+
+ +
+
+ + +
+
+
+
+ +

Tijdlijn & Time-blocking

+
+
+ + + + + Outlook 365 + + – +
+
+ +
+ +
+
+ +
+ +
+ + +
+
+ + - + + + + Lunchpauze (Gereserveerd) + +
+ Pauze +
+ + + + +
+
+
+ + - + + + + +
+ +
+
+ + + + +
+
+
+ + + + + + +
+
+ + + + + + + + + + + +
+
+
+ + + + + +
+
+ +
+
+
+
+
+ + + + + + + diff --git a/profile.php b/profile.php new file mode 100644 index 0000000..2f03965 --- /dev/null +++ b/profile.php @@ -0,0 +1,944 @@ +prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'"); + $stmtExisting->execute([$userId]); + $existing = $stmtExisting->fetch(); + $settings = json_decode($existing['settings'] ?? '{}', true) ?: []; + $settings['sync_interval_seconds'] = $syncInterval; + + $stmtUp = $db->prepare("UPDATE integrations SET settings = ? WHERE user_id = ? AND provider = 'OUTLOOK'"); + $stmtUp->execute([json_encode($settings), $userId]); + + $message = "Uw Microsoft Outlook synchronisatievoorkeur is succesvol opgeslagen."; + $messageType = 'success'; + } + + // 3. Jira Opslaan & Testen + elseif ($action === 'save_jira') { + $companyDomain = jira_get_company_domain(); + $rawDomain = trim($_POST['jira_domain'] ?? ''); + $jiraDomain = !empty($rawDomain) ? jira_normalize_domain($rawDomain) : $companyDomain; + $jiraEmail = trim($_POST['jira_email'] ?? ''); + $jiraToken = trim($_POST['jira_api_token'] ?? ''); + + // Haal bestaande instellingen op + $stmtExisting = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'"); + $stmtExisting->execute([$userId]); + $existing = $stmtExisting->fetch(); + $existingSettings = json_decode($existing['settings'] ?? '{}', true) ?: []; + + if (($jiraToken === '' || $jiraToken === '••••••••••••••••') && !empty($existingSettings['jira_api_token']) && $existingSettings['jira_api_token'] !== '••••••••••••••••') { + $jiraToken = $existingSettings['jira_api_token']; + } + + $accountName = $existingSettings['account_name'] ?? ''; + $accountId = $existingSettings['account_id'] ?? ''; + + if (!empty($jiraDomain) && !empty($jiraEmail) && !empty($jiraToken)) { + $testRes = jira_test_connection($jiraDomain, $jiraEmail, $jiraToken); + if (!$testRes['success']) { + $message = "Jira Verbindingsfout: " . $testRes['error']; + $messageType = 'error'; + } else { + $accountName = $testRes['user']['displayName'] ?? 'Jira Gebruiker'; + $accountId = $testRes['user']['accountId'] ?? ''; + $message = "Verbinding met Jira geslaagd! Ingelogd als: {$accountName}"; + $messageType = 'success'; + } + } else { + $message = "Jira instellingen opgeslagen."; + $messageType = 'success'; + } + + $newSettings = [ + 'jira_domain' => $jiraDomain, + 'jira_email' => $jiraEmail, + 'jira_api_token' => $jiraToken, + 'account_name' => $accountName, + 'account_id' => $accountId, + ]; + + $stmt = $db->prepare(" + INSERT INTO integrations (user_id, provider, is_enabled, is_demo_mode, settings, last_sync_at) + VALUES (?, 'JIRA', 1, 0, ?, NOW()) + ON DUPLICATE KEY UPDATE + is_demo_mode = 0, + settings = VALUES(settings), + last_sync_at = NOW() + "); + $stmt->execute([$userId, json_encode($newSettings)]); + unset($_SESSION["jira_issues_user_{$userId}"]); + } + + // 4. Jira ontkoppelen + elseif ($action === 'disconnect_jira') { + jira_disconnect($userId); + header('Location: /profile.php?success=' . urlencode('Atlassian Jira is succesvol ontkoppeld.')); + exit; + } + + // 5. Notificatievoorkeuren opslaan + elseif ($action === 'save_notifications') { + $settings = [ + 'enabled' => isset($_POST['notif_enabled']) ? 1 : 0, + 'lead_time_minutes' => (int)($_POST['lead_time_minutes'] ?? 5), + 'repeat_count' => (int)($_POST['repeat_count'] ?? 2), + 'repeat_interval_minutes' => (int)($_POST['repeat_interval_minutes'] ?? 2), + 'sound_enabled' => isset($_POST['sound_enabled']) ? 1 : 0, + ]; + update_user_notification_settings($userId, $settings); + $message = "Uw persoonlijke notificatievoorkeuren zijn succesvol opgeslagen."; + $messageType = 'success'; + } + + // 6. Profielnaam bijwerken + elseif ($action === 'update_profile') { + $name = trim($_POST['name'] ?? ''); + if (strlen($name) < 2) { + $message = "Voer een geldige naam in van minimaal 2 tekens."; + $messageType = 'error'; + } else { + $stmt = $db->prepare("UPDATE users SET name = ? WHERE id = ?"); + $stmt->execute([$name, $userId]); + $_SESSION['user_name'] = $name; + $user['name'] = $name; + $message = "Uw profielnaam is succesvol bijgewerkt."; + $messageType = 'success'; + } + } + + // 7. Wachtwoord wijzigen + elseif ($action === 'change_password') { + $currentPassword = $_POST['current_password'] ?? ''; + $newPassword = $_POST['new_password'] ?? ''; + $confirmPassword = $_POST['confirm_password'] ?? ''; + + $stmtU = $db->prepare("SELECT password_hash FROM users WHERE id = ?"); + $stmtU->execute([$userId]); + $storedHash = $stmtU->fetchColumn(); + + if (!password_verify($currentPassword, $storedHash)) { + $message = "Uw huidige wachtwoord is niet correct ingevoerd."; + $messageType = 'error'; + } elseif (strlen($newPassword) < 8) { + $message = "Het nieuwe wachtwoord moet minimaal 8 tekens lang zijn."; + $messageType = 'error'; + } elseif ($newPassword !== $confirmPassword) { + $message = "Het nieuwe wachtwoord en de bevestiging komen niet overeen."; + $messageType = 'error'; + } else { + $newHash = password_hash($newPassword, PASSWORD_DEFAULT); + $stmtUp = $db->prepare("UPDATE users SET password_hash = ? WHERE id = ?"); + $stmtUp->execute([$newHash, $userId]); + $message = "Uw wachtwoord is succesvol gewijzigd."; + $messageType = 'success'; + } + } +} + +// Actuele gegevens ophalen +$stmtUser = $db->prepare("SELECT * FROM users WHERE id = ?"); +$stmtUser->execute([$userId]); +$userProfile = $stmtUser->fetch(); + +// Outlook status ophalen +$stmtOutlook = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'"); +$stmtOutlook->execute([$userId]); +$outlookRow = $stmtOutlook->fetch(); +$isOutlookConnected = !empty($outlookRow['access_token']) && empty($outlookRow['is_demo_mode']); +$outlookSettings = json_decode($outlookRow['settings'] ?? '{}', true) ?: []; + +// Jira status ophalen +$stmtJira = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA'"); +$stmtJira->execute([$userId]); +$jiraRow = $stmtJira->fetch(); +$jiraSettings = json_decode($jiraRow['settings'] ?? '{}', true) ?: []; +$isJiraConnected = !empty($jiraSettings['jira_api_token']); +$companyJiraDomain = jira_get_company_domain(); +$activeJiraDomain = !empty($jiraSettings['jira_domain']) ? $jiraSettings['jira_domain'] : $companyJiraDomain; + +// Centrale Azure App configuratie status +$appConfig = microsoft_get_app_config(); + +// Notificatie-instellingen +$notifSettings = get_user_notification_settings($userId); + +// Thema ophalen +$userTheme = get_user_theme($userId); + +$pageTitle = 'Mijn Profiel & Koppelingen — Fociz'; +require_once __DIR__ . '/includes/header.php'; +?> + +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+

+ +

+ + + + Beheerder + + + + + Collega + + + + + + 2FA Beveiligd + +
+ +

+ + • + Lid sinds +

+
+
+ + + + +
+
+ + + +
+ +
+
+ + + +
+
+
+
+ +
+
+
+

Weergave & Thema

+ + Actief: + +
+

Kies uw gewenste uiterlijk: rustig donker grafiet, kraakhelder licht of synchroniseer met uw apparaat.

+
+
+
+ +
+ + + + + + + + +
+
+ + +
+
+
+
+ +
+
+
+

Mijn Microsoft 365 Agenda

+ + + + Live Verbonden + + + + Niet Gekoppeld + + +
+

Synchroniseer uw agenda-afspraken automatisch met uw dagplanning

+
+
+ + +
+ + Opnieuw Verbinden + +
+ + +
+
+ +
+ +
+ +
+
+ Gekoppeld Microsoft Account + + +
+ +
+ Microsoft Teams Koppeling + + + Automatische Meeting Links + + Vergaderlinks in uw agenda worden direct klikbaar in uw dagplanning +
+
+ +
+ +
+ +

+ Bepaalt hoe vaak het dagplanningscherm in de achtergrond controleert op gewijzigde of verwijderde Outlook afspraken. +

+
+
+ + + +
+
+ +
+
+ + Uw agenda synchroniseert live. Afspraken van vandaag verschijnen direct in uw Dagplanning. +
+ + Naar Dagplanning → + +
+ + +
+
+ +
+
+

Verbind uw Microsoft Outlook Agenda

+

+ Krijg uw dagelijkse afspraken automatisch te zien in Fociz en ontvang herinneringen vóórdat uw vergaderingen beginnen. +

+
+ + +
+
+ + +
+ U wordt veilig doorgestuurd naar Microsoft om in te loggen +
+ +
+ + Microsoft 365 koppeling is nog niet geconfigureerd door de beheerder. + + Stel Azure in via Systeembeheer → + + Vraag uw beheerder om dit in te schakelen. + +
+ +
+ +
+
+ + +
+
+
+
+ +
+
+
+

Mijn Atlassian Jira Koppeling

+ + + Verbonden + + + + Niet Gekoppeld + + +
+

Importeer uw toegewezen Jira issues direct naar uw dagelijkse takenlijst

+
+
+ + +
+ + +
+ +
+ +
+ + +
+
+ + + Standaard ingevuld door beheerder indien beschikbaar +
+ +
+ + +
+
+ +
+
+ + + API Token Aanmaken + + +
+
+ + +
+
+ +
+ + + + Verbonden als: + + + Verbinding wordt automatisch gevalideerd bij het opslaan + + + +
+
+
+ + +
+
+
+ +
+
+

Mijn Meldingen & Agenda Herinneringen

+

Krijg realtime meldingen vanuit uw browser zodra een afspraak nadert

+
+
+ +
+ + + +
+
+ +
+ Toestemming van Browser Vereist + Klik op de knop om pushmeldingen in te schakelen voor Fociz. +
+
+ +
+ + + +
+ + +
+
+ Meldingen Actief + Automatisch waarschuwen bij naderende afspraken +
+ +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ +
+ Subtiel Geluidssignaal + Speel een zachte chime af bij binnenkomst van de herinnering +
+
+ +
+ +
+ +
+
+
+
+ + +
+ +
+
+
+ +
+
+
+

Tweestapsverificatie (2FA) is Actief

+ Beveiligd +
+

+ Uw Fociz account is optimaal beveiligd met een Authenticator app (TOTP) voor maximale privacy- en dataveiligheid. +

+
+
+
+ + Verplicht & Actief +
+
+ +
+ +
+
+ +

Persoonlijke Gegevens

+
+ +
+ + +
+ + +
+ +
+ + + E-mailadres is gekoppeld aan uw account en 2FA. +
+ +
+ +
+
+
+ + +
+
+ +

Wachtwoord Wijzigen

+
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+
+ + + + diff --git a/roadmap.php b/roadmap.php new file mode 100644 index 0000000..81ac70f --- /dev/null +++ b/roadmap.php @@ -0,0 +1,327 @@ +prepare($sql); +$stmt->execute($params); +$roadmapItems = $stmt->fetchAll(); + +$quarters = ['Q1', 'Q2', 'Q3', 'Q4']; +?> + +
+ +
+
+
+ + Planning & Strategie +
+

+ Roadmap & Tijdlijn +

+

+ Centraal overzicht van alle mijlpalen, features en doelen, direct voortgekomen uit brainstorms en gesynchroniseerd met Jira. +

+
+ + +
+ + +
+ + + + +
+ Status: + +
+
+ + +
+ +
+ +

Geen items gevonden op de roadmap

+

+ Ga naar een Brainstorm sessie en zet ideeën met één klik om naar de roadmap, of voeg handmatig een item toe. +

+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + +
+
+ + +
+

+ +

+ +

+ +

+ +
+ + + +
+ + Ontstaan uit brainstormsessie +
+ + + +
+ +
+ Voortgang: +
+
+
+ % +
+ + + +
+
+ + +
+ + + + Deadline: + + + Toegevoegd door +
+
+
+ + +
+
+ + +