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): ?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, direct retourneren if ($expiresAt > (time() + 240)) { return $integration['access_token']; } // Token is verlopen of verloopt bijna; 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 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']))); return null; } /** * 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'); $token = microsoft_get_valid_token($userId); if (!$token) { return []; } // Venster van 00:00:00 tot 23:59:59 lokale tijd $startIso = "{$targetDate}T00:00:00"; $endIso = "{$targetDate}T23:59:59"; $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' => '50', ]); $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'])) { return []; } $rawEvents = $response['json']['value']; $events = []; foreach ($rawEvents as $e) { // Annuleringen overslaan if (!empty($e['isCancelled'])) { continue; } $rawStart = $e['start']['dateTime'] ?? ''; $rawEnd = $e['end']['dateTime'] ?? ''; // Formatteer naar H:i (bijv. 09:30) $startTime = ''; $endTime = ''; if (!empty($rawStart)) { $startTime = date('H:i', strtotime($rawStart)); } if (!empty($rawEnd)) { $endTime = date('H:i', strtotime($rawEnd)); } // Teams of meeting URL detecteren $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)', 'start' => $startTime ?: '09:00', 'end' => $endTime ?: '10:00', 'start_raw' => $rawStart, 'end_raw' => $rawEnd, 'location' => $locationName ?: 'Geen locatie', 'meeting_url' => $meetingUrl, 'is_all_day' => !empty($e['isAllDay']), 'source' => 'OUTLOOK_LIVE', ]; } return $events; } /** * 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']]); } }