feat(planner): add widget layout saving and scratchpad note functionality

feat(notifications): implement real-time live sync for mail, Jira, and calendar updates

fix(database): add new columns for widget layout and last check timestamps

fix(schema): update users table schema to include new fields for widget layout and last checks

feat(microsoft): enhance Microsoft integration with demo email data and inbox fetching

feat(jira): add listener for live updates on Jira tickets
This commit is contained in:
2026-09-10 14:16:45 +02:00
parent dc0b33a278
commit fb38c42fe9
7 changed files with 636 additions and 7 deletions
+475 -1
View File
@@ -10,7 +10,7 @@ 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';
const MS_DEFAULT_SCOPES = 'offline_access User.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send';
/**
* Haalt de centrale Microsoft App Registratie configuratie op (Client ID, Secret, Tenant ID)
@@ -881,3 +881,477 @@ function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate,
'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' => '<p>Beste Niek,</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' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (12 * 60)), // 12 minuten geleden
'received_human' => '12 min geleden',
'is_read' => false,
'has_attachments' => true,
'importance' => 'high',
'web_link' => 'https://outlook.office.com/mail/inbox',
'is_demo' => true,
],
[
'id' => 'demo-mail-2',
'subject' => 'Factuur & Urenverantwoording September — Goedkeuring vereist',
'body_preview' => 'Hallo team, bijgaand tref je de voorlopige urenstaat en factuurspecificatie aan voor de lopende sprint. Graag voor het einde van de week accorderen...',
'body_html' => '<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' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (65 * 60)), // 1 uur geleden
'received_human' => '1 uur geleden',
'is_read' => false,
'has_attachments' => false,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/inbox',
'is_demo' => true,
],
[
'id' => 'demo-mail-3',
'subject' => 'Jira SSO Migratie & Staging server testomgeving',
'body_preview' => 'Goedemiddag, de testomgeving voor de nieuwe Atlassian API migratie (CHANGE-2046) is succesvol klaargezet. We kunnen starten met de verificatietesten...',
'body_html' => '<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' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (3 * 3600)), // 3 uur geleden
'received_human' => '3 uur geleden',
'is_read' => true,
'has_attachments' => true,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/inbox',
'is_demo' => true,
],
[
'id' => 'demo-mail-4',
'subject' => 'Herinnering: Kwartaal Roadmap Review & Strategie sessie',
'body_preview' => 'Beste collega\'s, vergeet niet je prioriteiten en feature requests in te dienen in de Fociz Roadmap module voor de meeting van aanstaande donderdag...',
'body_html' => '<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)), // Gisteren
'received_human' => 'Gisteren',
'is_read' => true,
'has_attachments' => false,
'importance' => 'normal',
'web_link' => 'https://outlook.office.com/mail/inbox',
'is_demo' => true,
],
[
'id' => 'demo-mail-5',
'subject' => 'Welkom bij Fociz Live — Handige tips voor jouw dagplanning',
'body_preview' => 'Welkom bij Fociz! Wist je dat je binnengekomen Outlook e-mails en Jira tickets met één klik direct kunt inplannen in jouw dagrooster?...',
'body_html' => '<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' => 'Niek Rabelink', 'address' => 'niek@fociz.nl']],
'cc_recipients' => [],
'received_at' => date('Y-m-d H:i:s', $now - (48 * 3600)), // 2 dagen geleden
'received_human' => '2 dagen geleden',
'is_read' => true,
'has_attachments' => false,
'importance' => 'low',
'web_link' => 'https://outlook.office.com/mail/inbox',
'is_demo' => true,
],
];
}
/**
* Haalt inbox berichten op van Microsoft Graph API (of retourneert demo data)
*/
function microsoft_fetch_inbox_messages(int $userId, int $limit = 25, bool $unreadOnly = false): array {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
$isLive = !empty($integration['access_token']) && empty($integration['is_demo_mode']);
if (!$isLive) {
$demo = microsoft_get_mail_demo_data();
if ($unreadOnly) {
$demo = array_values(array_filter($demo, function($m) { return empty($m['is_read']); }));
}
return [
'success' => true,
'is_demo' => true,
'messages' => array_slice($demo, 0, $limit),
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
'total_count' => count($demo),
];
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return [
'success' => false,
'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
'messages' => [],
'unread_count' => 0,
];
}
$queryParams = [
'$top' => min(50, max(1, $limit)),
'$select' => 'id,subject,bodyPreview,from,toRecipients,receivedDateTime,isRead,hasAttachments,importance,webLink',
'$orderby' => 'receivedDateTime desc',
];
if ($unreadOnly) {
$queryParams['$filter'] = 'isRead eq false';
}
$url = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox/messages?' . http_build_query($queryParams);
$res = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
'Prefer' => 'outlook.body-content-type="text"',
],
'timeout' => 15,
]);
if ($res['status'] !== 200 || !isset($res['json']['value'])) {
error_log("Microsoft fetch inbox error: HTTP {$res['status']} - " . ($res['error'] ?? json_encode($res['json'])));
// Val terug op demo data indien API fout optreedt
$demo = microsoft_get_mail_demo_data();
return [
'success' => true,
'is_demo' => true,
'messages' => array_slice($demo, 0, $limit),
'unread_count' => count(array_filter($demo, function($m) { return empty($m['is_read']); })),
'total_count' => count($demo),
'warning' => 'Kon live e-mails niet ophalen van Microsoft Graph. Demomodus actief.',
];
}
$tzAmsterdam = new DateTimeZone('Europe/Amsterdam');
$messages = [];
foreach ($res['json']['value'] as $msg) {
$fromObj = $msg['from']['emailAddress'] ?? [];
$fromName = $fromObj['name'] ?? ($fromObj['address'] ?? 'Onbekende afzender');
$fromEmail = $fromObj['address'] ?? '';
$receivedIso = $msg['receivedDateTime'] ?? '';
$receivedFormatted = '';
$receivedHuman = '';
if (!empty($receivedIso)) {
try {
$dt = new DateTime($receivedIso);
$dt->setTimezone($tzAmsterdam);
$receivedFormatted = $dt->format('Y-m-d H:i:s');
$todayStr = date('Y-m-d');
$msgDate = $dt->format('Y-m-d');
if ($msgDate === $todayStr) {
$receivedHuman = $dt->format('H:i');
} elseif ($msgDate === date('Y-m-d', strtotime('-1 day'))) {
$receivedHuman = 'Gisteren ' . $dt->format('H:i');
} else {
$receivedHuman = $dt->format('d-m H:i');
}
} catch (Throwable $t) {
$receivedFormatted = $receivedIso;
$receivedHuman = substr($receivedIso, 0, 10);
}
}
$messages[] = [
'id' => $msg['id'] ?? '',
'subject' => $msg['subject'] ?: '(Geen onderwerp)',
'body_preview' => $msg['bodyPreview'] ?? '',
'from_name' => $fromName,
'from_email' => $fromEmail,
'to_recipients' => array_map(function($r) {
return [
'name' => $r['emailAddress']['name'] ?? '',
'address' => $r['emailAddress']['address'] ?? '',
];
}, $msg['toRecipients'] ?? []),
'received_at' => $receivedFormatted,
'received_human' => $receivedHuman,
'is_read' => !empty($msg['isRead']),
'has_attachments' => !empty($msg['hasAttachments']),
'importance' => strtolower($msg['importance'] ?? 'normal'),
'web_link' => $msg['webLink'] ?? 'https://outlook.office.com/mail/',
'is_demo' => false,
];
}
$unreadCount = count(array_filter($messages, function($m) { return empty($m['is_read']); }));
return [
'success' => true,
'is_demo' => false,
'messages' => $messages,
'unread_count' => $unreadCount,
'total_count' => count($messages),
];
}
/**
* Haalt de volledige inhoud en details van een enkel e-mailbericht op
*/
function microsoft_fetch_message_detail(int $userId, string $messageId): ?array {
// Check op demo ID
if (strpos($messageId, 'demo-mail-') === 0) {
$demo = microsoft_get_mail_demo_data();
foreach ($demo as $m) {
if ($m['id'] === $messageId) {
$m['is_read'] = true;
return $m;
}
}
return $demo[0] ?? null;
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return null;
}
$url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId) . '?$select=id,subject,body,bodyPreview,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,importance,webLink';
$res = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
],
'timeout' => 15,
]);
if ($res['status'] !== 200 || !isset($res['json']['id'])) {
return null;
}
$msg = $res['json'];
$tzAmsterdam = new DateTimeZone('Europe/Amsterdam');
$receivedFormatted = '';
$receivedHuman = '';
if (!empty($msg['receivedDateTime'])) {
try {
$dt = new DateTime($msg['receivedDateTime']);
$dt->setTimezone($tzAmsterdam);
$receivedFormatted = $dt->format('Y-m-d H:i:s');
$receivedHuman = $dt->format('d-m-Y H:i');
} catch (Throwable $t) {
$receivedFormatted = $msg['receivedDateTime'];
$receivedHuman = $msg['receivedDateTime'];
}
}
$bodyType = strtolower($msg['body']['contentType'] ?? 'text');
$bodyContent = $msg['body']['content'] ?? '';
// Markeer direct als gelezen via Graph API
if (empty($msg['isRead'])) {
microsoft_mark_message_read($userId, $messageId, true);
}
return [
'id' => $msg['id'],
'subject' => $msg['subject'] ?: '(Geen onderwerp)',
'body_preview' => $msg['bodyPreview'] ?? '',
'body_html' => ($bodyType === 'html') ? $bodyContent : nl2br(htmlspecialchars($bodyContent)),
'body_text' => ($bodyType === 'text') ? $bodyContent : strip_tags($bodyContent),
'from_name' => $msg['from']['emailAddress']['name'] ?? ($msg['from']['emailAddress']['address'] ?? 'Onbekend'),
'from_email' => $msg['from']['emailAddress']['address'] ?? '',
'to_recipients' => array_map(function($r) {
return [
'name' => $r['emailAddress']['name'] ?? '',
'address' => $r['emailAddress']['address'] ?? '',
];
}, $msg['toRecipients'] ?? []),
'cc_recipients' => array_map(function($r) {
return [
'name' => $r['emailAddress']['name'] ?? '',
'address' => $r['emailAddress']['address'] ?? '',
];
}, $msg['ccRecipients'] ?? []),
'received_at' => $receivedFormatted,
'received_human' => $receivedHuman,
'is_read' => true,
'has_attachments' => !empty($msg['hasAttachments']),
'importance' => strtolower($msg['importance'] ?? 'normal'),
'web_link' => $msg['webLink'] ?? 'https://outlook.office.com/mail/',
'is_demo' => false,
];
}
/**
* Markeert een e-mailbericht als gelezen of ongelezen
*/
function microsoft_mark_message_read(int $userId, string $messageId, bool $isRead = true): bool {
if (strpos($messageId, 'demo-mail-') === 0) {
return true;
}
$token = microsoft_get_valid_token($userId);
if (!$token) return false;
$url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId);
$res = fociz_http_request($url, [
'method' => 'PATCH',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
],
'body' => ['isRead' => $isRead],
'timeout' => 10,
]);
return ($res['status'] >= 200 && $res['status'] < 300);
}
/**
* Beantwoordt een e-mailbericht via Microsoft Graph API
*/
function microsoft_reply_message(int $userId, string $messageId, string $comment, bool $replyAll = false): array {
if (strpos($messageId, 'demo-mail-') === 0) {
return ['success' => true, 'message' => 'Demo antwoord succesvol gesimuleerd.'];
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return ['success' => false, 'error' => 'Geen geldige Microsoft verbinding.'];
}
$action = $replyAll ? 'replyAll' : 'reply';
$url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId) . '/' . $action;
$res = fociz_http_request($url, [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
],
'body' => [
'comment' => $comment,
],
'timeout' => 15,
]);
if ($res['status'] >= 200 && $res['status'] < 300) {
return ['success' => true, 'message' => 'Antwoord succesvol verzonden via Outlook.'];
}
$errMsg = $res['json']['error']['message'] ?? ($res['error'] ?? 'Fout bij verzenden van antwoord.');
return ['success' => false, 'error' => $errMsg];
}
/**
* Stuurt een e-mailbericht door via Microsoft Graph API
*/
function microsoft_forward_message(int $userId, string $messageId, array $toRecipients, string $comment = ''): array {
if (strpos($messageId, 'demo-mail-') === 0) {
return ['success' => true, 'message' => 'Demo doorsturen succesvol gesimuleerd.'];
}
$token = microsoft_get_valid_token($userId);
if (!$token) {
return ['success' => false, 'error' => 'Geen geldige Microsoft verbinding.'];
}
$recipientsPayload = [];
foreach ($toRecipients as $r) {
$addr = is_array($r) ? ($r['address'] ?? '') : trim($r);
$name = is_array($r) ? ($r['name'] ?? $addr) : $addr;
if (!empty($addr)) {
$recipientsPayload[] = [
'emailAddress' => [
'address' => $addr,
'name' => $name,
]
];
}
}
if (empty($recipientsPayload)) {
return ['success' => false, 'error' => 'Geen geldige ontvangers opgegeven om door te sturen.'];
}
$url = MS_GRAPH_BASE_URL . '/me/messages/' . urlencode($messageId) . '/forward';
$res = fociz_http_request($url, [
'method' => 'POST',
'headers' => [
'Authorization' => "Bearer {$token}",
'Content-Type' => 'application/json',
],
'body' => [
'toRecipients' => $recipientsPayload,
'comment' => $comment,
],
'timeout' => 15,
]);
if ($res['status'] >= 200 && $res['status'] < 300) {
return ['success' => true, 'message' => 'E-mail succesvol doorgestuurd via Outlook.'];
}
$errMsg = $res['json']['error']['message'] ?? ($res['error'] ?? 'Fout bij doorsturen van e-mail.');
return ['success' => false, 'error' => $errMsg];
}
/**
* Haalt het aantal ongelezen inbox e-mails op
*/
function microsoft_get_unread_mail_count(int $userId): int {
$db = get_db_connection();
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
$stmt->execute([$userId]);
$integration = $stmt->fetch();
if (!$integration || empty($integration['access_token']) || !empty($integration['is_demo_mode'])) {
$demo = microsoft_get_mail_demo_data();
return count(array_filter($demo, function($m) { return empty($m['is_read']); }));
}
$token = microsoft_get_valid_token($userId);
if (!$token) return 0;
$url = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox?$select=unreadItemCount';
$res = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
],
'timeout' => 10,
]);
if ($res['status'] === 200 && isset($res['json']['unreadItemCount'])) {
return (int)$res['json']['unreadItemCount'];
}
return 0;
}