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;
}
$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, 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' => 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',
];
}
fociz_cache_set($cacheKey, $events, 60);
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'])) {
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'];
}
$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(): 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
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
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
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
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