Compare commits

...

2 Commits

Author SHA1 Message Date
NiekRabelink 15caa5f341 Merge pull request 'feat(planner): add widget layout saving and scratchpad note functionality' (#35) from dev into main
Reviewed-on: #35
2026-09-10 12:17:54 +00:00
NiekRabelink fb38c42fe9 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
2026-09-10 14:16:45 +02:00
7 changed files with 636 additions and 7 deletions
+34
View File
@@ -32,6 +32,40 @@ try {
exit;
}
// 1b. Widget layout opslaan
if ($action === 'save_widget_layout') {
$data = json_decode(file_get_contents('php://input'), true);
$layout = $data['layout'] ?? null;
if (!is_array($layout)) {
echo json_encode(['success' => false, 'error' => 'Ongeldig layout formaat']);
exit;
}
$stmt = $db->prepare("UPDATE users SET fociz_widget_layout = ? WHERE id = ?");
$stmt->execute([json_encode($layout), $user['id']]);
echo json_encode(['success' => true, 'message' => 'Widget indeling succesvol opgeslagen']);
exit;
}
// 1c. Scratchpad snelle notities opslaan
if ($action === 'save_scratchpad') {
$data = json_decode(file_get_contents('php://input'), true);
$date = $data['date'] ?? $today;
$notes = $data['notes'] ?? '';
$stmt = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date, notes)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE notes = VALUES(notes)
");
$stmt->execute([$user['id'], $date, $notes]);
echo json_encode(['success' => true]);
exit;
}
// 2. Taak toevoegen
if ($action === 'add_task') {
$data = json_decode(file_get_contents('php://input'), true);
+102 -2
View File
@@ -408,13 +408,113 @@
}
};
// 7b. Real-time Live Sync (Mail, Jira & Agenda)
let lastKnownMailId = localStorage.getItem('fociz_last_mail_id') || '';
let lastKnownJiraKey = localStorage.getItem('fociz_last_jira_key') || '';
let lastCalendarHash = '';
let isLiveSyncRunning = false;
let isInitialLiveSync = true;
async function checkLiveSync() {
if (isLiveSyncRunning) return;
isLiveSyncRunning = true;
try {
const url = `/api/live_sync.php?last_mail_id=${encodeURIComponent(lastKnownMailId)}&last_jira_key=${encodeURIComponent(lastKnownJiraKey)}&calendar_hash=${encodeURIComponent(lastCalendarHash)}`;
const res = await fetch(url, { cache: 'no-store' });
if (res.status === 401) return;
if (res.ok) {
const data = await res.json();
if (data && data.success) {
// Update mail badge in navigatie
const mailBadge = document.getElementById('navMailBadge');
if (mailBadge && typeof data.mail?.unread_count !== 'undefined') {
if (data.mail.unread_count > 0) {
mailBadge.innerText = data.mail.unread_count;
mailBadge.classList.remove('hidden');
} else {
mailBadge.classList.add('hidden');
}
}
// E-mail melding
if (!isInitialLiveSync && data.mail?.has_new && data.mail.new_mail) {
const m = data.mail.new_mail;
sendBrowserNotification(
`✉️ Nieuwe mail: ${m.from_name}`,
`${m.subject} • ${m.received_human || 'Zojuist'}`,
m.web_link || '/mail.php',
`fociz-mail-${m.id}`
);
}
// Jira ticket melding
if (!isInitialLiveSync && data.jira?.has_new && data.jira.new_issue) {
const j = data.jira.new_issue;
sendBrowserNotification(
`⚡ Nieuw Jira ticket: [${j.key}]`,
`${j.summary} (${j.priority})`,
j.url || '/jira.php',
`fociz-jira-${j.key}`
);
}
// Dispatch custom event voor actieve pagina's
if (data.mail?.has_new || (data.mail?.latest_id && data.mail.latest_id !== lastKnownMailId)) {
window.dispatchEvent(new CustomEvent('fociz:mail-updated', { detail: data.mail }));
}
if (data.jira?.has_new || (data.jira?.newest_key && data.jira.newest_key !== lastKnownJiraKey)) {
window.dispatchEvent(new CustomEvent('fociz:jira-updated', { detail: data.jira }));
}
if (data.calendar?.has_changes) {
window.dispatchEvent(new CustomEvent('fociz:calendar-updated', { detail: data.calendar }));
}
if (data.mail?.latest_id) {
lastKnownMailId = data.mail.latest_id;
localStorage.setItem('fociz_last_mail_id', lastKnownMailId);
}
if (data.jira?.newest_key) {
lastKnownJiraKey = data.jira.newest_key;
localStorage.setItem('fociz_last_jira_key', lastKnownJiraKey);
}
if (data.calendar?.hash) {
lastCalendarHash = data.calendar.hash;
}
isInitialLiveSync = false;
}
}
} catch(e) {
// Stil negeren bij netwerkvertraging
} finally {
isLiveSyncRunning = false;
}
}
// 10. Initialisatie
document.addEventListener('DOMContentLoaded', () => {
refreshEvents();
// Polling interval elke 60 seconden
checkLiveSync();
// Polling interval elke 60 seconden voor agenda evaluatie
setInterval(refreshEvents, 60000);
// Evaluatie interval elke 15 seconden
// Evaluatie interval elke 15 seconden voor agenda popups
setInterval(evaluateEvents, 15000);
// Live sync interval elke 12 seconden voor instant mail, Jira & calendar updates
setInterval(checkLiveSync, 12000);
window.addEventListener('focus', () => {
checkLiveSync();
refreshEvents();
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
checkLiveSync();
refreshEvents();
}
});
updateDotStatus();
});
+3
View File
@@ -148,6 +148,9 @@ function ensure_tables_exist(PDO $pdo): void {
"ALTER TABLE users ADD COLUMN avatar_url VARCHAR(255) NULL AFTER theme",
"ALTER TABLE users ADD COLUMN backup_codes TEXT NULL AFTER two_factor_enabled",
"ALTER TABLE users ADD COLUMN default_jira_filter_code VARCHAR(50) NULL AFTER avatar_url",
"ALTER TABLE users ADD COLUMN fociz_widget_layout TEXT NULL AFTER default_jira_filter_code",
"ALTER TABLE users ADD COLUMN last_mail_check_at DATETIME NULL AFTER fociz_widget_layout",
"ALTER TABLE users ADD COLUMN last_jira_check_at DATETIME NULL AFTER last_mail_check_at",
"ALTER TABLE board_card_schedules ADD UNIQUE KEY uk_sched_card (card_id)",
"CREATE TABLE IF NOT EXISTS `password_resets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `email` VARCHAR(191) NOT NULL, `token` VARCHAR(64) NOT NULL UNIQUE, `expires_at` DATETIME NOT NULL, `used_at` DATETIME NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX (`email`), INDEX (`token`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
"CREATE TABLE IF NOT EXISTS `jira_queue_filters` (`id` INT AUTO_INCREMENT PRIMARY KEY, `filter_code` VARCHAR(50) NOT NULL UNIQUE, `filter_name` VARCHAR(120) NOT NULL, `project_key` VARCHAR(50) NULL, `jql_clause` TEXT NOT NULL, `description` VARCHAR(255) NULL, `is_active` TINYINT(1) NOT NULL DEFAULT 1, `sort_order` INT NOT NULL DEFAULT 0, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
+3
View File
@@ -15,6 +15,9 @@ CREATE TABLE IF NOT EXISTS `users` (
`notification_settings` TEXT NULL,
`avatar_url` VARCHAR(255) NULL,
`default_jira_filter_code` VARCHAR(50) NULL,
`fociz_widget_layout` TEXT NULL,
`last_mail_check_at` DATETIME NULL,
`last_jira_check_at` DATETIME NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+14 -4
View File
@@ -161,8 +161,14 @@ require_once __DIR__ . '/env_badge.php';
<!-- Nav Tabs -->
<nav class="hidden md:flex items-center space-x-1">
<a href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span>Planning</span>
<i data-lucide="layout-dashboard" class="w-4 h-4"></i>
<span>Fociz</span>
</a>
<a href="/mail.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition relative <?= $currentPage === 'mail.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
<i data-lucide="mail" class="w-4 h-4 text-sky-500"></i>
<span>Mail</span>
<span id="navMailBadge" class="hidden px-1.5 py-0.2 rounded-full text-[10px] font-black bg-sky-600 text-white leading-none"></span>
</a>
<!-- Dropdown: Projecten (Bord, Tijdlijn, Brainstorm, Roadmap) -->
@@ -275,8 +281,12 @@ require_once __DIR__ . '/env_badge.php';
<!-- Mobile Navigation -->
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
<span>Planning</span>
<i data-lucide="layout-dashboard" class="w-4 h-4 mb-0.5"></i>
<span>Fociz</span>
</a>
<a href="/mail.php" class="flex flex-col items-center py-1 <?= $currentPage === 'mail.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="mail" class="w-4 h-4 mb-0.5"></i>
<span>Mail</span>
</a>
<a href="/board.php" class="flex flex-col items-center py-1 <?= $currentPage === 'board.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
<i data-lucide="kanban" class="w-4 h-4 mb-0.5"></i>
+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;
}
+5
View File
@@ -1552,6 +1552,11 @@ document.addEventListener('DOMContentLoaded', () => {
updateQueuePills();
}
applyFilters();
// Luister naar live events voor nieuwe/gewijzigde Jira tickets
window.addEventListener('fociz:jira-updated', () => {
window.location.reload();
});
});
</script>