Add roadmap management, service worker, and user invitation features
- Implemented roadmap.php for managing roadmap items with filtering options by quarter and status. - Added a service worker (sw.js) to handle push notifications and click events for calendar appointments. - Created users.php for admin to manage team members and invitations, including user creation and deletion functionalities.
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz AI Co-Pilot Engine (Release v1.5)
|
||||
* Beheert intelligente assistentie voor Brainstorming en Dagplanning.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/http.php';
|
||||
require_once __DIR__ . '/settings.php';
|
||||
|
||||
/**
|
||||
* Voert een gestructureerde chat completion uit met de geconfigureerde AI provider
|
||||
*
|
||||
* @param array $messages [['role' => 'system'|'user'|'assistant', 'content' => string]]
|
||||
* @param array $options ['temperature' => float, 'json_mode' => bool, 'max_tokens' => int]
|
||||
* @return array ['success' => bool, 'content' => ?string, 'json' => ?array, 'error' => ?string]
|
||||
*/
|
||||
function fociz_ai_chat(array $messages, array $options = []): array {
|
||||
$aiSettings = get_ai_settings();
|
||||
if (empty($aiSettings['ai_api_key'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Geen AI API-sleutel geconfigureerd. Stel deze in via Systeembeheer (admin.php).',
|
||||
];
|
||||
}
|
||||
|
||||
$apiKey = $aiSettings['ai_api_key'];
|
||||
$model = !empty($aiSettings['ai_model']) ? $aiSettings['ai_model'] : 'gpt-4o-mini';
|
||||
$temperature = $options['temperature'] ?? 0.3;
|
||||
$maxTokens = $options['max_tokens'] ?? 1500;
|
||||
$jsonMode = !empty($options['json_mode']);
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $temperature,
|
||||
'max_tokens' => $maxTokens,
|
||||
];
|
||||
|
||||
if ($jsonMode) {
|
||||
$payload['response_format'] = ['type' => 'json_object'];
|
||||
}
|
||||
|
||||
$response = fociz_http_request('https://api.openai.com/v1/chat/completions', [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$apiKey}",
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 35,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['choices'][0]['message']['content'])) {
|
||||
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "AI aanroep mislukt (HTTP {$response['status']})");
|
||||
error_log("fociz_ai_chat fout: {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
$rawContent = trim($response['json']['choices'][0]['message']['content']);
|
||||
$parsedJson = null;
|
||||
|
||||
if ($jsonMode) {
|
||||
$parsedJson = json_decode($rawContent, true);
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'content' => $rawContent,
|
||||
'json' => $parsedJson,
|
||||
'model' => $response['json']['model'] ?? $model,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Categoriseert sticky notes automatisch in:
|
||||
* - 'Ideeën'
|
||||
* - 'Knelpunten'
|
||||
* - 'Oplossingen'
|
||||
* - 'Quick Wins'
|
||||
*/
|
||||
function fociz_ai_categorize_cards(string $sessionTitle, string $theme, array $cards): array {
|
||||
if (empty($cards)) {
|
||||
return ['success' => true, 'categorized' => []];
|
||||
}
|
||||
|
||||
$cardItems = [];
|
||||
foreach ($cards as $c) {
|
||||
$cardItems[] = [
|
||||
'id' => $c['id'],
|
||||
'content' => $c['content'],
|
||||
'category' => $c['category'] ?? 'Ideeën',
|
||||
];
|
||||
}
|
||||
|
||||
$systemPrompt = "Je bent een deskundige agile facilitator en brainstorm co-pilot voor software- en productontwikkeling.\n"
|
||||
. "Je ontvangt een lijst met brainstorm kaartjes voor de sessie: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. "Je taak is om elk kaartje in te delen in exact één van deze 4 categorieën:\n"
|
||||
. "1. 'Ideeën' (nieuwe concepten, functies, wensen)\n"
|
||||
. "2. 'Knelpunten' (problemen, blokkades, risico's, frustraties)\n"
|
||||
. "3. 'Oplossingen' (concrete antwoorden op knelpunten, architectuurkeuzes)\n"
|
||||
. "4. 'Quick Wins' (kleine, makkelijk uitvoerbare verbeteringen met hoge impact)\n\n"
|
||||
. "Retourneer uitsluitend een JSON-object met het veld 'categorized' dat een array is van objecten met 'id' en 'category'.";
|
||||
|
||||
$userPrompt = "Hier zijn de kaartjes:\n" . json_encode($cardItems, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['categorized'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon kaartjes niet categoriseren'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'categorized' => $aiRes['json']['categorized']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert nieuwe relevante brainstorm ideeën voor een sessie
|
||||
*/
|
||||
function fociz_ai_generate_ideas(string $sessionTitle, string $theme, int $count = 4): array {
|
||||
$systemPrompt = "Je bent een innovatieve productstrateeg en brainstorm co-pilot.\n"
|
||||
. "Genereer {$count} creatieve, scherpe en direct toepasbare ideeën voor een brainstormsessie getiteld: '{$sessionTitle}' (Thema: '{$theme}').\n"
|
||||
. "Houd de teksten beknopt en krachtig (1-2 zinnen per idee, alsof ze op een gele post-it staan).\n"
|
||||
. "Retourneer uitsluitend een JSON-object met het veld 'ideas' dat een array van strings is.";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Genereer {$count} vernieuwende ideeën."],
|
||||
], ['json_mode' => true]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['ideas'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen ideeën genereren'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'ideas' => $aiRes['json']['ideas']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zet brainstorm kaartjes om in een scherp Roadmap Item
|
||||
*/
|
||||
function fociz_ai_summarize_to_roadmap(string $content, string $sessionTitle = ''): array {
|
||||
$systemPrompt = "Je bent een senior product manager. Zet onderstaand brainstorm idee om in een helder, professioneel Roadmap Item.\n"
|
||||
. "Retourneer een JSON-object met:\n"
|
||||
. "- 'title': Een beknopte, actiegerichte titel (max 8 woorden)\n"
|
||||
. "- 'description': Een duidelijke beschrijving van wat er gebouwd moet worden inclusief gebruikerswaarde en 2-3 acceptatiecriteria\n"
|
||||
. "- 'priority': 'HIGH', 'MEDIUM' of 'LOW'\n"
|
||||
. "- 'type': 'FEATURE', 'IMPROVEMENT' of 'TECH_DEBT'\n"
|
||||
. "- 'quarter': 'Q3' of 'Q4'";
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => "Sessie: {$sessionTitle}\nIdee:\n{$content}"],
|
||||
], ['json_mode' => true]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['title'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon roadmap item niet formuleren'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'item' => $aiRes['json']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Berekent een slimme dagindeling voor open taken rondom bestaande afspraken
|
||||
*/
|
||||
function fociz_ai_smart_schedule(array $tasks, array $events, array $workdaySettings): array {
|
||||
$startH = (int)explode(':', $workdaySettings['workday_start'] ?? '08:30')[0];
|
||||
$endH = (int)explode(':', $workdaySettings['workday_end'] ?? '17:30')[0];
|
||||
$lunchStart = $workdaySettings['lunch_start'] ?? '12:30';
|
||||
$lunchEnd = $workdaySettings['lunch_end'] ?? '13:00';
|
||||
|
||||
$systemPrompt = "Je bent een productiviteits- en time-blocking assistent.\n"
|
||||
. "Maak een optimale dagplanning door openstaande taken in te plannen tussen de werkdaguren ({$workdaySettings['workday_start']} tot {$workdaySettings['workday_end']}).\n"
|
||||
. "Regels:\n"
|
||||
. "1. Plan GEEN taken tijdens bestaande afspraken of tijdens de lunchpauze ({$lunchStart} - {$lunchEnd}).\n"
|
||||
. "2. Geef prioriteit aan P1 (brandend/focus) vroeg op de dag, daarna P2 en P3.\n"
|
||||
. "3. Zorg voor realistische tijdsblokken (15, 30, 45 of 60 minuten) met format 'HH:mm'.\n"
|
||||
. "Retourneer een JSON-object met het veld 'scheduled' als array van objecten: {'id': int, 'time_block_start': 'HH:mm', 'duration_minutes': int}.";
|
||||
|
||||
$userPrompt = "Bestaande afspraken:\n" . json_encode($events, JSON_UNESCAPED_UNICODE) . "\n\n"
|
||||
. "In te plannen taken:\n" . json_encode($tasks, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$aiRes = fociz_ai_chat([
|
||||
['role' => 'system', 'content' => $systemPrompt],
|
||||
['role' => 'user', 'content' => $userPrompt],
|
||||
], ['json_mode' => true]);
|
||||
|
||||
if (!$aiRes['success'] || empty($aiRes['json']['scheduled'])) {
|
||||
return ['success' => false, 'error' => $aiRes['error'] ?? 'Kon geen slimme indeling berekenen'];
|
||||
}
|
||||
|
||||
return ['success' => true, 'scheduled' => $aiRes['json']['scheduled']];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
<?php
|
||||
/**
|
||||
* Authenticatie & Autorisatie Guard voor Fociz
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
require_once __DIR__ . '/totp.php';
|
||||
|
||||
/**
|
||||
* Bepaalt of de huidige verbinding HTTPS gebruikt (inclusief reverse proxies / OpenResty / Docker)
|
||||
*/
|
||||
function is_https_request(): bool {
|
||||
if (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off') {
|
||||
return true;
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO']) && strtolower($_SERVER['HTTP_X_FORWARDED_PROTO']) === 'https') {
|
||||
return true;
|
||||
}
|
||||
if (!empty($_SERVER['HTTP_X_FORWARDED_SSL']) && strtolower($_SERVER['HTTP_X_FORWARDED_SSL']) === 'on') {
|
||||
return true;
|
||||
}
|
||||
if (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443) {
|
||||
return true;
|
||||
}
|
||||
if (defined('APP_URL') && str_starts_with(APP_URL, 'https://')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function start_secure_session(): void {
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
// Zorg voor een ruime sessieduur (30 dagen)
|
||||
ini_set('session.gc_maxlifetime', '2592000');
|
||||
ini_set('session.cookie_lifetime', '2592000');
|
||||
ini_set('session.use_strict_mode', '1');
|
||||
ini_set('session.use_only_cookies', '1');
|
||||
|
||||
$isSecure = is_https_request();
|
||||
session_set_cookie_params([
|
||||
'lifetime' => 30 * 24 * 60 * 60, // 30 dagen
|
||||
'path' => '/',
|
||||
'domain' => '',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
session_start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Controleert of er al een beheerder bestaat in MariaDB
|
||||
*/
|
||||
function has_admin_user(): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->query("SELECT COUNT(*) FROM users WHERE role = 'ADMIN'");
|
||||
return ((int)$stmt->fetchColumn()) > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garandeert dat de kolommen voor persistent sessiebeheer aanwezig zijn in MariaDB
|
||||
*/
|
||||
function ensure_remember_token_columns(PDO $db): void {
|
||||
static $columnsChecked = false;
|
||||
if ($columnsChecked) return;
|
||||
$columnsChecked = true;
|
||||
|
||||
try {
|
||||
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'remember_token'");
|
||||
if (!$result || $result->rowCount() === 0) {
|
||||
$db->exec("ALTER TABLE `users` ADD COLUMN `remember_token` VARCHAR(64) NULL AFTER `backup_codes`, ADD COLUMN `remember_token_expires_at` DATETIME NULL AFTER `remember_token`");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien al aanwezig of tijdens migraties
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garandeert dat de kolom voor notificatie-instellingen aanwezig is in MariaDB
|
||||
*/
|
||||
function ensure_notification_columns(PDO $db): void {
|
||||
static $notifColumnsChecked = false;
|
||||
if ($notifColumnsChecked) return;
|
||||
$notifColumnsChecked = true;
|
||||
|
||||
try {
|
||||
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'notification_settings'");
|
||||
if (!$result || $result->rowCount() === 0) {
|
||||
$db->exec("ALTER TABLE `users` ADD COLUMN `notification_settings` TEXT NULL AFTER `remember_token_expires_at`");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien al aanwezig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Garandeert dat de kolom voor weergavethema (dark, light, system) aanwezig is in MariaDB
|
||||
*/
|
||||
function ensure_theme_column(PDO $db): void {
|
||||
static $themeChecked = false;
|
||||
if ($themeChecked) return;
|
||||
$themeChecked = true;
|
||||
|
||||
try {
|
||||
$result = $db->query("SHOW COLUMNS FROM `users` LIKE 'theme'");
|
||||
if (!$result || $result->rowCount() === 0) {
|
||||
$db->exec("ALTER TABLE `users` ADD COLUMN `theme` VARCHAR(20) NOT NULL DEFAULT 'dark' AFTER `notification_settings`");
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// Stil negeren indien al aanwezig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert en bewaart een persistente 'Onthoud mij' token in MariaDB en plaatst een veilige cookie
|
||||
*/
|
||||
function issue_remember_token(int $userId): string {
|
||||
$rawToken = bin2hex(random_bytes(32));
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
$expiresAt = date('Y-m-d H:i:s', time() + (30 * 86400)); // 30 dagen geldig
|
||||
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
|
||||
$stmt = $db->prepare("UPDATE users SET remember_token = ?, remember_token_expires_at = ? WHERE id = ?");
|
||||
$stmt->execute([$tokenHash, $expiresAt, $userId]);
|
||||
|
||||
$isSecure = is_https_request();
|
||||
setcookie('fociz_remember', $rawToken, [
|
||||
'expires' => time() + (30 * 86400),
|
||||
'path' => '/',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
|
||||
return $rawToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert de persistente remember cookie
|
||||
*/
|
||||
function clear_remember_cookie(): void {
|
||||
$isSecure = is_https_request();
|
||||
setcookie('fociz_remember', '', [
|
||||
'expires' => time() - 86400,
|
||||
'path' => '/',
|
||||
'secure' => $isSecure,
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de huidige ingelogde gebruiker op uit de database.
|
||||
* Herstelt automatisch de sessie als een geldige 'fociz_remember' cookie aanwezig is.
|
||||
*/
|
||||
function get_logged_in_user(): ?array {
|
||||
start_secure_session();
|
||||
|
||||
$db = get_db_connection();
|
||||
ensure_remember_token_columns($db);
|
||||
|
||||
// 1. Controleer actieve PHP sessie
|
||||
if (!empty($_SESSION['user_id']) && !empty($_SESSION['is_2fa_verified'])) {
|
||||
$stmt = $db->prepare("SELECT id, name, email, role, two_factor_enabled, created_at FROM users WHERE id = ?");
|
||||
$stmt->execute([$_SESSION['user_id']]);
|
||||
$user = $stmt->fetch();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
// Gebruiker bestaat niet meer in database
|
||||
$_SESSION = [];
|
||||
}
|
||||
|
||||
// 2. Controleer persistente 'remember me' cookie (voorkomt ongewenst uitloggen bij refresh of herstart)
|
||||
if (!empty($_COOKIE['fociz_remember'])) {
|
||||
$rawToken = trim($_COOKIE['fociz_remember']);
|
||||
$tokenHash = hash('sha256', $rawToken);
|
||||
|
||||
$stmt = $db->prepare("
|
||||
SELECT id, name, email, role, two_factor_enabled, created_at
|
||||
FROM users
|
||||
WHERE remember_token = ? AND remember_token_expires_at > NOW()
|
||||
");
|
||||
$stmt->execute([$tokenHash]);
|
||||
$user = $stmt->fetch();
|
||||
|
||||
if ($user) {
|
||||
// Herstel volledige PHP sessie
|
||||
$_SESSION['user_id'] = $user['id'];
|
||||
$_SESSION['user_name'] = $user['name'];
|
||||
$_SESSION['user_email'] = $user['email'];
|
||||
$_SESSION['user_role'] = $user['role'];
|
||||
$_SESSION['is_2fa_verified'] = true;
|
||||
|
||||
// Verleng direct de geldigheid van de remember token voor nog eens 30 dagen
|
||||
issue_remember_token((int)$user['id']);
|
||||
|
||||
return $user;
|
||||
} else {
|
||||
clear_remember_cookie();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beschermt pagina's: dwingt login + 2FA af
|
||||
*/
|
||||
function require_auth(bool $json = false): array {
|
||||
start_secure_session();
|
||||
|
||||
$isAjax = $json ||
|
||||
(isset($_SERVER['HTTP_ACCEPT']) && str_contains($_SERVER['HTTP_ACCEPT'], 'application/json')) ||
|
||||
(isset($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest');
|
||||
|
||||
// 1. Controleer of eerste setup nodig is
|
||||
if (!has_admin_user()) {
|
||||
if ($isAjax) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Setup vereist']);
|
||||
exit;
|
||||
}
|
||||
header('Location: /auth/setup.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// 2. Controleer of gebruiker ingelogd én 2FA geverifieerd is
|
||||
$user = get_logged_in_user();
|
||||
if (!$user) {
|
||||
if ($isAjax) {
|
||||
http_response_code(401);
|
||||
header('Content-Type: application/json');
|
||||
echo json_encode(['success' => false, 'error' => 'Niet geauthenticeerd of sessie verlopen']);
|
||||
exit;
|
||||
}
|
||||
header('Location: /auth/login.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dwingt beheerder (ADMIN) rol af
|
||||
*/
|
||||
function require_admin(): array {
|
||||
$user = require_auth();
|
||||
if ($user['role'] !== 'ADMIN') {
|
||||
header('Location: /planner.php');
|
||||
exit;
|
||||
}
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maakt een nieuwe uitnodiging aan voor een collega
|
||||
*/
|
||||
function create_invite(int $createdById, string $email, string $role = 'MEMBER'): string {
|
||||
$db = get_db_connection();
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$expiresAt = date('Y-m-d H:i:s', strtotime('+7 days'));
|
||||
|
||||
$stmt = $db->prepare("INSERT INTO invites (token, email, role, expires_at, created_by_id) VALUES (?, ?, ?, ?, ?)");
|
||||
$stmt->execute([$token, strtolower(trim($email)), $role, $expiresAt, $createdById]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valideert een invite token
|
||||
*/
|
||||
function validate_invite(string $token): ?array {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("
|
||||
SELECT i.*, u.name as inviter_name, u.email as inviter_email
|
||||
FROM invites i
|
||||
JOIN users u ON i.created_by_id = u.id
|
||||
WHERE i.token = ?
|
||||
");
|
||||
$stmt->execute([$token]);
|
||||
$invite = $stmt->fetch();
|
||||
|
||||
if (!$invite) return null;
|
||||
if ($invite['used_at'] !== null) return null;
|
||||
if (strtotime($invite['expires_at']) < time()) return null;
|
||||
|
||||
return $invite;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF token helpers
|
||||
*/
|
||||
function get_csrf_token(): string {
|
||||
start_secure_session();
|
||||
if (empty($_SESSION['csrf_token'])) {
|
||||
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||
}
|
||||
return $_SESSION['csrf_token'];
|
||||
}
|
||||
|
||||
function verify_csrf_token(?string $token): bool {
|
||||
start_secure_session();
|
||||
return !empty($token) && !empty($_SESSION['csrf_token']) && hash_equals($_SESSION['csrf_token'], $token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een cryptografisch ondertekende OAuth state parameter waarin de user_id en timestamp veilig zijn opgeslagen.
|
||||
* Dit voorkomt CSRF en maakt sessieherstel mogelijk bij cross-site OAuth redirects.
|
||||
*/
|
||||
function generate_oauth_state(int $userId): string {
|
||||
$secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026');
|
||||
$payload = [
|
||||
'uid' => $userId,
|
||||
'ts' => time(),
|
||||
'rnd' => bin2hex(random_bytes(8))
|
||||
];
|
||||
$json = json_encode($payload);
|
||||
$b64 = rtrim(strtr(base64_encode($json), '+/', '-_'), '=');
|
||||
$sig = hash_hmac('sha256', $b64, $secret);
|
||||
return $b64 . '.' . $sig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valideert een ontvangen OAuth state parameter en geeft bij succes het geverifieerde user_id terug.
|
||||
* Geeft null terug bij ongeldige handtekening of verlopen state (> 30 minuten).
|
||||
*/
|
||||
function verify_oauth_state(string $state): ?int {
|
||||
$secret = defined('APP_SECRET') ? APP_SECRET : (defined('DB_PASS') ? DB_PASS : 'fociz_oauth_salt_2026');
|
||||
$parts = explode('.', $state, 2);
|
||||
if (count($parts) !== 2) {
|
||||
return null;
|
||||
}
|
||||
[$b64, $sig] = $parts;
|
||||
$expectedSig = hash_hmac('sha256', $b64, $secret);
|
||||
if (!hash_equals($expectedSig, $sig)) {
|
||||
return null;
|
||||
}
|
||||
$remainder = strlen($b64) % 4;
|
||||
if ($remainder) {
|
||||
$b64 .= str_repeat('=', 4 - $remainder);
|
||||
}
|
||||
$json = base64_decode(strtr($b64, '-_', '+/'));
|
||||
$payload = json_decode($json, true);
|
||||
if (!is_array($payload) || empty($payload['uid']) || empty($payload['ts'])) {
|
||||
return null;
|
||||
}
|
||||
// Geldigheidsduur: maximaal 30 minuten (1800 seconden)
|
||||
if (time() - (int)$payload['ts'] > 1800) {
|
||||
return null;
|
||||
}
|
||||
return (int)$payload['uid'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de persoonlijke notificatie-instellingen van een gebruiker op
|
||||
*/
|
||||
function get_user_notification_settings(int $userId): array {
|
||||
$db = get_db_connection();
|
||||
ensure_notification_columns($db);
|
||||
|
||||
$defaults = [
|
||||
'enabled' => 1,
|
||||
'lead_time_minutes' => 5, // 0, 2, 5, 10, 15, 30
|
||||
'repeat_count' => 2, // 1, 2, 3, 5
|
||||
'repeat_interval_minutes' => 2, // 1, 2, 3, 5
|
||||
'sound_enabled' => 1, // 0 of 1
|
||||
];
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT notification_settings FROM users WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
$json = $stmt->fetchColumn();
|
||||
if (!empty($json)) {
|
||||
$saved = json_decode($json, true);
|
||||
if (is_array($saved)) {
|
||||
return array_merge($defaults, $saved);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("get_user_notification_settings error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat de persoonlijke notificatie-instellingen van een gebruiker op
|
||||
*/
|
||||
function update_user_notification_settings(int $userId, array $settings): bool {
|
||||
$db = get_db_connection();
|
||||
ensure_notification_columns($db);
|
||||
|
||||
$current = get_user_notification_settings($userId);
|
||||
$merged = [
|
||||
'enabled' => !empty($settings['enabled']) ? 1 : 0,
|
||||
'lead_time_minutes' => max(0, min(60, (int)($settings['lead_time_minutes'] ?? $current['lead_time_minutes']))),
|
||||
'repeat_count' => max(1, min(10, (int)($settings['repeat_count'] ?? $current['repeat_count']))),
|
||||
'repeat_interval_minutes' => max(1, min(30, (int)($settings['repeat_interval_minutes'] ?? $current['repeat_interval_minutes']))),
|
||||
'sound_enabled' => !empty($settings['sound_enabled']) ? 1 : 0,
|
||||
];
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE users SET notification_settings = ? WHERE id = ?");
|
||||
return $stmt->execute([json_encode($merged), $userId]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("update_user_notification_settings error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt het voorkeursthema op voor een gebruiker ('dark', 'light' of 'system')
|
||||
*/
|
||||
function get_user_theme(int $userId): string {
|
||||
$db = get_db_connection();
|
||||
ensure_theme_column($db);
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("SELECT theme FROM users WHERE id = ?");
|
||||
$stmt->execute([$userId]);
|
||||
$theme = $stmt->fetchColumn();
|
||||
if ($theme && in_array($theme, ['dark', 'light', 'system'])) {
|
||||
return $theme;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("get_user_theme error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return 'dark';
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat het voorkeursthema van een gebruiker op ('dark', 'light' of 'system')
|
||||
*/
|
||||
function update_user_theme(int $userId, string $theme): bool {
|
||||
$db = get_db_connection();
|
||||
ensure_theme_column($db);
|
||||
|
||||
$theme = in_array($theme, ['dark', 'light', 'system']) ? $theme : 'dark';
|
||||
|
||||
try {
|
||||
$stmt = $db->prepare("UPDATE users SET theme = ? WHERE id = ?");
|
||||
return $stmt->execute([$theme, $userId]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("update_user_theme error: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* =========================================================================
|
||||
* FOCIZ OMGEVINGSINDICATOR (DEV VS MAIN/LIVE)
|
||||
* =========================================================================
|
||||
*
|
||||
* Dit bestandje bepaalt de flashy notificatie links bovenin het scherm.
|
||||
*
|
||||
* - OP DE DEV OMGEVING: $environment = 'development'; (Rood met 'Development' + radar ping)
|
||||
* - OP DE MAIN OMGEVING: $environment = 'live'; (Groen met 'Live' + radar ping)
|
||||
*
|
||||
* U kunt dit hieronder aanpassen, of instellen via APP_ENV in .env / .env.example!
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
// Standaard waarde voor de main branch: 'live' (kan overschreven worden via APP_ENV)
|
||||
$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']);
|
||||
?>
|
||||
|
||||
<!-- FLASHY OMGEVINGSINDICATOR LINKS BOVENIN -->
|
||||
<div id="fociz-env-badge" class="fixed top-2.5 left-2.5 z-[9999] pointer-events-auto select-none transition-all duration-300 hover:scale-105">
|
||||
<?php if ($isLive): ?>
|
||||
<!-- LIVE OMGEVING (TEAL) -->
|
||||
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/90 border border-teal-500/50 shadow-xs backdrop-blur-md group">
|
||||
<!-- Pulsing Teal Radar Indicator -->
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2 w-2 bg-teal-500"></span>
|
||||
</span>
|
||||
|
||||
<span class="text-[10px] font-bold tracking-wider text-teal-400 uppercase flex items-center space-x-1">
|
||||
<span>Live</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-teal-500/15 text-teal-300 border border-teal-500/30">
|
||||
MAIN
|
||||
</span>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<!-- DEVELOPMENT OMGEVING (ROOD) -->
|
||||
<div class="flex items-center space-x-2 px-3 py-1 rounded-full bg-slate-950/95 border border-red-500/70 shadow-lg shadow-red-500/30 backdrop-blur-md group">
|
||||
<!-- Pulsing Red Radar Indicator -->
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-80"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-red-500 shadow-sm shadow-red-500"></span>
|
||||
</span>
|
||||
|
||||
<span class="text-[11px] font-black tracking-wider text-red-400 uppercase flex items-center space-x-1">
|
||||
<span>Development</span>
|
||||
</span>
|
||||
|
||||
<span class="hidden sm:inline-block text-[9px] font-mono font-bold px-1.5 py-0.2 rounded bg-red-500/20 text-red-300 border border-red-500/30">
|
||||
DEV
|
||||
</span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
</main>
|
||||
|
||||
<footer class="border-t border-slate-200/80 dark:border-slate-800/60 bg-white/40 dark:bg-transparent backdrop-blur-sm py-5 text-center text-xs text-slate-500 transition">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-col sm:flex-row items-center justify-between gap-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="font-bold text-slate-800 dark:text-slate-300">Fociz</span>
|
||||
<span>•</span>
|
||||
<span class="text-slate-500 dark:text-slate-400">Focus & Structuur Systeem</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400 dark:text-slate-500">Beveiligd met MariaDB & Tweestapsverificatie (2FA)</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Fociz Agenda Notificaties Engine -->
|
||||
<script src="/assets/js/notifications.js"></script>
|
||||
|
||||
<script>
|
||||
// Lucide Icons initialiseren
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,365 @@
|
||||
<?php
|
||||
/**
|
||||
* Header & Navigatiebalk voor Fociz (Dual-Mode: Dark & Light, Executive Styling)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/auth.php';
|
||||
$currentUser = $currentUser ?? require_auth();
|
||||
$currentPage = basename($_SERVER['PHP_SELF']);
|
||||
$currentUserTheme = !empty($currentUser['id']) ? get_user_theme((int)$currentUser['id']) : 'dark';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="nl" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title><?= htmlspecialchars($pageTitle ?? 'Fociz — Focus, Dagplanning & Roadmap') ?></title>
|
||||
|
||||
<!-- Anti-Flicker Theme Script: direct in head zodat er nooit een flits optreedt -->
|
||||
<script>
|
||||
(function() {
|
||||
try {
|
||||
const serverTheme = <?= json_encode($currentUserTheme ?? 'dark') ?>;
|
||||
const savedTheme = localStorage.getItem('fociz_theme') || serverTheme || 'dark';
|
||||
let effective = savedTheme;
|
||||
if (savedTheme === 'system') {
|
||||
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
} catch(e) {}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
<style>
|
||||
html.dark body {
|
||||
background-color: #0c0f17;
|
||||
color: #f8fafc;
|
||||
}
|
||||
html:not(.dark) body {
|
||||
background-color: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
html.dark ::-webkit-scrollbar-track { background: #0c0f17; }
|
||||
html.dark ::-webkit-scrollbar-thumb { background: #1e2638; border-radius: 4px; }
|
||||
html.dark ::-webkit-scrollbar-thumb:hover { background: #2d3852; }
|
||||
html:not(.dark) ::-webkit-scrollbar-track { background: #f8fafc; }
|
||||
html:not(.dark) ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
|
||||
html:not(.dark) ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col antialiased selection:bg-indigo-500 selection:text-white transition-colors duration-150">
|
||||
|
||||
<?php
|
||||
// Include omgevingsindicator (Dev = Rood, Live = Groen)
|
||||
require_once __DIR__ . '/env_badge.php';
|
||||
?>
|
||||
|
||||
<?php if ($currentUser): ?>
|
||||
<header class="sticky top-0 z-40 bg-white/80 dark:bg-[#0c0f17]/85 backdrop-blur-md border-b border-slate-200/80 dark:border-slate-800/60 shadow-xs transition-colors duration-150">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Brand Logo (met linker padding zodat het dev/live badge vrij staat) -->
|
||||
<div class="flex items-center space-x-6 pl-28 sm:pl-36">
|
||||
<a href="/planner.php" class="flex items-center space-x-2.5 group">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-tr from-indigo-600 to-sky-500 flex items-center justify-center text-white font-black text-sm shadow-sm shadow-indigo-500/20 group-hover:scale-105 transition">
|
||||
F
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-lg font-bold tracking-tight text-slate-900 dark:text-white leading-none">Fociz</span>
|
||||
<span class="text-[9px] text-indigo-600 dark:text-indigo-400 tracking-wider font-semibold uppercase">Focus & Structuur</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Nav Tabs -->
|
||||
<nav class="hidden md:flex items-center space-x-1">
|
||||
<a href="/planner.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'planner.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
<span>Dagplanning</span>
|
||||
</a>
|
||||
<a href="/brainstorm.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4"></i>
|
||||
<span>Brainstorm</span>
|
||||
</a>
|
||||
<a href="/roadmap.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'roadmap.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="map" class="w-4 h-4"></i>
|
||||
<span>Roadmap</span>
|
||||
</a>
|
||||
<a href="/profile.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'profile.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="user" class="w-4 h-4"></i>
|
||||
<span>Mijn Profiel</span>
|
||||
</a>
|
||||
<?php if ($currentUser['role'] === 'ADMIN'): ?>
|
||||
<a href="/users.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'users.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
<span>Team</span>
|
||||
</a>
|
||||
<a href="/admin.php" class="flex items-center space-x-2 px-3 py-1.5 rounded-xl text-xs font-semibold transition <?= $currentPage === 'admin.php' ? 'bg-indigo-50 text-indigo-700 border border-indigo-200/70 shadow-2xs dark:bg-indigo-500/10 dark:text-indigo-300 dark:border-indigo-500/20' : 'text-slate-600 hover:text-slate-900 hover:bg-slate-100 dark:text-slate-400 dark:hover:text-slate-200 dark:hover:bg-slate-800/60' ?>">
|
||||
<i data-lucide="shield-check" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<span>Beheer</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Right side: Clean User Profile & Logout -->
|
||||
<div class="flex items-center space-x-2 sm:space-x-3">
|
||||
<!-- Profiel link rechtsboven -->
|
||||
<a href="/profile.php" class="flex items-center space-x-2.5 group hover:opacity-90 transition px-2 py-1.5 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800/60" title="Mijn Profiel & Instellingen">
|
||||
<div class="w-7 h-7 rounded-lg bg-gradient-to-tr from-indigo-600 to-sky-500 p-0.5 flex-shrink-0 shadow-xs">
|
||||
<div class="w-full h-full rounded-[6px] bg-white dark:bg-slate-950 flex items-center justify-center font-bold text-[11px] text-indigo-600 dark:text-indigo-400 uppercase">
|
||||
<?= strtoupper(substr($currentUser['name'], 0, 1)) ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left hidden sm:block">
|
||||
<div class="text-xs font-semibold text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition leading-tight">
|
||||
<?= htmlspecialchars($currentUser['name']) ?>
|
||||
</div>
|
||||
<div class="text-[10px] text-slate-500 dark:text-slate-400 leading-tight">
|
||||
<?= $currentUser['role'] === 'ADMIN' ? 'Beheerder' : 'Collega' ?>
|
||||
</div>
|
||||
</div>
|
||||
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400 dark:text-slate-500 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition hidden sm:block"></i>
|
||||
</a>
|
||||
|
||||
<!-- Uitloggen -->
|
||||
<a href="/auth/logout.php" title="Veilig uitloggen" class="p-2 rounded-xl text-slate-400 hover:text-red-600 hover:bg-red-50 dark:text-slate-400 dark:hover:text-red-400 dark:hover:bg-red-500/10 transition">
|
||||
<i data-lucide="log-out" class="w-4 h-4"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div class="flex md:hidden items-center justify-around py-2 border-t border-slate-200/80 dark:border-slate-800/60 text-xs">
|
||||
<a href="/planner.php" class="flex flex-col items-center py-1 <?= $currentPage === 'planner.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Planning</span>
|
||||
</a>
|
||||
<a href="/brainstorm.php" class="flex flex-col items-center py-1 <?= in_array($currentPage, ['brainstorm.php', 'brainstorm_canvas.php']) ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="lightbulb" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Brainstorm</span>
|
||||
</a>
|
||||
<a href="/roadmap.php" class="flex flex-col items-center py-1 <?= $currentPage === 'roadmap.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="map" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Roadmap</span>
|
||||
</a>
|
||||
<a href="/profile.php" class="flex flex-col items-center py-1 <?= $currentPage === 'profile.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="user" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Profiel</span>
|
||||
</a>
|
||||
<?php if ($currentUser['role'] === 'ADMIN'): ?>
|
||||
<a href="/admin.php" class="flex flex-col items-center py-1 <?= $currentPage === 'admin.php' ? 'text-indigo-600 dark:text-indigo-400 font-bold' : 'text-slate-500 dark:text-slate-400' ?>">
|
||||
<i data-lucide="shield" class="w-4 h-4 mb-0.5"></i>
|
||||
<span>Beheer</span>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Fociz Agenda Notificaties Modal (Dual-Mode) -->
|
||||
<div id="focizNotificationModal" class="hidden fixed inset-0 z-50 bg-black/60 dark:bg-black/80 backdrop-blur-sm items-center justify-center p-4">
|
||||
<div class="bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-2xl w-full max-w-md overflow-hidden shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<!-- Modal Header -->
|
||||
<div class="p-5 border-b border-slate-100 dark:border-slate-800/80 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2.5">
|
||||
<div class="w-9 h-9 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 text-blue-600 dark:text-blue-400 flex items-center justify-center font-bold">
|
||||
<i data-lucide="bell-ring" class="w-4 h-4"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-900 dark:text-white">Agenda Push Meldingen</h3>
|
||||
<p class="text-[11px] text-slate-500 dark:text-slate-400">Persoonlijke waarschuwingen bij actuele afspraken</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" onclick="closeNotificationModal()" class="text-slate-400 hover:text-slate-700 dark:hover:text-white p-1 rounded-lg transition">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Inhoud Formulier -->
|
||||
<form onsubmit="saveNotificationPreferences(event)" class="p-5 space-y-4 text-xs">
|
||||
<!-- Browser Permissie Banner -->
|
||||
<div id="notifPermGranted" class="hidden p-3 rounded-xl bg-indigo-50 dark:bg-indigo-500/10 border border-indigo-200 dark:border-indigo-500/30 text-indigo-800 dark:text-indigo-300 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="check-circle" class="w-4 h-4 text-indigo-600 dark:text-indigo-400 flex-shrink-0"></i>
|
||||
<span class="text-[11px]">Browser pushberichten zijn actief</span>
|
||||
</div>
|
||||
<button type="button" onclick="focizTestNotification()" class="px-2 py-1 rounded-lg bg-indigo-100 dark:bg-indigo-500/20 hover:bg-indigo-200 dark:hover:bg-indigo-500/30 text-indigo-800 dark:text-indigo-300 font-semibold text-[10px] transition">
|
||||
Test Melding
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notifPermRequest" class="p-3 rounded-xl bg-sky-50 dark:bg-sky-500/10 border border-sky-200 dark:border-sky-500/30 text-sky-900 dark:text-sky-200 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="bell" class="w-4 h-4 text-sky-600 dark:text-sky-400 flex-shrink-0"></i>
|
||||
<span class="text-[11px]">Toestemming browser vereist</span>
|
||||
</div>
|
||||
<button type="button" onclick="focizRequestPermission()" class="px-2.5 py-1 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white font-bold text-[10px] transition shadow-xs">
|
||||
Inschakelen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="notifPermDenied" class="hidden p-3 rounded-xl bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/30 text-rose-800 dark:text-rose-300 text-[11px] leading-relaxed">
|
||||
<i data-lucide="alert-triangle" class="w-4 h-4 text-rose-600 dark:text-rose-400 inline mr-1 -mt-0.5"></i>
|
||||
Browser meldingen zijn geblokkeerd. Klik op het slotje links in de adresbalk om meldingen toe te staan.
|
||||
</div>
|
||||
|
||||
<!-- Toggle Meldingen Inschakelen -->
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<span class="font-semibold text-slate-900 dark:text-white block">Meldingen Actief</span>
|
||||
<span class="text-[10px] text-slate-500 dark:text-slate-400">Automatisch waarschuwen bij naderende afspraken</span>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="notifEnabledInput" class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Vooraankondigingstijd -->
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Hoeveel tijd van tevoren waarschuwen?
|
||||
</label>
|
||||
<select id="notifLeadTimeSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="0">0 minuten (Precies op aanvangstijd)</option>
|
||||
<option value="2">2 minuten van tevoren</option>
|
||||
<option value="5" selected>5 minuten van tevoren (Aanbevolen)</option>
|
||||
<option value="10">10 minuten van tevoren</option>
|
||||
<option value="15">15 minuten van tevoren</option>
|
||||
<option value="30">30 minuten van tevoren</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Aantal Herhalingen & Interval Grid -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Aantal herhalingen
|
||||
</label>
|
||||
<select id="notifRepeatCountSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="1">1x (Alleen 1e melding)</option>
|
||||
<option value="2" selected>2x (Aanbevolen)</option>
|
||||
<option value="3">3x</option>
|
||||
<option value="5">5x (Intensief)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block font-semibold text-slate-700 dark:text-slate-300 mb-1">
|
||||
Herhalingsinterval
|
||||
</label>
|
||||
<select id="notifIntervalSelect" class="w-full bg-slate-50 dark:bg-slate-950 border border-slate-200 dark:border-slate-800 rounded-xl px-3 py-2 text-slate-900 dark:text-white focus:outline-none focus:border-indigo-500">
|
||||
<option value="1">Elke 1 minuut</option>
|
||||
<option value="2" selected>Elke 2 minuten</option>
|
||||
<option value="3">Elke 3 minuten</option>
|
||||
<option value="5">Elke 5 minuten</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geluidssignaal Toggle -->
|
||||
<div class="flex items-center justify-between p-3 rounded-xl bg-slate-50 dark:bg-slate-950/60 border border-slate-200 dark:border-slate-800">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="volume-2" class="w-4 h-4 text-indigo-600 dark:text-indigo-400"></i>
|
||||
<div>
|
||||
<span class="font-semibold text-slate-900 dark:text-white block">Geluidssignaal</span>
|
||||
<span class="text-[10px] text-slate-500 dark:text-slate-400">Zachte chime afspelen bij melding</span>
|
||||
</div>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="notifSoundInput" checked class="sr-only peer">
|
||||
<div class="w-9 h-5 bg-slate-200 dark:bg-slate-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-indigo-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="notifSaveStatus" class="hidden text-indigo-600 dark:text-indigo-400 text-center font-semibold text-[11px] py-1"></div>
|
||||
|
||||
<!-- Knoppen -->
|
||||
<div class="pt-2 flex items-center space-x-2">
|
||||
<button type="button" onclick="closeNotificationModal()" class="flex-1 py-2 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 font-semibold transition">
|
||||
Annuleren
|
||||
</button>
|
||||
<button type="submit" id="saveNotifBtn" class="flex-1 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-bold transition shadow-sm shadow-indigo-600/20">
|
||||
Voorkeuren Opslaan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* Universele Thema-controller voor Fociz
|
||||
*/
|
||||
function focizSetTheme(theme, saveServer = true) {
|
||||
localStorage.setItem('fociz_theme', theme);
|
||||
let effective = theme;
|
||||
if (theme === 'system') {
|
||||
effective = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (effective === 'dark') {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update Lucide pictogrammen in header
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
|
||||
// Update profiel preview selectoren als aanwezig op pagina
|
||||
if (typeof updateProfileThemeUI === 'function') {
|
||||
updateProfileThemeUI(theme);
|
||||
}
|
||||
|
||||
if (saveServer) {
|
||||
fetch('/api/theme.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ theme: theme })
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function focizQuickToggleTheme() {
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const nextTheme = isDark ? 'light' : 'dark';
|
||||
focizSetTheme(nextTheme, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-4 sm:p-6 lg:p-8">
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
/**
|
||||
* Fociz Pure PHP HTTP Client Helper
|
||||
* Ondersteunt cURL met automatische fallback naar stream wrappers (file_get_contents).
|
||||
*/
|
||||
|
||||
if (!function_exists('fociz_http_request')) {
|
||||
/**
|
||||
* Voert een HTTP request uit en retourneert de statuscode, body en geparsede JSON.
|
||||
*
|
||||
* @param string $url
|
||||
* @param array $options [
|
||||
* 'method' => 'GET'|'POST'|'PUT'|'DELETE',
|
||||
* 'headers' => ['Header-Name: Value'] of ['Header-Name' => 'Value'],
|
||||
* 'body' => string|array,
|
||||
* 'timeout' => int (seconden, default 12),
|
||||
* 'verify_ssl' => bool (default true)
|
||||
* ]
|
||||
* @return array ['status' => int, 'body' => string, 'json' => ?array, 'error' => ?string]
|
||||
*/
|
||||
function fociz_http_request(string $url, array $options = []): array {
|
||||
$method = strtoupper($options['method'] ?? 'GET');
|
||||
$timeout = (int)($options['timeout'] ?? 12);
|
||||
$verifySsl = $options['verify_ssl'] ?? true;
|
||||
|
||||
// Normaliseer headers naar 'Name: Value' regels
|
||||
$rawHeaders = $options['headers'] ?? [];
|
||||
$headerLines = [];
|
||||
foreach ($rawHeaders as $k => $v) {
|
||||
if (is_numeric($k)) {
|
||||
$headerLines[] = $v;
|
||||
} else {
|
||||
$headerLines[] = "{$k}: {$v}";
|
||||
}
|
||||
}
|
||||
|
||||
// Bereid payload voor
|
||||
$body = $options['body'] ?? null;
|
||||
if (is_array($body)) {
|
||||
$hasJsonHeader = false;
|
||||
foreach ($headerLines as $h) {
|
||||
if (stripos($h, 'application/json') !== false) {
|
||||
$hasJsonHeader = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($hasJsonHeader) {
|
||||
$body = json_encode($body);
|
||||
} else {
|
||||
$body = http_build_query($body);
|
||||
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
|
||||
}
|
||||
}
|
||||
|
||||
// Voeg standaard User-Agent toe als die er niet is
|
||||
$hasUserAgent = false;
|
||||
foreach ($headerLines as $h) {
|
||||
if (stripos($h, 'User-Agent:') !== false) {
|
||||
$hasUserAgent = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasUserAgent) {
|
||||
$headerLines[] = 'User-Agent: Fociz-Integrations/1.0';
|
||||
}
|
||||
|
||||
// 1. Probeer cURL indien beschikbaar
|
||||
if (extension_loaded('curl')) {
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 6);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headerLines);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $verifySsl);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $verifySsl ? 2 : 0);
|
||||
|
||||
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
$responseBody = curl_exec($ch);
|
||||
$curlError = curl_error($ch);
|
||||
$statusCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($responseBody === false && !empty($curlError)) {
|
||||
return [
|
||||
'status' => $statusCode ?: 0,
|
||||
'body' => '',
|
||||
'json' => null,
|
||||
'error' => "cURL fout: {$curlError}"
|
||||
];
|
||||
}
|
||||
|
||||
$parsedJson = json_decode((string)$responseBody, true);
|
||||
|
||||
return [
|
||||
'status' => $statusCode,
|
||||
'body' => (string)$responseBody,
|
||||
'json' => is_array($parsedJson) ? $parsedJson : null,
|
||||
'error' => null
|
||||
];
|
||||
}
|
||||
|
||||
// 2. Fallback: PHP Streams (file_get_contents)
|
||||
$contextOptions = [
|
||||
'http' => [
|
||||
'method' => $method,
|
||||
'header' => implode("\r\n", $headerLines),
|
||||
'timeout' => $timeout,
|
||||
'ignore_errors' => true // Zorgt dat we ook bij 4xx / 5xx responses de body en statuscode kunnen lezen
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => $verifySsl,
|
||||
'verify_peer_name' => $verifySsl
|
||||
]
|
||||
];
|
||||
|
||||
if ($body !== null && in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'])) {
|
||||
$contextOptions['http']['content'] = $body;
|
||||
}
|
||||
|
||||
$context = stream_context_create($contextOptions);
|
||||
$responseBody = @file_get_contents($url, false, $context);
|
||||
|
||||
$statusCode = 0;
|
||||
if (!empty($http_response_header)) {
|
||||
// Zoek de laatste HTTP status regel (bij eventuele redirects)
|
||||
foreach (array_reverse($http_response_header) as $hdr) {
|
||||
if (preg_match('#HTTP/\d\.\d\s+(\d{3})#i', $hdr, $matches)) {
|
||||
$statusCode = (int)$matches[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($responseBody === false) {
|
||||
$lastErr = error_get_last();
|
||||
return [
|
||||
'status' => $statusCode ?: 0,
|
||||
'body' => '',
|
||||
'json' => null,
|
||||
'error' => 'Stream fout: ' . ($lastErr['message'] ?? 'Kan verbinding niet voltooien')
|
||||
];
|
||||
}
|
||||
|
||||
$parsedJson = json_decode((string)$responseBody, true);
|
||||
|
||||
return [
|
||||
'status' => $statusCode,
|
||||
'body' => (string)$responseBody,
|
||||
'json' => is_array($parsedJson) ? $parsedJson : null,
|
||||
'error' => null
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
/**
|
||||
* Atlassian Jira Cloud REST API Integratie voor Fociz
|
||||
* Beheert verbindingstest, ophalen van aan de gebruiker toegewezen issues en import naar dagtaken.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../http.php';
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
/**
|
||||
* Normaliseert het Jira domein naar een schone URL zonder slash aan het einde
|
||||
*/
|
||||
function jira_normalize_domain(string $domain): string {
|
||||
$domain = trim($domain);
|
||||
if (!preg_match('#^https?://#i', $domain)) {
|
||||
$domain = 'https://' . $domain;
|
||||
}
|
||||
return rtrim($domain, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt het centrale bedrijfsdomein voor Atlassian Jira op
|
||||
*/
|
||||
function jira_get_company_domain(): string {
|
||||
$domain = fociz_env('JIRA_DOMAIN', '');
|
||||
if (!empty($domain)) {
|
||||
return jira_normalize_domain($domain);
|
||||
}
|
||||
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
// Zoek bij beheerder(s) in de integrations tabel
|
||||
$stmt = $db->query("
|
||||
SELECT i.settings
|
||||
FROM integrations i
|
||||
JOIN users u ON i.user_id = u.id
|
||||
WHERE u.role = 'ADMIN' AND i.provider = 'JIRA' AND i.settings IS NOT NULL
|
||||
ORDER BY i.updated_at DESC
|
||||
LIMIT 1
|
||||
");
|
||||
$row = $stmt->fetch();
|
||||
if (!empty($row['settings'])) {
|
||||
$settings = json_decode($row['settings'], true) ?: [];
|
||||
if (!empty($settings['jira_domain'])) {
|
||||
return jira_normalize_domain($settings['jira_domain']);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback naar een willekeurige JIRA rij
|
||||
$fallback = $db->query("
|
||||
SELECT settings FROM integrations WHERE provider = 'JIRA' AND settings LIKE '%jira_domain%' LIMIT 1
|
||||
")->fetch();
|
||||
if (!empty($fallback['settings'])) {
|
||||
$settings = json_decode($fallback['settings'], true) ?: [];
|
||||
if (!empty($settings['jira_domain'])) {
|
||||
return jira_normalize_domain($settings['jira_domain']);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("jira_get_company_domain error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert de Basic Authentication header voor Atlassian REST API
|
||||
*/
|
||||
function jira_get_auth_header(string $email, string $apiToken): string {
|
||||
return 'Basic ' . base64_encode(trim($email) . ':' . trim($apiToken));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test de verbinding met Atlassian Jira Cloud via /rest/api/3/myself
|
||||
*/
|
||||
function jira_test_connection(string $domain, string $email, string $apiToken): array {
|
||||
$cleanDomain = jira_normalize_domain($domain);
|
||||
if (empty($cleanDomain) || empty($email) || empty($apiToken)) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Vul een Jira domein, e-mailadres en Atlassian API token in.',
|
||||
];
|
||||
}
|
||||
|
||||
$authHeader = jira_get_auth_header($email, $apiToken);
|
||||
$url = $cleanDomain . '/rest/api/3/myself';
|
||||
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => $authHeader,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
// Fallback naar api v2 indien v3 niet beschikbaar is
|
||||
if ($response['status'] === 404) {
|
||||
$urlV2 = $cleanDomain . '/rest/api/2/myself';
|
||||
$response = fociz_http_request($urlV2, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => $authHeader,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($response['status'] === 200 && is_array($response['json'])) {
|
||||
$user = $response['json'];
|
||||
return [
|
||||
'success' => true,
|
||||
'user' => [
|
||||
'displayName' => $user['displayName'] ?? 'Atlassian Gebruiker',
|
||||
'emailAddress' => $user['emailAddress'] ?? $email,
|
||||
'accountId' => $user['accountId'] ?? ($user['key'] ?? ''),
|
||||
'active' => $user['active'] ?? true,
|
||||
'timeZone' => $user['timeZone'] ?? 'Europe/Amsterdam',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$errMsg = 'Verbinding mislukt met Jira.';
|
||||
if ($response['status'] === 401) {
|
||||
$errMsg = 'Authenticatie geweigerd (HTTP 401). Controleer uw e-mailadres en zorg dat u een geldig Atlassian API token gebruikt.';
|
||||
} elseif ($response['status'] === 403) {
|
||||
$errMsg = 'Toegang geweigerd (HTTP 403). Uw account heeft onvoldoende rechten op dit Jira domein.';
|
||||
} elseif ($response['status'] === 404) {
|
||||
$errMsg = 'Jira Cloud domein niet gevonden (HTTP 404). Controleer het ingevoerde domein (bijv. uwbedrijf.atlassian.net).';
|
||||
} elseif (!empty($response['error'])) {
|
||||
$errMsg = "Netwerkfout: {$response['error']}";
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
'status' => $response['status'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle actieve issues op die zijn toegewezen aan de ingelogde Jira gebruiker
|
||||
*/
|
||||
function jira_fetch_assigned_issues(string $domain, string $email, string $apiToken, int $maxResults = 50): array {
|
||||
$cleanDomain = jira_normalize_domain($domain);
|
||||
$authHeader = jira_get_auth_header($email, $apiToken);
|
||||
|
||||
// JQL: Alle openstaande taken toegewezen aan mij, gesorteerd op recent bijgewerkt
|
||||
$jql = 'assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC';
|
||||
|
||||
$params = [
|
||||
'jql' => $jql,
|
||||
'maxResults' => min($maxResults, 100),
|
||||
'fields' => 'summary,status,priority,issuetype,parent,project,duedate,updated',
|
||||
];
|
||||
|
||||
$url = $cleanDomain . '/rest/api/3/search?' . http_build_query($params);
|
||||
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => $authHeader,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 12,
|
||||
]);
|
||||
|
||||
// Fallback naar api v2
|
||||
if ($response['status'] === 404) {
|
||||
$urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($params);
|
||||
$response = fociz_http_request($urlV2, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => $authHeader,
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 12,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($response['status'] !== 200 || !isset($response['json']['issues'])) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $response['json']['errorMessages'][0] ?? ($response['error'] ?? 'Kan Jira tickets niet ophalen'),
|
||||
'issues' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$rawIssues = $response['json']['issues'];
|
||||
$issues = [];
|
||||
|
||||
foreach ($rawIssues as $item) {
|
||||
$fields = $item['fields'] ?? [];
|
||||
$key = $item['key'] ?? '';
|
||||
$summary = $fields['summary'] ?? '(Geen titel)';
|
||||
$statusName = $fields['status']['name'] ?? 'To Do';
|
||||
$priorityName = $fields['priority']['name'] ?? 'Medium';
|
||||
$issueTypeName = $fields['issuetype']['name'] ?? 'Taak';
|
||||
$projectName = $fields['project']['name'] ?? 'Project';
|
||||
$dueDate = $fields['duedate'] ?? null;
|
||||
|
||||
$issues[] = [
|
||||
'key' => $key,
|
||||
'summary' => $summary,
|
||||
'status' => $statusName,
|
||||
'priority' => $priorityName,
|
||||
'issue_type' => $issueTypeName,
|
||||
'project' => $projectName,
|
||||
'due_date' => $dueDate,
|
||||
'url' => "{$cleanDomain}/browse/{$key}",
|
||||
'source' => 'JIRA_LIVE',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'issues' => $issues,
|
||||
'total' => count($issues),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt de Jira tickets op voor een Fociz gebruiker
|
||||
* Schakelt naadloos tussen live Jira tickets en sandbox demotickets
|
||||
*/
|
||||
function jira_get_user_issues(int $userId, bool $forceRefresh = false): array {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'JIRA' AND is_enabled = 1");
|
||||
$stmt->execute([$userId]);
|
||||
$integration = $stmt->fetch();
|
||||
|
||||
// Als de integratie niet is ingesteld of in demo mode staat, geef interactieve fallback issues terug
|
||||
if (!$integration || !empty($integration['is_demo_mode'])) {
|
||||
return [
|
||||
'is_demo' => true,
|
||||
'success' => true,
|
||||
'issues' => [
|
||||
[
|
||||
'key' => 'FOC-101',
|
||||
'summary' => 'Tweestapsverificatie (2FA TOTP) flow implementeren',
|
||||
'status' => 'In Progress',
|
||||
'priority' => 'High',
|
||||
'issue_type' => 'Story',
|
||||
'project' => 'Fociz Platform',
|
||||
'due_date' => date('Y-m-d', strtotime('+2 days')),
|
||||
'url' => '#',
|
||||
'source' => 'SANDBOX',
|
||||
],
|
||||
[
|
||||
'key' => 'FOC-102',
|
||||
'summary' => 'Interactief whiteboard canvas voor brainstormsessies',
|
||||
'status' => 'To Do',
|
||||
'priority' => 'High',
|
||||
'issue_type' => 'Feature',
|
||||
'project' => 'Fociz Platform',
|
||||
'due_date' => date('Y-m-d', strtotime('+4 days')),
|
||||
'url' => '#',
|
||||
'source' => 'SANDBOX',
|
||||
],
|
||||
[
|
||||
'key' => 'FOC-103',
|
||||
'summary' => 'Koppeling met Microsoft Outlook Calendar & Teams Graph API',
|
||||
'status' => 'In Review',
|
||||
'priority' => 'Medium',
|
||||
'issue_type' => 'Task',
|
||||
'project' => 'Integrations Hub',
|
||||
'due_date' => date('Y-m-d'),
|
||||
'url' => '#',
|
||||
'source' => 'SANDBOX',
|
||||
],
|
||||
[
|
||||
'key' => 'FOC-104',
|
||||
'summary' => 'Roadmap kwartaalweergave en voortgangsindicatoren',
|
||||
'status' => 'To Do',
|
||||
'priority' => 'Medium',
|
||||
'issue_type' => 'Story',
|
||||
'project' => 'Roadmap Module',
|
||||
'due_date' => date('Y-m-d', strtotime('+7 days')),
|
||||
'url' => '#',
|
||||
'source' => 'SANDBOX',
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
||||
$domain = !empty($settings['jira_domain']) ? $settings['jira_domain'] : jira_get_company_domain();
|
||||
$email = $settings['jira_email'] ?? fociz_env('JIRA_EMAIL', '');
|
||||
$apiToken = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', '');
|
||||
|
||||
if (empty($domain) || empty($email) || empty($apiToken)) {
|
||||
return [
|
||||
'is_demo' => false,
|
||||
'success' => false,
|
||||
'error' => 'Jira configuratie is onvolledig.',
|
||||
'issues' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// Sessiecache voor 90 seconden om onnodige Jira API calls bij snelle pagina refreshes te beperken
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@session_start();
|
||||
}
|
||||
$cacheKey = "jira_issues_user_{$userId}";
|
||||
$cached = $_SESSION[$cacheKey] ?? null;
|
||||
|
||||
if (!$forceRefresh && $cached && is_array($cached) && ($cached['timestamp'] ?? 0) > (time() - 90)) {
|
||||
return [
|
||||
'is_demo' => false,
|
||||
'success' => true,
|
||||
'issues' => $cached['issues'],
|
||||
'from_cache' => true,
|
||||
];
|
||||
}
|
||||
|
||||
$result = jira_fetch_assigned_issues($domain, $email, $apiToken, 50);
|
||||
|
||||
if ($result['success']) {
|
||||
$_SESSION[$cacheKey] = [
|
||||
'timestamp' => time(),
|
||||
'issues' => $result['issues'],
|
||||
];
|
||||
|
||||
// Werk last_sync_at bij
|
||||
$stmtUpdate = $db->prepare("UPDATE integrations SET last_sync_at = NOW() WHERE id = ?");
|
||||
$stmtUpdate->execute([$integration['id']]);
|
||||
|
||||
return [
|
||||
'is_demo' => false,
|
||||
'success' => true,
|
||||
'issues' => $result['issues'],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'is_demo' => false,
|
||||
'success' => false,
|
||||
'error' => $result['error'],
|
||||
'issues' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ontkoppelt Jira integratie voor een gebruiker
|
||||
*/
|
||||
function jira_disconnect(int $userId): bool {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("DELETE FROM integrations WHERE user_id = ? AND provider = 'JIRA'");
|
||||
if (session_status() === PHP_SESSION_NONE) {
|
||||
@session_start();
|
||||
}
|
||||
unset($_SESSION["jira_issues_user_{$userId}"]);
|
||||
return $stmt->execute([$userId]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,885 @@
|
||||
<?php
|
||||
/**
|
||||
* Microsoft 365 / Graph API Integratie voor Fociz
|
||||
* Beheert OAuth2 authenticatie, token refresh en kalendersynchronisatie.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../http.php';
|
||||
require_once __DIR__ . '/../../config/database.php';
|
||||
|
||||
const MS_OAUTH_AUTHORIZE_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/authorize';
|
||||
const MS_OAUTH_TOKEN_URL = 'https://login.microsoftonline.com/%s/oauth2/v2.0/token';
|
||||
const MS_GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0';
|
||||
const MS_DEFAULT_SCOPES = 'offline_access User.Read Calendars.ReadWrite';
|
||||
|
||||
/**
|
||||
* Haalt de centrale Microsoft App Registratie configuratie op (Client ID, Secret, Tenant ID)
|
||||
* Prioriteit:
|
||||
* 1. Environment variabelen (.env of server env)
|
||||
* 2. Opgeslagen configuratie van een Beheerder (ADMIN) in de integrations tabel
|
||||
* 3. Fallback naar integrations tabel waar settings client_id bevat
|
||||
*/
|
||||
function microsoft_get_app_config(): array {
|
||||
$clientId = fociz_env('MICROSOFT_CLIENT_ID', '');
|
||||
$clientSecret = fociz_env('MICROSOFT_CLIENT_SECRET', '');
|
||||
$tenantId = fociz_env('MICROSOFT_TENANT_ID', '');
|
||||
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
$adminOrgDomain = '';
|
||||
|
||||
// Zoek bij actieve beheerder(s) in de integrations tabel
|
||||
$stmt = $db->query("
|
||||
SELECT i.settings, u.email as admin_email
|
||||
FROM integrations i
|
||||
JOIN users u ON i.user_id = u.id
|
||||
WHERE u.role = 'ADMIN' AND i.provider = 'OUTLOOK' AND i.settings IS NOT NULL
|
||||
ORDER BY i.updated_at DESC
|
||||
LIMIT 5
|
||||
");
|
||||
$rows = $stmt->fetchAll();
|
||||
foreach ($rows as $row) {
|
||||
if (!empty($row['admin_email']) && empty($adminOrgDomain)) {
|
||||
$emailParts = explode('@', $row['admin_email']);
|
||||
$domain = strtolower(trim(end($emailParts)));
|
||||
if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
|
||||
$adminOrgDomain = $domain;
|
||||
}
|
||||
}
|
||||
if (!empty($row['settings'])) {
|
||||
$saved = json_decode($row['settings'], true) ?: [];
|
||||
if (empty($clientId) && !empty($saved['client_id'])) {
|
||||
$clientId = trim($saved['client_id']);
|
||||
}
|
||||
if (empty($clientSecret) && !empty($saved['client_secret'])) {
|
||||
$clientSecret = trim($saved['client_secret']);
|
||||
}
|
||||
if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') {
|
||||
$tenantId = trim($saved['tenant_id']);
|
||||
}
|
||||
if (!empty($clientId) && !empty($clientSecret) && !empty($tenantId) && $tenantId !== 'common') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: zoek een willekeurige OUTLOOK rij met ingevulde client_id
|
||||
if (empty($clientId) || empty($clientSecret) || empty($tenantId) || $tenantId === 'common') {
|
||||
$stmtFallback = $db->query("
|
||||
SELECT settings
|
||||
FROM integrations
|
||||
WHERE provider = 'OUTLOOK' AND settings LIKE '%client_id%'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 3
|
||||
");
|
||||
$fallbackRows = $stmtFallback->fetchAll();
|
||||
foreach ($fallbackRows as $fbRow) {
|
||||
if (!empty($fbRow['settings'])) {
|
||||
$saved = json_decode($fbRow['settings'], true) ?: [];
|
||||
if (empty($clientId) && !empty($saved['client_id'])) {
|
||||
$clientId = trim($saved['client_id']);
|
||||
}
|
||||
if (empty($clientSecret) && !empty($saved['client_secret'])) {
|
||||
$clientSecret = trim($saved['client_secret']);
|
||||
}
|
||||
if ((empty($tenantId) || $tenantId === 'common') && !empty($saved['tenant_id']) && $saved['tenant_id'] !== 'common') {
|
||||
$tenantId = trim($saved['tenant_id']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Als tenantId nog leeg of 'common' is, leid het domein af van de beheerder
|
||||
if (empty($tenantId) || $tenantId === 'common') {
|
||||
if (empty($adminOrgDomain)) {
|
||||
$firstAdminEmail = $db->query("SELECT email FROM users WHERE role = 'ADMIN' ORDER BY id ASC LIMIT 1")->fetchColumn();
|
||||
if ($firstAdminEmail) {
|
||||
$parts = explode('@', $firstAdminEmail);
|
||||
$domain = strtolower(trim(end($parts)));
|
||||
if (!in_array($domain, ['gmail.com', 'outlook.com', 'hotmail.com', 'live.com', 'yahoo.com', 'icloud.com'])) {
|
||||
$adminOrgDomain = $domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($adminOrgDomain)) {
|
||||
$tenantId = $adminOrgDomain;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log("microsoft_get_app_config error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
// Default tenantId voor de organisatie
|
||||
if (empty($tenantId) || $tenantId === 'common') {
|
||||
$tenantId = 'planningsagenda.nl';
|
||||
}
|
||||
|
||||
return [
|
||||
'client_id' => $clientId ?: '',
|
||||
'client_secret' => $clientSecret ?: '',
|
||||
'tenant_id' => $tenantId,
|
||||
'is_configured' => !empty($clientId) && !empty($clientSecret),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert de Microsoft OAuth2 login URL
|
||||
*/
|
||||
function microsoft_get_auth_url(string $clientId, string $redirectUri, string $tenantId = '', string $state = ''): string {
|
||||
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
|
||||
$baseUrl = sprintf(MS_OAUTH_AUTHORIZE_URL, urlencode($tenant));
|
||||
|
||||
$params = [
|
||||
'client_id' => trim($clientId),
|
||||
'response_type' => 'code',
|
||||
'redirect_uri' => trim($redirectUri),
|
||||
'response_mode' => 'query',
|
||||
'scope' => MS_DEFAULT_SCOPES,
|
||||
'state' => $state,
|
||||
'prompt' => 'select_account',
|
||||
];
|
||||
|
||||
return $baseUrl . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wisselt de ontvangen autorisatiecode om voor access- en refresh tokens
|
||||
*/
|
||||
function microsoft_exchange_code(string $code, string $clientId, string $clientSecret, string $redirectUri, string $tenantId = ''): array {
|
||||
$tenant = (!empty($tenantId) && $tenantId !== 'common') ? trim($tenantId) : 'planningsagenda.nl';
|
||||
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($tenant));
|
||||
|
||||
$payload = [
|
||||
'client_id' => trim($clientId),
|
||||
'client_secret' => trim($clientSecret),
|
||||
'code' => trim($code),
|
||||
'redirect_uri' => trim($redirectUri),
|
||||
'grant_type' => 'authorization_code',
|
||||
'scope' => MS_DEFAULT_SCOPES,
|
||||
];
|
||||
|
||||
$response = fociz_http_request($tokenUrl, [
|
||||
'method' => 'POST',
|
||||
'body' => $payload,
|
||||
'timeout' => 15,
|
||||
]);
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['access_token'])) {
|
||||
$errorMsg = $response['json']['error_description'] ?? ($response['error'] ?? 'Onbekende fout bij token uitwisseling');
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errorMsg,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'access_token' => $response['json']['access_token'],
|
||||
'refresh_token' => $response['json']['refresh_token'] ?? null,
|
||||
'expires_in' => (int)($response['json']['expires_in'] ?? 3600),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt het Microsoft gebruikersprofiel op via Graph API
|
||||
*/
|
||||
function microsoft_get_user_profile(string $accessToken): ?array {
|
||||
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me', [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$accessToken}",
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'timeout' => 10,
|
||||
]);
|
||||
|
||||
if ($response['status'] === 200 && is_array($response['json'])) {
|
||||
return [
|
||||
'displayName' => $response['json']['displayName'] ?? 'Microsoft 365 Gebruiker',
|
||||
'mail' => $response['json']['mail'] ?? ($response['json']['userPrincipalName'] ?? ''),
|
||||
'id' => $response['json']['id'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een geldige access_token op voor de gebruiker; vernieuwt automatisch indien verlopen
|
||||
*/
|
||||
function microsoft_get_valid_token(int $userId, bool $forceRefresh = false): ?string {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK' AND is_enabled = 1");
|
||||
$stmt->execute([$userId]);
|
||||
$integration = $stmt->fetch();
|
||||
|
||||
if (!$integration || empty($integration['access_token'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
||||
$expiresAt = (int)($settings['expires_at'] ?? 0);
|
||||
|
||||
// Als token nog minstens 4 minuten geldig is én we niet forceren, direct retourneren
|
||||
if (!$forceRefresh && $expiresAt > (time() + 240)) {
|
||||
return $integration['access_token'];
|
||||
}
|
||||
|
||||
// Token is verlopen of forceRefresh gevraagd; refresh met refresh_token
|
||||
$refreshToken = $integration['refresh_token'] ?? '';
|
||||
$appConfig = microsoft_get_app_config();
|
||||
$clientId = !empty($settings['client_id']) ? $settings['client_id'] : $appConfig['client_id'];
|
||||
$clientSecret = !empty($settings['client_secret']) ? $settings['client_secret'] : $appConfig['client_secret'];
|
||||
$tenantId = !empty($settings['tenant_id']) ? $settings['tenant_id'] : $appConfig['tenant_id'];
|
||||
|
||||
if (empty($refreshToken) || empty($clientId) || empty($clientSecret)) {
|
||||
return !$forceRefresh ? ($integration['access_token'] ?? null) : null;
|
||||
}
|
||||
|
||||
$cleanTenant = (!empty($tenantId) && $tenantId !== 'common') ? $tenantId : 'planningsagenda.nl';
|
||||
$tokenUrl = sprintf(MS_OAUTH_TOKEN_URL, urlencode($cleanTenant));
|
||||
$payload = [
|
||||
'client_id' => $clientId,
|
||||
'client_secret' => $clientSecret,
|
||||
'grant_type' => 'refresh_token',
|
||||
'refresh_token' => $refreshToken,
|
||||
'scope' => MS_DEFAULT_SCOPES,
|
||||
];
|
||||
|
||||
$response = fociz_http_request($tokenUrl, [
|
||||
'method' => 'POST',
|
||||
'body' => $payload,
|
||||
'timeout' => 15,
|
||||
]);
|
||||
|
||||
if ($response['status'] === 200 && !empty($response['json']['access_token'])) {
|
||||
$newAccessToken = $response['json']['access_token'];
|
||||
$newRefreshToken = $response['json']['refresh_token'] ?? $refreshToken;
|
||||
$expiresIn = (int)($response['json']['expires_in'] ?? 3600);
|
||||
$settings['expires_at'] = time() + $expiresIn;
|
||||
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE integrations
|
||||
SET access_token = ?, refresh_token = ?, settings = ?, last_sync_at = NOW()
|
||||
WHERE id = ?
|
||||
");
|
||||
$updateStmt->execute([$newAccessToken, $newRefreshToken, json_encode($settings), $integration['id']]);
|
||||
|
||||
return $newAccessToken;
|
||||
}
|
||||
|
||||
error_log("Microsoft token refresh mislukt voor user {$userId}: " . ($response['error'] ?? json_encode($response['json'])));
|
||||
// Fallback: indien het huidige token nog aanwezig is, probeer dit toch te gebruiken
|
||||
if (!empty($integration['access_token'])) {
|
||||
return $integration['access_token'];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt agenda-afspraken van Microsoft Outlook op voor een datumbereik (bijv. een hele week)
|
||||
*/
|
||||
function microsoft_fetch_events_range(int $userId, string $startDate, string $endDate): array {
|
||||
$token = microsoft_get_valid_token($userId);
|
||||
if (!$token) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$tzLocal = new DateTimeZone('Europe/Amsterdam');
|
||||
$tzUtc = new DateTimeZone('UTC');
|
||||
|
||||
$dtStart = new DateTime("{$startDate} 00:00:00", $tzLocal);
|
||||
$dtEnd = new DateTime("{$endDate} 23:59:59", $tzLocal);
|
||||
|
||||
$startIso = $dtStart->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
|
||||
$endIso = $dtEnd->setTimezone($tzUtc)->format('Y-m-d\TH:i:s\Z');
|
||||
} catch (Throwable $e) {
|
||||
$startIso = "{$startDate}T00:00:00Z";
|
||||
$endIso = "{$endDate}T23:59:59Z";
|
||||
}
|
||||
|
||||
$url = MS_GRAPH_BASE_URL . '/me/calendarView?' . http_build_query([
|
||||
'startDateTime' => $startIso,
|
||||
'endDateTime' => $endIso,
|
||||
'$select' => 'id,subject,bodyPreview,start,end,location,isAllDay,webLink,onlineMeeting,isCancelled',
|
||||
'$orderby' => 'start/dateTime',
|
||||
'$top' => '100',
|
||||
]);
|
||||
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||
],
|
||||
'timeout' => 12,
|
||||
]);
|
||||
|
||||
// Retry bij 401 Unauthorized
|
||||
if ($response['status'] === 401) {
|
||||
$freshToken = microsoft_get_valid_token($userId, true);
|
||||
if ($freshToken && $freshToken !== $token) {
|
||||
$token = $freshToken;
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||
],
|
||||
'timeout' => 12,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($response['status'] !== 200 || empty($response['json']['value'])) {
|
||||
if ($response['status'] !== 200) {
|
||||
error_log("microsoft_fetch_events_range error (HTTP {$response['status']}): " . ($response['error'] ?? json_encode($response['json'])));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
$rawEvents = $response['json']['value'];
|
||||
$events = [];
|
||||
|
||||
foreach ($rawEvents as $e) {
|
||||
if (!empty($e['isCancelled'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$rawStart = $e['start']['dateTime'] ?? '';
|
||||
$rawEnd = $e['end']['dateTime'] ?? '';
|
||||
|
||||
$eventDate = '';
|
||||
$startTime = '';
|
||||
$endTime = '';
|
||||
|
||||
if (!empty($rawStart)) {
|
||||
$eventDate = substr($rawStart, 0, 10);
|
||||
if (preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
|
||||
$startTime = $m[1];
|
||||
} else {
|
||||
$startTime = date('H:i', strtotime($rawStart));
|
||||
}
|
||||
}
|
||||
if (!empty($rawEnd)) {
|
||||
if (preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
|
||||
$endTime = $m[1];
|
||||
} else {
|
||||
$endTime = date('H:i', strtotime($rawEnd));
|
||||
}
|
||||
}
|
||||
|
||||
$meetingUrl = $e['onlineMeeting']['joinUrl'] ?? ($e['webLink'] ?? 'https://outlook.office.com');
|
||||
if (empty($meetingUrl) && !empty($e['bodyPreview'])) {
|
||||
if (preg_match('#https://teams\.microsoft\.com/l/meetup-join/[^\s"\'<>]+#i', $e['bodyPreview'], $m)) {
|
||||
$meetingUrl = $m[0];
|
||||
}
|
||||
}
|
||||
|
||||
$locationName = $e['location']['displayName'] ?? '';
|
||||
if (empty($locationName) && !empty($e['onlineMeeting']['joinUrl'])) {
|
||||
$locationName = 'Microsoft Teams';
|
||||
}
|
||||
|
||||
$events[] = [
|
||||
'id' => $e['id'] ?? uniqid('ms_'),
|
||||
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
|
||||
'date' => $eventDate ?: $startDate,
|
||||
'start' => $startTime ?: '09:00',
|
||||
'end' => $endTime ?: '10:00',
|
||||
'start_raw' => $rawStart,
|
||||
'end_raw' => $rawEnd,
|
||||
'location' => $locationName ?: 'Geen locatie',
|
||||
'meeting_url' => $meetingUrl,
|
||||
'web_link' => $e['webLink'] ?? '',
|
||||
'is_all_day' => !empty($e['isAllDay']),
|
||||
'source' => 'OUTLOOK_LIVE',
|
||||
];
|
||||
}
|
||||
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt agenda-afspraken van Microsoft Outlook op voor de opgegeven datum
|
||||
*/
|
||||
function microsoft_fetch_today_events(int $userId, ?string $date = null): array {
|
||||
$targetDate = $date ?: date('Y-m-d');
|
||||
return microsoft_fetch_events_range($userId, $targetDate, $targetDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maakt een nieuwe afspraak aan in de Microsoft Outlook agenda via Graph API
|
||||
*
|
||||
* @param int $userId
|
||||
* @param array $eventData [
|
||||
* 'subject' => string,
|
||||
* 'description' => ?string,
|
||||
* 'date' => ?string (Y-m-d),
|
||||
* 'start_time' => ?string (H:i),
|
||||
* 'end_time' => ?string (H:i),
|
||||
* 'duration_minutes' => ?int,
|
||||
* ]
|
||||
* @return array ['success' => bool, 'event_id' => ?string, 'web_link' => ?string, 'error' => ?string]
|
||||
*/
|
||||
function microsoft_create_event(int $userId, array $eventData): array {
|
||||
$token = microsoft_get_valid_token($userId);
|
||||
if (!$token) {
|
||||
return ['success' => false, 'error' => 'Geen geldige Outlook 365 verbinding gevonden. Koppel uw account via Mijn Profiel.'];
|
||||
}
|
||||
|
||||
$subject = trim($eventData['subject'] ?? 'Fociz Dagplanning Taak');
|
||||
$description = trim($eventData['description'] ?? 'Gepland via Fociz Dagplanning & Structuur');
|
||||
|
||||
// Normaliseer datum naar strikt YYYY-MM-DD
|
||||
$date = !empty($eventData['date']) ? trim($eventData['date']) : date('Y-m-d');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
|
||||
$date = date('Y-m-d', strtotime($date) ?: time());
|
||||
}
|
||||
|
||||
// Normaliseer starttijd naar strikt HH:mm met 2-cijferig uur
|
||||
$rawStart = !empty($eventData['start_time']) ? trim($eventData['start_time']) : '09:00';
|
||||
if (preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawStart, $m)) {
|
||||
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
|
||||
} elseif (preg_match('/^(\d{1,2})$/', $rawStart, $m)) {
|
||||
$startTime = sprintf('%02d:00', (int)$m[1]);
|
||||
} else {
|
||||
$startTime = '09:00';
|
||||
}
|
||||
|
||||
// Duur
|
||||
$duration = (int)($eventData['duration_minutes'] ?? 30);
|
||||
if ($duration <= 0) $duration = 30;
|
||||
|
||||
// Normaliseer eindtijd naar strikt HH:mm
|
||||
$rawEnd = !empty($eventData['end_time']) ? trim($eventData['end_time']) : '';
|
||||
if (!empty($rawEnd) && preg_match('/^(\d{1,2})[:.](\d{2})(?::\d{2})?$/', $rawEnd, $m)) {
|
||||
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
|
||||
} else {
|
||||
try {
|
||||
$dt = new DateTime("{$date} {$startTime}:00", new DateTimeZone('Europe/Amsterdam'));
|
||||
$dt->modify("+{$duration} minutes");
|
||||
$endTime = $dt->format('H:i');
|
||||
} catch (Throwable $e) {
|
||||
$endTime = '10:00';
|
||||
}
|
||||
}
|
||||
|
||||
$startDateTimeStr = "{$date}T{$startTime}:00";
|
||||
$endDateTimeStr = "{$date}T{$endTime}:00";
|
||||
|
||||
$payload = [
|
||||
'subject' => $subject,
|
||||
'body' => [
|
||||
'contentType' => 'HTML',
|
||||
'content' => nl2br(htmlspecialchars($description)) . '<br><br><small style="color: #64748b;">Aangemaakt via <a href="https://dev.fociz.nl/planner.php">Fociz Dagplanning</a></small>',
|
||||
],
|
||||
'start' => [
|
||||
'dateTime' => $startDateTimeStr,
|
||||
'timeZone' => 'Europe/Amsterdam',
|
||||
],
|
||||
'end' => [
|
||||
'dateTime' => $endDateTimeStr,
|
||||
'timeZone' => 'Europe/Amsterdam',
|
||||
],
|
||||
'showAs' => 'busy',
|
||||
'isReminderOn' => true,
|
||||
'reminderMinutesBeforeStart' => 15,
|
||||
];
|
||||
|
||||
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 15,
|
||||
]);
|
||||
|
||||
// Retry bij 401 Unauthorized (token verlopen)
|
||||
if ($response['status'] === 401) {
|
||||
$freshToken = microsoft_get_valid_token($userId, true);
|
||||
if ($freshToken && $freshToken !== $token) {
|
||||
$token = $freshToken;
|
||||
$response = fociz_http_request(MS_GRAPH_BASE_URL . '/me/events', [
|
||||
'method' => 'POST',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 15,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($response['status'] === 201 && is_array($response['json'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'event_id' => $response['json']['id'] ?? null,
|
||||
'web_link' => $response['json']['webLink'] ?? null,
|
||||
'start_time'=> $startTime,
|
||||
'end_time' => $endTime,
|
||||
];
|
||||
}
|
||||
|
||||
$errMsg = $response['json']['error']['message'] ?? ($response['error'] ?? "Fout bij aanmaken Outlook afspraak (HTTP {$response['status']})");
|
||||
error_log("microsoft_create_event fout (user {$userId}): {$errMsg}");
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => $errMsg,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwijdert een afspraak uit Microsoft Outlook via Graph API
|
||||
*/
|
||||
function microsoft_delete_event(int $userId, string $eventId): bool {
|
||||
if (empty($eventId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = microsoft_get_valid_token($userId);
|
||||
if (!$token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'DELETE',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
],
|
||||
'timeout' => 12,
|
||||
]);
|
||||
|
||||
return in_array($response['status'], [200, 204, 404]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkt een bestaande afspraak bij in Microsoft Outlook via Graph API (PATCH)
|
||||
*/
|
||||
function microsoft_update_event(int $userId, string $eventId, array $eventData): bool {
|
||||
if (empty($eventId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = microsoft_get_valid_token($userId);
|
||||
if (!$token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = [];
|
||||
if (!empty($eventData['subject'])) {
|
||||
$payload['subject'] = $eventData['subject'];
|
||||
}
|
||||
|
||||
$date = !empty($eventData['date']) ? $eventData['date'] : date('Y-m-d');
|
||||
$startTime = !empty($eventData['start_time']) ? $eventData['start_time'] : '09:00';
|
||||
$endTime = !empty($eventData['end_time']) ? $eventData['end_time'] : '09:30';
|
||||
|
||||
if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $startTime, $m)) {
|
||||
$startTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
|
||||
}
|
||||
if (preg_match('/^(\d{1,2})[:.](\d{2})$/', $endTime, $m)) {
|
||||
$endTime = sprintf('%02d:%02d', (int)$m[1], (int)$m[2]);
|
||||
}
|
||||
|
||||
$payload['start'] = [
|
||||
'dateTime' => "{$date}T{$startTime}:00",
|
||||
'timeZone' => 'Europe/Amsterdam',
|
||||
];
|
||||
$payload['end'] = [
|
||||
'dateTime' => "{$date}T{$endTime}:00",
|
||||
'timeZone' => 'Europe/Amsterdam',
|
||||
];
|
||||
|
||||
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId);
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'PATCH',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 12,
|
||||
]);
|
||||
|
||||
if ($response['status'] === 401) {
|
||||
$freshToken = microsoft_get_valid_token($userId, true);
|
||||
if ($freshToken && $freshToken !== $token) {
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'PATCH',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$freshToken}",
|
||||
'Content-Type' => 'application/json',
|
||||
'Accept' => 'application/json',
|
||||
],
|
||||
'body' => $payload,
|
||||
'timeout' => 12,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return in_array($response['status'], [200, 204]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ontkoppelt Microsoft integratie voor een gebruiker
|
||||
* Wist uitsluitend de persoonlijke tokens; behoudt de centrale Azure App gegevens indien aanwezig.
|
||||
*/
|
||||
function microsoft_disconnect(int $userId): bool {
|
||||
$db = get_db_connection();
|
||||
$stmt = $db->prepare("SELECT * FROM integrations WHERE user_id = ? AND provider = 'OUTLOOK'");
|
||||
$stmt->execute([$userId]);
|
||||
$integration = $stmt->fetch();
|
||||
|
||||
if (!$integration) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$settings = json_decode($integration['settings'] ?? '{}', true) ?: [];
|
||||
// Verwijder gebruikers-specifieke token gegevens
|
||||
unset($settings['account_email'], $settings['account_name'], $settings['expires_at']);
|
||||
|
||||
// Als er centrale app credentials in staan (bijv. van beheerder), behouden we deze netjes
|
||||
if (!empty($settings['client_id']) || !empty($settings['client_secret'])) {
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE integrations
|
||||
SET access_token = NULL, refresh_token = NULL, settings = ?, last_sync_at = NULL, is_enabled = 1, is_demo_mode = 0
|
||||
WHERE id = ?
|
||||
");
|
||||
return $updateStmt->execute([json_encode($settings), $integration['id']]);
|
||||
} else {
|
||||
$delStmt = $db->prepare("DELETE FROM integrations WHERE id = ?");
|
||||
return $delStmt->execute([$integration['id']]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt één specifieke afspraak op uit Microsoft Outlook via Graph API.
|
||||
* Retourneert:
|
||||
* - array met event data indien gevonden (HTTP 200)
|
||||
* - false indien definitief verwijderd / geannuleerd (HTTP 404 of isCancelled)
|
||||
* - null indien er een netwerk/server error optreedt (beschermt taken tegen onterecht wissen)
|
||||
*/
|
||||
function microsoft_get_single_event(int $userId, string $eventId): false|array|null {
|
||||
if (empty($eventId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$token = microsoft_get_valid_token($userId);
|
||||
if (!$token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$url = MS_GRAPH_BASE_URL . '/me/events/' . urlencode($eventId) . '?' . http_build_query([
|
||||
'$select' => 'id,subject,start,end,isCancelled',
|
||||
]);
|
||||
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||
],
|
||||
'timeout' => 8,
|
||||
]);
|
||||
|
||||
if ($response['status'] === 401) {
|
||||
$freshToken = microsoft_get_valid_token($userId, true);
|
||||
if ($freshToken && $freshToken !== $token) {
|
||||
$token = $freshToken;
|
||||
$response = fociz_http_request($url, [
|
||||
'method' => 'GET',
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer {$token}",
|
||||
'Accept' => 'application/json',
|
||||
'Prefer' => 'outlook.timezone="Europe/Amsterdam"',
|
||||
],
|
||||
'timeout' => 8,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($response['status'] === 404) {
|
||||
return false; // Definitief niet meer gevonden in Outlook
|
||||
}
|
||||
|
||||
if ($response['status'] === 200 && is_array($response['json'])) {
|
||||
$e = $response['json'];
|
||||
if (!empty($e['isCancelled'])) {
|
||||
return false; // Geannuleerd
|
||||
}
|
||||
|
||||
$rawStart = $e['start']['dateTime'] ?? '';
|
||||
$rawEnd = $e['end']['dateTime'] ?? '';
|
||||
|
||||
$eventDate = !empty($rawStart) ? substr($rawStart, 0, 10) : '';
|
||||
$startTime = '';
|
||||
$endTime = '';
|
||||
|
||||
if (!empty($rawStart) && preg_match('/T(\d{2}:\d{2})/', $rawStart, $m)) {
|
||||
$startTime = $m[1];
|
||||
}
|
||||
if (!empty($rawEnd) && preg_match('/T(\d{2}:\d{2})/', $rawEnd, $m)) {
|
||||
$endTime = $m[1];
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $e['id'],
|
||||
'subject' => !empty($e['subject']) ? $e['subject'] : '(Geen onderwerp)',
|
||||
'date' => $eventDate,
|
||||
'start' => $startTime ?: '09:00',
|
||||
'end' => $endTime ?: '10:00',
|
||||
'start_raw' => $rawStart,
|
||||
'end_raw' => $rawEnd,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchroniseert en reconciled Fociz taken met de live Microsoft Outlook agenda.
|
||||
*
|
||||
* - Als een gekoppelde afspraak in Outlook is verwijderd of geannuleerd, wordt de taak in Fociz gewist.
|
||||
* - Als een afspraak in Outlook van tijd, datum of titel is gewijzigd, wordt de taak in Fociz bijgewerkt.
|
||||
*
|
||||
* @param int $userId
|
||||
* @param string $startDate Datumbereik begin (Y-m-d)
|
||||
* @param string $endDate Datumbereik einde (Y-m-d)
|
||||
* @param array $liveEvents Reeds opgehaalde Outlook events via microsoft_fetch_events_range
|
||||
* @return array ['deleted' => int, 'updated' => int]
|
||||
*/
|
||||
function microsoft_reconcile_tasks_with_outlook(int $userId, string $startDate, string $endDate, array $liveEvents): array {
|
||||
$db = get_db_connection();
|
||||
$deletedCount = 0;
|
||||
$updatedCount = 0;
|
||||
|
||||
// 1. Indexeer live events op id
|
||||
$liveEventMap = [];
|
||||
foreach ($liveEvents as $evt) {
|
||||
if (!empty($evt['id'])) {
|
||||
$liveEventMap[$evt['id']] = $evt;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Haal alle taken op voor deze gebruiker in dit datumbereik met een external_id (gekoppeld aan Outlook)
|
||||
$stmt = $db->prepare("
|
||||
SELECT t.id, t.title, t.time_block_start, t.time_block_end, t.duration_minutes, t.external_id, dp.plan_date, dp.id as daily_plan_id
|
||||
FROM tasks t
|
||||
JOIN daily_plans dp ON t.daily_plan_id = dp.id
|
||||
WHERE t.user_id = ?
|
||||
AND t.source = 'OUTLOOK'
|
||||
AND t.external_id IS NOT NULL
|
||||
AND t.external_id != ''
|
||||
AND dp.plan_date BETWEEN ? AND ?
|
||||
");
|
||||
$stmt->execute([$userId, $startDate, $endDate]);
|
||||
$syncedTasks = $stmt->fetchAll();
|
||||
|
||||
foreach ($syncedTasks as $task) {
|
||||
$extId = $task['external_id'];
|
||||
|
||||
if (isset($liveEventMap[$extId])) {
|
||||
// Het event bestaat in het huidige week/datumbereik!
|
||||
$evt = $liveEventMap[$extId];
|
||||
|
||||
$dateChanged = (!empty($evt['date']) && $evt['date'] !== $task['plan_date']);
|
||||
$titleChanged = (!empty($evt['subject']) && $evt['subject'] !== $task['title']);
|
||||
$startChanged = (!empty($evt['start']) && $evt['start'] !== $task['time_block_start']);
|
||||
$endChanged = (!empty($evt['end']) && $evt['end'] !== $task['time_block_end']);
|
||||
|
||||
if ($dateChanged || $titleChanged || $startChanged || $endChanged) {
|
||||
$targetPlanId = (int)$task['daily_plan_id'];
|
||||
|
||||
// Als datum gewijzigd is, verplaats naar de daily_plan van de nieuwe datum
|
||||
if ($dateChanged) {
|
||||
$stmtPlan = $db->prepare("
|
||||
INSERT INTO daily_plans (user_id, plan_date)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
|
||||
");
|
||||
$stmtPlan->execute([$userId, $evt['date']]);
|
||||
$targetPlanId = (int)$db->lastInsertId();
|
||||
}
|
||||
|
||||
// Bereken duur
|
||||
$newStart = !empty($evt['start']) ? $evt['start'] : $task['time_block_start'];
|
||||
$newEnd = !empty($evt['end']) ? $evt['end'] : $task['time_block_end'];
|
||||
$newDuration = (int)$task['duration_minutes'];
|
||||
if (!empty($newStart) && !empty($newEnd)) {
|
||||
$durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60;
|
||||
if ($durCalc > 0) {
|
||||
$newDuration = (int)$durCalc;
|
||||
}
|
||||
}
|
||||
|
||||
$newTitle = !empty($evt['subject']) ? $evt['subject'] : $task['title'];
|
||||
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
$updateStmt->execute([$targetPlanId, $newTitle, $newStart, $newEnd, $newDuration, $task['id'], $userId]);
|
||||
$updatedCount++;
|
||||
}
|
||||
} else {
|
||||
// Event niet gevonden in het weekbereik!
|
||||
// Controleer specifiek of het event in Outlook gewist is of naar een andere week verplaatst
|
||||
$singleEvent = microsoft_get_single_event($userId, $extId);
|
||||
|
||||
if ($singleEvent === false) {
|
||||
// Event is definitief verwijderd of geannuleerd in Outlook!
|
||||
$delStmt = $db->prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?");
|
||||
$delStmt->execute([$task['id'], $userId]);
|
||||
$deletedCount++;
|
||||
} elseif (is_array($singleEvent)) {
|
||||
// Event bestaat nog, maar staat op een andere datum (buiten huidig bereik)
|
||||
$newDate = $singleEvent['date'];
|
||||
if (!empty($newDate)) {
|
||||
$stmtPlan = $db->prepare("
|
||||
INSERT INTO daily_plans (user_id, plan_date)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE id=LAST_INSERT_ID(id)
|
||||
");
|
||||
$stmtPlan->execute([$userId, $newDate]);
|
||||
$newPlanId = (int)$db->lastInsertId();
|
||||
|
||||
$newStart = $singleEvent['start'];
|
||||
$newEnd = $singleEvent['end'];
|
||||
$newDuration = (int)$task['duration_minutes'];
|
||||
if (!empty($newStart) && !empty($newEnd)) {
|
||||
$durCalc = (strtotime("2000-01-01 {$newEnd}") - strtotime("2000-01-01 {$newStart}")) / 60;
|
||||
if ($durCalc > 0) $newDuration = (int)$durCalc;
|
||||
}
|
||||
|
||||
$updateStmt = $db->prepare("
|
||||
UPDATE tasks
|
||||
SET daily_plan_id = ?, title = ?, time_block_start = ?, time_block_end = ?, duration_minutes = ?
|
||||
WHERE id = ? AND user_id = ?
|
||||
");
|
||||
$updateStmt->execute([$newPlanId, $singleEvent['subject'], $newStart, $newEnd, $newDuration, $task['id'], $userId]);
|
||||
$updatedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'deleted' => $deletedCount,
|
||||
'updated' => $updatedCount,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* Centrale Systeeminstellingen Helper voor Fociz
|
||||
* Beheert organisatie-brede werkdagstandaarden (v1.4) en AI configuratie (v1.5).
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
/**
|
||||
* Zorgt ervoor dat de system_settings tabel bestaat in MariaDB
|
||||
*/
|
||||
function ensure_system_settings_table(?PDO $db = null): void {
|
||||
static $ensured = false;
|
||||
if ($ensured) return;
|
||||
|
||||
try {
|
||||
$pdo = $db ?: get_db_connection();
|
||||
$pdo->exec("
|
||||
CREATE TABLE IF NOT EXISTS `system_settings` (
|
||||
`setting_key` VARCHAR(64) PRIMARY KEY,
|
||||
`setting_value` TEXT NULL,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
");
|
||||
$ensured = true;
|
||||
} catch (Throwable $e) {
|
||||
error_log("ensure_system_settings_table error: " . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt een enkele instelling op uit MariaDB
|
||||
*/
|
||||
function get_system_setting(string $key, $default = null) {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_system_settings_table($db);
|
||||
|
||||
$stmt = $db->prepare("SELECT setting_value FROM system_settings WHERE setting_key = ?");
|
||||
$stmt->execute([$key]);
|
||||
$val = $stmt->fetchColumn();
|
||||
|
||||
if ($val === false || $val === null) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
// Decodeer JSON indien van toepassing
|
||||
$decoded = json_decode($val, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE) {
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
return $val;
|
||||
} catch (Throwable $e) {
|
||||
error_log("get_system_setting error for {$key}: " . $e->getMessage());
|
||||
return $default;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slaat een instelling op in MariaDB
|
||||
*/
|
||||
function set_system_setting(string $key, $value): bool {
|
||||
try {
|
||||
$db = get_db_connection();
|
||||
ensure_system_settings_table($db);
|
||||
|
||||
$valToStore = is_array($value) || is_object($value) ? json_encode($value) : (string)$value;
|
||||
|
||||
$stmt = $db->prepare("
|
||||
INSERT INTO system_settings (setting_key, setting_value)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()
|
||||
");
|
||||
return $stmt->execute([$key, $valToStore]);
|
||||
} catch (Throwable $e) {
|
||||
error_log("set_system_setting error for {$key}: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt alle organisatie werkdag standaarden op (Release v1.4)
|
||||
*/
|
||||
function get_workday_settings(): array {
|
||||
return [
|
||||
'workday_start' => get_system_setting('workday_start', '08:30'),
|
||||
'workday_end' => get_system_setting('workday_end', '17:30'),
|
||||
'lunch_start' => get_system_setting('lunch_start', '12:30'),
|
||||
'lunch_end' => get_system_setting('lunch_end', '13:00'),
|
||||
'pomodoro_focus' => (int)get_system_setting('pomodoro_focus', 25),
|
||||
'pomodoro_deep' => (int)get_system_setting('pomodoro_deep', 50),
|
||||
'pomodoro_break' => (int)get_system_setting('pomodoro_break', 5),
|
||||
'default_task_duration' => (int)get_system_setting('default_task_duration', 30),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Haalt AI configuratie-instellingen op (Release v1.5)
|
||||
*/
|
||||
function get_ai_settings(): array {
|
||||
$envKey = fociz_env('OPENAI_API_KEY', '');
|
||||
$savedKey = get_system_setting('ai_api_key', '');
|
||||
$apiKey = !empty($savedKey) ? $savedKey : $envKey;
|
||||
|
||||
return [
|
||||
'ai_enabled' => (bool)get_system_setting('ai_enabled', !empty($apiKey)),
|
||||
'ai_provider' => get_system_setting('ai_provider', 'openai'),
|
||||
'ai_api_key' => $apiKey,
|
||||
'ai_model' => get_system_setting('ai_model', 'gpt-4o-mini'),
|
||||
'has_key' => !empty($apiKey),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
/**
|
||||
* Pure PHP TOTP (RFC 6238) & 2FA Implementatie
|
||||
* Volledig compatibel met Google Authenticator, Microsoft Authenticator & 1Password
|
||||
*/
|
||||
|
||||
class TOTP {
|
||||
private static string $base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
|
||||
/**
|
||||
* Genereert een willekeurige 16-teken Base32 geheime sleutel
|
||||
*/
|
||||
public static function generateSecret(int $length = 16): string {
|
||||
$secret = '';
|
||||
$bytes = random_bytes($length);
|
||||
for ($i = 0; $i < $length; $i++) {
|
||||
$secret .= self::$base32Chars[ord($bytes[$i]) % 32];
|
||||
}
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodeert een Base32 string naar binaire data
|
||||
*/
|
||||
private static function base32Decode(string $b32): string {
|
||||
$b32 = strtoupper(trim($b32));
|
||||
$buffer = 0;
|
||||
$bufferBits = 0;
|
||||
$binary = '';
|
||||
|
||||
for ($i = 0; $i < strlen($b32); $i++) {
|
||||
$char = $b32[$i];
|
||||
$val = strpos(self::$base32Chars, $char);
|
||||
if ($val === false) continue; // Negeer spaties/ongeldige tekens
|
||||
|
||||
$buffer = ($buffer << 5) | $val;
|
||||
$bufferBits += 5;
|
||||
|
||||
if ($bufferBits >= 8) {
|
||||
$bufferBits -= 8;
|
||||
$binary .= chr(($buffer >> $bufferBits) & 0xFF);
|
||||
}
|
||||
}
|
||||
|
||||
return $binary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Berekent de 6-cijferige TOTP code voor een bepaald tijdsinterval
|
||||
*/
|
||||
public static function getCode(string $secret, ?int $timestamp = null): string {
|
||||
if ($timestamp === null) {
|
||||
$timestamp = time();
|
||||
}
|
||||
|
||||
$timeCounter = floor($timestamp / 30);
|
||||
$binaryKey = self::base32Decode($secret);
|
||||
|
||||
// Converteer tijdsinterval naar 8-byte big-endian binaire string
|
||||
$binaryTime = pack('N*', 0) . pack('N*', $timeCounter);
|
||||
|
||||
$hash = hash_hmac('sha1', $binaryTime, $binaryKey, true);
|
||||
$offset = ord($hash[strlen($hash) - 1]) & 0x0F;
|
||||
|
||||
$binaryCode = (
|
||||
((ord($hash[$offset]) & 0x7F) << 24) |
|
||||
((ord($hash[$offset + 1]) & 0xFF) << 16) |
|
||||
((ord($hash[$offset + 2]) & 0xFF) << 8) |
|
||||
(ord($hash[$offset + 3]) & 0xFF)
|
||||
);
|
||||
|
||||
$otp = $binaryCode % 1000000;
|
||||
return str_pad((string)$otp, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifieert een ingevoerde 6-cijferige 2FA code (met 1 tijdstap speling naar voor/achter)
|
||||
*/
|
||||
public static function verifyCode(string $secret, string $userInputCode, int $window = 1): bool {
|
||||
$userInputCode = trim(str_replace(' ', '', $userInputCode));
|
||||
if (strlen($userInputCode) !== 6 || !ctype_digit($userInputCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$currentTime = time();
|
||||
for ($i = -$window; $i <= $window; $i++) {
|
||||
$calcCode = self::getCode($secret, $currentTime + ($i * 30));
|
||||
if (hash_equals($calcCode, $userInputCode)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maakt de otpauth:// URL aan voor authenticator apps
|
||||
*/
|
||||
public static function getOtpAuthUrl(string $email, string $secret, string $issuer = 'Fociz'): string {
|
||||
return sprintf(
|
||||
'otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=6&period=30',
|
||||
rawurlencode($issuer),
|
||||
rawurlencode($email),
|
||||
$secret,
|
||||
rawurlencode($issuer)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een QR code afbeelding URL (via QuickChart HTTPS)
|
||||
*/
|
||||
public static function getQrCodeUrl(string $otpAuthUrl, int $size = 220): string {
|
||||
return sprintf(
|
||||
'https://quickchart.io/qr?text=%s&size=%d&margin=1&ecLevel=M',
|
||||
urlencode($otpAuthUrl),
|
||||
$size
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Genereert een lijst met eenmalige herstelcodes
|
||||
*/
|
||||
public static function generateBackupCodes(int $count = 8): array {
|
||||
$codes = [];
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$bytes = bin2hex(random_bytes(4));
|
||||
$codes[] = strtoupper(substr($bytes, 0, 4) . '-' . substr($bytes, 4, 4));
|
||||
}
|
||||
return $codes;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user