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, ]; } /** * ============================================================================== * MICROSOFT 365 E-MAIL INTEGRATIE (GRAPH API) * ============================================================================== */ /** * Levert realistische demo e-mails voor accounts zonder actieve Outlook verbinding */ function microsoft_get_mail_demo_data(): array { $now = time(); return [ [ 'id' => 'demo-mail-1', 'subject' => '⚡ Urgent: UWV Koppeling REST-endpoints en Datamigratie timing', 'body_preview' => 'Beste Niek, we hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de UWV REST-endpoints en de validatie van werknemersdossiers...', 'body_html' => '

Beste Niek,

We hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de UWV REST-endpoints en de validatie van werknemersdossiers.

Zou je kunnen verifiëren of onze SSO-koppeling en de datadump export hier al op aansluiten? Het zou fijn zijn als we dit vanmiddag kort kunnen afstemmen in de planning.

Met vriendelijke groet,
Directie UWV Koppelingen & Integraties

', 'from_name' => 'Directie UWV Integraties', 'from_email' => 'koppelingen@uwv-overheid.nl', 'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']], 'cc_recipients' => [], 'received_at' => date('Y-m-d H:i:s', $now - (12 * 60)), // 12 minuten geleden 'received_human' => '12 min geleden', 'is_read' => false, 'has_attachments' => true, 'importance' => 'high', 'web_link' => 'https://outlook.office.com/mail/inbox', 'is_demo' => true, ], [ 'id' => 'demo-mail-2', 'subject' => 'Factuur & Urenverantwoording September — Goedkeuring vereist', 'body_preview' => 'Hallo team, bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de week accorderen...', 'body_html' => '

Hallo team,

Bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de werkweek accorderen in het systeem.

Laat het weten indien er correcties nodig zijn op de dagregistraties.

Hartelijke groet,
Financiële Administratie Fociz

', 'from_name' => 'Financiële Administratie', 'from_email' => 'facturatie@fociz.nl', 'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']], 'cc_recipients' => [], 'received_at' => date('Y-m-d H:i:s', $now - (65 * 60)), // 1 uur geleden 'received_human' => '1 uur geleden', 'is_read' => false, 'has_attachments' => false, 'importance' => 'normal', 'web_link' => 'https://outlook.office.com/mail/inbox', 'is_demo' => true, ], [ 'id' => 'demo-mail-3', 'subject' => 'Jira SSO Migratie & Staging server testomgeving', 'body_preview' => 'Goedemiddag, de testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten...', 'body_html' => '

Goedemiddag,

De testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten op de Servicedesk wachtrijen en DX filters.

Alle endpoint logs zijn beschikbaar via het beheerpaneel.

Groeten,
Team DevOps & Infrastructuur

', 'from_name' => 'DevOps Systeembeheer', 'from_email' => 'infra@fociz.nl', 'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']], 'cc_recipients' => [], 'received_at' => date('Y-m-d H:i:s', $now - (3 * 3600)), // 3 uur geleden 'received_human' => '3 uur geleden', 'is_read' => true, 'has_attachments' => true, 'importance' => 'normal', 'web_link' => 'https://outlook.office.com/mail/inbox', 'is_demo' => true, ], [ 'id' => 'demo-mail-4', 'subject' => 'Herinnering: Kwartaal Roadmap Review & Strategie sessie', 'body_preview' => 'Beste collega\'s, vergeet niet je prioriteiten en feature requests in te dienen in de Fociz Roadmap module voor de meeting van aanstaande donderdag...', 'body_html' => '

Beste collega\'s,

Vergeet niet je prioriteiten en feature requests in te dienen in de Fociz Roadmap module voor de meeting van aanstaande donderdag.

We behandelen dan tevens de nieuwe Kanban borden en de Outlook time-blocking ervaring.

Tot dan!
Product Management

', 'from_name' => 'Product Management', 'from_email' => 'product@fociz.nl', 'to_recipients' => [['name' => 'Alle Medewerkers', 'address' => 'team@fociz.nl']], 'cc_recipients' => [], 'received_at' => date('Y-m-d H:i:s', $now - (24 * 3600)), // Gisteren 'received_human' => 'Gisteren', 'is_read' => true, 'has_attachments' => false, 'importance' => 'normal', 'web_link' => 'https://outlook.office.com/mail/inbox', 'is_demo' => true, ], [ 'id' => 'demo-mail-5', 'subject' => 'Welkom bij Fociz Live — Handige tips voor jouw dagplanning', 'body_preview' => 'Welkom bij Fociz! Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw dagrooster?...', 'body_html' => '

Welkom bij Fociz!

Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw persoonlijke dagplanning?

Hierdoor houd je maximale grip op je werkdag en behoud je de ultieme focus.

Veel productiviteit gewenst,
Het Fociz Team

', 'from_name' => 'Fociz Support Team', 'from_email' => 'no-reply@fociz.nl', 'to_recipients' => [['name' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']], 'cc_recipients' => [], 'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)), // 2 dagen geleden 'received_human' => '2 dagen geleden', 'is_read' => true, 'has_attachments' => false, 'importance' => 'low', 'web_link' => 'https://outlook.office.com/mail/inbox', 'is_demo' => true, ], ]; } /** * Haalt inbox berichten op van Microsoft Graph API (of retourneert demo data) */ function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unreadOnly = false): array { $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(); $isLive = !empty($integration['access_token']) && empty($integration['is_demo_mode']); if (!$isLive) { $demo = microsoft_get_mail_demo_data(); if ($unreadOnly) { $demo = array_values(array_filter($demo, function($m) { return empty($m['is_read']); })); } return [ 'success' => true, 'is_demo' => true, 'messages' => array_slice($demo, 0, $limit), 'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })), 'total_count' => count($demo), ]; } $token = microsoft_get_valid_token($userId); if (!$token) { return [ 'success' => false, 'error' => 'Geen geldig Microsoft 365 token beschikbaar.', 'messages' => [], 'unread_count' => 0, ]; } $queryParams = [ '$top' => min(50, max(1, $limit)), '$select' => 'id,subject,bodyPreview,from,toRecipients,receivedDateTime,isRead,hasAttachments,importance,webLink', '$orderby' => 'receivedDateTime desc', ]; if ($unreadOnly) { $queryParams['$filter'] = 'isRead eq false'; } $url = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox/messages?' . http_build_query($queryParams); $res = fociz_http_request($url, [ 'method' => 'GET', 'headers' => [ 'Authorization' => "Bearer {$token}", 'Accept' => 'application/json', 'Prefer' => 'outlook.body-content-type="text"', ], 'timeout' => 15, ]); if ($res['status'] !== 200 || !isset($res['json']['value'])) { error_log("Microsoft fetch inbox error: HTTP {$res['status']} - " . ($res['error'] ?? json_encode($res['json']))); // Val terug op demo data indien API fout optreedt $demo = microsoft_get_mail_demo_data(); return [ 'success' => true, 'is_demo' => true, 'messages' => array_slice($demo, 0, $limit), 'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })), 'total_count' => count($demo), 'warning' => 'Kon live e-mails niet ophalen van Microsoft Graph. Demomodus actief.', ]; } $tzAmsterdam = new DateTimeZone('Europe/Amsterdam'); $messages = []; foreach ($res['json']['value'] as $msg) { $fromObj = $msg['from']['emailAddress'] ?? []; $fromName = $fromObj['name'] ?? ($fromObj['address'] ?? 'Onbekende afzender'); $fromEmail = $fromObj['address'] ?? ''; $receivedIso = $msg['receivedDateTime'] ?? ''; $receivedFormatted = ''; $receivedHuman = ''; if (!empty($receivedIso)) { try { $dt = new DateTime($receivedIso); $dt->setTimezone($tzAmsterdam); $receivedFormatted = $dt->format('Y-m-d H:i:s'); $todayStr = date('Y-m-d'); $msgDate = $dt->format('Y-m-d'); if ($msgDate === $todayStr) { $receivedHuman = $dt->format('H:i'); } elseif ($msgDate === date('Y-m-d', strtotime('-1 day'))) { $receivedHuman = 'Gisteren ' . $dt->format('H:i'); } else { $receivedHuman = $dt->format('d-m H:i'); } } catch (Throwable $t) { $receivedFormatted = $receivedIso; $receivedHuman = substr($receivedIso, 0, 10); } } $messages[] = [ 'id' => $msg['id'] ?? '', 'subject' => $msg['subject'] ?: '(Geen onderwerp)', 'body_preview' => $msg['bodyPreview'] ?? '', 'from_name' => $fromName, 'from_email' => $fromEmail, 'to_recipients' => array_map(function($r) { return [ 'name' => $r['emailAddress']['name'] ?? '', 'address' => $r['emailAddress']['address'] ?? '', ]; }, $msg['toRecipients'] ?? []), 'received_at' => $receivedFormatted, 'received_human' => $receivedHuman, 'is_read' => !empty($msg['isRead']), 'has_attachments' => !empty($msg['hasAttachments']), 'importance' => strtolower($msg['importance'] ?? 'normal'), 'web_link' => $msg['webLink'] ?? 'https://outlook.office.com/mail/', 'is_demo' => false, ]; } $unreadCount = count(array_filter($messages, function($m) { return empty($m['is_read']); })); return [ 'success' => true, 'is_demo' => false, 'messages' => $messages, 'unread_count' => $unreadCount, 'total_count' => count($messages), ]; } /** * Haalt de volledige inhoud en details van een enkel e-mailbericht op */ function microsoft_fetch_message_detail(int $userId, string $messageId): ?array { // Check op demo ID if (strpos($messageId, 'demo-mail-') === 0) { $demo = microsoft_get_mail_demo_data(); foreach ($demo as $m) { if ($m['id'] === $messageId) { $m['is_read'] = true; return $m; } } return $demo[0] ?? null; } $token = microsoft_get_valid_token($userId); if (!$token) { return null; } $url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId) . '?$select=id,subject,body,bodyPreview,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,importance,webLink'; $res = fociz_http_request($url, [ 'method' => 'GET', 'headers' => [ 'Authorization' => "Bearer {$token}", 'Accept' => 'application/json', ], 'timeout' => 15, ]); if ($res['status'] !== 200 || !isset($res['json']['id'])) { return null; } $msg = $res['json']; $tzAmsterdam = new DateTimeZone('Europe/Amsterdam'); $receivedFormatted = ''; $receivedHuman = ''; if (!empty($msg['receivedDateTime'])) { try { $dt = new DateTime($msg['receivedDateTime']); $dt->setTimezone($tzAmsterdam); $receivedFormatted = $dt->format('Y-m-d H:i:s'); $receivedHuman = $dt->format('d-m-Y H:i'); } catch (Throwable $t) { $receivedFormatted = $msg['receivedDateTime']; $receivedHuman = $msg['receivedDateTime']; } } $bodyType = strtolower($msg['body']['contentType'] ?? 'text'); $bodyContent = $msg['body']['content'] ?? ''; // Markeer direct als gelezen via Graph API if (empty($msg['isRead'])) { microsoft_mark_message_read($userId, $messageId, true); } return [ 'id' => $msg['id'], 'subject' => $msg['subject'] ?: '(Geen onderwerp)', 'body_preview' => $msg['bodyPreview'] ?? '', 'body_html' => ($bodyType === 'html') ? $bodyContent : nl2br(htmlspecialchars($bodyContent)), 'body_text' => ($bodyType === 'text') ? $bodyContent : strip_tags($bodyContent), 'from_name' => $msg['from']['emailAddress']['name'] ?? ($msg['from']['emailAddress']['address'] ?? 'Onbekend'), 'from_email' => $msg['from']['emailAddress']['address'] ?? '', 'to_recipients' => array_map(function($r) { return [ 'name' => $r['emailAddress']['name'] ?? '', 'address' => $r['emailAddress']['address'] ?? '', ]; }, $msg['toRecipients'] ?? []), 'cc_recipients' => array_map(function($r) { return [ 'name' => $r['emailAddress']['name'] ?? '', 'address' => $r['emailAddress']['address'] ?? '', ]; }, $msg['ccRecipients'] ?? []), 'received_at' => $receivedFormatted, 'received_human' => $receivedHuman, 'is_read' => true, 'has_attachments' => !empty($msg['hasAttachments']), 'importance' => strtolower($msg['importance'] ?? 'normal'), 'web_link' => $msg['webLink'] ?? 'https://outlook.office.com/mail/', 'is_demo' => false, ]; } /** * Markeert een e-mailbericht als gelezen of ongelezen */ function microsoft_mark_message_read(int $userId, string $messageId, bool $isRead = true): bool { if (strpos($messageId, 'demo-mail-') === 0) { return true; } $token = microsoft_get_valid_token($userId); if (!$token) return false; $url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId); $res = fociz_http_request($url, [ 'method' => 'PATCH', 'headers' => [ 'Authorization' => "Bearer {$token}", 'Content-Type' => 'application/json', ], 'body' => ['isRead' => $isRead], 'timeout' => 10, ]); return ($res['status'] >= 200 && $res['status'] < 300); } /** * Beantwoordt een e-mailbericht via Microsoft Graph API */ function microsoft_reply_message(int $userId, string $messageId, string $comment, bool $replyAll = false): array { if (strpos($messageId, 'demo-mail-') === 0) { return ['success' => true, 'message' => 'Demo antwoord succesvol gesimuleerd.']; } $token = microsoft_get_valid_token($userId); if (!$token) { return ['success' => false, 'error' => 'Geen geldige Microsoft verbinding.']; } $action = $replyAll ? 'replyAll' : 'reply'; $url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId) . '/' . $action; $res = fociz_http_request($url, [ 'method' => 'POST', 'headers' => [ 'Authorization' => "Bearer {$token}", 'Content-Type' => 'application/json', ], 'body' => [ 'comment' => $comment, ], 'timeout' => 15, ]); if ($res['status'] >= 200 && $res['status'] < 300) { return ['success' => true, 'message' => 'Antwoord succesvol verzonden via Outlook.']; } $errMsg = $res['json']['error']['message'] ?? ($res['error'] ?? 'Fout bij verzenden van antwoord.'); return ['success' => false, 'error' => $errMsg]; } /** * Stuurt een e-mailbericht door via Microsoft Graph API */ function microsoft_forward_message(int $userId, string $messageId, array $toRecipients, string $comment = ''): array { if (strpos($messageId, 'demo-mail-') === 0) { return ['success' => true, 'message' => 'Demo doorsturen succesvol gesimuleerd.']; } $token = microsoft_get_valid_token($userId); if (!$token) { return ['success' => false, 'error' => 'Geen geldige Microsoft verbinding.']; } $recipientsPayload = []; foreach ($toRecipients as $r) { $addr = is_array($r) ? ($r['address'] ?? '') : trim($r); $name = is_array($r) ? ($r['name'] ?? $addr) : $addr; if (!empty($addr)) { $recipientsPayload[] = [ 'emailAddress' => [ 'address' => $addr, 'name' => $name, ] ]; } } if (empty($recipientsPayload)) { return ['success' => false, 'error' => 'Geen geldige ontvangers opgegeven om door te sturen.']; } $url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId) . '/forward'; $res = fociz_http_request($url, [ 'method' => 'POST', 'headers' => [ 'Authorization' => "Bearer {$token}", 'Content-Type' => 'application/json', ], 'body' => [ 'toRecipients' => $recipientsPayload, 'comment' => $comment, ], 'timeout' => 15, ]); if ($res['status'] >= 200 && $res['status'] < 300) { return ['success' => true, 'message' => 'E-mail succesvol doorgestuurd via Outlook.']; } $errMsg = $res['json']['error']['message'] ?? ($res['error'] ?? 'Fout bij doorsturen van e-mail.'); return ['success' => false, 'error' => $errMsg]; } /** * Haalt het aantal ongelezen inbox e-mails op */ function microsoft_get_unread_mail_count(int $userId): int { $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']) || !empty($integration['is_demo_mode'])) { $demo = microsoft_get_mail_demo_data(); return count(array_filter($demo, function($m) { return empty($m['is_read']); })); } $token = microsoft_get_valid_token($userId); if (!$token) return 0; $url = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox?$select=unreadItemCount'; $res = fociz_http_request($url, [ 'method' => 'GET', 'headers' => [ 'Authorization' => "Bearer {$token}", 'Accept' => 'application/json', ], 'timeout' => 10, ]); if ($res['status'] === 200 && isset($res['json']['unreadItemCount'])) { return (int)$res['json']['unreadItemCount']; } return 0; }