Files
2026-09-16 13:53:54 +02:00

1918 lines
78 KiB
PHP

<?php
/**
* Microsoft 365 / Graph API Integratie voor Fociz
* Beheert OAuth2 authenticatie, token refresh en kalendersynchronisatie.
*/
require_once __DIR__ . '/../http.php';
require_once __DIR__ . '/../../config/database.php';
require_once __DIR__ . '/../cache.php';
const MS_OAUTH_AUTHORIZE_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize';
const MS_OAUTH_TOKEN_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token';
const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
const MS_DEFAULT_SCOPES = 'offline_access User.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send OnlineMeetings.ReadWrite';
/**
* Haalt de centrale Microsoft App Registratie configuratie op (Client ID, Secret, Tenant ID)
* Prioriteit:
* 1. Environment variabelen (.env of server env)
* 2. Opgeslagen configuratie van een Beheerder (ADMIN) in de integrations tabel
* 3. Fallback naar integrations tabel waar settings client_id bevat
*/
function microsoft_get_app_config(): array {
$clientId = fociz_env('MICROSOFT_CLIENT_ID', '');
$clientSecret = fociz_env('MICROSOFT_CLIENT_SECRET', '');
$tenantId = fociz_env('MICROSOFT_TENANT_ID', '');
try {
$db = get_db_connection();
$adminOrgDomain = '';
// Zoek bij actieve beheerder(s) in de integrations tabel
$stmt = $db->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 = '', bool $forceConsent = false): 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' => $forceConsent ? 'consent' : '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;
}
$accessToken = fociz_decrypt($integration['access_token']);
$refreshToken = fociz_decrypt($integration['refresh_token'] ?? '');
$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 $accessToken;
}
// Token is verlopen of forceRefresh gevraagd; refresh met 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 ? $accessToken : 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([
fociz_encrypt($newAccessToken),
fociz_encrypt($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($accessToken)) {
return $accessToken;
}
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, bool $forceRefresh = false): array {
$cacheKey = "ms_events_{$userId}_{$startDate}_{$endDate}";
if (!$forceRefresh) {
$cached = fociz_cache_get($cacheKey);
if ($cached !== null && is_array($cached)) {
return $cached;
}
}
$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' => 6,
]);
// 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' => 6,
]);
}
}
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',
];
}
fociz_cache_set($cacheKey, $events, 300); // 5 minuten cache
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';
}
}
$isAllDay = !empty($eventData['is_all_day']);
if ($isAllDay) {
$startDate = $date;
$endDate = !empty($eventData['end_date']) ? trim($eventData['end_date']) : $date;
// In MS Graph API is de einddatum van een all-day event exclusief (dus +1 dag om hele periode te beslaan)
$endPlusOne = date('Y-m-d', strtotime('+1 day', strtotime($endDate)));
$startDateTimeStr = "{$startDate}T00:00:00";
$endDateTimeStr = "{$endPlusOne}T00:00:00";
$showAs = !empty($eventData['show_as']) ? $eventData['show_as'] : 'oof'; // Out of Office / Afwezig
$isReminderOn = false;
$startTime = '00:00';
$endTime = '23:59';
} else {
$startDateTimeStr = "{$date}T{$startTime}:00";
$endDateTimeStr = "{$date}T{$endTime}:00";
$showAs = 'busy';
$isReminderOn = true;
}
$payload = [
'subject' => $subject,
'body' => [
'contentType' => 'HTML',
'content' => nl2br(htmlspecialchars($description)) . '<br><br><small style="color: #64748b;">Aangemaakt via <a href="' . htmlspecialchars(defined('APP_URL') ? APP_URL : '') . '/planner.php">Fociz Dagplanning</a></small>',
],
'start' => [
'dateTime' => $startDateTimeStr,
'timeZone' => 'Europe/Amsterdam',
],
'end' => [
'dateTime' => $endDateTimeStr,
'timeZone' => 'Europe/Amsterdam',
],
'isAllDay' => $isAllDay,
'showAs' => $showAs,
'isReminderOn' => $isReminderOn,
'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'])) {
fociz_cache_delete_pattern("ms_events_{$userId}_");
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,
]);
$ok = in_array($response['status'], [200, 204, 404]);
if ($ok) {
fociz_cache_delete_pattern("ms_events_{$userId}_");
}
return $ok;
}
/**
* 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'];
}
if (isset($eventData['description']) && $eventData['description'] !== '') {
$payload['body'] = [
'contentType' => 'HTML',
'content' => nl2br(htmlspecialchars($eventData['description'])),
];
}
$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,
]);
}
}
$ok = in_array($response['status'], [200, 204]);
if ($ok) {
fociz_cache_delete_pattern("ms_events_{$userId}_");
}
return $ok;
}
/**
* 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(?string $folderId = 'inbox'): array {
$now = time();
$fId = strtolower(trim($folderId ?: 'inbox'));
if ($fId === 'demo-folder-dx') {
return [
[
'id' => 'demo-mail-dx-1',
'folder_id' => 'demo-folder-dx',
'subject' => '⚡ Update: REST endpoint v2 specificatie voor UWV & Koppelingen',
'body_preview' => 'Beste collega, hierbij de bijgewerkte documentatie voor de REST endpoints en de schema-wijzigingen voor de datadump...',
'body_html' => '<p>Beste collega,</p><p>Hierbij de bijgewerkte documentatie voor de <strong>REST endpoints</strong> en de schema-wijzigingen voor de datadump.</p><p>Graag controleren of de data mapping aansluit bij onze migratiestappen.</p><p>Met vriendelijke groet,<br><strong>Team Koppelingen & DX</strong></p>',
'from_name' => 'Koppelingen Team DX',
'from_email' => 'dx-koppelingen@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (25 * 60)),
'received_human' => '25 min geleden',
'is_read' => false,
'has_attachments' => true,
'importance' => 'high',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
[
'id' => 'demo-mail-dx-2',
'folder_id' => 'demo-folder-dx',
'subject' => 'Migratie draaiboek koppelingen fase 3 gereed',
'body_preview' => 'Het draaiboek voor de uitrol van de vernieuwde interfaces is goedgekeurd door de lead architect...',
'body_html' => '<p>Het draaiboek voor de uitrol van de vernieuwde interfaces is goedgekeurd door de lead architect. Zie de bijlage voor de tijdsplanning.</p>',
'from_name' => 'Lead Architect',
'from_email' => 'architect@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (4 * 3600)),
'received_human' => '4 uur geleden',
'is_read' => true,
'has_attachments' => false,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
[
'id' => 'demo-mail-dx-3',
'folder_id' => 'demo-folder-dx',
'subject' => 'SSO Certificaat verificatie voor staging omgeving',
'body_preview' => 'Het SSL/TLS certificaat voor de authenticatiekoppeling verloopt over 30 dagen. Het nieuwe certificaat is klaargezet...',
'body_html' => '<p>Het SSL/TLS certificaat voor de authenticatiekoppeling verloopt over 30 dagen. Het nieuwe certificaat is klaargezet op de staging server.</p>',
'from_name' => 'Security & Certificaten',
'from_email' => 'security@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (26 * 3600)),
'received_human' => 'Gisteren',
'is_read' => true,
'has_attachments' => false,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
];
}
if ($fId === 'demo-folder-klanten') {
return [
[
'id' => 'demo-mail-klant-1',
'folder_id' => 'demo-folder-klanten',
'subject' => 'Project kick-off partnerportaal integratie',
'body_preview' => 'Beste Fociz team, bedankt voor de toelichting gisteren. We kijken uit naar de gezamenlijke sprintstart volgende maand...',
'body_html' => '<p>Beste Fociz team,</p><p>Bedankt voor de toelichting gisteren. We kijken uit naar de gezamenlijke sprintstart volgende maand.</p><p>Vriendelijke groet,<br><strong>Partner Relatiemanager</strong></p>',
'from_name' => 'Partner Relaties',
'from_email' => 'partners@klantportaal.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (5 * 3600)),
'received_human' => '5 uur geleden',
'is_read' => true,
'has_attachments' => false,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
[
'id' => 'demo-mail-klant-2',
'folder_id' => 'demo-folder-klanten',
'subject' => 'Vraag over API sleutels en documentatie',
'body_preview' => 'Hallo, kunnen we de sandbox tokens alvast ontvangen voor onze ontwikkelaars?...',
'body_html' => '<p>Hallo, kunnen we de sandbox tokens alvast ontvangen voor onze ontwikkelaars? Alvast dank!</p>',
'from_name' => 'IT Lead Klant',
'from_email' => 'tech@partnerbedrijf.com',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (28 * 3600)),
'received_human' => 'Gisteren',
'is_read' => true,
'has_attachments' => false,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
];
}
if ($fId === 'demo-folder-fin') {
return [
[
'id' => 'demo-mail-fin-1',
'folder_id' => 'demo-folder-fin',
'subject' => 'Nieuwe factuur September licenties ter goedkeuring',
'body_preview' => 'Beste directie, bijgaand de specificatie van de maandelijks cloud infrastructuur kosten ter akkoord...',
'body_html' => '<p>Beste directie,</p><p>Bijgaand de specificatie van de maandelijkse cloud infrastructuur kosten ter akkoord. Graag uiterlijk vrijdag verwerken.</p>',
'from_name' => 'Financiële Administratie',
'from_email' => 'facturen@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (40 * 60)),
'received_human' => '40 min geleden',
'is_read' => false,
'has_attachments' => true,
'importance' => 'high',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
[
'id' => 'demo-mail-fin-2',
'folder_id' => 'demo-folder-fin',
'subject' => 'Betaalbevestiging software koppelingen',
'body_preview' => 'Uw betaling voor de kwartaallicenties is succesvol verwerkt...',
'body_html' => '<p>Uw betaling voor de kwartaallicenties is succesvol verwerkt. Factuurnummer INV-2026-09.</p>',
'from_name' => 'Accounts Payable',
'from_email' => 'billing@cloudvendor.com',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)),
'received_human' => '2 dagen geleden',
'is_read' => true,
'has_attachments' => false,
'importance' => 'low',
'web_link' => 'https://outlook.office.com/mail/',
'is_demo' => true,
],
];
}
// Standaard Inbox demo berichten
return [
[
'id' => 'demo-mail-1',
'folder_id' => 'inbox',
'subject' => '⚡ Urgent: UWV Koppeling REST-endpoints en Datamigratie timing',
'body_preview' => 'Beste collega, we hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de UWV REST-endpoints en de validatie van werknemersdossiers...',
'body_html' => '<p>Beste collega,</p><p>We hebben vanuit de overheidsspecificaties aanvullende eisen ontvangen omtrent de <strong>UWV REST-endpoints</strong> en de validatie van werknemersdossiers.</p><p>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.</p><p>Met vriendelijke groet,<br><strong>Directie UWV Koppelingen & Integraties</strong></p>',
'from_name' => 'Directie UWV Integraties',
'from_email' => 'koppelingen@uwv-overheid.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (12 * 60)),
'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',
'folder_id' => 'inbox',
'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' => '<p>Hallo team,</p><p>Bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de werkweek accorderen in het systeem.</p><p>Laat het weten indien er correcties nodig zijn op de dagregistraties.</p><p>Hartelijke groet,<br><strong>Financiële Administratie Fociz</strong></p>',
'from_name' => 'Financiële Administratie',
'from_email' => 'facturatie@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (65 * 60)),
'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',
'folder_id' => 'inbox',
'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' => '<p>Goedemiddag,</p><p>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.</p><p>Alle endpoint logs zijn beschikbaar via het beheerpaneel.</p><p>Groeten,<br><strong>Team DevOps & Infrastructuur</strong></p>',
'from_name' => 'DevOps Systeembeheer',
'from_email' => 'infra@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (3 * 3600)),
'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',
'folder_id' => 'inbox',
'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' => '<p>Beste collega\'s,</p><p>Vergeet niet je prioriteiten en feature requests in te dienen in de <strong>Fociz Roadmap</strong> module voor de meeting van aanstaande donderdag.</p><p>We behandelen dan tevens de nieuwe Kanban borden en de Outlook time-blocking ervaring.</p><p>Tot dan!<br><strong>Product Management</strong></p>',
'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)),
'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',
'folder_id' => 'inbox',
'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' => '<p>Welkom bij Fociz!</p><p>Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw persoonlijke dagplanning?</p><p>Hierdoor houd je maximale grip op je werkdag en behoud je de ultieme focus.</p><p>Veel productiviteit gewenst,<br><strong>Het Fociz Team</strong></p>',
'from_name' => 'Fociz Support Team',
'from_email' => 'no-reply@fociz.nl',
'to_recipients' => [['name' => 'Collega', 'address' => 'collega@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)),
'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 de door de gebruiker uitgeschakelde mailmappen op
*/
function microsoft_get_disabled_folders(int $userId): array {
try {
$db = get_db_connection();
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row || empty($row['settings'])) return [];
$settings = json_decode($row['settings'], true);
if (is_array($settings) && !empty($settings['disabled_mail_folders']) && is_array($settings['disabled_mail_folders'])) {
return array_values(array_unique(array_filter(array_map('strval', $settings['disabled_mail_folders']))));
}
return [];
} catch (Throwable $e) {
return [];
}
}
/**
* Slaat de uitgeschakelde mailmappen op voor een gebruiker
*/
function microsoft_save_disabled_folders(int $userId, array $disabledFolders): bool {
try {
$db = get_db_connection();
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$userId]);
$row = $stmt->fetch();
$settings = ($row && !empty($row['settings'])) ? (json_decode($row['settings'], true) ?: []) : [];
$clean = array_values(array_unique(array_filter(array_map('strval', $disabledFolders))));
$settings['disabled_mail_folders'] = $clean;
$update = $db->prepare("UPDATE integrations SET settings = ?, updated_at = NOW() WHERE user_id = ? AND provider = 'OUTLOOK'");
$ok = $update->execute([json_encode($settings, JSON_UNESCAPED_UNICODE), $userId]);
fociz_cache_delete("ms_folders_{$userId}");
return $ok;
} catch (Throwable $e) {
error_log("microsoft_save_disabled_folders error: " . $e->getMessage());
return false;
}
}
/**
* Haalt alle beschikbare mailmappen op van Microsoft Graph API (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox en Concepten)
*/
function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false): array {
$cacheKey = "ms_folders_{$userId}";
if (!$forceRefresh) {
$cached = fociz_cache_get($cacheKey);
if ($cached !== null && is_array($cached) && !empty($cached['success'])) {
return $cached;
}
}
$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) {
$demoFolders = [
[
'id' => 'inbox',
'name' => 'Postvak IN',
'unread_count' => 2,
'total_count' => 5,
'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
],
[
'id' => 'demo-folder-dx',
'name' => 'Projecten & DX Koppelingen',
'unread_count' => 1,
'total_count' => 3,
'is_inbox' => false,
'parent_id' => null,
'is_child' => false,
],
[
'id' => 'demo-folder-klanten',
'name' => 'Klanten & Partners',
'unread_count' => 0,
'total_count' => 2,
'is_inbox' => false,
'parent_id' => null,
'is_child' => false,
],
[
'id' => 'demo-folder-fin',
'name' => 'Financiën & Facturatie',
'unread_count' => 1,
'total_count' => 2,
'is_inbox' => false,
'parent_id' => null,
'is_child' => false,
],
];
$disabledFolders = microsoft_get_disabled_folders($userId);
$disabledSet = array_flip($disabledFolders);
$totalUnread = 0;
foreach ($demoFolders as $df) {
if (!isset($disabledSet[$df['id']])) {
$totalUnread += (int)($df['unread_count'] ?? 0);
}
}
return [
'success' => true,
'is_demo' => true,
'folders' => $demoFolders,
'total_unread' => $totalUnread,
'disabled_folders' => $disabledFolders,
];
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return [
'success' => false,
'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
'folders' => [
[
'id' => 'inbox',
'name' => 'Postvak IN',
'unread_count' => 0,
'total_count' => 0,
'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
]
],
'total_unread' => 0,
];
}
// 1. Haal de hoofdmappen op ZONDER restrictief $select (voorkomt 400 Bad Request op wellKnownName)
$url = MS_GRAPH_BASE_URL . '/me/mailFolders?$top=100';
$res = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
],
'timeout' => 15,
]);
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
$graphError = $res['json']['error']['message'] ?? ($res['error'] ?? 'HTTP ' . $res['status']);
error_log("Microsoft fetch folders error: HTTP {$res['status']} - {$graphError}");
return [
'success' => false,
'error' => $graphError,
'folders' => [
[
'id' => 'inbox',
'name' => 'Postvak IN',
'unread_count' => 0,
'total_count' => 0,
'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
]
],
'total_unread' => 0,
];
}
$rawFolders = is_array($res['json']['value']) ? $res['json']['value'] : [];
// 2. Haal ALTIJD expliciet de submappen van Postvak IN op
// (In Outlook bevinden 95% van de door gebruikers aangemaakte mappen zich direct onder Inbox)
$inboxChildUrl = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox/childFolders?$top=100';
$inboxChildRes = fociz_http_request($inboxChildUrl, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
],
'timeout' => 8,
]);
if ($inboxChildRes['status'] === 200 && !empty($inboxChildRes['json']['value']) && is_array($inboxChildRes['json']['value'])) {
foreach ($inboxChildRes['json']['value'] as $child) {
$rawFolders[] = $child;
// Als een eigen submap onder Inbox zelf ook weer submappen heeft: haal die ook op
$cfc = (int)($child['childFolderCount'] ?? 0);
$cId = $child['id'] ?? '';
if ($cfc > 0 && !empty($cId)) {
$subRes = fociz_http_request(MS_GRAPH_BASE_URL . '/me/mailFolders/' . rawurlencode($cId) . '/childFolders?$top=100', [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
],
'timeout' => 6,
]);
if ($subRes['status'] === 200 && !empty($subRes['json']['value']) && is_array($subRes['json']['value'])) {
foreach ($subRes['json']['value'] as $subChild) {
$rawFolders[] = $subChild;
}
}
}
}
}
// 4. Uitsluitingen conform gebruikerswens:
// "de SPAM , Ongewenste email en Prullenbak hoeven er niet in."
// Ook Outbox, Drafts en technische sync logs uitsluiten
$excludedWellKnown = [
'junkemail', 'deleteditems', 'outbox', 'drafts',
'conflicts', 'localfailures', 'serverfailures', 'syncissues', 'conversationhistory',
'clutter', 'recoverableitemsdeletions'
];
$excludedExactNames = [
// Nederlands
'ongewenste e-mail', 'ongewenste email', 'ongewenst', 'spam', 'junk',
'verwijderde items', 'prullenbak', 'trash', 'verwijderd',
'postvak uit', 'outbox',
'concepten', 'drafts',
'gespreksgeschiedenis', 'conversation history',
'problemen met synchronisatie', 'synchronisatieproblemen', 'conflicten',
// Engels
'junk email', 'deleted items', 'sync issues', 'conflicts', 'local failures', 'server failures'
];
$folders = [];
$seenIds = [];
foreach ($rawFolders as $rf) {
$fId = trim($rf['id'] ?? '');
if (empty($fId) || isset($seenIds[$fId])) {
continue;
}
// Sla verborgen mappen over
if (!empty($rf['isHidden'])) {
continue;
}
$wk = strtolower(trim($rf['wellKnownName'] ?? ''));
if (in_array($wk, $excludedWellKnown, true)) {
continue;
}
$dName = trim($rf['displayName'] ?? '');
$dNameLower = strtolower($dName);
if (in_array($dNameLower, $excludedExactNames, true)) {
continue;
}
if (strpos($dNameLower, 'problemen met synchronisatie') === 0 || strpos($dNameLower, 'sync issues') === 0) {
continue;
}
$isInbox = ($wk === 'inbox' || $dNameLower === 'inbox' || $dNameLower === 'postvak in');
$seenIds[$fId] = true;
$folders[] = [
'id' => $isInbox ? 'inbox' : $fId,
'name' => $dName ?: ($isInbox ? 'Postvak IN' : 'Map'),
'unread_count' => (int)($rf['unreadItemCount'] ?? 0),
'total_count' => (int)($rf['totalItemCount'] ?? 0),
'is_inbox' => $isInbox,
'parent_id' => $rf['parentFolderId'] ?? null,
'is_child' => !empty($rf['parentFolderId']) && !$isInbox,
];
}
// Garandeer dat Inbox altijd aanwezig is
$hasInbox = false;
foreach ($folders as $f) {
if (!empty($f['is_inbox'])) {
$hasInbox = true;
break;
}
}
if (!$hasInbox) {
array_unshift($folders, [
'id' => 'inbox',
'name' => 'Postvak IN',
'unread_count' => 0,
'total_count' => 0,
'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
]);
}
// Sorteer: Inbox altijd bovenaan, daarna alfabetisch
usort($folders, function($a, $b) {
if (!empty($a['is_inbox'])) return -1;
if (!empty($b['is_inbox'])) return 1;
return strcasecmp($a['name'], $b['name']);
});
$disabledFolders = microsoft_get_disabled_folders($userId);
$disabledSet = array_flip($disabledFolders);
$totalUnread = 0;
foreach ($folders as $f) {
if (!isset($disabledSet[$f['id']])) {
$totalUnread += (int)($f['unread_count'] ?? 0);
}
}
$result = [
'success' => true,
'is_demo' => false,
'folders' => $folders,
'total_unread' => $totalUnread,
'disabled_folders' => $disabledFolders,
];
fociz_cache_set($cacheKey, $result, 300); // 5 minuten cache
return $result;
}
/**
* Haalt berichten op voor een specifieke map van Microsoft Graph API (of retourneert demo data)
*/
function microsoft_fetch_folder_messages(int $userId, string $folderId = 'inbox', int $limit = 30, bool $unreadOnly = false, bool $forceRefresh = false): array {
$cleanFolderId = trim($folderId ?: 'inbox');
$cacheKey = "ms_folder_{$userId}_" . md5($cleanFolderId) . "_{$limit}_" . ($unreadOnly ? '1' : '0');
if (!$forceRefresh) {
$cached = fociz_cache_get($cacheKey);
if ($cached !== null && is_array($cached)) {
return $cached;
}
}
$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($cleanFolderId);
if ($unreadOnly) {
$demo = array_values(array_filter($demo, function($m) { return empty($m['is_read']); }));
}
return [
'success' => true,
'is_demo' => true,
'folder_id' => $cleanFolderId,
'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.',
'folder_id' => $cleanFolderId,
'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';
}
$folderEndpoint = ($cleanFolderId === 'inbox') ? 'inbox' : rawurlencode($cleanFolderId);
$url = MS_GRAPH_BASE_URL . '/me/mailFolders/' . $folderEndpoint . '/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' => 6,
]);
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
$graphError = $res['json']['error']['message'] ?? ($res['error'] ?? 'HTTP ' . $res['status']);
$graphCode = $res['json']['error']['code'] ?? '';
error_log("Microsoft fetch folder messages error for {$cleanFolderId}: HTTP {$res['status']} [{$graphCode}] - {$graphError}");
$demo = microsoft_get_mail_demo_data($cleanFolderId);
$needsReconsent = ($res['status'] === 403 || stripos($graphError, 'access') !== false || stripos($graphError, 'privilege') !== false || stripos($graphError, 'denied') !== false || stripos($graphError, 'consent') !== false);
return [
'success' => true,
'is_demo' => true,
'folder_id' => $cleanFolderId,
'messages' => array_slice($demo, 0, $limit),
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
'total_count' => count($demo),
'warning' => $needsReconsent
? 'Je Microsoft koppeling mist machtiging voor e-mail (Mail.ReadWrite). Autoriseer opnieuw om je inbox live in te laden.'
: 'Kon live e-mails niet ophalen van Microsoft Graph (' . $graphError . '). Demomodus actief.',
'needs_reconsent' => $needsReconsent,
'graph_error' => $graphError,
'graph_code' => $graphCode,
'http_status' => $res['status'],
];
}
$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'] ?? '',
'folder_id' => $cleanFolderId,
'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']); }));
$result = [
'success' => true,
'is_demo' => false,
'folder_id' => $cleanFolderId,
'messages' => $messages,
'unread_count' => $unreadCount,
'total_count' => count($messages),
];
fociz_cache_set($cacheKey, $result, 45); // 45 seconden cache
return $result;
}
/**
* Haalt inbox berichten op van Microsoft Graph API (of retourneert demo data)
*/
function microsoft_fetch_inbox_messages(int $userId, int $limit = 30, bool $unreadOnly = false, bool $forceRefresh = false): array {
return microsoft_fetch_folder_messages($userId, 'inbox', $limit, $unreadOnly, $forceRefresh);
}
/**
* 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,
]);
$ok = ($res['status'] >= 200 && $res['status'] < 300);
if ($ok) {
fociz_cache_delete_pattern("ms_inbox_{$userId}_");
}
return $ok;
}
/**
* 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) {
fociz_cache_delete_pattern("ms_inbox_{$userId}_");
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;
}