Schone start na index corruptie

This commit is contained in:
2026-09-09 14:31:23 +02:00
commit a5324548ec
41 changed files with 9351 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
<?php
/**
* Atlassian Jira Cloud REST API Integratie voor Fociz
* Beheert verbindingstest, ophalen van aan de gebruiker toegewezen issues en import naar dagtaken.
*/
require_once __DIR__ . '/../http.php';
require_once __DIR__ . '/../../config/database.php';
/**
* Normaliseert het Jira domein naar een schone URL zonder slash aan het einde
*/
function jira_normalize_domain(string $domain): string {
$domain = trim($domain);
if (!preg_match('#^https?://#i', $domain)) {
$domain = 'https://' . $domain;
}
return rtrim($domain, '/');
}
/**
* Haalt het centrale bedrijfsdomein voor Atlassian Jira op
*/
function jira_get_company_domain(): string {
$domain = fociz_env('JIRA_DOMAIN', '');
if (!empty($domain)) {
return jira_normalize_domain($domain);
}
try {
$db = get_db_connection();
// Zoek bij beheerder(s) in de integrations tabel
$stmt = $db->query("
SELECT i.settings
FROM integrations i
JOIN users u ON i.user_id = u.id
WHERE u.role = 'ADMIN' AND i.provider = 'JIRA' AND i.settings IS NOT NULL
ORDER BY i.updated_at DESC
LIMIT 1
");
$row = $stmt->fetch();
if (!empty($row['settings'])) {
$settings = json_decode($row['settings'], true) ?: [];
if (!empty($settings['jira_domain'])) {
return jira_normalize_domain($settings['jira_domain']);
}
}
// Fallback naar een willekeurige JIRA rij
$fallback = $db->query("
SELECT settings FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_domain%' LIMIT 1
")->fetch();
if (!empty($fallback['settings'])) {
$settings = json_decode($fallback['settings'], true) ?: [];
if (!empty($settings['jira_domain'])) {
return jira_normalize_domain($settings['jira_domain']);
}
}
} catch (Throwable $e) {
error_log("jira_get_company_domain error: " . $e->getMessage());
}
return '';
}
/**
* Genereert de Basic Authentication header voor Atlassian REST API
*/
function jira_get_auth_header(string $email, string $apiToken): string {
return 'Basic ' . base64_encode(trim($email) . ':' . trim($apiToken));
}
/**
* Test de verbinding met Atlassian Jira Cloud via /rest/api/3/myself
*/
function jira_test_connection(string $domain, string $email, string $apiToken): array {
$cleanDomain = jira_normalize_domain($domain);
if (empty($cleanDomain) || empty($email) || empty($apiToken)) {
return [
'success' => false,
'error' => 'Vul een Jira domein, e-mailadres en Atlassian API token in.',
];
}
$authHeader = jira_get_auth_header($email, $apiToken);
$url = $cleanDomain . '/rest/api/3/myself';
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
// Fallback naar api v2 indien v3 niet beschikbaar is
if ($response['status'] === 404) {
$urlV2 = $cleanDomain . '/rest/api/2/myself';
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
}
if ($response['status'] === 200 && is_array($response['json'])) {
$user = $response['json'];
return [
'success' => true,
'user' => [
'displayName' => $user['displayName'] ?? 'Atlassian Gebruiker',
'emailAddress' => $user['emailAddress'] ?? $email,
'accountId' => $user['accountId'] ?? ($user['key'] ?? ''),
'active' => $user['active'] ?? true,
'timeZone' => $user['timeZone'] ?? 'Europe/Amsterdam',
],
];
}
$errMsg = 'Verbinding mislukt met Jira.';
if ($response['status'] === 401) {
$errMsg = 'Authenticatie geweigerd (HTTP 401). Controleer uw e-mailadres en zorg dat u een geldig Atlassian API token gebruikt.';
} elseif ($response['status'] === 403) {
$errMsg = 'Toegang geweigerd (HTTP 403). Uw account heeft onvoldoende rechten op dit Jira domein.';
} elseif ($response['status'] === 404) {
$errMsg = 'Jira Cloud domein niet gevonden (HTTP 404). Controleer het ingevoerde domein (bijv. uwbedrijf.atlassian.net).';
} elseif (!empty($response['error'])) {
$errMsg = "Netwerkfout: {$response['error']}";
}
return [
'success' => false,
'error' => $errMsg,
'status' => $response['status'],
];
}
/**
* Haalt alle actieve issues op die zijn toegewezen aan de ingelogde Jira gebruiker
*/
function jira_fetch_assigned_issues(string $domain, string $email, string $apiToken, int $maxResults = 50): array {
$cleanDomain = jira_normalize_domain($domain);
$authHeader = jira_get_auth_header($email, $apiToken);
// JQL: Alle openstaande taken toegewezen aan mij, gesorteerd op recent bijgewerkt
$jql = 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC';
$params = [
'jql' => $jql,
'maxResults' => min($maxResults, 100),
'fields' => 'summary,status,priority,issuetype,parent,project,duedate,updated',
];
$url = $cleanDomain . '/rest/api/3/search?' . http_build_query($params);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
// Fallback naar api v2
if ($response['status'] === 404) {
$urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($params);
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
}
if ($response['status'] !== 200 || !isset($response['json']['issues'])) {
return [
'success' => false,
'error' => $response['json']['errorMessages'][0] ?? ($response['error'] ?? 'Kan Jira tickets niet ophalen'),
'issues' => [],
];
}
$rawIssues = $response['json']['issues'];
$issues = [];
foreach ($rawIssues as $item) {
$fields = $item['fields'] ?? [];
$key = $item['key'] ?? '';
$summary = $fields['summary'] ?? '(Geen titel)';
$statusName = $fields['status']['name'] ?? 'To Do';
$priorityName = $fields['priority']['name'] ?? 'Medium';
$issueTypeName = $fields['issuetype']['name'] ?? 'Taak';
$projectName = $fields['project']['name'] ?? 'Project';
$dueDate = $fields['duedate'] ?? null;
$issues[] = [
'key' => $key,
'summary' => $summary,
'status' => $statusName,
'priority' => $priorityName,
'issue_type' => $issueTypeName,
'project' => $projectName,
'due_date' => $dueDate,
'url' => "{$cleanDomain}/browse/{$key}",
'source' => 'JIRA_LIVE',
];
}
return [
'success' => true,
'issues' => $issues,
'total' => count($issues),
];
}
/**
* Haalt de Jira tickets op voor een Fociz gebruiker
* Schakelt naadloos tussen live Jira tickets en sandbox demotickets
*/
function jira_get_user_issues(int $userId, bool $forceRefresh = false): array {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
// Als de integratie niet is ingesteld of in demo mode staat, geef interactieve fallback issues terug
if (!$integration || !empty($integration['is_demo_mode'])) {
return [
'is_demo' => true,
'success' => true,
'issues' => [
[
'key' => 'FOC-101',
'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren',
'status' => 'In Progress',
'priority' => 'High',
'issue_type' => 'Story',
'project' => 'Fociz Platform',
'due_date' => date('Y-m-d', strtotime('+2 days')),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-102',
'summary' => 'Interactief whiteboard canvas voor brainstormsessies',
'status' => 'To Do',
'priority' => 'High',
'issue_type' => 'Feature',
'project' => 'Fociz Platform',
'due_date' => date('Y-m-d', strtotime('+4 days')),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-103',
'summary' => 'Koppeling met Microsoft Outlook Calendar & Teams Graph API',
'status' => 'In Review',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Integrations Hub',
'due_date' => date('Y-m-d'),
'url' => '#',
'source' => 'SANDBOX',
],
[
'key' => 'FOC-104',
'summary' => 'Roadmap kwartaalweergave en voortgangsindicatoren',
'status' => 'To Do',
'priority' => 'Medium',
'issue_type' => 'Story',
'project' => 'Roadmap Module',
'due_date' => date('Y-m-d', strtotime('+7 days')),
'url' => '#',
'source' => 'SANDBOX',
],
],
];
}
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
$domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain();
$email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', '');
$apiToken = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', '');
if (empty($domain) || empty($email) || empty($apiToken)) {
return [
'is_demo' => false,
'success' => false,
'error' => 'Jira configuratie is onvolledig.',
'issues' => [],
];
}
// Sessiecache voor 90 seconden om onnodige Jira API calls bij snelle pagina refreshes te beperken
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
$cacheKey = "jira_issues_user_{$userId}";
$cached = $_SESSION[$cacheKey] ?? null;
if (!$forceRefresh && $cached && is_array($cached) && ($cached['timestamp'] ?? 0) > (time() - 90)) {
return [
'is_demo' => false,
'success' => true,
'issues' => $cached['issues'],
'from_cache' => true,
];
}
$result = jira_fetch_assigned_issues($domain, $email, $apiToken, 50);
if ($result['success']) {
$_SESSION[$cacheKey] = [
'timestamp' => time(),
'issues' => $result['issues'],
];
// Werk last_sync_at bij
$stmtUpdate = $db->prepare("UPDATE integrations SET last_sync_at = NOW() WHERE id = ?");
$stmtUpdate->execute([$integration['id']]);
return [
'is_demo' => false,
'success' => true,
'issues' => $result['issues'],
];
}
return [
'is_demo' => false,
'success' => false,
'error' => $result['error'],
'issues' => [],
];
}
/**
* Ontkoppelt Jira integratie voor een gebruiker
*/
function jira_disconnect(int $userId): bool {
$db = get_db_connection();
$stmt = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
if (session_status() === PHP_SESSION_NONE) {
@session_start();
}
unset($_SESSION["jira_issues_user_{$userId}"]);
return $stmt->execute([$userId]);
}
+596
View File
@@ -0,0 +1,596 @@
<?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';
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';
/**
* 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 = ''): string {
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
$baseUrl = sprintf(MS_OAUTH_AUTHORIZE_URL, urlencode($tenant));
$params = [
'client_id' => trim($clientId),
'response_type' => 'code',
'redirect_uri' => trim($redirectUri),
'response_mode' => 'query',
'scope' => MS_DEFAULT_SCOPES,
'state' => $state,
'prompt' => 'select_account',
];
return $baseUrl . '?' . http_build_query($params);
}
/**
* Wisselt de ontvangen autorisatiecode om voor access- en refresh tokens
*/
function microsoft_exchange_code(string $code, string $clientId, string $clientSecret, string $redirectUri, string $tenantId = ''): array {
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($tenant));
$payload = [
'client_id' => trim($clientId),
'client_secret' => trim($clientSecret),
'code' => trim($code),
'redirect_uri' => trim($redirectUri),
'grant_type' => 'authorization_code',
'scope' => MS_DEFAULT_SCOPES,
];
$response = fociz_http_request($tokenUrl, [
'method' => 'POST',
'body' => $payload,
'timeout' => 15,
]);
if ($response['status'] !== 200 || empty($response['json']['access_token'])) {
$errorMsg = $response['json']['error_description'] ?? ($response['error'] ?? 'Onbekende fout bij token uitwisseling');
return [
'success' => false,
'error' => $errorMsg,
];
}
return [
'success' => true,
'access_token' => $response['json']['access_token'],
'refresh_token' => $response['json']['refresh_token'] ?? null,
'expires_in' => (int)($response['json']['expires_in'] ?? 3600),
];
}
/**
* Haalt het Microsoft gebruikersprofiel op via Graph API
*/
function microsoft_get_user_profile(string $accessToken): ?array {
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me', [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$accessToken}",
'Accept' => 'application/json',
],
'timeout' => 10,
]);
if ($response['status'] === 200 && is_array($response['json'])) {
return [
'displayName' => $response['json']['displayName'] ?? 'Microsoft 365 Gebruiker',
'mail' => $response['json']['mail'] ?? ($response['json']['userPrincipalName'] ?? ''),
'id' => $response['json']['id'] ?? '',
];
}
return null;
}
/**
* Haalt een geldige access_token op voor de gebruiker; vernieuwt automatisch indien verlopen
*/
function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?string {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
if (!$integration || empty($integration['access_token'])) {
return null;
}
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
$expiresAt = (int)($settings['expires_at'] ?? 0);
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
if (!$forceRefresh && $expiresAt > (time() + 240)) {
return $integration['access_token'];
}
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
$refreshToken = $integration['refresh_token'] ?? '';
$appConfig = microsoft_get_app_config();
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
return !$forceRefresh ? ($integration['access_token'] ?? null) : null;
}
$cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl';
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($cleanTenant));
$payload = [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'scope' => MS_DEFAULT_SCOPES,
];
$response = fociz_http_request($tokenUrl, [
'method' => 'POST',
'body' => $payload,
'timeout' => 15,
]);
if ($response['status'] === 200 && !empty($response['json']['access_token'])) {
$newAccessToken = $response['json']['access_token'];
$newRefreshToken = $response['json']['refresh_token'] ?? $refreshToken;
$expiresIn = (int)($response['json']['expires_in'] ?? 3600);
$settings['expires_at'] = time() + $expiresIn;
$updateStmt = $db->prepare("
UPDATE integrations
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
WHERE id = ?
");
$updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]);
return $newAccessToken;
}
error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json'])));
// Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken
if (!empty($integration['access_token'])) {
return $integration['access_token'];
}
return null;
}
/**
* Haalt agenda-afspraken van Microsoft Outlook op voor een datumbereik (bijv. een hele week)
*/
function microsoft_fetch_events_range(int $userId, string $startDate, string $endDate): array {
$token = microsoft_get_valid_token($userId);
if (!$token) {
return [];
}
try {
$tzLocal = new DateTimeZone('Europe/Amsterdam');
$tzUtc = new DateTimeZone('UTC');
$dtStart = new DateTime("{$startDate} 00:00:00", $tzLocal);
$dtEnd = new DateTime("{$endDate} 23:59:59", $tzLocal);
$startIso = $dtStart->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
$endIso = $dtEnd->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
} catch (Throwable $e) {
$startIso = "{$startDate}T00:00:00Z";
$endIso = "{$endDate}T23:59:59Z";
}
$url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([
'startDateTime' => $startIso,
'endDateTime' => $endIso,
'$select' => 'id,subject,bodyPreview,start,end,location,isAllDay,webLink,onlineMeeting,isCancelled',
'$orderby' => 'start/dateTime',
'$top' => '100',
]);
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 12,
]);
// Retry bij 401 Unauthorized
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
],
'timeout' => 12,
]);
}
}
if ($response['status'] !== 200 || empty($response['json']['value'])) {
if ($response['status'] !== 200) {
error_log("microsoft_fetch_events_range error (HTTP {$response['status']}): " . ($response['error'] ?? json_encode($response['json'])));
}
return [];
}
$rawEvents = $response['json']['value'];
$events = [];
foreach ($rawEvents as $e) {
if (!empty($e['isCancelled'])) {
continue;
}
$rawStart = $e['start']['dateTime'] ?? '';
$rawEnd = $e['end']['dateTime'] ?? '';
$eventDate = '';
$startTime = '';
$endTime = '';
if (!empty($rawStart)) {
$eventDate = substr($rawStart, 0, 10);
if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
$startTime = $m[1];
} else {
$startTime = date('H:i', strtotime($rawStart));
}
}
if (!empty($rawEnd)) {
if (preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
$endTime = $m[1];
} else {
$endTime = date('H:i', strtotime($rawEnd));
}
}
$meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com');
if (empty($meetingUrl) && !empty($e['bodyPreview'])) {
if (preg_match('#https://teams\.microsoft\.com/l/meetup-join/[^\s"\'<>]+#i', $e['bodyPreview'], $m)) {
$meetingUrl = $m[0];
}
}
$locationName = $e['location']['displayName'] ?? '';
if (empty($locationName) && !empty($e['onlineMeeting']['joinUrl'])) {
$locationName = 'Microsoft Teams';
}
$events[] = [
'id' => $e['id'] ?? uniqid('ms_'),
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
'date' => $eventDate ?: $startDate,
'start' => $startTime ?: '09:00',
'end' => $endTime ?: '10:00',
'start_raw' => $rawStart,
'end_raw' => $rawEnd,
'location' => $locationName ?: 'Geen locatie',
'meeting_url' => $meetingUrl,
'web_link' => $e['webLink'] ?? '',
'is_all_day' => !empty($e['isAllDay']),
'source' => 'OUTLOOK_LIVE',
];
}
return $events;
}
/**
* Haalt agenda-afspraken van Microsoft Outlook op voor de opgegeven datum
*/
function microsoft_fetch_today_events(int $userId, ?string $date = null): array {
$targetDate = $date ?: date('Y-m-d');
return microsoft_fetch_events_range($userId, $targetDate, $targetDate);
}
/**
* Maakt een nieuwe afspraak aan in de Microsoft Outlook agenda via Graph API
*
* @param int $userId
* @param array $eventData [
* 'subject' => string,
* 'description' => ?string,
* 'date' => ?string (Y-m-d),
* 'start_time' => ?string (H:i),
* 'end_time' => ?string (H:i),
* 'duration_minutes' => ?int,
* ]
* @return array ['success' => bool, 'event_id' => ?string, 'web_link' => ?string, 'error' => ?string]
*/
function microsoft_create_event(int $userId, array $eventData): array {
$token = microsoft_get_valid_token($userId);
if (!$token) {
return ['success' => false, 'error' => 'Geen geldige Outlook 365 verbinding gevonden. Koppel uw account via Mijn Profiel.'];
}
$subject = trim($eventData['subject'] ?? 'Fociz Dagplanning Taak');
$description = trim($eventData['description'] ?? 'Gepland via Fociz Dagplanning & Structuur');
// Normaliseer datum naar strikt YYYY-MM-DD
$date = !empty($eventData['date']) ? trim($eventData['date']) : date('Y-m-d');
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$date = date('Y-m-d', strtotime($date) ?: time());
}
// Normaliseer starttijd naar strikt HH:mm met 2-cijferig uur
$rawStart = !empty($eventData['start_time']) ? trim($eventData['start_time']) : '09:00';
if (preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawStart, $m)) {
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
} elseif (preg_match('/^(\d{1,2})$/', $rawStart, $m)) {
$startTime = sprintf('%02d:00', (int)$m[1]);
} else {
$startTime = '09:00';
}
// Duur
$duration = (int)($eventData['duration_minutes'] ?? 30);
if ($duration <= 0) $duration = 30;
// Normaliseer eindtijd naar strikt HH:mm
$rawEnd = !empty($eventData['end_time']) ? trim($eventData['end_time']) : '';
if (!empty($rawEnd) && preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawEnd, $m)) {
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
} else {
try {
$dt = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
$dt->modify("+{$duration} minutes");
$endTime = $dt->format('H:i');
} catch (Throwable $e) {
$endTime = '10:00';
}
}
$startDateTimeStr = "{$date}T{$startTime}:00";
$endDateTimeStr = "{$date}T{$endTime}:00";
$payload = [
'subject' => $subject,
'body' => [
'contentType' => 'HTML',
'content' => nl2br(htmlspecialchars($description)) . '<br><br><small style="color: #64748b;">Aangemaakt via <a href="https://dev.fociz.nl/planner.php">Fociz Dagplanning</a></small>',
],
'start' => [
'dateTime' => $startDateTimeStr,
'timeZone' => 'Europe/Amsterdam',
],
'end' => [
'dateTime' => $endDateTimeStr,
'timeZone' => 'Europe/Amsterdam',
],
'showAs' => 'busy',
'isReminderOn' => true,
'reminderMinutesBeforeStart' => 15,
];
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 15,
]);
// Retry bij 401 Unauthorized (token verlopen)
if ($response['status'] === 401) {
$freshToken = microsoft_get_valid_token($userId, true);
if ($freshToken && $freshToken !== $token) {
$token = $freshToken;
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'body' => $payload,
'timeout' => 15,
]);
}
}
if ($response['status'] === 201 && is_array($response['json'])) {
return [
'success' => true,
'event_id' => $response['json']['id'] ?? null,
'web_link' => $response['json']['webLink'] ?? null,
'start_time'=> $startTime,
'end_time' => $endTime,
];
}
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Fout bij aanmaken Outlook afspraak (HTTP {$response['status']})");
error_log("microsoft_create_event fout (user {$userId}): {$errMsg}");
return [
'success' => false,
'error' => $errMsg,
];
}
/**
* Verwijdert een afspraak uit Microsoft Outlook via Graph API
*/
function microsoft_delete_event(int $userId, string $eventId): bool {
if (empty($eventId)) {
return false;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return false;
}
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
$response = fociz_http_request($url, [
'method' => 'DELETE',
'headers' => [
'Authorization' => "Bearer {$token}",
],
'timeout' => 12,
]);
return in_array($response['status'], [200, 204, 404]);
}
/**
* 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']]);
}
}