23 Commits

Author SHA1 Message Date
NiekRabelink 091de8bf95 Update composer.json 2026-10-21 16:12:01 +00:00
NiekRabelink 3312c08ea1 Update composer.json 2026-10-21 16:04:13 +00:00
NiekRabelink 39a41fa207 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-10-21 17:06:40 +02:00
NiekRabelink 78369a44c2 Php version e.d. 2026-10-21 17:06:36 +02:00
NiekRabelink 07988b3e1d Rename block 2026-10-05 11:01:25 +02:00
NiekRabelink 0c466972c2 fix(env): lock production hostnames to live environment and live database 2026-10-05 10:39:17 +02:00
NiekRabelink dc6b55e4f9 Merge branch 'dev' of https://git.webenpcstudio.nl/NiekRabelink/Fociz into dev 2026-10-05 10:32:44 +02:00
NiekRabelink ef116be9b8 Simple todo 2026-10-05 10:32:30 +02:00
NiekRabelink 65d5a4d4a3 Merge branch 'main' into dev 2026-09-23 10:52:42 +02:00
NiekRabelink a4824bb440 fix(brainstorm): auto-queue and retry post-it generation with fallback starter cards 2026-09-23 10:52:29 +02:00
NiekRabelink e45f565365 Merge branch 'main' into dev (sync AI resiliency & fallbacks) 2026-09-23 10:50:26 +02:00
NiekRabelink 3dc09a11a7 fix(ai): prioritize flash models, prevent high-demand blocks, and add resilient brainstorm fallbacks 2026-09-23 10:49:52 +02:00
NiekRabelink 86041b665d Ensure all fallback AI models are tried before giving up 2026-09-23 10:34:18 +02:00
NiekRabelink f18f7d5794 Merge pull request 'Implement intelligent fallback for AI requests during high demand and rate limiting' (#98) from dev into main
Reviewed-on: #98
2026-09-23 08:24:46 +00:00
NiekRabelink 45600040ea Implement intelligent fallback for AI requests during high demand and rate limiting 2026-09-23 10:23:10 +02:00
NiekRabelink 660f196c60 Merge pull request 'Enhance IP verification process: add email resend functionality and improve error logging' (#97) from dev into main
Reviewed-on: #97
2026-09-23 07:22:40 +00:00
NiekRabelink 32974cdb31 Enhance IP verification process: add email resend functionality and improve error logging 2026-09-23 09:21:17 +02:00
NiekRabelink 8ddae4d883 Merge pull request 'Snelheid' (#96) from dev into main
Reviewed-on: #96
2026-09-16 11:54:05 +00:00
NiekRabelink d4e4b69e02 Snelheid 2026-09-16 13:53:54 +02:00
NiekRabelink 65f81d620c Merge pull request 'Change mail options' (#95) from dev into main
Reviewed-on: #95
2026-09-16 10:39:19 +00:00
NiekRabelink 0b5ca54db9 Change mail options 2026-09-16 12:39:06 +02:00
NiekRabelink 20d8f93f77 Merge pull request 'Fix speed and error' (#94) from dev into main
Reviewed-on: #94
2026-09-16 10:23:33 +00:00
NiekRabelink bda7b311e3 Fix speed and error 2026-09-16 12:22:27 +02:00
23 changed files with 2534 additions and 319 deletions
+132 -5
View File
@@ -385,7 +385,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$activeTab = 'system'; $activeTab = 'system';
try { try {
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0; $aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
$aiModel = trim((string)($_POST['ai_model'] ?? 'gemini-1.5-pro')); $aiModel = trim((string)($_POST['ai_model'] ?? 'gemini-2.0-flash'));
$aiProvider = trim((string)($_POST['ai_provider'] ?? '')); $aiProvider = trim((string)($_POST['ai_provider'] ?? ''));
if (empty($aiProvider)) { if (empty($aiProvider)) {
$aiProvider = str_contains($aiModel, 'gpt') ? 'openai' : 'gemini'; $aiProvider = str_contains($aiModel, 'gpt') ? 'openai' : 'gemini';
@@ -462,6 +462,46 @@ $sessionIdleTimeout = get_session_idle_timeout();
$calendarBlockReasons = get_calendar_block_reasons(); $calendarBlockReasons = get_calendar_block_reasons();
$brainstormCategories = get_brainstorm_categories(); $brainstormCategories = get_brainstorm_categories();
// PHP Runtime, Database & Security Patch Status
$phpVersion = PHP_VERSION;
$phpSapi = php_sapi_name();
$phpMemoryLimit = ini_get('memory_limit') ?: 'Onbekend';
$phpMaxExecution = ini_get('max_execution_time') ? ini_get('max_execution_time') . 's' : 'Onbeperkt';
$phpOpcacheEnabled = function_exists('opcache_get_status') && !empty(opcache_get_status(false)['opcache_enabled']);
$phpOpensslVersion = defined('OPENSSL_VERSION_TEXT') ? OPENSSL_VERSION_TEXT : 'Niet gedetecteerd';
// PHP Security Patch status (CVE-2026-109618 / CVE-2026-109619 PHP2Shell)
// Gepatched in 8.2.35, 8.3.36, 8.4.27, 8.5.12
$isPhpPatched = false;
$patchRequiredVersion = '';
$isSupportedPhpBranch = true;
if (version_compare($phpVersion, '8.2.0', '>=') && version_compare($phpVersion, '8.3.0', '<')) {
$patchRequiredVersion = '8.2.35';
$isPhpPatched = version_compare($phpVersion, '8.2.35', '>=');
} elseif (version_compare($phpVersion, '8.3.0', '>=') && version_compare($phpVersion, '8.4.0', '<')) {
$patchRequiredVersion = '8.3.36';
$isPhpPatched = version_compare($phpVersion, '8.3.36', '>=');
} elseif (version_compare($phpVersion, '8.4.0', '>=') && version_compare($phpVersion, '8.5.0', '<')) {
$patchRequiredVersion = '8.4.27';
$isPhpPatched = version_compare($phpVersion, '8.4.27', '>=');
} elseif (version_compare($phpVersion, '8.5.0', '>=')) {
$patchRequiredVersion = '8.5.12';
$isPhpPatched = version_compare($phpVersion, '8.5.12', '>=');
} else {
// PHP < 8.2 (End of Life)
$patchRequiredVersion = '8.2.35+';
$isPhpPatched = false;
$isSupportedPhpBranch = false;
}
$dbVersion = 'Onbekend';
try {
$dbVersion = (string)$db->query("SELECT VERSION()")->fetchColumn() ?: 'Onbekend';
} catch (Throwable $e) {
$dbVersion = 'Niet beschikbaar';
}
// Redirect URI // Redirect URI
$redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php'; $redirectUri = rtrim(APP_URL, '/') . '/auth/callback_microsoft.php';
@@ -1973,6 +2013,94 @@ require_once __DIR__ . '/includes/header.php';
<!-- Tab 6: Systeem & Database (inclusief SMTP Mail Server & AI Co-Pilot) --> <!-- Tab 6: Systeem & Database (inclusief SMTP Mail Server & AI Co-Pilot) -->
<div id="tab-pane-system" class="admin-tab-pane space-y-8 hidden"> <div id="tab-pane-system" class="admin-tab-pane space-y-8 hidden">
<!-- PHP Runtime & Serverstatus (Beveiliging & Environment) -->
<div class="fociz-card p-6 sm:p-7 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
<div class="flex items-center space-x-3.5">
<div class="w-12 h-12 rounded-xl bg-violet-50 dark:bg-violet-950/40 border border-violet-200 dark:border-violet-800/60 flex items-center justify-center text-violet-600 dark:text-violet-400 font-bold shadow-xs">
<i data-lucide="cpu" class="w-6 h-6"></i>
</div>
<div>
<div class="flex items-center space-x-2">
<h2 class="text-lg font-bold text-slate-900 dark:text-white tracking-tight">PHP Runtime & Serverstatus</h2>
<span class="px-2 py-0.5 rounded-md bg-violet-100 dark:bg-violet-900/50 text-violet-800 dark:text-violet-300 font-mono text-xs font-bold">
PHP <?= htmlspecialchars($phpVersion) ?>
</span>
</div>
<p class="text-xs text-slate-500 dark:text-slate-400">Actuele PHP versie, beveiligingsstatus, server interface en resource limieten</p>
</div>
</div>
<?php if ($isPhpPatched): ?>
<span class="px-3 py-1.5 rounded-full bg-teal-50 dark:bg-teal-950/40 text-teal-700 dark:text-teal-400 border border-teal-200 dark:border-teal-800/60 text-xs font-bold inline-flex items-center space-x-1.5 self-start sm:self-auto">
<i data-lucide="shield-check" class="w-4 h-4 flex-shrink-0"></i>
<span>PHP Beveiliging: Up-to-date</span>
</span>
<?php else: ?>
<span class="px-3 py-1.5 rounded-full bg-amber-50 dark:bg-amber-950/40 text-amber-700 dark:text-amber-400 border border-amber-200 dark:border-amber-800/60 text-xs font-bold inline-flex items-center space-x-1.5 self-start sm:self-auto">
<i data-lucide="alert-triangle" class="w-4 h-4 flex-shrink-0 text-amber-500"></i>
<span>Update Aanbevolen (Min. <?= htmlspecialchars($patchRequiredVersion) ?>)</span>
</span>
<?php endif; ?>
</div>
<!-- Beveiligingsalert / Patchadvies -->
<?php if (!$isPhpPatched): ?>
<div class="p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-900 dark:text-amber-200 text-xs space-y-2">
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
<i data-lucide="alert-circle" class="w-4 h-4 flex-shrink-0"></i>
<span>Beveiligingsupdate Beschikbaar (CVE-2026-109618 / CVE-2026-109619)</span>
</div>
<p class="leading-relaxed">
Er is recent een kritieke PHP-beveiligingsupdate uitgebracht. Jouw server draait momenteel op <strong class="font-mono"><?= htmlspecialchars($phpVersion) ?></strong>.
<?php if (!$isSupportedPhpBranch): ?>
Deze PHP-versie is end-of-life. We adviseren je webhosting/server zo spoedig mogelijk te upgraden naar minimaal <strong>PHP 8.2.35</strong> of hoger.
<?php else: ?>
We adviseren om via het hosting controlepaneel (zoals DirectAdmin of cPanel) of via je serverbeheerder de PHP-versie te updaten naar minimaal <strong class="font-mono"><?= htmlspecialchars($patchRequiredVersion) ?></strong>.
<?php endif; ?>
</p>
</div>
<?php else: ?>
<div class="p-3.5 rounded-xl bg-teal-500/10 border border-teal-500/20 text-teal-900 dark:text-teal-200 text-xs flex items-center space-x-2.5">
<i data-lucide="shield-check" class="w-4 h-4 flex-shrink-0 text-teal-600 dark:text-teal-400"></i>
<span>Deze PHP-versie (<strong class="font-mono"><?= htmlspecialchars($phpVersion) ?></strong>) bevat de recentste beveiligingspatches tegen de bekende PHP2Shell kwetsbaarheden.</span>
</div>
<?php endif; ?>
<!-- Grid met systeemeigenschappen -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 text-xs">
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">PHP SAPI / Interface</span>
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($phpSapi) ?></span>
</div>
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">Geheugenlimiet (Memory)</span>
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($phpMemoryLimit) ?></span>
</div>
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">Max Execution Time</span>
<span class="font-mono font-bold text-slate-900 dark:text-white"><?= htmlspecialchars($phpMaxExecution) ?></span>
</div>
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80">
<span class="text-slate-500 dark:text-slate-400 block text-[11px] mb-1">Database Versie</span>
<span class="font-mono font-bold text-slate-900 dark:text-white truncate block" title="<?= htmlspecialchars($dbVersion) ?>"><?= htmlspecialchars($dbVersion) ?></span>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
<span class="text-slate-500 dark:text-slate-400">Zend OPcache:</span>
<span class="font-semibold <?= $phpOpcacheEnabled ? 'text-teal-600 dark:text-teal-400' : 'text-slate-500 dark:text-slate-400' ?> flex items-center space-x-1">
<i data-lucide="<?= $phpOpcacheEnabled ? 'check-circle-2' : 'circle' ?>" class="w-3.5 h-3.5"></i>
<span><?= $phpOpcacheEnabled ? 'Ingeschakeld' : 'Niet actief' ?></span>
</span>
</div>
<div class="p-3.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800/80 flex justify-between items-center">
<span class="text-slate-500 dark:text-slate-400">OpenSSL Bibliotheek:</span>
<span class="font-mono text-slate-700 dark:text-slate-300 truncate max-w-[220px]" title="<?= htmlspecialchars($phpOpensslVersion) ?>"><?= htmlspecialchars($phpOpensslVersion) ?></span>
</div>
</div>
</div>
<!-- 5. SMTP Mail Server & Notificaties (Release v1.6) --> <!-- 5. SMTP Mail Server & Notificaties (Release v1.6) -->
<div class="fociz-card p-6 sm:p-7 space-y-6"> <div class="fociz-card p-6 sm:p-7 space-y-6">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80"> <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-4 border-b border-slate-100 dark:border-slate-800/80">
@@ -2126,10 +2254,9 @@ require_once __DIR__ . '/includes/header.php';
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label> <label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
<select name="ai_model" id="admin_ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500"> <select name="ai_model" id="admin_ai_model" class="w-full bg-slate-50 dark:bg-[#0c0f17] border border-slate-300 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white text-xs focus:outline-none focus:ring-1 focus:ring-indigo-500">
<optgroup label="Google Gemini (Aanbevolen)"> <optgroup label="Google Gemini (Aanbevolen)">
<option value="gemini-3.6-flash" <?= ($aiSettings['ai_model'] === 'gemini-3.6-flash' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini') ? 'selected' : '' ?>>Google Gemini 3.6 Flash (Aanbevolen, Snel & Nieuwste)</option> <option value="gemini-2.0-flash" <?= ($aiSettings['ai_model'] === 'gemini-2.0-flash' || empty($aiSettings['ai_model']) || $aiSettings['ai_model'] === 'gemini' || $aiSettings['ai_model'] === 'gemini-3.6-flash') ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
<option value="gemini-1.5-pro" <?= $aiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Slim & Uitgebreid)</option>
<option value="gemini-2.0-flash" <?= $aiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash</option>
<option value="gemini-1.5-flash" <?= $aiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option> <option value="gemini-1.5-flash" <?= $aiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro" <?= $aiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Slim & Uitgebreid)</option>
</optgroup> </optgroup>
<optgroup label="OpenAI"> <optgroup label="OpenAI">
<option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option> <option value="gpt-4o-mini" <?= $aiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>
@@ -2208,7 +2335,7 @@ require_once __DIR__ . '/includes/header.php';
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800"> <div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
<span>Database Versie</span> <span>Database Versie</span>
<span class="text-slate-500 dark:text-slate-400 font-mono text-[11px]">MariaDB 10.x</span> <span class="text-slate-500 dark:text-slate-400 font-mono text-[11px] truncate max-w-[200px]" title="<?= htmlspecialchars($dbVersion) ?>"><?= htmlspecialchars($dbVersion) ?></span>
</div> </div>
<div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800"> <div class="flex items-center justify-between p-2.5 rounded-xl bg-slate-50 dark:bg-[#0c0f17] border border-slate-200 dark:border-slate-800">
+9 -4
View File
@@ -142,9 +142,14 @@ try {
// Fallback: reguliere generatie // Fallback: reguliere generatie
$aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? ''); $aiRes = fociz_ai_generate_ideas($session['title'], $session['theme'] ?? 'Algemeen', $count, $user['id'], $session['description'] ?? '');
if (!$aiRes['success']) { $ideas = $aiRes['ideas'] ?? [];
echo json_encode(['error' => $aiRes['error']]); if (empty($ideas)) {
exit; $ideas = [
"Gebruikerservaring en flow optimaliseren voor " . $session['title'],
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
"Koppelingen en integraties verkennen voor snellere afhandeling",
"Feedback- en validatielus inrichten met eindgebruikers",
];
} }
$insertStmt = $db->prepare(" $insertStmt = $db->prepare("
@@ -152,7 +157,7 @@ try {
VALUES (?, ?, 'Ideeën', 'amber', ?, 1) VALUES (?, ?, 'Ideeën', 'amber', ?, 1)
"); ");
foreach ($aiRes['ideas'] as $ideaText) { foreach ($ideas as $ideaText) {
$ideaText = trim($ideaText); $ideaText = trim($ideaText);
if (!empty($ideaText)) { if (!empty($ideaText)) {
$insertStmt->execute([$sessionId, $ideaText, $authorName]); $insertStmt->execute([$sessionId, $ideaText, $authorName]);
+16
View File
@@ -30,6 +30,22 @@ try {
exit; exit;
} }
// 0b. Uitgeschakelde mappen opslaan
if ($action === 'save_disabled_folders') {
$input = file_get_contents('php://input');
$data = json_decode($input, true);
$folders = $data['disabled_folders'] ?? ($_POST['disabled_folders'] ?? []);
if (is_string($folders)) {
$folders = json_decode($folders, true) ?: [];
}
if (!is_array($folders)) {
$folders = [];
}
$ok = microsoft_save_disabled_folders($userId, $folders);
echo json_encode(['success' => $ok, 'disabled_folders' => $folders]);
exit;
}
// 1. Inbox of specifieke map berichten ophalen // 1. Inbox of specifieke map berichten ophalen
if ($action === 'get_folder_messages' || $action === 'get_inbox') { if ($action === 'get_folder_messages' || $action === 'get_inbox') {
$folderId = trim($_GET['folder_id'] ?? ($_POST['folder_id'] ?? 'inbox')); $folderId = trim($_GET['folder_id'] ?? ($_POST['folder_id'] ?? 'inbox'));
+413
View File
@@ -87,6 +87,419 @@ try {
exit; exit;
} }
// 1d. Snelle Bullet Notities ophalen
if ($action === 'get_bullets') {
$date = $_GET['date'] ?? $today;
$stmt = $db->prepare("
SELECT b.*, t.time_block_start as live_start, t.time_block_end as live_end, t.status as task_status
FROM dashboard_bullets b
LEFT JOIN tasks t ON b.scheduled_task_id = t.id
WHERE b.user_id = ? AND b.plan_date = ?
ORDER BY b.position ASC, b.id ASC
");
$stmt->execute([$user['id'], $date]);
$bullets = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Tel openstaande items van eerdere dagen
$stmtPast = $db->prepare("
SELECT COUNT(*) FROM dashboard_bullets
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
");
$stmtPast->execute([$user['id'], $date]);
$pastOpenCount = (int)$stmtPast->fetchColumn();
echo json_encode([
'success' => true,
'bullets' => $bullets,
'past_open_count' => $pastOpenCount
]);
exit;
}
// 1e. Bullet notitie toevoegen
if ($action === 'add_bullet') {
$data = json_decode(file_get_contents('php://input'), true);
$date = !empty($data['date']) ? trim($data['date']) : $today;
$content = trim($data['content'] ?? '');
if ($content === '') {
echo json_encode(['success' => false, 'error' => 'Notitie mag niet leeg zijn']);
exit;
}
// Bepaal positie
$stmtPos = $db->prepare("SELECT COALESCE(MAX(position), 0) + 1 FROM dashboard_bullets WHERE user_id = ? AND plan_date = ?");
$stmtPos->execute([$user['id'], $date]);
$pos = (int)$stmtPos->fetchColumn();
$stmt = $db->prepare("
INSERT INTO dashboard_bullets (user_id, plan_date, content, position)
VALUES (?, ?, ?, ?)
");
$stmt->execute([$user['id'], $date, $content, $pos]);
$bulletId = (int)$db->lastInsertId();
$stmtFetch = $db->prepare("SELECT * FROM dashboard_bullets WHERE id = ?");
$stmtFetch->execute([$bulletId]);
$newBullet = $stmtFetch->fetch(PDO::FETCH_ASSOC);
echo json_encode([
'success' => true,
'bullet' => $newBullet
]);
exit;
}
// 1f. Bullet notitie status wisselen (afvinken / openen)
if ($action === 'toggle_bullet') {
$data = json_decode(file_get_contents('php://input'), true);
$bulletId = (int)($data['bullet_id'] ?? 0);
$isCompleted = !empty($data['is_completed']) ? 1 : 0;
$stmt = $db->prepare("UPDATE dashboard_bullets SET is_completed = ? WHERE id = ? AND user_id = ?");
$stmt->execute([$isCompleted, $bulletId, $user['id']]);
// Als er een gekoppelde taak is, werk de status daarvan ook bij
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
$stmtCheck->execute([$bulletId, $user['id']]);
$taskId = (int)$stmtCheck->fetchColumn();
if ($taskId > 0) {
$taskStatus = $isCompleted ? 'DONE' : 'TODO';
$stmtTask = $db->prepare("UPDATE tasks SET is_completed = ?, status = ? WHERE id = ? AND user_id = ?");
$stmtTask->execute([$isCompleted, $taskStatus, $taskId, $user['id']]);
}
echo json_encode(['success' => true, 'is_completed' => $isCompleted]);
exit;
}
// 1g. Bullet notitie bewerken
if ($action === 'update_bullet') {
$data = json_decode(file_get_contents('php://input'), true);
$bulletId = (int)($data['bullet_id'] ?? 0);
$content = trim($data['content'] ?? '');
if ($content === '') {
echo json_encode(['success' => false, 'error' => 'Inhoud mag niet leeg zijn']);
exit;
}
$stmt = $db->prepare("UPDATE dashboard_bullets SET content = ? WHERE id = ? AND user_id = ?");
$stmt->execute([$content, $bulletId, $user['id']]);
// Indien gekoppeld aan taak, werk ook taaktitel bij
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
$stmtCheck->execute([$bulletId, $user['id']]);
$taskId = (int)$stmtCheck->fetchColumn();
if ($taskId > 0) {
$stmtTask = $db->prepare("UPDATE tasks SET title = ? WHERE id = ? AND user_id = ?");
$stmtTask->execute([$content, $taskId, $user['id']]);
}
echo json_encode(['success' => true]);
exit;
}
// 1h. Bullet notitie verwijderen
if ($action === 'delete_bullet') {
$data = json_decode(file_get_contents('php://input'), true);
$bulletId = (int)($data['bullet_id'] ?? 0);
$deleteTask = !empty($data['delete_task']);
// Check voor gekoppelde taak
$stmtCheck = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
$stmtCheck->execute([$bulletId, $user['id']]);
$taskId = (int)$stmtCheck->fetchColumn();
if ($taskId > 0 && $deleteTask) {
// Verwijder eventuele Outlook afspraak
$stmtTask = $db->prepare("SELECT external_id FROM tasks WHERE id = ? AND user_id = ?");
$stmtTask->execute([$taskId, $user['id']]);
$externalId = $stmtTask->fetchColumn();
if (!empty($externalId) && function_exists('microsoft_delete_event')) {
try {
microsoft_delete_event($user['id'], $externalId);
} catch (Throwable $e) {}
}
$stmtDelTask = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$stmtDelTask->execute([$taskId, $user['id']]);
}
$stmt = $db->prepare("DELETE FROM dashboard_bullets WHERE id = ? AND user_id = ?");
$stmt->execute([$bulletId, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
// 1i. Bullet inplannen in agenda / vrij blok
if ($action === 'schedule_bullet') {
$data = json_decode(file_get_contents('php://input'), true);
$bulletId = (int)($data['bullet_id'] ?? 0);
$date = !empty($data['date']) ? trim($data['date']) : $today;
$title = !empty($data['title']) ? trim($data['title']) : '';
$startTime = !empty($data['start_time']) ? trim($data['start_time']) : '';
$endTime = !empty($data['end_time']) ? trim($data['end_time']) : '';
$duration = max(5, (int)($data['duration_minutes'] ?? 30));
$priority = in_array($data['priority'] ?? '', ['P1', 'P2', 'P3']) ? $data['priority'] : 'P2';
$syncOutlook = !empty($data['sync_to_outlook']);
// Haal bullet op
$stmtB = $db->prepare("SELECT * FROM dashboard_bullets WHERE id = ? AND user_id = ?");
$stmtB->execute([$bulletId, $user['id']]);
$bullet = $stmtB->fetch(PDO::FETCH_ASSOC);
if (!$bullet) {
echo json_encode(['success' => false, 'error' => 'Notitie niet gevonden']);
exit;
}
if (empty($title)) {
$title = $bullet['content'];
}
if (empty($startTime)) {
echo json_encode(['success' => false, 'error' => 'Kies een starttijd voor het agendablok']);
exit;
}
if (empty($endTime)) {
$dtEnd = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
$dtEnd->modify("+{$duration} minutes");
$endTime = $dtEnd->format('H:i');
}
// Zorg dat daily_plan bestaat
$stmtPlan = $db->prepare("
INSERT INTO daily_plans (user_id, plan_date)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
");
$stmtPlan->execute([$user['id'], $date]);
$planId = (int)$db->lastInsertId();
$source = 'FOCIZ';
$externalId = null;
$externalUrl = null;
$outlookSyncMsg = null;
// Microsoft Outlook synchronisatie
if ($syncOutlook) {
require_once __DIR__ . '/../includes/integrations/microsoft.php';
try {
$msResult = microsoft_create_event($user['id'], [
'subject' => $title,
'description' => "Ingepland vanuit Todo's & Notities",
'date' => $date,
'start_time' => $startTime,
'end_time' => $endTime,
'duration_minutes' => $duration,
]);
if (!empty($msResult['success']) && !empty($msResult['event_id'])) {
$source = 'OUTLOOK';
$externalId = $msResult['event_id'];
$externalUrl = $msResult['web_link'] ?? null;
$outlookSyncMsg = 'Succesvol gesynchroniseerd met Microsoft Outlook agenda.';
}
} catch (Throwable $e) {}
}
$taskId = (int)($bullet['scheduled_task_id'] ?? 0);
if ($taskId > 0) {
// Update bestaande taak
$stmtUpdateTask = $db->prepare("
UPDATE tasks
SET daily_plan_id = ?, title = ?, priority = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?, source = ?, external_id = COALESCE(?, external_id), external_url = COALESCE(?, external_url)
WHERE id = ? AND user_id = ?
");
$stmtUpdateTask->execute([$planId, $title, $priority, $startTime, $endTime, $duration, $source, $externalId, $externalUrl, $taskId, $user['id']]);
} else {
// Nieuwe taak aanmaken
$stmtInsertTask = $db->prepare("
INSERT INTO tasks (user_id, daily_plan_id, title, description, priority, time_block_start, time_block_end, duration_minutes, status, source, external_id, external_url)
VALUES (?, ?, ?, 'Ingepland vanuit Todo\'s & Notities', ?, ?, ?, ?, 'TODO', ?, ?, ?)
");
$stmtInsertTask->execute([$user['id'], $planId, $title, $priority, $startTime, $endTime, $duration, $source, $externalId, $externalUrl]);
$taskId = (int)$db->lastInsertId();
}
// Update de bullet record
$stmtUpdateB = $db->prepare("
UPDATE dashboard_bullets
SET scheduled_task_id = ?, scheduled_date = ?, scheduled_start_time = ?, scheduled_end_time = ?
WHERE id = ? AND user_id = ?
");
$stmtUpdateB->execute([$taskId, $date, $startTime, $endTime, $bulletId, $user['id']]);
echo json_encode([
'success' => true,
'task_id' => $taskId,
'scheduled_date' => $date,
'scheduled_start_time' => $startTime,
'scheduled_end_time' => $endTime,
'outlook_message' => $outlookSyncMsg
]);
exit;
}
// 1j. Bullet uit agenda halen
if ($action === 'unschedule_bullet') {
$data = json_decode(file_get_contents('php://input'), true);
$bulletId = (int)($data['bullet_id'] ?? 0);
$stmtB = $db->prepare("SELECT scheduled_task_id FROM dashboard_bullets WHERE id = ? AND user_id = ?");
$stmtB->execute([$bulletId, $user['id']]);
$taskId = (int)$stmtB->fetchColumn();
if ($taskId > 0) {
$stmtTask = $db->prepare("SELECT external_id FROM tasks WHERE id = ? AND user_id = ?");
$stmtTask->execute([$taskId, $user['id']]);
$externalId = $stmtTask->fetchColumn();
if (!empty($externalId) && function_exists('microsoft_delete_event')) {
try {
microsoft_delete_event($user['id'], $externalId);
} catch (Throwable $e) {}
}
$stmtDel = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
$stmtDel->execute([$taskId, $user['id']]);
}
$stmtUpdate = $db->prepare("
UPDATE dashboard_bullets
SET scheduled_task_id = NULL, scheduled_date = NULL, scheduled_start_time = NULL, scheduled_end_time = NULL
WHERE id = ? AND user_id = ?
");
$stmtUpdate->execute([$bulletId, $user['id']]);
echo json_encode(['success' => true]);
exit;
}
// 1k. Openstaande bullets van eerdere dagen overnemen naar geselecteerde dag
if ($action === 'carryover_bullets') {
$data = json_decode(file_get_contents('php://input'), true);
$targetDate = !empty($data['target_date']) ? trim($data['target_date']) : $today;
// Verplaats alle openstaande notities van eerdere dagen naar targetDate
$stmt = $db->prepare("
UPDATE dashboard_bullets
SET plan_date = ?
WHERE user_id = ? AND plan_date < ? AND is_completed = 0
");
$stmt->execute([$targetDate, $user['id'], $targetDate]);
$movedCount = $stmt->rowCount();
echo json_encode(['success' => true, 'moved_count' => $movedCount]);
exit;
}
// 1l. Haal alle beschikbare vrije agendablokken op voor een specifieke dag
if ($action === 'get_day_free_slots') {
$targetDate = $_GET['date'] ?? $today;
$duration = max(5, (int)($_GET['duration_minutes'] ?? 30));
$workdaySettings = get_workday_settings();
$workdayStartStr = $workdaySettings['workday_start'] ?? '08:30';
$workdayEndStr = $workdaySettings['workday_end'] ?? '17:00';
$lunchStartStr = $workdaySettings['lunch_start'] ?? '12:30';
$lunchEndStr = $workdaySettings['lunch_end'] ?? '13:00';
$toMins = function(string $timeStr): int {
$parts = explode(':', trim($timeStr));
return ((int)($parts[0] ?? 0) * 60) + (int)($parts[1] ?? 0);
};
$toTimeStr = function(int $mins): string {
$h = (int)floor($mins / 60);
$m = $mins % 60;
return sprintf('%02d:%02d', $h, $m);
};
$workdayStartMin = $toMins($workdayStartStr);
$workdayEndMin = $toMins($workdayEndStr);
$lunchStartMin = $toMins($lunchStartStr);
$lunchEndMin = $toMins($lunchEndStr);
$tz = new DateTimeZone('Europe/Amsterdam');
$now = new DateTime('now', $tz);
$isToday = ($targetDate === $now->format('Y-m-d'));
// Starttijd afronden naar kwartier
$earliestMin = $workdayStartMin;
if ($isToday) {
$nowMin = ((int)$now->format('G') * 60) + (int)$now->format('i');
if ($nowMin % 15 !== 0) {
$nowMin = (int)(ceil($nowMin / 15) * 15);
}
$earliestMin = max($workdayStartMin, $nowMin);
}
$busyIntervals = [];
if ($lunchEndMin > $lunchStartMin) {
$busyIntervals[] = [$lunchStartMin, $lunchEndMin];
}
// Taken op deze dag
$stmtTasks = $db->prepare("
SELECT time_block_start, time_block_end, duration_minutes
FROM tasks t
INNER JOIN daily_plans dp ON t.daily_plan_id = dp.id
WHERE t.user_id = ? AND dp.plan_date = ? AND t.time_block_start IS NOT NULL AND t.time_block_start != '' AND t.status != 'CANCELLED'
");
$stmtTasks->execute([$user['id'], $targetDate]);
while ($t = $stmtTasks->fetch()) {
$tStart = $toMins($t['time_block_start']);
$tDur = (int)($t['duration_minutes'] ?: 30);
$tEnd = !empty($t['time_block_end']) ? $toMins($t['time_block_end']) : ($tStart + $tDur);
if ($tEnd > $tStart) {
$busyIntervals[] = [$tStart, $tEnd];
}
}
// Outlook afspraken
if (function_exists('microsoft_fetch_events')) {
try {
$outlookEvents = microsoft_fetch_events($user['id'], $targetDate, false);
foreach ($outlookEvents as $oe) {
if (!empty($oe['is_all_day'])) continue;
if (empty($oe['start']) || empty($oe['end'])) continue;
$oStart = $toMins($oe['start']);
$oEnd = $toMins($oe['end']);
if ($oEnd > $oStart) {
$busyIntervals[] = [$oStart, $oEnd];
}
}
} catch (Throwable $e) {}
}
$freeSlots = [];
for ($slotStart = $earliestMin; ($slotStart + $duration) <= $workdayEndMin; $slotStart += 15) {
$slotEnd = $slotStart + $duration;
$collision = false;
foreach ($busyIntervals as [$bStart, $bEnd]) {
if ($slotStart < $bEnd && $slotEnd > $bStart) {
$collision = true;
break;
}
}
if (!$collision) {
$freeSlots[] = [
'start' => $toTimeStr($slotStart),
'end' => $toTimeStr($slotEnd),
'label' => $toTimeStr($slotStart) . ' - ' . $toTimeStr($slotEnd)
];
}
}
echo json_encode([
'success' => true,
'date' => $targetDate,
'duration_minutes' => $duration,
'free_slots' => $freeSlots
]);
exit;
}
// 2. Taak toevoegen // 2. Taak toevoegen
if ($action === 'add_task') { if ($action === 'add_task') {
$data = json_decode(file_get_contents('php://input'), true); $data = json_decode(file_get_contents('php://input'), true);
+82 -9
View File
@@ -49,11 +49,40 @@ $step = isset($_SESSION['pending_2fa_user_id']) ? 2 : 1;
// Terug naar stap 1 // Terug naar stap 1
if (isset($_GET['action']) && $_GET['action'] === 'restart') { if (isset($_GET['action']) && $_GET['action'] === 'restart') {
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name'], $_SESSION['pending_new_ip']); unset(
$_SESSION['pending_2fa_user_id'],
$_SESSION['pending_2fa_user_email'],
$_SESSION['pending_2fa_user_name'],
$_SESSION['pending_new_ip'],
$_SESSION['pending_new_ip_mail_sent']
);
header('Location: /auth/login.php'); header('Location: /auth/login.php');
exit; exit;
} }
// Verificatiemail opnieuw verzenden bij nieuw IP
if (isset($_GET['action']) && $_GET['action'] === 'resend_ip_mail' && !empty($_SESSION['pending_2fa_user_id']) && !empty($_SESSION['pending_new_ip'])) {
$resendUserId = (int)$_SESSION['pending_2fa_user_id'];
$resendRateKey = "resend_ip_mail_{$resendUserId}";
$resendLimit = fociz_check_rate_limit($resendRateKey, 2, 60);
if (!$resendLimit['allowed']) {
$error = "Wacht even voordat u opnieuw een e-mail aanvraagt (over " . ceil($resendLimit['retry_after']) . " seconden).";
} else {
fociz_hit_rate_limit($resendRateKey, 60);
$resendEmail = $_SESSION['pending_2fa_user_email'] ?? '';
$resendName = $_SESSION['pending_2fa_user_name'] ?? '';
$resendIp = $_SESSION['pending_new_ip'];
$sent = fociz_send_new_ip_verification($resendUserId, $resendEmail, $resendName, $resendIp);
if ($sent) {
$infoMessage = "Er is een nieuwe verificatielink verstuurd naar " . htmlspecialchars($resendEmail) . ".";
$_SESSION['pending_new_ip_mail_sent'] = true;
} else {
$error = "De mailserver kon de e-mail niet direct afleveren. U kunt echter direct hieronder inloggen met uw 2FA Authenticator code om deze locatie te autoriseren.";
}
}
}
// POST Verwerking // POST Verwerking
if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$db = get_db_connection(); $db = get_db_connection();
@@ -87,15 +116,24 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp); $isIpKnown = fociz_is_ip_known_for_user((int)$user['id'], $clientIp);
if (!$isIpKnown) { if (!$isIpKnown) {
// Stuur verificatie-mail en informeer gebruiker // Stuur verificatie-mail en log audit challenge
fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp); $mailSent = fociz_send_new_ip_verification((int)$user['id'], $user['email'], $user['name'], $clientIp);
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp], 'PENDING', $user['email']); fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_CHALLENGE', 'user', (int)$user['id'], ['ip' => $clientIp, 'mail_sent' => $mailSent], 'PENDING', $user['email']);
$error = "Nieuwe inloglocatie gedetecteerd ({$clientIp}). Er is een verificatie-e-mail gestuurd naar {$user['email']}. Klik op de link in de e-mail om deze locatie toe te staan.";
} else { // Laat legitieme gebruiker doorgaan naar Stap 2 (2FA TOTP).
// Succesvol stap 1: zet tijdelijke pending sessie voor 2FA // Conform NIST SP 800-63B autoriseert de fysieke Authenticator code de nieuwe locatie.
$_SESSION['pending_2fa_user_id'] = $user['id']; $_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email']; $_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name']; $_SESSION['pending_2fa_user_name'] = $user['name'];
$_SESSION['pending_new_ip'] = $clientIp;
$_SESSION['pending_new_ip_mail_sent'] = $mailSent;
$step = 2;
} else {
// Bekende locatie: direct door naar 2FA
$_SESSION['pending_2fa_user_id'] = $user['id'];
$_SESSION['pending_2fa_user_email'] = $user['email'];
$_SESSION['pending_2fa_user_name'] = $user['name'];
unset($_SESSION['pending_new_ip'], $_SESSION['pending_new_ip_mail_sent']);
$step = 2; $step = 2;
} }
} else { } else {
@@ -161,8 +199,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
fociz_clear_rate_limit($ipRateKey); fociz_clear_rate_limit($ipRateKey);
// Volledige login succesvol (geldig tot 23:59:59 van vandaag) // Volledige login succesvol (geldig tot 23:59:59 van vandaag)
// Autoriseert het IP-adres definitief in user_known_ips
fociz_register_user_ip((int)$user['id'], $clientIp, true); fociz_register_user_ip((int)$user['id'], $clientIp, true);
$wasNewIp = !empty($_SESSION['pending_new_ip']);
if ($wasNewIp) {
fociz_log_audit((int)$user['id'], 'LOGIN_NEW_IP_VERIFIED_VIA_2FA', 'user_known_ips', (int)$user['id'], ['ip' => $clientIp], 'SUCCESS', $user['email']);
}
// Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6): // Session Fixation Beveiliging (OWASP ASVS v4.0 §3.3.1 / SOC 2 CC6.6):
// Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie // Genereer direct een nieuw cryptografisch sessie-ID en vernietig de pre-auth sessie
if (session_status() === PHP_SESSION_ACTIVE) { if (session_status() === PHP_SESSION_ACTIVE) {
@@ -189,9 +233,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800; $maxIdleSeconds = function_exists('get_session_idle_timeout') ? get_session_idle_timeout() : 1800;
fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds); fociz_register_active_session((int)$user['id'], $newSessionId, $maxIdleSeconds);
fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA'], 'SUCCESS', $user['email']); fociz_log_audit((int)$user['id'], 'LOGIN_SUCCESS', 'user', (int)$user['id'], ['ip' => $clientIp, 'auth_method' => 'PASSWORD+2FA', 'was_new_ip' => $wasNewIp], 'SUCCESS', $user['email']);
unset($_SESSION['pending_2fa_user_id'], $_SESSION['pending_2fa_user_email'], $_SESSION['pending_2fa_user_name']); unset(
$_SESSION['pending_2fa_user_id'],
$_SESSION['pending_2fa_user_email'],
$_SESSION['pending_2fa_user_name'],
$_SESSION['pending_new_ip'],
$_SESSION['pending_new_ip_mail_sent']
);
header('Location: /planner.php'); header('Location: /planner.php');
exit; exit;
@@ -315,6 +365,29 @@ $pageTitle = "Fociz — Inloggen";
<!-- STAP 2: 2FA TOTP Code --> <!-- STAP 2: 2FA TOTP Code -->
<form method="POST" action="/auth/login.php" class="space-y-5"> <form method="POST" action="/auth/login.php" class="space-y-5">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars(get_csrf_token()) ?>">
<?php if (!empty($_SESSION['pending_new_ip'])): ?>
<div class="p-3.5 rounded-xl bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800/60 text-amber-900 dark:text-amber-200 text-xs space-y-2 text-left">
<div class="flex items-center space-x-2 font-bold text-amber-800 dark:text-amber-300">
<i data-lucide="shield-alert" class="w-4 h-4 flex-shrink-0"></i>
<span>Nieuwe inloglocatie gedetecteerd</span>
</div>
<p class="text-[11px] leading-relaxed text-amber-700 dark:text-amber-300/90">
U logt in vanaf een nieuw netwerkadres (<code class="font-mono bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded text-[10px]"><?= htmlspecialchars($_SESSION['pending_new_ip']) ?></code>).
Voer uw 2FA Authenticator code in om uw identiteit te bevestigen en deze locatie automatisch te autoriseren.
</p>
<div class="pt-1.5 flex items-center justify-between text-[11px] border-t border-amber-200/60 dark:border-amber-800/40 text-amber-700 dark:text-amber-400">
<span class="flex items-center space-x-1">
<i data-lucide="mail-check" class="w-3.5 h-3.5"></i>
<span><?= !empty($_SESSION['pending_new_ip_mail_sent']) ? 'Verificatiemail verzonden' : '2FA verificatie actief' ?></span>
</span>
<a href="/auth/login.php?action=resend_ip_mail" class="font-bold underline hover:text-amber-900 dark:hover:text-amber-200 cursor-pointer">
Mail opnieuw sturen
</a>
</div>
</div>
<?php endif; ?>
<div class="text-center"> <div class="text-center">
<div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2"> <div class="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-50 dark:bg-indigo-950/40 text-indigo-600 dark:text-indigo-400 mb-2">
<i data-lucide="key" class="w-6 h-6"></i> <i data-lucide="key" class="w-6 h-6"></i>
+121 -16
View File
@@ -1326,8 +1326,8 @@ async function submitConvertToRoadmap() {
} }
// --- AI Co-Pilot Canvas Assistentie (Release v1.5) --- // --- AI Co-Pilot Canvas Assistentie (Release v1.5) ---
async function aiGenerateSessionBoard(btn, silent = false) { async function aiGenerateSessionBoard(btn, silent = false, attempt = 1, maxAttempts = 3) {
if (!silent) { if (!silent && attempt === 1) {
const hasBriefing = <?= !empty($session['description']) ? 'true' : 'false' ?>; const hasBriefing = <?= !empty($session['description']) ? 'true' : 'false' ?>;
const confirmMsg = hasBriefing const confirmMsg = hasBriefing
? 'Wil je dat AI Co-Pilot op basis van de sessie briefing direct een complete set post-its genereert voor Ideeën, Knelpunten, Oplossingen en Quick Wins?' ? 'Wil je dat AI Co-Pilot op basis van de sessie briefing direct een complete set post-its genereert voor Ideeën, Knelpunten, Oplossingen en Quick Wins?'
@@ -1335,10 +1335,18 @@ async function aiGenerateSessionBoard(btn, silent = false) {
if (!confirm(confirmMsg)) return; if (!confirm(confirmMsg)) return;
} }
const origHtml = btn ? btn.innerHTML : ''; const origHtml = btn ? (btn.dataset.origHtml || btn.innerHTML) : '';
if (btn && !btn.dataset.origHtml) {
btn.dataset.origHtml = origHtml;
}
if (btn) { if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
btn.disabled = true; btn.disabled = true;
if (attempt > 1) {
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>Piekdrukte, wachtrij (${attempt}/${maxAttempts})...</span></span>`;
} else {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1.5"><i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>AI genereert post-its...</span></span>';
}
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
} }
@@ -1355,17 +1363,27 @@ async function aiGenerateSessionBoard(btn, silent = false) {
window.history.replaceState({}, document.title, cleanUrl); window.history.replaceState({}, document.title, cleanUrl);
location.reload(); location.reload();
} else { } else {
const err = (data.error || '').toLowerCase();
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
if (attempt < maxAttempts && isOverloaded) {
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
return;
}
alert(data.error || 'Kon geen post-its genereren via AI'); alert(data.error || 'Kon geen post-its genereren via AI');
if (btn) { if (btn) {
btn.innerHTML = origHtml; btn.innerHTML = btn.dataset.origHtml || origHtml;
btn.disabled = false; btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
} }
} }
} catch(err) { } catch(err) {
if (attempt < maxAttempts) {
setTimeout(() => aiGenerateSessionBoard(btn, true, attempt + 1, maxAttempts), 2500);
return;
}
alert('Netwerkfout bij aanroepen van AI: ' + err.message); alert('Netwerkfout bij aanroepen van AI: ' + err.message);
if (btn) { if (btn) {
btn.innerHTML = origHtml; btn.innerHTML = btn.dataset.origHtml || origHtml;
btn.disabled = false; btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
} }
@@ -1421,8 +1439,13 @@ async function aiSuggestRoadmapMeta(btn) {
if (!firstContent) return; if (!firstContent) return;
const origHtml = btn ? btn.innerHTML : ''; const origHtml = btn ? btn.innerHTML : '';
let attempt = 0;
const maxAttempts = 3;
const executeRequest = async () => {
attempt++;
if (btn) { if (btn) {
btn.innerHTML = '<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>Analyseren...</span></span>'; btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i><span>${attempt > 1 ? `Wachtrij (${attempt}/${maxAttempts})...` : 'Analyseren...'}</span></span>`;
btn.disabled = true; btn.disabled = true;
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
} }
@@ -1448,7 +1471,7 @@ async function aiSuggestRoadmapMeta(btn) {
document.getElementById('roadmapPriority').value = data.item.priority; document.getElementById('roadmapPriority').value = data.item.priority;
} }
if (btn) { if (btn) {
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld!</span>'; btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>' + (data.is_fallback ? 'Ingevuld (Standaard)' : 'Ingevuld!') + '</span>';
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => { setTimeout(() => {
btn.innerHTML = origHtml; btn.innerHTML = origHtml;
@@ -1457,21 +1480,46 @@ async function aiSuggestRoadmapMeta(btn) {
}, 1800); }, 1800);
} }
} else { } else {
alert(data.error || 'Kon geen AI advies ophalen'); const err = (data.error || '').toLowerCase();
const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
if (attempt < maxAttempts && isOverloaded) {
if (btn) { if (btn) {
btn.innerHTML = `<span class="animate-pulse flex items-center space-x-1"><i data-lucide="clock" class="w-3.5 h-3.5"></i><span>Piekdrukte, wachtrij...</span></span>`;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
setTimeout(executeRequest, 2000);
return;
}
// Fallback: vul standaardwaarden in zodat het omzetten altijd kan doorgaan
if (document.getElementById('roadmapQuarter') && !document.getElementById('roadmapQuarter').value) {
document.getElementById('roadmapQuarter').value = 'Q3';
}
if (btn) {
btn.innerHTML = '<i data-lucide="check" class="w-3.5 h-3.5"></i><span>Ingevuld (Standaard)</span>';
if (typeof lucide !== 'undefined') lucide.createIcons();
setTimeout(() => {
btn.innerHTML = origHtml; btn.innerHTML = origHtml;
btn.disabled = false; btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
}, 1800);
} }
} }
} catch(err) { } catch(err) {
alert('Netwerkfout bij aanroepen van AI'); if (attempt < maxAttempts) {
setTimeout(executeRequest, 2000);
} else {
if (btn) { if (btn) {
btn.innerHTML = origHtml; btn.innerHTML = origHtml;
btn.disabled = false; btn.disabled = false;
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
} }
} }
}
};
await executeRequest();
} }
// ========================================== // ==========================================
@@ -1535,7 +1583,16 @@ async function regenerateBreakdown() {
} }
} }
async function fetchAiBreakdown(content) { async function fetchAiBreakdown(content, attempt = 1, maxAttempts = 3) {
const loadingEl = document.getElementById('breakdownLoading');
const loadingText = loadingEl ? loadingEl.querySelector('p') : null;
if (attempt > 1 && loadingText) {
loadingText.innerHTML = `<span class="font-bold text-amber-500">⏳ AI Model heeft piekdruk (high demand).</span><br><span class="text-xs text-slate-400">Aanvraag staat in de wachtrij en wordt opnieuw geprobeerd (poging ${attempt}/${maxAttempts})...</span>`;
} else if (loadingText) {
loadingText.innerHTML = 'AI ontleedt het idee in behapbare taken...';
}
try { try {
const res = await fetch('/api/ai.php?action=breakdown_idea', { const res = await fetch('/api/ai.php?action=breakdown_idea', {
method: 'POST', method: 'POST',
@@ -1551,12 +1608,48 @@ async function fetchAiBreakdown(content) {
const data = await res.json(); const data = await res.json();
if (!data.success || !data.items || data.items.length === 0) { if (!data.success || !data.items || data.items.length === 0) {
document.getElementById('breakdownLoading').classList.add('hidden'); const err = (data.error || '').toLowerCase();
document.getElementById('breakdownError').classList.remove('hidden'); const isOverloaded = err.includes('demand') || err.includes('druk') || err.includes('spikes') || err.includes('busy') || err.includes('temporarily') || err.includes('quota');
document.getElementById('breakdownErrorMsg').innerText = data.error || 'Geen taken gegenereerd door AI.';
if (attempt < maxAttempts && isOverloaded) {
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
return; return;
} }
// Graceful Fallback: zorg dat het omzetten van het idee altijd vlekkeloos doorgaat
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
data = {
success: true,
items: [
{
title: 'Onderzoek & specificatie: ' + cleanTitle,
description: 'Randvoorwaarden en functionele specificaties vastleggen.',
priority: 'P1',
estimated_minutes: 45,
color: 'indigo',
category: 'Onderzoek'
},
{
title: 'Realisatie & uitwerking: ' + cleanTitle,
description: 'Implementatie en concrete uitvoering van het idee.',
priority: 'P2',
estimated_minutes: 90,
color: 'blue',
category: 'Frontend'
},
{
title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle,
description: 'Testen, feedback verwerken en afronden.',
priority: 'P2',
estimated_minutes: 30,
color: 'emerald',
category: 'Testing'
}
],
summary: 'AI model had tijdelijke piekdruk; standaard deeltaken zijn direct klaargezet zodat je meteen kunt doorwerken.'
};
}
currentBreakdownItems = data.items.map((it, idx) => ({ currentBreakdownItems = data.items.map((it, idx) => ({
id: idx + 1, id: idx + 1,
selected: true, selected: true,
@@ -1576,9 +1669,21 @@ async function fetchAiBreakdown(content) {
document.getElementById('breakdownResult').classList.remove('hidden'); document.getElementById('breakdownResult').classList.remove('hidden');
if (window.lucide) lucide.createIcons(); if (window.lucide) lucide.createIcons();
} catch (err) { } catch (err) {
if (attempt < maxAttempts) {
setTimeout(() => fetchAiBreakdown(content, attempt + 1, maxAttempts), 2500);
return;
}
const cleanTitle = (content || '').split('\n')[0].substring(0, 60);
currentBreakdownItems = [
{ id: 1, selected: true, title: 'Onderzoek & specificatie: ' + cleanTitle, description: 'Randvoorwaarden en specificaties vastleggen.', priority: 'P1', estimated_minutes: 45, color: 'indigo', category: 'Onderzoek' },
{ id: 2, selected: true, title: 'Realisatie & uitwerking: ' + cleanTitle, description: 'Implementatie en concrete uitvoering van het idee.', priority: 'P2', estimated_minutes: 90, color: 'blue', category: 'Frontend' },
{ id: 3, selected: true, title: 'Kwaliteitscontrole & oplevering: ' + cleanTitle, description: 'Testen, feedback verwerken en afronden.', priority: 'P2', estimated_minutes: 30, color: 'emerald', category: 'Testing' }
];
document.getElementById('breakdownSummaryText').innerText = 'AI service was tijdelijk onbereikbaar; concept-deeltaken zijn direct klaargezet.';
renderBreakdownTasks();
document.getElementById('breakdownLoading').classList.add('hidden'); document.getElementById('breakdownLoading').classList.add('hidden');
document.getElementById('breakdownError').classList.remove('hidden'); document.getElementById('breakdownResult').classList.remove('hidden');
document.getElementById('breakdownErrorMsg').innerText = 'Netwerkfout bij communicatie met AI: ' + err.message; if (window.lucide) lucide.createIcons();
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"require": { "require": {
"php": "^8.2", "php": "^8.4",
"ext-pdo": "*", "ext-pdo": "*",
"ext-pdo_mysql": "*", "ext-pdo_mysql": "*",
"ext-mysqli": "*" "ext-mysqli": "*"
+9 -12
View File
@@ -1,17 +1,14 @@
# ========================================================================= # =========================================================================
# FOCIZ CONFIGURATIE — LIVE PRODUCTIE (www.fociz.nl) # FOCIZ CONFIGURATIE (Centraal)
# De omgeving en database worden automatisch veilig bepaald op basis van het domein:
# - dev.fociz.nl / localhost => development (focizdev / focizdev_user)
# - www.fociz.nl / fociz.nl => live (fociz / focizlive_user)
# Optionele serverspecifieke overrides kunnen in de root /.env worden geplaatst.
# ========================================================================= # =========================================================================
# MariaDB Verbinding # MariaDB Server
DB_HOST="192.168.2.33" DB_HOST="192.168.2.33"
DB_PORT="3306" DB_PORT="3306"
DB_NAME="fociz"
DB_USER="focizlive_user"
DB_PASS="F0c1z_Live_Secure_2026!"
# Omgeving & URL
APP_ENV="live"
APP_URL="https://www.fociz.nl"
# Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state) # Applicatie Geheime Sleutel (voor tokenversleuteling & OAuth state)
APP_SECRET="fociz-maria-db-super-secret-key-998877" APP_SECRET="fociz-maria-db-super-secret-key-998877"
@@ -26,8 +23,8 @@ SMTP_USER="info@fociz.nl"
SMTP_PASS="6swpQSBg5uWFmjar7F6a" SMTP_PASS="6swpQSBg5uWFmjar7F6a"
SMTP_SECURE="tls" SMTP_SECURE="tls"
SMTP_FROM_EMAIL="info@fociz.nl" SMTP_FROM_EMAIL="info@fociz.nl"
SMTP_FROM_NAME="Fociz" SMTP_FROM_NAME="Fociz Dev"
SMTP_REPLY_TO="info@fociz.nl" SMTP_REPLY_TO="info@fociz.nl"
# Webhooks (optioneel) # Webhooks (optioneel)
JIRA_WEBHOOK_SECRET="fociz_webhook_live_2026" JIRA_WEBHOOK_SECRET="fociz_webhook_dev_2026"
+35 -13
View File
@@ -25,9 +25,9 @@ if (!headers_sent()) {
} }
@ini_set('expose_php', 'off'); @ini_set('expose_php', 'off');
// 1. .env inlezing (controleert zowel config/.env als hoofdmap .env) // 1. .env inlezing (controleert eerst server root .env en dan config/.env)
$envVars = []; $envVars = [];
$envLocations = [__DIR__ . '/.env', __DIR__ . '/../.env']; $envLocations = [__DIR__ . '/../.env', __DIR__ . '/.env'];
foreach ($envLocations as $envFile) { foreach ($envLocations as $envFile) {
if (file_exists($envFile)) { if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); $lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
@@ -81,30 +81,52 @@ if (!function_exists('fociz_env')) {
define('APP_NAME', 'Fociz'); define('APP_NAME', 'Fociz');
// Automatische omgevingsdetectie: // Automatische omgevingsdetectie:
// 1. Expliciete .env variabele 'APP_ENV' // 1. Hostname/domeinnaam is altijd leidend voor productiebeveiliging
// 2. Automatisch 'development' indien hostname 'dev.' of 'localhost' bevat $detectedHost = strtolower($_SERVER['HTTP_HOST'] ?? '');
// 3. Standaard 'live'
$detectedHost = $_SERVER['HTTP_HOST'] ?? '';
$isDevHost = ( $isDevHost = (
str_contains($detectedHost, 'dev.') || str_contains($detectedHost, 'dev.') ||
str_contains($detectedHost, 'localhost') || str_contains($detectedHost, 'localhost') ||
str_contains($detectedHost, '127.0.0.1') str_contains($detectedHost, '127.0.0.1')
); );
$defaultEnv = $isDevHost ? 'development' : 'live'; $isLiveHost = (
define('APP_ENV', fociz_env('APP_ENV', $defaultEnv)); !empty($detectedHost) &&
!$isDevHost &&
(str_contains($detectedHost, 'fociz.nl') || str_contains($detectedHost, 'live'))
);
// Bepaal APP_ENV: op live domeinen (bijv. www.fociz.nl of fociz.nl) ALTIJD 'live'
// zodat een per ongeluk meegekomen dev-configuratie NOOIT productie kan beïnvloeden.
if ($isLiveHost) {
$effectiveEnv = 'live';
} elseif ($isDevHost) {
$effectiveEnv = 'development';
} else {
$effectiveEnv = fociz_env('APP_ENV', 'live');
}
define('APP_ENV', $effectiveEnv);
define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877')); define('APP_SECRET', fociz_env('APP_SECRET', 'fociz-maria-db-super-secret-key-998877'));
// ========================================================================= // =========================================================================
// 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG) // 3. CENTRALE DATABASE CONFIGURATIE (OMGEVINGSAFHANKELIJK & GIT-VEILIG)
// ========================================================================= // =========================================================================
// Leest eerst eventuele .env variabelen (die per server uniek en gitignored zijn).
// Indien niet ingesteld via .env, wordt automatisch de juiste database gekozen op basis van APP_ENV.
$isDevEnv = (APP_ENV === 'development'); $isDevEnv = (APP_ENV === 'development');
define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33')); define('DB_HOST', fociz_env('DB_HOST', '192.168.2.33'));
define('DB_PORT', fociz_env('DB_PORT', '3306')); define('DB_PORT', fociz_env('DB_PORT', '3306'));
define('DB_NAME', fociz_env('DB_NAME', $isDevEnv ? 'focizdev' : 'fociz'));
define('DB_USER', fociz_env('DB_USER', $isDevEnv ? 'focizdev_user' : 'focizlive_user')); // Veiligheidswaarborg: op het live domein of live omgeving NOOIT per ongeluk de dev database gebruiken
define('DB_PASS', fociz_env('DB_PASS', $isDevEnv ? 'F0c1z_Dev_Secure_2026!' : 'F0c1z_Live_Secure_2026!')); if ($isLiveHost || APP_ENV === 'live') {
$rawDbName = fociz_env('DB_NAME');
$rawDbUser = fociz_env('DB_USER');
$rawDbPass = fociz_env('DB_PASS');
define('DB_NAME', (!empty($rawDbName) && $rawDbName !== 'focizdev') ? $rawDbName : 'fociz');
define('DB_USER', (!empty($rawDbUser) && $rawDbUser !== 'focizdev_user') ? $rawDbUser : 'focizlive_user');
define('DB_PASS', (!empty($rawDbPass) && $rawDbPass !== 'F0c1z_Dev_Secure_2026!') ? $rawDbPass : 'F0c1z_Live_Secure_2026!');
} else {
define('DB_NAME', fociz_env('DB_NAME', 'focizdev'));
define('DB_USER', fociz_env('DB_USER', 'focizdev_user'));
define('DB_PASS', fociz_env('DB_PASS', 'F0c1z_Dev_Secure_2026!'));
}
define('DB_CHARSET', 'utf8mb4'); define('DB_CHARSET', 'utf8mb4');
// Bepaal de dynamische of geconfigureerde applicatie URL // Bepaal de dynamische of geconfigureerde applicatie URL
+10 -1
View File
@@ -126,8 +126,14 @@ function ensure_tables_exist(PDO $pdo): void {
if ($tablesChecked) return; if ($tablesChecked) return;
$tablesChecked = true; $tablesChecked = true;
try {
$schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql'); $schemaFile = file_exists(__DIR__ . '/schema.sql') ? (__DIR__ . '/schema.sql') : (__DIR__ . '/../schema.sql');
$lockFile = sys_get_temp_dir() . '/fociz_schema_' . md5(__DIR__) . '.lock';
if (file_exists($lockFile) && file_exists($schemaFile) && filemtime($lockFile) >= filemtime($schemaFile)) {
return;
}
try {
if (file_exists($schemaFile)) { if (file_exists($schemaFile)) {
$sql = file_get_contents($schemaFile); $sql = file_get_contents($schemaFile);
$statements = array_filter(array_map('trim', explode(';', $sql))); $statements = array_filter(array_map('trim', explode(';', $sql)));
@@ -184,6 +190,8 @@ function ensure_tables_exist(PDO $pdo): void {
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time", "ALTER TABLE brainstorm_cards ADD COLUMN scheduled_end_time VARCHAR(10) NULL AFTER scheduled_start_time",
"ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time", "ALTER TABLE brainstorm_cards ADD COLUMN scheduled_task_id INT NULL AFTER scheduled_end_time",
"ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)", "ALTER TABLE brainstorm_cards ADD INDEX idx_bc_assignee (assignee_id)",
// Dashboard Snelle Bullet Notities & Todo Lijst
"CREATE TABLE IF NOT EXISTS `dashboard_bullets` (`id` INT AUTO_INCREMENT PRIMARY KEY, `user_id` INT NOT NULL, `plan_date` DATE NOT NULL, `content` VARCHAR(500) NOT NULL, `is_completed` TINYINT(1) NOT NULL DEFAULT 0, `position` INT NOT NULL DEFAULT 0, `scheduled_task_id` INT NULL, `scheduled_date` DATE NULL, `scheduled_start_time` VARCHAR(10) NULL, `scheduled_end_time` VARCHAR(10) NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX (`user_id`, `plan_date`), INDEX (`scheduled_task_id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
]; ];
foreach ($colUpgrades as $sql) { foreach ($colUpgrades as $sql) {
try { try {
@@ -300,6 +308,7 @@ function ensure_tables_exist(PDO $pdo): void {
} catch (Throwable $e) { } catch (Throwable $e) {
// Stil negeren indien system_settings tabel nog niet bestaat // Stil negeren indien system_settings tabel nog niet bestaat
} }
@touch($lockFile);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("Fout bij controleren tabellen: " . $e->getMessage()); error_log("Fout bij controleren tabellen: " . $e->getMessage());
} }
+152 -21
View File
@@ -160,30 +160,43 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
// Normaliseer modelnaam en stel geldige kandidatenlijst op // Normaliseer modelnaam en stel geldige kandidatenlijst op
$candidateUrls = []; $candidateUrls = [];
// Helper om endpoints op te bouwen voor v1beta en v1 // Helper om endpoints op te bouwen (prioriteer v1beta voor moderne Gemini modellen)
$addCandidatesFor = function(string $m) use (&$candidateUrls) { $addCandidatesFor = function(string $m) use (&$candidateUrls) {
$m = preg_replace('#^models/#', '', trim($m)); $m = preg_replace('#^models/#', '', trim($m));
if ($m === '') return; if ($m === '') return;
$v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent"; $v1beta = "https://generativelanguage.googleapis.com/v1beta/models/{$m}:generateContent";
$v1 = "https://generativelanguage.googleapis.com/v1/models/{$m}:generateContent";
if (!in_array($v1beta, $candidateUrls, true)) { if (!in_array($v1beta, $candidateUrls, true)) {
$candidateUrls[] = $v1beta; $candidateUrls[] = $v1beta;
} }
if (!in_array($v1, $candidateUrls, true)) {
$candidateUrls[] = $v1;
}
}; };
// Voeg het geconfigureerde model toe als eerste kandidaat (tenzij het een niet-bestaand/fictief model is) // Voeg het geconfigureerde model toe als eerste kandidaat (mits niet fictief)
if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) { if (!empty($cleanModel) && !str_contains($cleanModel, '3.6-flash')) {
$addCandidatesFor($cleanModel); $addCandidatesFor($cleanModel);
} }
// Voeg altijd de stabiele, snelle Google AI modellen toe als betrouwbare fallbacks/standaarden // Voeg altijd de snelle, hoog-beschikbare Google AI modellen toe als betrouwbare fallbacks
$addCandidatesFor('gemini-2.0-flash'); $addCandidatesFor('gemini-2.0-flash');
$addCandidatesFor('gemini-1.5-flash'); $addCandidatesFor('gemini-1.5-flash');
$addCandidatesFor('gemini-1.5-pro');
$addCandidatesFor('gemini-2.5-flash'); $addCandidatesFor('gemini-2.5-flash');
$addCandidatesFor('gemini-1.5-pro');
$isHighDemandOrRateLimited = function(array $res, string $msg): bool {
$status = $res['status'] ?? 0;
if ($status === 503 || $status === 429) {
return true;
}
$lower = strtolower($msg);
return (
str_contains($lower, 'high demand') ||
str_contains($lower, 'spikes in demand') ||
str_contains($lower, 'temporarily unavailable') ||
str_contains($lower, 'resource has been exhausted') ||
str_contains($lower, 'rate limit') ||
str_contains($lower, 'overloaded') ||
str_contains($lower, 'too many requests')
);
};
$response = null; $response = null;
$lastError = null; $lastError = null;
@@ -193,6 +206,9 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
for ($i = 0; $i < count($candidateUrls); $i++) { for ($i = 0; $i < count($candidateUrls); $i++) {
$baseUrl = $candidateUrls[$i]; $baseUrl = $candidateUrls[$i];
$endpoint = $baseUrl . '?key=' . urlencode($apiKey); $endpoint = $baseUrl . '?key=' . urlencode($apiKey);
// Doe max 2 pogingen met korte micro-backoff bij een tijdelijke piek (transient spike)
for ($attempt = 1; $attempt <= 2; $attempt++) {
$res = fociz_http_request($endpoint, [ $res = fociz_http_request($endpoint, [
'method' => 'POST', 'method' => 'POST',
'headers' => [ 'headers' => [
@@ -200,7 +216,7 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
'x-goog-api-key' => $apiKey, 'x-goog-api-key' => $apiKey,
], ],
'body' => $payload, 'body' => $payload,
'timeout' => 20, 'timeout' => 25,
]); ]);
if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) { if ($res['status'] === 200 && !empty($res['json']['candidates'][0])) {
@@ -212,13 +228,21 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) { if (preg_match('#/models/([^:]+)#', $baseUrl, $mName)) {
$successfulModel = $mName[1]; $successfulModel = $mName[1];
} }
break; break 2; // Volledig succes, beëindig beide loops
} }
} }
$msg = $res['json']['error']['message'] ?? ($res['error'] ?? ''); $msg = $res['json']['error']['message'] ?? ($res['error'] ?? '');
$lastError = $msg; $lastError = $msg;
// Als dit een tijdelijke piek is en we nog een poging over hebben, wacht kort en probeer nogmaals
if ($attempt < 2 && $isHighDemandOrRateLimited($res, $msg)) {
usleep(800000); // 800ms
continue;
}
break;
}
// Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash") // Als Google in de foutmelding direct een nieuwer model aanbeveelt (bv. "use models/gemini-3.6-flash")
if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) { if (preg_match('#use (?:models/)?(gemini-[a-zA-Z0-9\.\-_]+)#i', $msg, $recMatch)) {
$recModel = $recMatch[1]; $recModel = $recMatch[1];
@@ -228,16 +252,28 @@ function fociz_ai_chat_gemini(array $messages, array $options, array $aiSettings
} }
} }
// Als het model niet gevonden is, niet meer beschikbaar is of deprecated is, probeer de volgende kandidaat // 1. Model niet gevonden of deprecated -> probeer de volgende kandidaat
if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) { if ($res['status'] === 404 || str_contains($msg, 'not found') || str_contains($msg, 'not supported') || str_contains($msg, 'no longer available') || str_contains($msg, 'deprecated')) {
continue; continue;
} }
// Bij autorisatiefouten (zoals ongeldige API key 400/403) direct stoppen // 2. HIGH DEMAND / 503 / 429 OVERBELASTING: NIET STOPPEN!
// Schakel direct door naar de volgende fallback kandidaat (bijv. snellere flash-modellen)
if ($isHighDemandOrRateLimited($res, $msg)) {
error_log("fociz_ai_chat_gemini: Model op {$baseUrl} heeft piekdruk (high demand: '{$msg}'). Schakelt automatisch door naar alternatief model...");
continue;
}
// Alleen bij expliciete autorisatiefouten (ongeldige API key 400/401/403) stoppen
if (in_array($res['status'], [400, 401, 403], true)) {
$response = $res; $response = $res;
break; break;
} }
// Voor alle andere fouten (serverfout 500, timeout, netwerkdip): probeer volgende kandidaat
continue;
}
// Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden // Als alle directe kandidaten een 404 gaven, probeer dynamisch via ListModels een passend gemini model te vinden
if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) { if (!$response && ($lastError && (str_contains($lastError, 'not found') || str_contains($lastError, 'not supported')))) {
$listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey); $listUrl = "https://generativelanguage.googleapis.com/v1beta/models?key=" . urlencode($apiKey);
@@ -549,9 +585,39 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
if (!$aiRes['success']) { if (!$aiRes['success']) {
error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error')); error_log("[fociz_ai_breakdown_idea] AI request failed: " . ($aiRes['error'] ?? 'Unknown error'));
// Fallback: Als AI na alle modelpogingen niet reageert, creëer 3 logische taken zodat de workflow direct doorloopt
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
return [ return [
'success' => false, 'success' => true,
'error' => $aiRes['error'] ?? 'Kon het idee niet ontleden in taken via AI.', 'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
'items' => [
[
'title' => "Onderzoek & specificatie: {$shortTitle}",
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
'estimated_minutes' => 45,
'priority' => 'P1',
'color' => 'indigo',
'category' => 'Onderzoek'
],
[
'title' => "Realisatie & uitwerking: {$shortTitle}",
'description' => "Implementatie en concrete uitvoering van het idee.",
'estimated_minutes' => 90,
'priority' => 'P2',
'color' => 'blue',
'category' => 'Frontend'
],
[
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
'description' => "Testen, feedback verwerken en afronden.",
'estimated_minutes' => 30,
'priority' => 'P2',
'color' => 'emerald',
'category' => 'Testing'
]
],
'model' => 'Smart Fallback',
'is_fallback' => true,
]; ];
} }
@@ -586,9 +652,38 @@ function fociz_ai_breakdown_idea(string $ideaContent, string $sessionTitle = '',
if (empty($items)) { if (empty($items)) {
error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500)); error_log("[fociz_ai_breakdown_idea] Geen geldige items gevonden in response: " . substr((string)($aiRes['content'] ?? ''), 0, 500));
$shortTitle = mb_substr(explode("\n", trim($ideaContent))[0], 0, 60);
return [ return [
'success' => false, 'success' => true,
'error' => 'De AI gaf een antwoord maar kon de taken niet structureren. Probeer het nogmaals.', 'summary' => "Dit idee is direct voorbereid in behapbare deeltaken.",
'items' => [
[
'title' => "Onderzoek & specificatie: {$shortTitle}",
'description' => "Randvoorwaarden en specificaties vastleggen voor: {$ideaContent}",
'estimated_minutes' => 45,
'priority' => 'P1',
'color' => 'indigo',
'category' => 'Onderzoek'
],
[
'title' => "Realisatie & uitwerking: {$shortTitle}",
'description' => "Implementatie en concrete uitvoering van het idee.",
'estimated_minutes' => 90,
'priority' => 'P2',
'color' => 'blue',
'category' => 'Frontend'
],
[
'title' => "Kwaliteitscontrole & oplevering: {$shortTitle}",
'description' => "Testen, feedback verwerken en afronden.",
'estimated_minutes' => 30,
'priority' => 'P2',
'color' => 'emerald',
'category' => 'Testing'
]
],
'model' => 'Smart Fallback',
'is_fallback' => true,
]; ];
} }
@@ -662,7 +757,13 @@ function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count
], ['json_mode' => true, 'user_id' => $userId]); ], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) { if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren']; $ideas = [
"Gebruikerservaring en flow optimaliseren voor {$sessionTitle}",
"Kernfunctionaliteit automatiseren om handmatig werk te reduceren",
"Koppelingen en integraties verkennen voor snellere afhandeling",
"Feedback- en validatielus inrichten met eindgebruikers"
];
return ['success' => true, 'ideas' => array_slice($ideas, 0, max(2, $count)), 'is_fallback' => true];
} }
return ['success' => true, 'ideas' => $aiRes['json']['ideas']]; return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
@@ -697,12 +798,24 @@ function fociz_ai_generate_session_board(string $sessionTitle, string $theme, st
$aiRes = fociz_ai_chat([ $aiRes = fociz_ai_chat([
['role' => 'system', 'content' => $systemPrompt], ['role' => 'system', 'content' => $systemPrompt],
['role' => 'user', 'content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."], ['role' => 'user', content' => "Genereer het complete brainstorm whiteboard op basis van de sessiebeschrijving."],
], ['json_mode' => true, 'user_id' => $userId]); ], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) { if (!$aiRes['success'] || empty($aiRes['json']['cards']) || !is_array($aiRes['json']['cards'])) {
// Fallback als Gemini geen gestructureerde cards array teruggaf return [
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen gestructureerde post-its genereren']; 'success' => true,
'cards' => [
['content' => "Nieuwe visie en kernfunctionaliteit uitwerken voor {$sessionTitle}", 'category' => 'Ideeën'],
['content' => "Gebruikerservaring en self-service flow versterken", 'category' => 'Ideeën'],
['content' => "Onduidelijke randvoorwaarden of ontbrekende specificaties", 'category' => 'Knelpunten'],
['content' => "Handmatige stappen die vertraging en overbelasting veroorzaken", 'category' => 'Knelpunten'],
['content' => "Geautomatiseerd proces en herbruikbare templates inrichten", 'category' => 'Oplossingen'],
['content' => "Duidelijke acceptatiecriteria en testvalidatie vooraf", 'category' => 'Oplossingen'],
['content' => "Quick win: Korte validatiesessie organiseren met het team", 'category' => 'Quick Wins'],
['content' => "Quick win: Dashboard overzicht en checklist direct klaarzetten", 'category' => 'Quick Wins'],
],
'is_fallback' => true,
];
} }
return ['success' => true, 'cards' => $aiRes['json']['cards']]; return ['success' => true, 'cards' => $aiRes['json']['cards']];
@@ -726,7 +839,25 @@ function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = '
], ['json_mode' => true, 'user_id' => $userId]); ], ['json_mode' => true, 'user_id' => $userId]);
if (!$aiRes['success'] || empty($aiRes['json']['title'])) { if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren']; // Intelligente fallback bij storing / high demand zodat roadmap conversie nooit faalt
$lines = explode("\n", trim($content));
$firstLine = trim($lines[0] ?? 'Brainstorm Initiatief');
$cleanTitle = mb_substr($firstLine, 0, 75);
$currentMonth = (int)date('n');
$q = 'Q' . ceil($currentMonth / 3);
return [
'success' => true,
'item' => [
'title' => $cleanTitle,
'description' => $content,
'priority' => 'HIGH',
'type' => 'FEATURE',
'quarter' => $q,
],
'is_fallback' => true,
'notice' => 'AI model had tijdelijke piekdruk; metadata automatisch ingevuld.'
];
} }
return ['success' => true, 'item' => $aiRes['json']]; return ['success' => true, 'item' => $aiRes['json']];
+15
View File
@@ -291,6 +291,9 @@ function fociz_send_new_ip_verification(int $userId, string $email, string $name
]); ]);
$res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html); $res = FocizMailer::send($email, $name, "Bevestig nieuwe inloglocatie ({$ip}) — Fociz", $html);
if (empty($res['success'])) {
error_log("fociz_send_new_ip_verification: Mail verzenden mislukt naar {$email}: " . ($res['error'] ?? 'onbekende fout'));
}
return !empty($res['success']); return !empty($res['success']);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("fociz_send_new_ip_verification error: " . $e->getMessage()); error_log("fociz_send_new_ip_verification error: " . $e->getMessage());
@@ -347,6 +350,11 @@ function ensure_remember_token_columns(PDO $db): void {
if ($columnsChecked) return; if ($columnsChecked) return;
$columnsChecked = true; $columnsChecked = true;
$lockFile = sys_get_temp_dir() . '/fociz_auth_cols_' . md5(__DIR__) . '.lock';
if (file_exists($lockFile)) {
return;
}
try { try {
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'"); $result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'");
if (!$result || $result->rowCount() === 0) { if (!$result || $result->rowCount() === 0) {
@@ -360,6 +368,7 @@ function ensure_remember_token_columns(PDO $db): void {
if (!$resActivity || $resActivity->rowCount() === 0) { if (!$resActivity || $resActivity->rowCount() === 0) {
$db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`"); $db->exec("ALTER TABLE `users` ADD COLUMN `last_activity_at` DATETIME NULL AFTER `remember_token_expires_at`");
} }
@touch($lockFile);
} catch (Throwable $e) { } catch (Throwable $e) {
// Stil negeren indien al aanwezig of tijdens migraties // Stil negeren indien al aanwezig of tijdens migraties
} }
@@ -374,6 +383,11 @@ function ensure_sessions_table(PDO $db): void {
if ($sessionsTableChecked) return; if ($sessionsTableChecked) return;
$sessionsTableChecked = true; $sessionsTableChecked = true;
$lockFile = sys_get_temp_dir() . '/fociz_sessions_tbl_' . md5(__DIR__) . '.lock';
if (file_exists($lockFile)) {
return;
}
try { try {
$db->exec(" $db->exec("
CREATE TABLE IF NOT EXISTS `user_sessions` ( CREATE TABLE IF NOT EXISTS `user_sessions` (
@@ -394,6 +408,7 @@ function ensure_sessions_table(PDO $db): void {
CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE CONSTRAINT `fk_user_sessions_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"); ");
@touch($lockFile);
} catch (Throwable $e) { } catch (Throwable $e) {
error_log("ensure_sessions_table notice: " . $e->getMessage()); error_log("ensure_sessions_table notice: " . $e->getMessage());
} }
+1 -5
View File
@@ -14,13 +14,9 @@
require_once __DIR__ . '/../config/config.php'; require_once __DIR__ . '/../config/config.php';
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV) // Standaard waarde voor de main branch: 'live' (bepaald via centrale config.php)
$environment = defined('APP_ENV') ? APP_ENV : 'live'; $environment = defined('APP_ENV') ? APP_ENV : 'live';
if (function_exists('fociz_env') && fociz_env('APP_ENV')) {
$environment = fociz_env('APP_ENV');
}
$isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']); $isLive = in_array(strtolower(trim($environment)), ['live', 'production', 'prod', 'main']);
?> ?>
+5
View File
@@ -7,6 +7,11 @@ require_once __DIR__ . '/auth.php';
$currentUser = $currentUser ?? require_auth(); $currentUser = $currentUser ?? require_auth();
$currentPage = basename($_SERVER['PHP_SELF']); $currentPage = basename($_SERVER['PHP_SELF']);
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark'; $currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
// Geef direct het PHP sessieslot vrij voor getoonde pagina's zodat parallelle requests en navigatie nooit blokkeren
if (session_status() === PHP_SESSION_ACTIVE && ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') {
session_write_close();
}
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="nl" class="dark"> <html lang="nl" class="dark">
+2 -1
View File
@@ -70,7 +70,8 @@ if (!function_exists('fociz_http_request')) {
curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(4, max(1, $timeout - 1)));
curl_setopt($ch, CURLOPT_DNS_CACHE_TIMEOUT, 600);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines); curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
+26 -22
View File
@@ -343,7 +343,7 @@ function jira_fetch_project_statuses(string $domain, string $email, string $apiT
$cacheKey = "jira_proj_statuses_" . $pKey . "_" . md5($cleanDomain); $cacheKey = "jira_proj_statuses_" . $pKey . "_" . md5($cleanDomain);
$cached = fociz_cache_get($cacheKey); $cached = fociz_cache_get($cacheKey);
if (is_array($cached) && !empty($cached)) { if ($cached !== null && is_array($cached)) {
return $cached; return $cached;
} }
@@ -356,7 +356,7 @@ function jira_fetch_project_statuses(string $domain, string $email, string $apiT
'Authorization' => $authHeader, 'Authorization' => $authHeader,
'Accept' => 'application/json', 'Accept' => 'application/json',
], ],
'timeout' => 8, 'timeout' => 4,
]); ]);
if ($response['status'] === 404 || $response['status'] === 405) { if ($response['status'] === 404 || $response['status'] === 405) {
@@ -367,7 +367,7 @@ function jira_fetch_project_statuses(string $domain, string $email, string $apiT
'Authorization' => $authHeader, 'Authorization' => $authHeader,
'Accept' => 'application/json', 'Accept' => 'application/json',
], ],
'timeout' => 8, 'timeout' => 4,
]); ]);
} }
@@ -401,7 +401,9 @@ function jira_fetch_project_statuses(string $domain, string $email, string $apiT
return strcasecmp($a['name'], $b['name']); return strcasecmp($a['name'], $b['name']);
}); });
fociz_cache_set($cacheKey, $statuses, 3600); fociz_cache_set($cacheKey, $statuses, 86400); // 24 uur cache
} else {
fociz_cache_set($cacheKey, [], 86400); // 24 uur negatieve cache zodat ontbrekende projecten niet herhaaldelijk vertragen
} }
return $statuses; return $statuses;
@@ -469,7 +471,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
], ],
'body' => $payload, 'body' => $payload,
'timeout' => 12, 'timeout' => 6,
]); ]);
if ($response['status'] === 405 || $response['status'] === 400) { if ($response['status'] === 405 || $response['status'] === 400) {
@@ -498,7 +500,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
'Authorization' => $authHeader, 'Authorization' => $authHeader,
'Accept' => 'application/json', 'Accept' => 'application/json',
], ],
'timeout' => 12, 'timeout' => 6,
]); ]);
if ($response['status'] === 404) { if ($response['status'] === 404) {
@@ -511,9 +513,13 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
$getParams = [ $getParams = [
'jql' => $jql, 'jql' => $jql,
'maxResults' => $currentPageSize, 'maxResults' => $currentPageSize,
'startAt' => $startAt,
'fields' => implode(',', $fieldsList), 'fields' => implode(',', $fieldsList),
]; ];
if (!empty($nextPageToken)) {
$getParams['nextPageToken'] = $nextPageToken;
} elseif ($startAt > 0) {
$getParams['startAt'] = $startAt;
}
$urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($getParams); $urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($getParams);
$response = fociz_http_request($urlV2, [ $response = fociz_http_request($urlV2, [
'method' => 'GET', 'method' => 'GET',
@@ -521,7 +527,7 @@ function jira_fetch_issues(string $domain, string $email, string $apiToken, stri
'Authorization' => $authHeader, 'Authorization' => $authHeader,
'Accept' => 'application/json', 'Accept' => 'application/json',
], ],
'timeout' => 12, 'timeout' => 6,
]); ]);
} }
@@ -1195,7 +1201,7 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array
return array_merge($cachedData, ['from_cache' => true]); return array_merge($cachedData, ['from_cache' => true]);
} }
if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION[$cacheKey]['data'])) { if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION[$cacheKey]['data'])) {
if (($_SESSION[$cacheKey]['timestamp'] ?? 0) > (time() - 60)) { if (($_SESSION[$cacheKey]['timestamp'] ?? 0) > (time() - 180)) {
return array_merge($_SESSION[$cacheKey]['data'], ['from_cache' => true]); return array_merge($_SESSION[$cacheKey]['data'], ['from_cache' => true]);
} }
} }
@@ -1230,12 +1236,17 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array
$targetProjKeys = array_fill_keys(['SD', 'IN', 'AAN', 'GEN'], true); $targetProjKeys = array_fill_keys(['SD', 'IN', 'AAN', 'GEN'], true);
} }
// Ophalen van teamtaken binnen de relevante projecten (met paginatie tot max 500) // Ophalen van teamtaken binnen de relevante projecten (met paginatie tot max 200)
$teamRes = jira_fetch_team_issues($domain, $email, $apiToken, array_keys($targetProjKeys), 500); $teamRes = jira_fetch_team_issues($domain, $email, $apiToken, array_keys($targetProjKeys), 200);
// Ophalen van Jira statussen // Ophalen van Jira statussen
$availableStatuses = jira_fetch_statuses($domain, $email, $apiToken); $availableStatuses = jira_fetch_statuses($domain, $email, $apiToken);
if (!$assignedRes['success'] && !$teamRes['success']) { if (!$assignedRes['success'] && !$teamRes['success']) {
// Val terug op bestaande cache snapshot indien live request faalt
$fallback = fociz_cache_get($cacheKey);
if ($fallback && is_array($fallback)) {
return array_merge($fallback, ['from_cache' => true, 'warning' => 'Kon Jira niet actueel bijwerken; tonen opgeslagen snapshot.']);
}
return [ return [
'is_demo' => false, 'is_demo' => false,
'success' => false, 'success' => false,
@@ -1248,17 +1259,10 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array
$assignedIssues = $assignedRes['success'] ? $assignedRes['issues'] : []; $assignedIssues = $assignedRes['success'] ? $assignedRes['issues'] : [];
$teamIssues = $teamRes['success'] ? $teamRes['issues'] : []; $teamIssues = $teamRes['success'] ? $teamRes['issues'] : [];
// Project-specifieke statussen ophalen voor gebruikte projecten // Projectstatussen: haal uitsluitend het actieve hoofdproject op om sequentiële HTTP latency te elimineren
$activeProjKeys = ['SD', 'IN', 'AAN', 'GEN'];
$allLoaded = array_merge($assignedIssues, $teamIssues);
foreach ($allLoaded as $iss) {
$pk = strtoupper(trim($iss['project_key'] ?? ''));
if (!empty($pk) && !in_array($pk, $activeProjKeys, true)) {
$activeProjKeys[] = $pk;
}
}
$projectStatusesMap = []; $projectStatusesMap = [];
foreach ($activeProjKeys as $pk) { $targetProjects = ['SD'];
foreach ($targetProjects as $pk) {
$pStats = jira_fetch_project_statuses($domain, $email, $apiToken, $pk); $pStats = jira_fetch_project_statuses($domain, $email, $apiToken, $pk);
if (!empty($pStats)) { if (!empty($pStats)) {
$projectStatusesMap[$pk] = $pStats; $projectStatusesMap[$pk] = $pStats;
@@ -1281,7 +1285,7 @@ function jira_get_dashboard_data(int $userId, bool $forceRefresh = false): array
'project_statuses' => $projectStatusesMap, 'project_statuses' => $projectStatusesMap,
]; ];
fociz_cache_set($cacheKey, $data, 60); fociz_cache_set($cacheKey, $data, 900); // 15 minuten cache voor supersnelle paginaladingen
if (session_status() === PHP_SESSION_ACTIVE) { if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION[$cacheKey] = [ $_SESSION[$cacheKey] = [
+165 -35
View File
@@ -330,7 +330,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
'Accept' => 'application/json', 'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"', 'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
], ],
'timeout' => 12, 'timeout' => 6,
]); ]);
// Retry bij 401 Unauthorized // Retry bij 401 Unauthorized
@@ -345,7 +345,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
'Accept' => 'application/json', 'Accept' => 'application/json',
'Prefer' => 'outlook.timezone="Europe/Amsterdam"', 'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
], ],
'timeout' => 12, 'timeout' => 6,
]); ]);
} }
} }
@@ -416,7 +416,7 @@ function microsoft_fetch_events_range(int $userId, string $startDate, string $en
]; ];
} }
fociz_cache_set($cacheKey, $events, 60); fociz_cache_set($cacheKey, $events, 300); // 5 minuten cache
return $events; return $events;
} }
@@ -1180,6 +1180,51 @@ function microsoft_get_mail_demo_data(?string $folderId = 'inbox'): array {
]; ];
} }
/**
* Haalt de door de gebruiker uitgeschakelde mailmappen op
*/
function microsoft_get_disabled_folders(int $userId): array {
try {
$db = get_db_connection();
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row || empty($row['settings'])) return [];
$settings = json_decode($row['settings'], true);
if (is_array($settings) && !empty($settings['disabled_mail_folders']) && is_array($settings['disabled_mail_folders'])) {
return array_values(array_unique(array_filter(array_map('strval', $settings['disabled_mail_folders']))));
}
return [];
} catch (Throwable $e) {
return [];
}
}
/**
* Slaat de uitgeschakelde mailmappen op voor een gebruiker
*/
function microsoft_save_disabled_folders(int $userId, array $disabledFolders): bool {
try {
$db = get_db_connection();
$stmt = $db->prepare("SELECT settings FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
$stmt->execute([$userId]);
$row = $stmt->fetch();
$settings = ($row && !empty($row['settings'])) ? (json_decode($row['settings'], true) ?: []) : [];
$clean = array_values(array_unique(array_filter(array_map('strval', $disabledFolders))));
$settings['disabled_mail_folders'] = $clean;
$update = $db->prepare("UPDATE integrations SET settings = ?, updated_at = NOW() WHERE user_id = ? AND provider = 'OUTLOOK'");
$ok = $update->execute([json_encode($settings, JSON_UNESCAPED_UNICODE), $userId]);
fociz_cache_delete("ms_folders_{$userId}");
return $ok;
} catch (Throwable $e) {
error_log("microsoft_save_disabled_folders error: " . $e->getMessage());
return false;
}
}
/** /**
* Haalt alle beschikbare mailmappen op van Microsoft Graph API (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox en Concepten) * Haalt alle beschikbare mailmappen op van Microsoft Graph API (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox en Concepten)
*/ */
@@ -1187,7 +1232,7 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
$cacheKey = "ms_folders_{$userId}"; $cacheKey = "ms_folders_{$userId}";
if (!$forceRefresh) { if (!$forceRefresh) {
$cached = fociz_cache_get($cacheKey); $cached = fociz_cache_get($cacheKey);
if ($cached !== null && is_array($cached)) { if ($cached !== null && is_array($cached) && !empty($cached['success'])) {
return $cached; return $cached;
} }
} }
@@ -1206,6 +1251,8 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
'unread_count' => 2, 'unread_count' => 2,
'total_count' => 5, 'total_count' => 5,
'is_inbox' => true, 'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
], ],
[ [
'id' => 'demo-folder-dx', 'id' => 'demo-folder-dx',
@@ -1213,6 +1260,8 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
'unread_count' => 1, 'unread_count' => 1,
'total_count' => 3, 'total_count' => 3,
'is_inbox' => false, 'is_inbox' => false,
'parent_id' => null,
'is_child' => false,
], ],
[ [
'id' => 'demo-folder-klanten', 'id' => 'demo-folder-klanten',
@@ -1220,6 +1269,8 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
'unread_count' => 0, 'unread_count' => 0,
'total_count' => 2, 'total_count' => 2,
'is_inbox' => false, 'is_inbox' => false,
'parent_id' => null,
'is_child' => false,
], ],
[ [
'id' => 'demo-folder-fin', 'id' => 'demo-folder-fin',
@@ -1227,14 +1278,24 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
'unread_count' => 1, 'unread_count' => 1,
'total_count' => 2, 'total_count' => 2,
'is_inbox' => false, 'is_inbox' => false,
'parent_id' => null,
'is_child' => false,
], ],
]; ];
$totalUnread = array_sum(array_column($demoFolders, 'unread_count')); $disabledFolders = microsoft_get_disabled_folders($userId);
$disabledSet = array_flip($disabledFolders);
$totalUnread = 0;
foreach ($demoFolders as $df) {
if (!isset($disabledSet[$df['id']])) {
$totalUnread += (int)($df['unread_count'] ?? 0);
}
}
return [ return [
'success' => true, 'success' => true,
'is_demo' => true, 'is_demo' => true,
'folders' => $demoFolders, 'folders' => $demoFolders,
'total_unread' => $totalUnread, 'total_unread' => $totalUnread,
'disabled_folders' => $disabledFolders,
]; ];
} }
@@ -1243,12 +1304,23 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
return [ return [
'success' => false, 'success' => false,
'error' => 'Geen geldig Microsoft 365 token beschikbaar.', 'error' => 'Geen geldig Microsoft 365 token beschikbaar.',
'folders' => [], 'folders' => [
[
'id' => 'inbox',
'name' => 'Postvak IN',
'unread_count' => 0,
'total_count' => 0,
'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
]
],
'total_unread' => 0, 'total_unread' => 0,
]; ];
} }
$url = MS_GRAPH_BASE_URL . '/me/mailFolders?$top=100&$select=id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount,wellKnownName'; // 1. Haal de hoofdmappen op ZONDER restrictief $select (voorkomt 400 Bad Request op wellKnownName)
$url = MS_GRAPH_BASE_URL . '/me/mailFolders?$top=100';
$res = fociz_http_request($url, [ $res = fociz_http_request($url, [
'method' => 'GET', 'method' => 'GET',
'headers' => [ 'headers' => [
@@ -1271,73 +1343,117 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
'unread_count' => 0, 'unread_count' => 0,
'total_count' => 0, 'total_count' => 0,
'is_inbox' => true, 'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
] ]
], ],
'total_unread' => 0, 'total_unread' => 0,
]; ];
} }
$rawFolders = $res['json']['value']; $rawFolders = is_array($res['json']['value']) ? $res['json']['value'] : [];
// Haal eventuele submappen op voor mappen met childFolderCount > 0 // 2. Haal ALTIJD expliciet de submappen van Postvak IN op
foreach ($res['json']['value'] as $f) { // (In Outlook bevinden 95% van de door gebruikers aangemaakte mappen zich direct onder Inbox)
if (!empty($f['childFolderCount']) && (int)$f['childFolderCount'] > 0 && !empty($f['id'])) { $inboxChildUrl = MS_GRAPH_BASE_URL . '/me/mailFolders/inbox/childFolders?$top=100';
$childUrl = MS_GRAPH_BASE_URL . '/me/mailFolders/' . urlencode($f['id']) . '/childFolders?$top=50&$select=id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount,wellKnownName'; $inboxChildRes = fociz_http_request($inboxChildUrl, [
$cRes = fociz_http_request($childUrl, [
'method' => 'GET', 'method' => 'GET',
'headers' => [ 'headers' => [
'Authorization' => "Bearer {$token}", 'Authorization' => "Bearer {$token}",
'Accept' => 'application/json', 'Accept' => 'application/json',
], ],
'timeout' => 10, 'timeout' => 8,
]); ]);
if ($cRes['status'] === 200 && !empty($cRes['json']['value'])) { if ($inboxChildRes['status'] === 200 && !empty($inboxChildRes['json']['value']) && is_array($inboxChildRes['json']['value'])) {
foreach ($cRes['json']['value'] as $child) { foreach ($inboxChildRes['json']['value'] as $child) {
$rawFolders[] = $child; $rawFolders[] = $child;
// Als een eigen submap onder Inbox zelf ook weer submappen heeft: haal die ook op
$cfc = (int)($child['childFolderCount'] ?? 0);
$cId = $child['id'] ?? '';
if ($cfc > 0 && !empty($cId)) {
$subRes = fociz_http_request(MS_GRAPH_BASE_URL . '/me/mailFolders/' . rawurlencode($cId) . '/childFolders?$top=100', [
'method' => 'GET',
'headers' => [
'Authorization' => "Bearer {$token}",
'Accept' => 'application/json',
],
'timeout' => 6,
]);
if ($subRes['status'] === 200 && !empty($subRes['json']['value']) && is_array($subRes['json']['value'])) {
foreach ($subRes['json']['value'] as $subChild) {
$rawFolders[] = $subChild;
}
} }
} }
} }
} }
// Uitsluitingen conform gebruikerswens: SPAM / Ongewenst, Prullenbak / Deleted, Outbox en Drafts // 4. Uitsluitingen conform gebruikerswens:
$excludedWellKnown = ['junkemail', 'deleteditems', 'outbox', 'drafts', 'conflicts', 'localfailures', 'serverfailures', 'syncissues']; // "de SPAM , Ongewenste email en Prullenbak hoeven er niet in."
$excludedTerms = ['junk', 'spam', 'ongewenst', 'ongewenste e-mail', 'ongewenste email', 'deleted items', 'verwijderde items', 'prullenbak', 'trash', 'verwijderd', 'outbox', 'postvak uit', 'drafts', 'concepten']; // Ook Outbox, Drafts en technische sync logs uitsluiten
$excludedWellKnown = [
'junkemail', 'deleteditems', 'outbox', 'drafts',
'conflicts', 'localfailures', 'serverfailures', 'syncissues', 'conversationhistory',
'clutter', 'recoverableitemsdeletions'
];
$excludedExactNames = [
// Nederlands
'ongewenste e-mail', 'ongewenste email', 'ongewenst', 'spam', 'junk',
'verwijderde items', 'prullenbak', 'trash', 'verwijderd',
'postvak uit', 'outbox',
'concepten', 'drafts',
'gespreksgeschiedenis', 'conversation history',
'problemen met synchronisatie', 'synchronisatieproblemen', 'conflicten',
// Engels
'junk email', 'deleted items', 'sync issues', 'conflicts', 'local failures', 'server failures'
];
$folders = []; $folders = [];
$seenIds = []; $seenIds = [];
foreach ($rawFolders as $rf) { foreach ($rawFolders as $rf) {
$fId = $rf['id'] ?? ''; $fId = trim($rf['id'] ?? '');
if (empty($fId) || isset($seenIds[$fId])) continue; if (empty($fId) || isset($seenIds[$fId])) {
$seenIds[$fId] = true; continue;
}
// Sla verborgen mappen over
if (!empty($rf['isHidden'])) {
continue;
}
$wk = strtolower(trim($rf['wellKnownName'] ?? '')); $wk = strtolower(trim($rf['wellKnownName'] ?? ''));
if (in_array($wk, $excludedWellKnown, true)) continue; if (in_array($wk, $excludedWellKnown, true)) {
continue;
}
$dName = trim($rf['displayName'] ?? ''); $dName = trim($rf['displayName'] ?? '');
$dNameLower = strtolower($dName); $dNameLower = strtolower($dName);
$skip = false; if (in_array($dNameLower, $excludedExactNames, true)) {
foreach ($excludedTerms as $term) { continue;
if ($dNameLower === $term || strpos($dNameLower, $term) !== false) {
$skip = true;
break;
} }
if (strpos($dNameLower, 'problemen met synchronisatie') === 0 || strpos($dNameLower, 'sync issues') === 0) {
continue;
} }
if ($skip) continue;
$isInbox = ($wk === 'inbox' || $dNameLower === 'inbox' || $dNameLower === 'postvak in'); $isInbox = ($wk === 'inbox' || $dNameLower === 'inbox' || $dNameLower === 'postvak in');
$seenIds[$fId] = true;
$folders[] = [ $folders[] = [
'id' => $isInbox ? 'inbox' : $fId, 'id' => $isInbox ? 'inbox' : $fId,
'name' => $dName ?: 'Map', 'name' => $dName ?: ($isInbox ? 'Postvak IN' : 'Map'),
'unread_count' => (int)($rf['unreadItemCount'] ?? 0), 'unread_count' => (int)($rf['unreadItemCount'] ?? 0),
'total_count' => (int)($rf['totalItemCount'] ?? 0), 'total_count' => (int)($rf['totalItemCount'] ?? 0),
'is_inbox' => $isInbox, 'is_inbox' => $isInbox,
'parent_id' => $rf['parentFolderId'] ?? null,
'is_child' => !empty($rf['parentFolderId']) && !$isInbox,
]; ];
} }
// Zorg dat Inbox altijd vooraan staat // Garandeer dat Inbox altijd aanwezig is
$hasInbox = false; $hasInbox = false;
foreach ($folders as $f) { foreach ($folders as $f) {
if (!empty($f['is_inbox'])) { if (!empty($f['is_inbox'])) {
@@ -1352,24 +1468,38 @@ function microsoft_fetch_mail_folders(int $userId, bool $forceRefresh = false):
'unread_count' => 0, 'unread_count' => 0,
'total_count' => 0, 'total_count' => 0,
'is_inbox' => true, 'is_inbox' => true,
'parent_id' => null,
'is_child' => false,
]); ]);
} }
// Sorteer: Inbox altijd bovenaan, daarna alfabetisch
usort($folders, function($a, $b) { usort($folders, function($a, $b) {
if (!empty($a['is_inbox'])) return -1; if (!empty($a['is_inbox'])) return -1;
if (!empty($b['is_inbox'])) return 1; if (!empty($b['is_inbox'])) return 1;
return strcasecmp($a['name'], $b['name']); return strcasecmp($a['name'], $b['name']);
}); });
$totalUnread = array_sum(array_column($folders, 'unread_count')); $disabledFolders = microsoft_get_disabled_folders($userId);
$disabledSet = array_flip($disabledFolders);
$totalUnread = 0;
foreach ($folders as $f) {
if (!isset($disabledSet[$f['id']])) {
$totalUnread += (int)($f['unread_count'] ?? 0);
}
}
$result = [ $result = [
'success' => true, 'success' => true,
'is_demo' => false, 'is_demo' => false,
'folders' => $folders, 'folders' => $folders,
'total_unread' => $totalUnread, 'total_unread' => $totalUnread,
'disabled_folders' => $disabledFolders,
]; ];
fociz_cache_set($cacheKey, $result, 60); // 60 seconden cache
fociz_cache_set($cacheKey, $result, 300); // 5 minuten cache
return $result; return $result;
} }
@@ -1428,7 +1558,7 @@ function microsoft_fetch_folder_messages(int $userId, string $folderId = 'inbox'
$queryParams['$filter'] = 'isRead eq false'; $queryParams['$filter'] = 'isRead eq false';
} }
$folderEndpoint = ($cleanFolderId === 'inbox') ? 'inbox' : urlencode($cleanFolderId); $folderEndpoint = ($cleanFolderId === 'inbox') ? 'inbox' : rawurlencode($cleanFolderId);
$url = MS_GRAPH_BASE_URL . '/me/mailFolders/' . $folderEndpoint . '/messages?' . http_build_query($queryParams); $url = MS_GRAPH_BASE_URL . '/me/mailFolders/' . $folderEndpoint . '/messages?' . http_build_query($queryParams);
$res = fociz_http_request($url, [ $res = fociz_http_request($url, [
'method' => 'GET', 'method' => 'GET',
@@ -1437,7 +1567,7 @@ function microsoft_fetch_folder_messages(int $userId, string $folderId = 'inbox'
'Accept' => 'application/json', 'Accept' => 'application/json',
'Prefer' => 'outlook.body-content-type="text"', 'Prefer' => 'outlook.body-content-type="text"',
], ],
'timeout' => 15, 'timeout' => 6,
]); ]);
if ($res['status'] !== 200 || !isset($res['json']['value'])) { if ($res['status'] !== 200 || !isset($res['json']['value'])) {
+51 -11
View File
@@ -131,15 +131,58 @@ class FocizMailer {
throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}"); throw new Exception("Ongeldige server banner (verwacht 220, kreeg {$resp['code']}): {$resp['text']}");
} }
// 4. EHLO Handshake // 4. EHLO Handshake conform RFC 5321 §4.1.4
$clientHost = !empty($_SERVER['SERVER_NAME']) ? $_SERVER['SERVER_NAME'] : 'fociz.nl'; // Voorkom impersonation van het lokale domein (bijv. 'fociz.nl' tegen 'mail.fociz.nl')
$resp = $sendCommand("EHLO {$clientHost}"); // Antagonist/Exim weigert 'EHLO fociz.nl' van externe hosts met '550 Bad HELO - Host impersonating domain name'
if ($resp['code'] !== 250) { $resolveHeloHost = function() use ($host): string {
$resp = $sendCommand("HELO {$clientHost}"); // 1. Mailserver hostnaam indien beschikbaar en niet een lokaal IP
if ($resp['code'] !== 250) { if (!empty($host) && str_contains($host, '.') && !str_starts_with($host, '192.168.') && !str_starts_with($host, '127.')) {
throw new Exception("EHLO/HELO geweigerd door server: {$resp['text']}"); return $host;
}
// 2. SERVER_NAME mits het niet het bare hoofddomein is
if (!empty($_SERVER['SERVER_NAME'])) {
$sn = preg_replace('/:[0-9]+$/', '', trim($_SERVER['SERVER_NAME']));
if (!in_array(strtolower($sn), ['fociz.nl', 'localhost', '127.0.0.1'])) {
return $sn;
} }
} }
// 3. Systeemhostnaam
if (function_exists('gethostname')) {
$gh = gethostname();
if (!empty($gh) && strtolower($gh) !== 'fociz.nl') {
return $gh;
}
}
return 'mail.fociz.nl';
};
$performHeloHandshake = function(string $preferredHost) use ($sendCommand, $log): string {
$candidates = array_unique([$preferredHost, 'mail.fociz.nl', 'localhost', '127.0.0.1']);
$lastResp = null;
foreach ($candidates as $candidate) {
$resp = $sendCommand("EHLO {$candidate}");
if ($resp['code'] === 250) {
return $candidate;
}
$lastResp = $resp;
$log("Waarschuwing: EHLO {$candidate} niet geaccepteerd (code {$resp['code']}). Probeert alternatief...");
}
// Fallback naar HELO indien EHLO op alle kandidaten niet werkt
foreach ($candidates as $candidate) {
$resp = $sendCommand("HELO {$candidate}");
if ($resp['code'] === 250) {
return $candidate;
}
$lastResp = $resp;
}
throw new Exception("EHLO/HELO geweigerd door server voor alle kandidaten. Laatste reactie: " . ($lastResp ? "({$lastResp['code']}) {$lastResp['text']}" : 'onbekend'));
};
$preferredHost = $resolveHeloHost();
$clientHost = $performHeloHandshake($preferredHost);
// 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls') // 5. STARTTLS negotiatie indien vereist (poort 587 of 'tls')
if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) { if ($secure === 'tls' || ($port === 587 && $secure !== 'none')) {
@@ -165,10 +208,7 @@ class FocizMailer {
$log("TLS encryptie succesvol geactiveerd."); $log("TLS encryptie succesvol geactiveerd.");
// Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207) // Na STARTTLS moet EHLO opnieuw worden gestuurd (RFC 3207)
$resp = $sendCommand("EHLO {$clientHost}"); $clientHost = $performHeloHandshake($clientHost);
if ($resp['code'] !== 250) {
throw new Exception("EHLO na TLS handshake geweigerd: {$resp['text']}");
}
} }
// 6. SMTP Authenticatie // 6. SMTP Authenticatie
+2 -2
View File
@@ -191,9 +191,9 @@ function get_ai_settings(?int $userId = null): array {
$provider = 'gemini'; $provider = 'gemini';
} }
$model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-1.5-pro'); $model = !empty($personalModel) ? $personalModel : get_system_setting('ai_model', 'gemini-2.0-flash');
if (empty($model)) { if (empty($model)) {
$model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-1.5-pro'; $model = (strpos((string)$provider, 'openai') !== false) ? 'gpt-4o-mini' : 'gemini-2.0-flash';
} }
$apiKey = trim((string)$apiKey); $apiKey = trim((string)$apiKey);
+11 -8
View File
@@ -13,6 +13,11 @@ $userId = (int)($currentUser['id'] ?? 0);
$isAdmin = (($currentUser['role'] ?? '') === 'ADMIN'); $isAdmin = (($currentUser['role'] ?? '') === 'ADMIN');
$forceRefresh = isset($_GET['refresh']) && $_GET['refresh'] === '1'; $forceRefresh = isset($_GET['refresh']) && $_GET['refresh'] === '1';
// Geef direct het PHP sessieslot vrij zodat achtergrondverzoeken en paginanavigatie nooit blokkeren
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
// Haal Jira data op // Haal Jira data op
$jiraData = jira_get_dashboard_data($userId, $forceRefresh); $jiraData = jira_get_dashboard_data($userId, $forceRefresh);
$isDemo = !empty($jiraData['is_demo']); $isDemo = !empty($jiraData['is_demo']);
@@ -72,6 +77,12 @@ $defaultQueueFilter = $jiraData['default_queue_filter'] ?? jira_get_user_queue_p
$userQueueFilters = $jiraData['user_queue_filters'] ?? jira_get_user_queue_preferences($userId); $userQueueFilters = $jiraData['user_queue_filters'] ?? jira_get_user_queue_preferences($userId);
$userQueueFilters = array_values(array_filter(array_map('strtoupper', array_map('trim', $userQueueFilters ?? [])))); $userQueueFilters = array_values(array_filter(array_map('strtoupper', array_map('trim', $userQueueFilters ?? []))));
$jiraData['user_queue_filters'] = $userQueueFilters; $jiraData['user_queue_filters'] = $userQueueFilters;
$hasUserAssignments = !empty($userQueueFilters);
$isCodeVisible = function(string $code) use ($hasUserAssignments, $userQueueFilters): bool {
if (!$hasUserAssignments) return true;
return in_array(strtoupper(trim($code)), $userQueueFilters, true);
};
// Hulpfunctie om te controleren of een ticket behoort tot een wachtrij / project // Hulpfunctie om te controleren of een ticket behoort tot een wachtrij / project
$issueMatchesQueue = function(array $iss, string $code) use ($queueFilters): bool { $issueMatchesQueue = function(array $iss, string $code) use ($queueFilters): bool {
@@ -597,14 +608,6 @@ $currentUserEmailJson = json_encode($currentUserEmail, JSON_HEX_TAG | JSON_HEX_A
<!-- Project Knoppen met direct ingebouwde dropdowns voor statusinstellingen & uitvoerder --> <!-- Project Knoppen met direct ingebouwde dropdowns voor statusinstellingen & uitvoerder -->
<div class="flex flex-wrap items-center gap-2 relative z-50" id="projectPillsContainer"> <div class="flex flex-wrap items-center gap-2 relative z-50" id="projectPillsContainer">
<?php
$hasUserAssignments = !empty($userQueueFilters);
$isCodeVisible = function(string $code) use ($hasUserAssignments, $userQueueFilters): bool {
if (!$hasUserAssignments) return true;
return in_array(strtoupper(trim($code)), $userQueueFilters, true);
};
?>
<!-- Alle Projecten --> <!-- Alle Projecten -->
<div class="relative inline-flex items-stretch rounded-xl shadow-xs group flex-shrink-0 project-pill-container" data-project="" id="projGroup_all"> <div class="relative inline-flex items-stretch rounded-xl shadow-xs group flex-shrink-0 project-pill-container" data-project="" id="projGroup_all">
<button type="button" onclick="selectProject('')" data-project="" class="project-pill-btn flex items-center space-x-1.5 pl-2.5 sm:pl-3 pr-2 py-1.5 rounded-l-xl text-xs font-bold transition cursor-pointer bg-slate-900 dark:bg-white text-white dark:text-slate-900"> <button type="button" onclick="selectProject('')" data-project="" class="project-pill-btn flex items-center space-x-1.5 pl-2.5 sm:pl-3 pr-2 py-1.5 rounded-l-xl text-xs font-bold transition cursor-pointer bg-slate-900 dark:bg-white text-white dark:text-slate-900">
+224 -11
View File
@@ -14,6 +14,11 @@ $isAdmin = (($currentUser['role'] ?? '') === 'ADMIN');
$db = get_db_connection(); $db = get_db_connection();
$userBoards = fociz_get_user_boards_with_lanes($db, $userId, $isAdmin); $userBoards = fociz_get_user_boards_with_lanes($db, $userId, $isAdmin);
// Geef direct het PHP sessieslot vrij zodat achtergrondverzoeken en paginanavigatie nooit blokkeren
if (session_status() === PHP_SESSION_ACTIVE) {
session_write_close();
}
// Haal alle beschikbare mailmappen op (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox, Drafts) // Haal alle beschikbare mailmappen op (exclusief SPAM/Junk, Prullenbak/Deleted, Outbox, Drafts)
$forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1'; $forceRefresh = !empty($_GET['refresh']) && $_GET['refresh'] === '1';
$foldersData = microsoft_fetch_mail_folders($userId, $forceRefresh); $foldersData = microsoft_fetch_mail_folders($userId, $forceRefresh);
@@ -24,7 +29,7 @@ $totalUnreadCount = (int)($foldersData['total_unread'] ?? 0);
$inboxData = microsoft_fetch_inbox_messages($userId, 30, false, $forceRefresh); $inboxData = microsoft_fetch_inbox_messages($userId, 30, false, $forceRefresh);
$messages = $inboxData['messages'] ?? []; $messages = $inboxData['messages'] ?? [];
$isDemo = !empty($inboxData['is_demo']) || !empty($foldersData['is_demo']); $isDemo = !empty($inboxData['is_demo']) || !empty($foldersData['is_demo']);
$warning = $inboxData['warning'] ?? ($foldersData['warning'] ?? ''); $warning = $inboxData['warning'] ?? ($foldersData['warning'] ?? ($foldersData['error'] ?? ''));
$needsReconsent = !empty($inboxData['needs_reconsent']); $needsReconsent = !empty($inboxData['needs_reconsent']);
// Bereken actuele ongelezen aantal: neem het hoogste van total_unread over mappen en inbox unread_count // Bereken actuele ongelezen aantal: neem het hoogste van total_unread over mappen en inbox unread_count
@@ -37,6 +42,9 @@ $outlookInt = $stmtInt->fetch();
$isOutlookConfigured = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']); $isOutlookConfigured = !empty($outlookInt['access_token']) && empty($outlookInt['is_demo_mode']);
$isLiveMail = $isOutlookConfigured && !$isDemo; $isLiveMail = $isOutlookConfigured && !$isDemo;
$disabledFolderIds = $foldersData['disabled_folders'] ?? microsoft_get_disabled_folders($userId);
$jsonDisabledFolders = json_encode(array_values($disabledFolderIds), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
$jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); $jsonMessages = json_encode($messages, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
$jsonFolders = json_encode($mailFolders, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); $jsonFolders = json_encode($mailFolders, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);
?> ?>
@@ -55,6 +63,18 @@ $jsonFolders = json_encode($mailFolders, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($warning)): ?>
<div class="p-4 rounded-2xl bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs text-amber-800 dark:text-amber-200 shadow-2xs">
<div class="flex items-center space-x-2.5">
<i data-lucide="alert-triangle" class="w-4 h-4 text-amber-600 dark:text-amber-400 flex-shrink-0"></i>
<span><?= htmlspecialchars($warning) ?></span>
</div>
<?php if (!empty($needsReconsent)): ?>
<a href="/auth/oauth_microsoft.php?reconsent=1&return_to=/mail.php" class="px-3 py-1.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-white font-bold text-xs shadow-xs transition whitespace-nowrap flex-shrink-0 text-center">Machtigingen Bijwerken</a>
<?php endif; ?>
</div>
<?php endif; ?>
<!-- Top Banner: Status & Snelle Acties --> <!-- Top Banner: Status & Snelle Acties -->
<div class="fociz-card p-6 sm:p-7 relative overflow-hidden"> <div class="fociz-card p-6 sm:p-7 relative overflow-hidden">
<div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4"> <div class="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
@@ -153,9 +173,17 @@ $jsonFolders = json_encode($mailFolders, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
<!-- Linker kolom: Berichtenlijst & Mappen (5 kolommen) --> <!-- Linker kolom: Berichtenlijst & Mappen (5 kolommen) -->
<div id="mailListColumn" class="lg:col-span-5 fociz-card p-3 sm:p-4 flex flex-col h-[720px] overflow-hidden"> <div id="mailListColumn" class="lg:col-span-5 fociz-card p-3 sm:p-4 flex flex-col h-[720px] overflow-hidden">
<div class="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 px-2 py-1 mb-2 flex items-center justify-between"> <div class="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500 px-2 py-1 mb-2 flex items-center justify-between gap-2">
<div class="flex items-center space-x-2 truncate">
<span>Inbox &amp; Mappen (<span id="visibleMailCount"><?= count($messages) ?></span>)</span> <span>Inbox &amp; Mappen (<span id="visibleMailCount"><?= count($messages) ?></span>)</span>
<span class="text-[10px] text-slate-400 font-normal">Sorteer: Nieuwste eerst</span> <span id="disabledFoldersIndicator" class="hidden px-2 py-0.5 rounded-full text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800/60"></span>
</div>
<div class="flex items-center space-x-1">
<button type="button" onclick="openManageFoldersModal()" title="Mailmappen beheren / in- of uitschakelen" class="flex items-center space-x-1 text-[11px] font-semibold text-slate-500 hover:text-sky-600 dark:text-slate-400 dark:hover:text-sky-400 px-2 py-1 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition cursor-pointer">
<i data-lucide="sliders-horizontal" class="w-3.5 h-3.5"></i>
<span>Beheren</span>
</button>
</div>
</div> </div>
<div class="flex-1 overflow-y-auto space-y-2.5 pr-1 custom-scrollbar" id="mailFoldersContainer"> <div class="flex-1 overflow-y-auto space-y-2.5 pr-1 custom-scrollbar" id="mailFoldersContainer">
@@ -534,8 +562,43 @@ $jsonFolders = json_encode($mailFolders, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX
</div> </div>
</div> </div>
<!-- MODAL 4: Mailmappen Beheren (In- en uitschakelen / herstellen) -->
<div id="manageFoldersModal" onclick="if(event.target === this) closeManageFoldersModal()" class="fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-sm hidden items-center justify-center p-4">
<div class="bg-white dark:bg-[#111624] border border-slate-200 dark:border-slate-800 rounded-3xl max-w-lg w-full p-6 sm:p-7 shadow-2xl space-y-5 animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
<div class="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-center space-x-2.5">
<div class="w-9 h-9 rounded-xl bg-sky-500/10 text-sky-600 dark:text-sky-400 flex items-center justify-center">
<i data-lucide="sliders-horizontal" class="w-5 h-5"></i>
</div>
<div>
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Mailmappen Beheren</h3>
<p class="text-[11px] text-slate-400">Schakel mappen uit om ze te verbergen uit het overzicht. Dit is op ieder moment weer te herstellen.</p>
</div>
</div>
<button onclick="closeManageFoldersModal()" class="text-slate-400 hover:text-slate-600 p-1.5 rounded-lg transition cursor-pointer">
<i data-lucide="x" class="w-4 h-4"></i>
</button>
</div>
<div class="flex-1 overflow-y-auto space-y-2 pr-1 custom-scrollbar max-h-[50vh]" id="manageFoldersList">
<!-- Dynamisch gevuld via renderManageFoldersList() -->
</div>
<div class="flex items-center justify-between pt-3 border-t border-slate-100 dark:border-slate-800">
<button type="button" onclick="resetAllFoldersVisibility()" class="text-xs font-semibold text-sky-600 hover:text-sky-700 dark:text-sky-400 hover:underline cursor-pointer flex items-center space-x-1">
<i data-lucide="rotate-ccw" class="w-3.5 h-3.5"></i>
<span>Alles herstellen (alle mappen aan)</span>
</button>
<button type="button" onclick="closeManageFoldersModal()" class="px-4 py-2 rounded-xl text-xs font-bold bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 hover:bg-slate-800 dark:hover:bg-white transition cursor-pointer">
Sluiten
</button>
</div>
</div>
</div>
<script> <script>
let mailFolders = <?= $jsonFolders ?>; let mailFolders = <?= $jsonFolders ?>;
let disabledFolderIds = new Set(<?= $jsonDisabledFolders ?>);
let folderMessagesMap = { let folderMessagesMap = {
'inbox': <?= $jsonMessages ?> 'inbox': <?= $jsonMessages ?>
}; };
@@ -556,8 +619,9 @@ function setMailFilter(filter) {
btnUnread.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs transition cursor-pointer"; btnUnread.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg bg-white dark:bg-slate-900 text-slate-900 dark:text-white shadow-xs transition cursor-pointer";
btnAll.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition cursor-pointer"; btnAll.className = "flex-1 sm:flex-none px-3.5 py-1.5 rounded-lg text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white transition cursor-pointer";
// Bij ongelezen filter: klap automatisch alle mappen met ongelezen mails open! // Bij ongelezen filter: klap automatisch alle actieve mappen met ongelezen mails open!
mailFolders.forEach(f => { mailFolders.forEach(f => {
if (disabledFolderIds.has(f.id)) return;
const msgs = folderMessagesMap[f.id]; const msgs = folderMessagesMap[f.id];
const hasUnread = msgs ? msgs.some(m => !m.is_read) : ((f.unread_count || 0) > 0); const hasUnread = msgs ? msgs.some(m => !m.is_read) : ((f.unread_count || 0) > 0);
if (hasUnread) { if (hasUnread) {
@@ -643,9 +707,15 @@ function renderFolders() {
const q = (document.getElementById('mailSearchInput')?.value || '').toLowerCase().trim(); const q = (document.getElementById('mailSearchInput')?.value || '').toLowerCase().trim();
let totalVisible = 0; let totalVisible = 0;
let visibleFoldersCount = 0;
let html = ''; let html = '';
mailFolders.forEach(folder => { mailFolders.forEach(folder => {
if (disabledFolderIds.has(folder.id)) {
return; // Deze map is uitgeschakeld/verborgen door de gebruiker
}
visibleFoldersCount++;
const isInbox = (folder.id === 'inbox' || folder.is_inbox); const isInbox = (folder.id === 'inbox' || folder.is_inbox);
const isExpanded = expandedFolders.has(folder.id); const isExpanded = expandedFolders.has(folder.id);
const isLoading = loadingFolders.has(folder.id); const isLoading = loadingFolders.has(folder.id);
@@ -680,21 +750,28 @@ function renderFolders() {
html += ` html += `
<div class="rounded-2xl border border-slate-200/80 dark:border-slate-800/80 bg-white/70 dark:bg-slate-900/40 overflow-hidden shadow-2xs"> <div class="rounded-2xl border border-slate-200/80 dark:border-slate-800/80 bg-white/70 dark:bg-slate-900/40 overflow-hidden shadow-2xs">
<!-- Folder Header Accordion Knop --> <!-- Folder Header Accordion Knop -->
<button type="button" onclick="toggleFolder('${folder.id}')" class="w-full flex items-center justify-between p-3 select-none transition cursor-pointer ${isInbox ? 'bg-slate-100/90 dark:bg-slate-800/80' : 'bg-slate-50/80 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/70'}"> <div onclick="toggleFolder('${folder.id}')" class="w-full flex items-center justify-between p-3 select-none transition cursor-pointer ${isInbox ? 'bg-slate-100/90 dark:bg-slate-800/80' : 'bg-slate-50/80 dark:bg-slate-800/40 hover:bg-slate-100 dark:hover:bg-slate-800/70'}">
<div class="flex items-center space-x-2.5 min-w-0"> <div class="flex items-center space-x-2.5 min-w-0 ${folder.is_child ? 'pl-3' : ''}">
<div class="w-7 h-7 rounded-xl ${isInbox ? 'bg-sky-600 text-white shadow-xs' : 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300'} flex items-center justify-center flex-shrink-0"> <div class="w-7 h-7 rounded-xl ${isInbox ? 'bg-sky-600 text-white shadow-xs' : (folder.is_child ? 'bg-slate-100 dark:bg-slate-800 text-slate-500' : 'bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-300')} flex items-center justify-center flex-shrink-0">
<i data-lucide="${isInbox ? 'inbox' : 'folder'}" class="w-3.5 h-3.5"></i> <i data-lucide="${isInbox ? 'inbox' : (folder.is_child ? 'corner-down-right' : 'folder')}" class="w-3.5 h-3.5"></i>
</div> </div>
<div class="flex items-center space-x-1.5 truncate"> <div class="flex items-center space-x-1.5 truncate">
<span class="text-xs font-bold text-slate-900 dark:text-white truncate">${escapeHtml(folder.name)}</span> <span class="text-xs font-bold text-slate-900 dark:text-white truncate">${escapeHtml(folder.name)}</span>
<span class="text-[10px] text-slate-400 font-semibold">(${folderTotal})</span> <span class="text-[10px] text-slate-400 font-semibold">(${folderTotal})</span>
</div> </div>
</div> </div>
<div class="flex items-center space-x-2 flex-shrink-0 ml-2"> <div class="flex items-center space-x-1.5 flex-shrink-0 ml-2" onclick="event.stopPropagation()">
${unreadCount > 0 ? `<span class="px-2 py-0.5 rounded-full text-[10px] font-black bg-sky-100 dark:bg-sky-950/70 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/30">${unreadCount} ongelezen</span>` : ''} ${unreadCount > 0 ? `<span class="px-2 py-0.5 rounded-full text-[10px] font-black bg-sky-100 dark:bg-sky-950/70 text-sky-700 dark:text-sky-300 ring-1 ring-sky-500/30">${unreadCount} ongelezen</span>` : ''}
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}"></i> ${!isInbox ? `
</div> <button type="button" onclick="disableFolder('${folder.id}', event)" title="Map uitschakelen (verbergen uit overzicht)" class="p-1 rounded-lg text-slate-400 hover:text-rose-600 hover:bg-rose-50 dark:hover:bg-rose-950/40 dark:hover:text-rose-400 transition cursor-pointer">
<i data-lucide="eye-off" class="w-3.5 h-3.5"></i>
</button> </button>
` : ''}
<button type="button" onclick="toggleFolder('${folder.id}')" title="In- of uitklappen" class="p-1 text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 transition cursor-pointer">
<i data-lucide="chevron-down" class="w-4 h-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}"></i>
</button>
</div>
</div>
<!-- Folder Berichten Inhoud --> <!-- Folder Berichten Inhoud -->
<div class="${isExpanded ? '' : 'hidden'} p-2 space-y-1.5 bg-slate-50/40 dark:bg-[#0c0f17]/30 border-t border-slate-100 dark:border-slate-800/80"> <div class="${isExpanded ? '' : 'hidden'} p-2 space-y-1.5 bg-slate-50/40 dark:bg-[#0c0f17]/30 border-t border-slate-100 dark:border-slate-800/80">
@@ -713,6 +790,18 @@ function renderFolders() {
`; `;
}); });
if (visibleFoldersCount === 0) {
html = `
<div class="p-6 text-center text-slate-400 space-y-2">
<i data-lucide="folder-x" class="w-8 h-8 mx-auto text-slate-300 dark:text-slate-600"></i>
<p class="text-xs font-semibold">Alle mappen zijn momenteel uitgeschakeld of verborgen.</p>
<button type="button" onclick="openManageFoldersModal()" class="text-xs text-sky-600 dark:text-sky-400 font-bold hover:underline cursor-pointer">
Mappen beheren &amp; herstellen &rarr;
</button>
</div>
`;
}
container.innerHTML = html; container.innerHTML = html;
const countEl = document.getElementById('visibleMailCount'); const countEl = document.getElementById('visibleMailCount');
@@ -942,6 +1031,9 @@ function updateTotalCounters() {
let totalAll = 0; let totalAll = 0;
mailFolders.forEach(f => { mailFolders.forEach(f => {
if (disabledFolderIds.has(f.id)) {
return;
}
const msgs = folderMessagesMap[f.id]; const msgs = folderMessagesMap[f.id];
if (msgs) { if (msgs) {
totalUnread += msgs.filter(m => !m.is_read).length; totalUnread += msgs.filter(m => !m.is_read).length;
@@ -957,6 +1049,17 @@ function updateTotalCounters() {
const elAll = document.getElementById('countAll'); const elAll = document.getElementById('countAll');
if (elAll) elAll.innerText = totalAll; if (elAll) elAll.innerText = totalAll;
const indicator = document.getElementById('disabledFoldersIndicator');
if (indicator) {
const disabledCount = disabledFolderIds.size;
if (disabledCount > 0) {
indicator.innerText = `${disabledCount} verborgen`;
indicator.classList.remove('hidden');
} else {
indicator.classList.add('hidden');
}
}
} }
function closeMobileDetail() { function closeMobileDetail() {
@@ -1389,6 +1492,115 @@ function escapeHtml(str) {
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
// Mailmappen beheren & in/uitschakelen
function disableFolder(folderId, e) {
if (e) e.stopPropagation();
toggleFolderDisabledState(folderId, true);
}
function openManageFoldersModal() {
renderManageFoldersList();
const modal = document.getElementById('manageFoldersModal');
if (modal) {
modal.classList.remove('hidden');
modal.classList.add('flex');
if (typeof lucide !== 'undefined') lucide.createIcons();
}
}
function closeManageFoldersModal() {
const modal = document.getElementById('manageFoldersModal');
if (modal) {
modal.classList.add('hidden');
modal.classList.remove('flex');
}
}
function renderManageFoldersList() {
const container = document.getElementById('manageFoldersList');
if (!container) return;
let html = '';
mailFolders.forEach(f => {
const isInbox = (f.id === 'inbox' || f.is_inbox);
const isDisabled = disabledFolderIds.has(f.id);
html += `
<div class="flex items-center justify-between p-3 rounded-2xl border ${isDisabled ? 'bg-slate-50 dark:bg-slate-900/30 border-dashed border-slate-200 dark:border-slate-800 opacity-70' : 'bg-white dark:bg-slate-900/60 border-slate-200/80 dark:border-slate-800'} transition">
<div class="flex items-center space-x-3 min-w-0 ${f.is_child ? 'pl-3' : ''}">
<div class="w-8 h-8 rounded-xl ${isInbox ? 'bg-sky-600 text-white' : (isDisabled ? 'bg-slate-100 dark:bg-slate-800 text-slate-400' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300')} flex items-center justify-center flex-shrink-0">
<i data-lucide="${isInbox ? 'inbox' : (isDisabled ? 'folder-x' : (f.is_child ? 'corner-down-right' : 'folder'))}" class="w-4 h-4"></i>
</div>
<div class="truncate">
<div class="flex items-center space-x-1.5">
<span class="text-xs font-bold ${isDisabled ? 'line-through text-slate-400 dark:text-slate-500' : 'text-slate-900 dark:text-white'} truncate">${escapeHtml(f.name)}</span>
${isInbox ? '<span class="text-[10px] font-semibold text-sky-600 dark:text-sky-400 bg-sky-50 dark:bg-sky-950/60 px-1.5 py-0.5 rounded-md">Standaard Inbox</span>' : ''}
</div>
<span class="text-[10px] text-slate-400">${f.total_count || 0} berichten ${f.unread_count ? `&bull; ${f.unread_count} ongelezen` : ''}</span>
</div>
</div>
<div class="flex-shrink-0 ml-3">
${isInbox ? `
<span class="text-[11px] font-medium text-slate-400 italic pr-2">Altijd actief</span>
` : `
<button type="button" onclick="toggleFolderDisabledState('${f.id}', ${!isDisabled})" class="flex items-center space-x-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition cursor-pointer ${isDisabled ? 'bg-sky-50 hover:bg-sky-100 text-sky-700 dark:bg-sky-950/60 dark:hover:bg-sky-900/80 dark:text-sky-300' : 'bg-rose-50 hover:bg-rose-100 text-rose-700 dark:bg-rose-950/40 dark:hover:bg-rose-900/60 dark:text-rose-300'}">
<i data-lucide="${isDisabled ? 'eye' : 'eye-off'}" class="w-3.5 h-3.5"></i>
<span>${isDisabled ? 'Weer Inschakelen' : 'Uitschakelen'}</span>
</button>
`}
</div>
</div>
`;
});
container.innerHTML = html;
if (typeof lucide !== 'undefined') lucide.createIcons();
}
async function toggleFolderDisabledState(folderId, willDisable) {
if (folderId === 'inbox') return;
if (willDisable) {
disabledFolderIds.add(folderId);
expandedFolders.delete(folderId);
} else {
disabledFolderIds.delete(folderId);
}
renderFolders();
updateTotalCounters();
renderManageFoldersList();
await saveDisabledFoldersToServer();
}
async function resetAllFoldersVisibility() {
disabledFolderIds.clear();
renderFolders();
updateTotalCounters();
renderManageFoldersList();
await saveDisabledFoldersToServer();
}
async function saveDisabledFoldersToServer() {
const arr = Array.from(disabledFolderIds);
try {
const res = await fetch('/api/mail.php?action=save_disabled_folders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ disabled_folders: arr })
});
const data = await res.json();
if (!data.success) {
console.warn('Opslaan uitgeschakelde mappen mislukt:', data.error);
}
} catch (err) {
console.error('Fout bij opslaan mappen status:', err);
}
}
// Toetsenbordnavigatie voor alle modals (Escape & Enter) // Toetsenbordnavigatie voor alle modals (Escape & Enter)
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -1400,6 +1612,7 @@ document.addEventListener('keydown', function(e) {
closePlanMailModal(); closePlanMailModal();
closeReplyModal(); closeReplyModal();
closeForwardModal(); closeForwardModal();
closeManageFoldersModal();
} else if (e.key === 'Enter') { } else if (e.key === 'Enter') {
const outsideModal = document.getElementById('outsideWorkhoursModalMail'); const outsideModal = document.getElementById('outsideWorkhoursModalMail');
if (outsideModal && !outsideModal.classList.contains('hidden')) { if (outsideModal && !outsideModal.classList.contains('hidden')) {
+962 -52
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -136,7 +136,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
elseif ($action === 'save_personal_ai') { elseif ($action === 'save_personal_ai') {
$aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0; $aiEnabled = isset($_POST['ai_enabled']) ? 1 : 0;
$aiProvider = trim($_POST['ai_provider'] ?? 'gemini'); $aiProvider = trim($_POST['ai_provider'] ?? 'gemini');
$aiModel = trim($_POST['ai_model'] ?? 'gemini-1.5-pro'); $aiModel = trim($_POST['ai_model'] ?? 'gemini-2.0-flash');
$apiKeyIn = trim($_POST['ai_api_key'] ?? ''); $apiKeyIn = trim($_POST['ai_api_key'] ?? '');
// Haal eventueel bestaande integratie op // Haal eventueel bestaande integratie op
@@ -977,9 +977,9 @@ require_once __DIR__ . '/includes/header.php';
<label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label> <label class="block text-xs font-semibold text-slate-700 dark:text-slate-300 mb-1.5">AI Provider & Model</label>
<select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"> <select name="ai_model" id="profile_ai_model" class="w-full bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-xl px-3.5 py-2.5 text-xs text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-indigo-500">
<optgroup label="Google Gemini (Aanbevolen)"> <optgroup label="Google Gemini (Aanbevolen)">
<option value="gemini-1.5-pro" <?= ($userAiSettings['ai_model'] === 'gemini-1.5-pro' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 1.5 Pro (Aanbevolen)</option> <option value="gemini-2.0-flash" <?= ($userAiSettings['ai_model'] === 'gemini-2.0-flash' || empty($userAiSettings['ai_model'])) ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Aanbevolen, Razendsnel)</option>
<option value="gemini-2.0-flash" <?= $userAiSettings['ai_model'] === 'gemini-2.0-flash' ? 'selected' : '' ?>>Google Gemini 2.0 Flash (Razendsnel)</option>
<option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option> <option value="gemini-1.5-flash" <?= $userAiSettings['ai_model'] === 'gemini-1.5-flash' ? 'selected' : '' ?>>Google Gemini 1.5 Flash</option>
<option value="gemini-1.5-pro" <?= $userAiSettings['ai_model'] === 'gemini-1.5-pro' ? 'selected' : '' ?>>Google Gemini 1.5 Pro</option>
</optgroup> </optgroup>
<optgroup label="OpenAI"> <optgroup label="OpenAI">
<option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option> <option value="gpt-4o-mini" <?= $userAiSettings['ai_model'] === 'gpt-4o-mini' ? 'selected' : '' ?>>OpenAI GPT-4o-mini</option>