Files
Fociz/includes/integrations/jira.php
T
2026-09-14 16:24:23 +02:00

1105 lines
49 KiB
PHP

<?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';
require_once __DIR__ . '/../cache.php';
/**
* Normaliseert het Jira domein naar een schone URL zonder slash aan het einde.
* Bevat SSRF-bescherming: weigert loopbacks, localhost en private netwerkadressen.
*/
function jira_normalize_domain(string $domain): string {
$domain = trim($domain);
if (empty($domain)) {
return '';
}
if (!preg_match('#^https?://#i', $domain)) {
$domain = 'https://' . $domain;
}
$domain = rtrim($domain, '/');
// Parseer en valideer de host ter voorkoming van SSRF (Server-Side Request Forgery)
$parsed = parse_url($domain);
$host = $parsed['host'] ?? '';
if (empty($host)) {
return '';
}
$lowerHost = strtolower($host);
// Weiger localhost en local domains
if ($lowerHost === 'localhost' || str_ends_with($lowerHost, '.localhost') || str_ends_with($lowerHost, '.local') || str_ends_with($lowerHost, '.internal')) {
return '';
}
// Weiger private IP-ranges en loopback adressen (bijv. 127.0.0.1, 192.168.x, 10.x, 172.16.x, 169.254.x)
if (filter_var($host, FILTER_VALIDATE_IP)) {
if (!filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return '';
}
}
return $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
*/
/**
* Converteert Atlassian Document Format (ADF) of tekst naar platte leesbare tekst
*/
function jira_adf_to_text($adf): string {
if (empty($adf)) {
return '';
}
if (is_string($adf)) {
return trim($adf);
}
if (!is_array($adf)) {
return '';
}
$text = '';
$type = $adf['type'] ?? '';
if ($type === 'text') {
return $adf['text'] ?? '';
}
if (!empty($adf['content']) && is_array($adf['content'])) {
foreach ($adf['content'] as $child) {
$childText = jira_adf_to_text($child);
$childType = $child['type'] ?? '';
if ($childType === 'paragraph' || $childType === 'heading') {
$text .= $childText . "\n\n";
} elseif ($childType === 'listItem') {
$text .= "• " . trim($childText) . "\n";
} elseif ($childType === 'codeBlock') {
$text .= "\n" . trim($childText) . "\n\n";
} else {
$text .= $childText;
}
}
}
return trim($text);
}
/**
* Haalt alle geconfigureerde Jira wachtrijfilters op uit de database
*/
function jira_get_queue_filters(bool $onlyActive = true): array {
try {
$db = get_db_connection();
$sql = "SELECT * FROM jira_queue_filters " . ($onlyActive ? "WHERE is_active = 1 " : "") . "ORDER BY sort_order ASC, id ASC";
return $db->query($sql)->fetchAll() ?: [];
} catch (Throwable $e) {
error_log("jira_get_queue_filters error: " . $e->getMessage());
return [];
}
}
/**
* Haalt alle gekoppelde wachtrijfilter codes voor een gebruiker op (meervoudig)
*/
function jira_get_user_queue_preferences(int $userId): array {
try {
$db = get_db_connection();
$stmt = $db->prepare("SELECT default_jira_filter_codes, default_jira_filter_code FROM users WHERE id = ?");
$stmt->execute([$userId]);
$row = $stmt->fetch();
if (!$row) return [];
if (!empty($row['default_jira_filter_codes'])) {
$decoded = json_decode($row['default_jira_filter_codes'], true);
if (is_array($decoded)) {
return array_values(array_filter(array_map('strtoupper', array_map('trim', $decoded))));
}
$parts = explode(',', $row['default_jira_filter_codes']);
return array_values(array_filter(array_map('strtoupper', array_map('trim', $parts))));
}
if (!empty($row['default_jira_filter_code'])) {
return [strtoupper(trim($row['default_jira_filter_code']))];
}
return [];
} catch (Throwable $e) {
return [];
}
}
/**
* Haalt de eerste standaard wachtrijfilter voorkeur van een gebruiker op (backwards compatibility)
*/
function jira_get_user_queue_preference(int $userId): ?string {
$prefs = jira_get_user_queue_preferences($userId);
return !empty($prefs) ? $prefs[0] : null;
}
/**
* Haalt alle officiële statussen op uit de Atlassian Jira Cloud instantie
*/
function jira_fetch_statuses(string $domain, string $email, string $apiToken): array {
$cleanDomain = jira_normalize_domain($domain);
if (empty($cleanDomain) || empty($email) || empty($apiToken)) {
return [];
}
$cacheKey = "jira_statuses_" . md5($cleanDomain);
$cached = fociz_cache_get($cacheKey);
if (is_array($cached) && !empty($cached)) {
return $cached;
}
$authHeader = jira_get_auth_header($email, $apiToken);
$url = $cleanDomain . '/rest/api/3/status';
$response = fociz_http_request($url, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
if ($response['status'] === 404 || $response['status'] === 405) {
$urlV2 = $cleanDomain . '/rest/api/2/status';
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 10,
]);
}
$statuses = [];
if ($response['status'] === 200 && is_array($response['json'])) {
foreach ($response['json'] as $st) {
$catKey = strtolower($st['statusCategory']['key'] ?? 'indeterminate');
$catName = $st['statusCategory']['name'] ?? 'In uitvoering';
$statuses[] = [
'id' => (string)($st['id'] ?? ''),
'name' => $st['name'] ?? '',
'description' => $st['description'] ?? '',
'status_category' => $catKey, // 'new', 'indeterminate', 'done'
'category_name' => $catName,
'color' => $st['statusCategory']['colorName'] ?? '',
];
}
// Sorteer: To Do eerst, dan In Progress, dan Done
usort($statuses, function($a, $b) {
$order = ['new' => 1, 'indeterminate' => 2, 'done' => 3];
$oa = $order[$a['status_category']] ?? 2;
$ob = $order[$b['status_category']] ?? 2;
if ($oa !== $ob) return $oa - $ob;
return strcasecmp($a['name'], $b['name']);
});
fociz_cache_set($cacheKey, $statuses, 3600);
}
return $statuses;
}
/**
* Haalt issues op via een flexibele JQL query via Atlassian REST API
*/
function jira_fetch_issues(string $domain, string $email, string $apiToken, string $jql, int $maxResults = 50): array {
$cleanDomain = jira_normalize_domain($domain);
$authHeader = jira_get_auth_header($email, $apiToken);
$fieldsList = [
'*navigable',
'summary',
'status',
'priority',
'issuetype',
'parent',
'project',
'assignee',
'reporter',
'duedate',
'updated',
'created',
'description',
'labels',
'components',
'story_points',
'customfield_10016', // Story Points (Jira Cloud standaard)
'customfield_10028', // Story Points (alternatief veld sommige instanties)
];
// 1. Moderne Atlassian Jira Cloud endpoint (/rest/api/3/search/jql) conform CHANGE-2046 via POST
$url = $cleanDomain . '/rest/api/3/search/jql';
$payload = [
'jql' => $jql,
'maxResults' => min($maxResults, 100),
'fields' => $fieldsList,
];
$response = fociz_http_request($url, [
'method' => 'POST',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
'Content-Type' => 'application/json',
],
'body' => $payload,
'timeout' => 12,
]);
// 2. Fallback: GET /rest/api/3/search/jql met query parameters indien de instantie GET vereist
if ($response['status'] === 405 || $response['status'] === 400) {
$getParams = [
'jql' => $jql,
'maxResults' => min($maxResults, 100),
'fields' => implode(',', $fieldsList),
];
$urlGet = $cleanDomain . '/rest/api/3/search/jql?' . http_build_query($getParams);
$response = fociz_http_request($urlGet, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
}
// 3. Fallback naar API v2 voor on-premise Jira Server / Data Center installaties (waar /rest/api/2/search actief is)
if ($response['status'] === 404) {
$getParams = [
'jql' => $jql,
'maxResults' => min($maxResults, 100),
'fields' => implode(',', $fieldsList),
];
$urlV2 = $cleanDomain . '/rest/api/2/search?' . http_build_query($getParams);
$response = fociz_http_request($urlV2, [
'method' => 'GET',
'headers' => [
'Authorization' => $authHeader,
'Accept' => 'application/json',
],
'timeout' => 12,
]);
}
if ($response['status'] !== 200 || !isset($response['json']['issues'])) {
$errorMsg = $response['json']['errorMessages'][0] ?? ($response['json']['message'] ?? ($response['error'] ?? 'Kan Jira tickets niet ophalen'));
return [
'success' => false,
'error' => $errorMsg,
'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';
$statusCategory = $fields['status']['statusCategory']['key'] ?? 'indeterminate';
$priorityName = $fields['priority']['name'] ?? 'Medium';
$issueTypeName = $fields['issuetype']['name'] ?? 'Taak';
$issueTypeIcon = $fields['issuetype']['iconUrl'] ?? '';
$projectName = $fields['project']['name'] ?? 'Project';
$projectKey = $fields['project']['key'] ?? '';
$dueDate = $fields['duedate'] ?? null;
$updated = $fields['updated'] ?? null;
$created = $fields['created'] ?? null;
$assigneeName = $fields['assignee']['displayName'] ?? 'Niet toegewezen';
$assigneeAvatar = $fields['assignee']['avatarUrls']['48x48'] ?? ($fields['assignee']['avatarUrls']['32x32'] ?? '');
$reporterName = $fields['reporter']['displayName'] ?? 'Onbekend';
$reporterAvatar = $fields['reporter']['avatarUrls']['48x48'] ?? ($fields['reporter']['avatarUrls']['32x32'] ?? '');
$descriptionText = jira_adf_to_text($fields['description'] ?? '');
// Detectie van custom field "Onderwerp logging" of routeringsveld
$onderwerpLogging = '';
$knownTopics = ['koppelingen', 'sso-koppeling', 'rest-endpoints', 'datadump', 'uwv-koppeling', 'migratie', 'incident', 'vraag', 'algemeen'];
foreach ($fields as $fKey => $fVal) {
if (str_starts_with($fKey, 'customfield_')) {
if (is_array($fVal)) {
if (isset($fVal['value']) && is_string($fVal['value'])) {
$valStr = trim($fVal['value']);
if (!empty($valStr)) {
foreach ($knownTopics as $kt) {
if (str_contains(strtolower($valStr), $kt)) {
$onderwerpLogging = $valStr;
break 2;
}
}
if (empty($onderwerpLogging) && (stripos($fKey, 'onderwerp') !== false || stripos($fKey, 'logging') !== false)) {
$onderwerpLogging = $valStr;
}
}
}
} elseif (is_string($fVal) && !empty($fVal)) {
$valStr = trim($fVal);
foreach ($knownTopics as $kt) {
if (str_contains(strtolower($valStr), $kt)) {
$onderwerpLogging = $valStr;
break 2;
}
}
}
}
}
if (empty($onderwerpLogging) && !empty($fields['onderwerp_logging'])) {
$onderwerpLogging = is_string($fields['onderwerp_logging']) ? $fields['onderwerp_logging'] : ($fields['onderwerp_logging']['value'] ?? '');
}
// Fallback: kijk naar labels & componenten
if (empty($onderwerpLogging) && !empty($fields['labels']) && is_array($fields['labels'])) {
foreach ($fields['labels'] as $lbl) {
$lblLower = strtolower(trim($lbl));
foreach ($knownTopics as $kt) {
if (str_contains($lblLower, $kt)) {
$onderwerpLogging = ucfirst($kt);
break 2;
}
}
}
}
// Fallback: detecteer onderwerp op basis van steekwoorden in summary
if (empty($onderwerpLogging)) {
$sumLower = strtolower($summary);
if (str_contains($sumLower, 'sso')) $onderwerpLogging = 'SSO-koppeling';
elseif (str_contains($sumLower, 'endpoint') || str_contains($sumLower, 'rest') || str_contains($sumLower, 'api')) $onderwerpLogging = 'Rest-endpoints';
elseif (str_contains($sumLower, 'uwv')) $onderwerpLogging = 'UWV-koppeling';
elseif (str_contains($sumLower, 'datadump') || str_contains($sumLower, 'dump')) $onderwerpLogging = 'Datadump';
elseif (str_contains($sumLower, 'migratie')) $onderwerpLogging = 'Migratie';
elseif (str_contains($sumLower, 'koppeling')) $onderwerpLogging = 'Koppelingen';
}
// Detectie van story points (customfield_10016 is Jira Cloud standaard, met fallback naar alternatieven)
$storyPoints = null;
if (isset($fields['customfield_10016']) && is_numeric($fields['customfield_10016'])) {
$storyPoints = (float)$fields['customfield_10016'];
} elseif (isset($fields['customfield_10028']) && is_numeric($fields['customfield_10028'])) {
$storyPoints = (float)$fields['customfield_10028'];
} elseif (isset($fields['story_points']) && is_numeric($fields['story_points'])) {
$storyPoints = (float)$fields['story_points'];
}
$issues[] = [
'key' => $key,
'summary' => $summary,
'description' => $descriptionText,
'status' => $statusName,
'status_category' => $statusCategory,
'priority' => $priorityName,
'issue_type' => $issueTypeName,
'issue_type_icon' => $issueTypeIcon,
'project' => $projectName,
'project_key' => $projectKey,
'assignee_name' => $assigneeName,
'assignee_avatar' => $assigneeAvatar,
'reporter_name' => $reporterName,
'reporter_avatar' => $reporterAvatar,
'onderwerp_logging' => $onderwerpLogging,
'due_date' => $dueDate,
'created' => $created,
'updated' => $updated,
'url' => "{$cleanDomain}/browse/{$key}",
'story_points' => $storyPoints,
'source' => 'JIRA_LIVE',
];
}
return [
'success' => true,
'issues' => $issues,
'total' => count($issues),
];
}
/**
* 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 = 100): array {
$jql = 'assignee = currentUser() ORDER BY updated DESC';
return jira_fetch_issues($domain, $email, $apiToken, $jql, $maxResults);
}
/**
* Haalt openstaande team issues op die horen bij de projecten van de gebruiker
*/
function jira_fetch_team_issues(string $domain, string $email, string $apiToken, int $maxResults = 100): array {
$jql = 'assignee != currentUser() AND statusCategory != Done ORDER BY updated DESC';
return jira_fetch_issues($domain, $email, $apiToken, $jql, $maxResults);
}
/**
* Haalt de complete dashboard data op (persoonlijk + team) inclusief statistieken
*/
function jira_get_dashboard_data(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();
// Ophalen van actieve wachtrijfilters en voorkeuren van gebruiker
$queueFilters = jira_get_queue_filters(true);
$userQueuePref = jira_get_user_queue_preference($userId);
$userQueuePrefs = jira_get_user_queue_preferences($userId);
// Sandbox / Demo dataset indien niet gekoppeld of expliciet in demomodus
if (!$integration || !empty($integration['is_demo_mode'])) {
$demoStatuses = [
['id' => '1', 'name' => 'To Do', 'status_category' => 'new', 'category_name' => 'Nieuw'],
['id' => '2', 'name' => 'Nieuw', 'status_category' => 'new', 'category_name' => 'Nieuw'],
['id' => '3', 'name' => 'In behandeling', 'status_category' => 'indeterminate', 'category_name' => 'In uitvoering'],
['id' => '4', 'name' => 'Wachten op klant', 'status_category' => 'indeterminate', 'category_name' => 'In uitvoering'],
['id' => '5', 'name' => 'In Review', 'status_category' => 'indeterminate', 'category_name' => 'In uitvoering'],
['id' => '6', 'name' => 'Gereed', 'status_category' => 'done', 'category_name' => 'Gereed'],
['id' => '7', 'name' => 'Gesloten', 'status_category' => 'done', 'category_name' => 'Gereed'],
];
$assigned = [
[
'key' => 'SD-401',
'summary' => 'AFAS Profit SSO-koppeling geeft 401 fout bij token refresh',
'description' => "Gebruikers die via AFAS SSO inloggen krijgen periodiek een HTTP 401 foutcode bij de geautomatiseerde token verversing.\n\nHet endpoint `/oauth/token` reageert met `invalid_grant` zodra de refresh token ouder is dan 24 uur.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'Highest',
'issue_type' => 'Bug',
'project' => 'Servicedesk',
'project_key' => 'SD',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'Kees van de Berg (AFAS Beheer)',
'reporter_avatar' => '',
'onderwerp_logging' => 'SSO-koppeling',
'due_date' => date('Y-m-d', strtotime('+1 day')),
'created' => date('Y-m-d H:i:s', strtotime('-1 day')),
'updated' => date('Y-m-d H:i:s', strtotime('-45 minutes')),
'url' => 'https://jira.atlassian.net/browse/SD-401',
'story_points' => 3,
'source' => 'SANDBOX',
],
[
'key' => 'SD-405',
'summary' => 'REST-endpoints voor medewerker synchronisatie beveiligen met rate limiting',
'description' => "De openbare REST-endpoints voor medewerkerssynchronisatie dienen conform security richtlijnen te worden voorzien van rate limiting.",
'status' => 'To Do',
'status_category' => 'new',
'priority' => 'High',
'issue_type' => 'Task',
'project' => 'Servicedesk',
'project_key' => 'SD',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'Security Team',
'reporter_avatar' => '',
'onderwerp_logging' => 'Rest-endpoints',
'due_date' => date('Y-m-d', strtotime('+3 days')),
'created' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-2 hours')),
'url' => 'https://jira.atlassian.net/browse/SD-405',
'story_points' => 5,
'source' => 'SANDBOX',
],
[
'key' => 'IN-201',
'summary' => 'Exact Online REST API webhook connectie configureren',
'description' => "Configuratie van de webhook triggers voor realtime debiteuren synchronisatie tussen Fociz en Exact Online.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'High',
'issue_type' => 'Story',
'project' => 'Interfaces',
'project_key' => 'IN',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'Financieel Beheer',
'reporter_avatar' => '',
'onderwerp_logging' => 'Koppelingen',
'due_date' => date('Y-m-d', strtotime('+2 days')),
'created' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-30 minutes')),
'url' => 'https://jira.atlassian.net/browse/IN-201',
'story_points' => 8,
'source' => 'SANDBOX',
],
[
'key' => 'IN-205',
'summary' => 'OAuth 2.0 PKCE handshake voor mobiele koppeling afronden',
'description' => "Migreer de verouderde implicit flow naar de PKCE authorisatie flow voor alle mobiele apparaten.",
'status' => 'To Do',
'status_category' => 'new',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Interfaces',
'project_key' => 'IN',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'Tech Lead',
'reporter_avatar' => '',
'onderwerp_logging' => 'SSO-koppeling',
'due_date' => date('Y-m-d', strtotime('+4 days')),
'created' => date('Y-m-d H:i:s', strtotime('-4 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-1 day')),
'url' => 'https://jira.atlassian.net/browse/IN-205',
'story_points' => 5,
'source' => 'SANDBOX',
],
[
'key' => 'AAN-102',
'summary' => 'Aanvraag nieuwe laptop en ergonomische thuiswerkplek beeldschermen',
'description' => "Nieuwe onboarding developer heeft MacBook Pro 16 inch en 2x 4K schermen nodig conform standaardpakket.",
'status' => 'Wachten op klant',
'status_category' => 'indeterminate',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Aanvragen',
'project_key' => 'AAN',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'HR Onboarding',
'reporter_avatar' => '',
'onderwerp_logging' => 'Hardware',
'due_date' => date('Y-m-d', strtotime('+5 days')),
'created' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-3 hours')),
'url' => 'https://jira.atlassian.net/browse/AAN-102',
'story_points' => 2,
'source' => 'SANDBOX',
],
[
'key' => 'AAN-108',
'summary' => 'Autorisatieverzoek rechten AFAS financieel beheer teamleider',
'description' => "Goedkeuring benodigd voor toekennen van geautoriseerde rollen in AFAS Profit.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'High',
'issue_type' => 'Task',
'project' => 'Aanvragen',
'project_key' => 'AAN',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'Teamleider Financiën',
'reporter_avatar' => '',
'onderwerp_logging' => 'Rechten',
'due_date' => date('Y-m-d'),
'created' => date('Y-m-d H:i:s', strtotime('-1 day')),
'updated' => date('Y-m-d H:i:s', strtotime('-1 hour')),
'url' => 'https://jira.atlassian.net/browse/AAN-108',
'story_points' => 1,
'source' => 'SANDBOX',
],
[
'key' => 'GEN-301',
'summary' => 'Jaarlijkse ISO-27001 interne security review voorbereiden',
'description' => "Samenstellen van security checklists, access reviews en back-up logs voor auditcommissie.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'High',
'issue_type' => 'Task',
'project' => 'General',
'project_key' => 'GEN',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'CISO',
'reporter_avatar' => '',
'onderwerp_logging' => 'Beveiliging',
'due_date' => date('Y-m-d', strtotime('+6 days')),
'created' => date('Y-m-d H:i:s', strtotime('-5 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-4 hours')),
'url' => 'https://jira.atlassian.net/browse/GEN-301',
'story_points' => 13,
'source' => 'SANDBOX',
],
[
'key' => 'GEN-305',
'summary' => 'Kantoorruimte vergadertafel opstelling aanpassen voor hybride team',
'description' => "Herinrichting van de videovergaderruimte met Yealink meetingbar.",
'status' => 'Gesloten',
'status_category' => 'done',
'priority' => 'Low',
'issue_type' => 'Task',
'project' => 'General',
'project_key' => 'GEN',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'Facility Management',
'reporter_avatar' => '',
'onderwerp_logging' => 'Facilitair',
'due_date' => date('Y-m-d', strtotime('-2 days')),
'created' => date('Y-m-d H:i:s', strtotime('-8 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-1 day')),
'url' => 'https://jira.atlassian.net/browse/GEN-305',
'story_points' => 1,
'source' => 'SANDBOX',
],
[
'key' => 'SD-380',
'summary' => 'Wachtwoord reset e-mail komt niet aan bij nieuwe medewerker',
'description' => "Controleer de SMTP-verbinding van de mailserver mail.fociz.nl en controleer of het mailadres in de spamfilter is beland.",
'status' => 'Gesloten',
'status_category' => 'done',
'priority' => 'High',
'issue_type' => 'Task',
'project' => 'Servicedesk',
'project_key' => 'SD',
'assignee_name' => 'Mijzelf',
'assignee_avatar' => '',
'reporter_name' => 'HR Servicedesk',
'reporter_avatar' => '',
'onderwerp_logging' => 'EMPTY',
'due_date' => date('Y-m-d', strtotime('-1 day')),
'created' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-6 hours')),
'url' => 'https://jira.atlassian.net/browse/SD-380',
'story_points' => 2,
'source' => 'SANDBOX',
],
];
$team = [
[
'key' => 'SD-408',
'summary' => 'UWV-koppeling polisadministratie import loopt vast op datumformaat',
'description' => "De XML batch import vanuit het UWV portaal faalt op records met afwijkende geboortedatumnotatie.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'High',
'issue_type' => 'Bug',
'project' => 'Servicedesk',
'project_key' => 'SD',
'assignee_name' => 'Lisa Bakker',
'assignee_avatar' => '',
'reporter_name' => 'Servicedesk L2',
'reporter_avatar' => '',
'onderwerp_logging' => 'UWV-koppeling',
'due_date' => date('Y-m-d', strtotime('+2 days')),
'created' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-1 hour')),
'url' => 'https://jira.atlassian.net/browse/SD-408',
'story_points' => 5,
'source' => 'SANDBOX',
],
[
'key' => 'SD-412',
'summary' => 'Datadump archief export voor externe accountant audit 2025',
'description' => "Genereer een beveiligde, versleutelde datadump van alle audit-logs en urenstaten voor de periodieke controle.",
'status' => 'To Do',
'status_category' => 'new',
'priority' => 'Medium',
'issue_type' => 'Story',
'project' => 'Servicedesk',
'project_key' => 'SD',
'assignee_name' => 'Daan van Dijk',
'assignee_avatar' => '',
'reporter_name' => 'Compliance Officer',
'reporter_avatar' => '',
'onderwerp_logging' => 'Datadump',
'due_date' => date('Y-m-d', strtotime('+6 days')),
'created' => date('Y-m-d H:i:s', strtotime('-3 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-4 hours')),
'url' => 'https://jira.atlassian.net/browse/SD-412',
'story_points' => 3,
'source' => 'SANDBOX',
],
[
'key' => 'IN-210',
'summary' => 'Koppelingen API webhook notificaties afleveren met retry-mechanisme',
'description' => "Webhooks die naar externe partnersystemen worden verzonden moeten bij een tijdelijke 5xx fout automatisch 3x opnieuw worden aangeboden.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Interfaces',
'project_key' => 'IN',
'assignee_name' => 'Sophie de Vries',
'assignee_avatar' => '',
'reporter_name' => 'Integratie Specialist',
'reporter_avatar' => '',
'onderwerp_logging' => 'Koppelingen',
'due_date' => date('Y-m-d', strtotime('+4 days')),
'created' => date('Y-m-d H:i:s', strtotime('-5 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-3 hours')),
'url' => 'https://jira.atlassian.net/browse/IN-210',
'story_points' => 5,
'source' => 'SANDBOX',
],
[
'key' => 'IN-215',
'summary' => 'Specificatie documentatie voor externe partner API opvragen',
'description' => "Opvragen van de actuele OpenAPI v3 JSON specificatie bij leverancier.",
'status' => 'Wachten op klant',
'status_category' => 'indeterminate',
'priority' => 'Low',
'issue_type' => 'Task',
'project' => 'Interfaces',
'project_key' => 'IN',
'assignee_name' => 'Sophie de Vries',
'assignee_avatar' => '',
'reporter_name' => 'Partner Manager',
'reporter_avatar' => '',
'onderwerp_logging' => 'Koppelingen',
'due_date' => date('Y-m-d', strtotime('+7 days')),
'created' => date('Y-m-d H:i:s', strtotime('-4 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-2 hours')),
'url' => 'https://jira.atlassian.net/browse/IN-215',
'story_points' => 1,
'source' => 'SANDBOX',
],
[
'key' => 'AAN-110',
'summary' => 'Aanvraag extra Microsoft 365 E5 licenties voor marketing team',
'description' => "Bestelling en activering van 5 aanvullende E5 licenties voor het datateam.",
'status' => 'To Do',
'status_category' => 'new',
'priority' => 'Medium',
'issue_type' => 'Task',
'project' => 'Aanvragen',
'project_key' => 'AAN',
'assignee_name' => 'Thomas Jansen',
'assignee_avatar' => '',
'reporter_name' => 'Marketing Director',
'reporter_avatar' => '',
'onderwerp_logging' => 'Licenties',
'due_date' => date('Y-m-d', strtotime('+3 days')),
'created' => date('Y-m-d H:i:s', strtotime('-2 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-1 day')),
'url' => 'https://jira.atlassian.net/browse/AAN-110',
'story_points' => 2,
'source' => 'SANDBOX',
],
[
'key' => 'AAN-115',
'summary' => 'Aanvraag tijdelijke VPN toegang externe auditor KPMG',
'description' => "Toegang configureren met verloopdatum 30 september en MFA verplichting.",
'status' => 'Gesloten',
'status_category' => 'done',
'priority' => 'High',
'issue_type' => 'Task',
'project' => 'Aanvragen',
'project_key' => 'AAN',
'assignee_name' => 'Bram Meijer',
'assignee_avatar' => '',
'reporter_name' => 'Security Officer',
'reporter_avatar' => '',
'onderwerp_logging' => 'Rechten',
'due_date' => date('Y-m-d', strtotime('-3 days')),
'created' => date('Y-m-d H:i:s', strtotime('-7 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-2 days')),
'url' => 'https://jira.atlassian.net/browse/AAN-115',
'story_points' => 2,
'source' => 'SANDBOX',
],
[
'key' => 'GEN-310',
'summary' => 'Organisatiebeleid hybride werken 2026 actualiseren',
'description' => "Aanpassing van reiskostenvergoeding en thuiswerkvergoeding conform nieuwe cao-afspraken.",
'status' => 'In behandeling',
'status_category' => 'indeterminate',
'priority' => 'Medium',
'issue_type' => 'Story',
'project' => 'General',
'project_key' => 'GEN',
'assignee_name' => 'Lisa Bakker',
'assignee_avatar' => '',
'reporter_name' => 'Directie',
'reporter_avatar' => '',
'onderwerp_logging' => 'Algemeen',
'due_date' => date('Y-m-d', strtotime('+10 days')),
'created' => date('Y-m-d H:i:s', strtotime('-6 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-1 day')),
'url' => 'https://jira.atlassian.net/browse/GEN-310',
'story_points' => 5,
'source' => 'SANDBOX',
],
[
'key' => 'GEN-315',
'summary' => 'Kwartaalplanning en doelstellingen inventarisatie Q4',
'description' => "Verzamel OKR's van alle teams voor de centrale kwartaalpresentatie.",
'status' => 'To Do',
'status_category' => 'new',
'priority' => 'High',
'issue_type' => 'Story',
'project' => 'General',
'project_key' => 'GEN',
'assignee_name' => 'Bram Meijer',
'assignee_avatar' => '',
'reporter_name' => 'Operations Lead',
'reporter_avatar' => '',
'onderwerp_logging' => 'Algemeen',
'due_date' => date('Y-m-d', strtotime('+8 days')),
'created' => date('Y-m-d H:i:s', strtotime('-4 days')),
'updated' => date('Y-m-d H:i:s', strtotime('-5 hours')),
'url' => 'https://jira.atlassian.net/browse/GEN-315',
'story_points' => 8,
'source' => 'SANDBOX',
],
];
return [
'is_demo' => true,
'success' => true,
'domain' => 'sandbox.atlassian.net',
'email' => 'demo@fociz.nl',
'account_name' => 'Demo Account',
'last_sync_at' => date('Y-m-d H:i:s'),
'assigned_issues' => $assigned,
'team_issues' => $team,
'queue_filters' => $queueFilters,
'default_queue_filter' => $userQueuePref,
'user_queue_filters' => $userQueuePrefs,
'available_statuses' => $demoStatuses,
];
}
$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', '');
$apiTokenRaw = $settings['jira_api_token'] ?? fociz_env('JIRA_API_TOKEN', '');
$apiToken = fociz_decrypt($apiTokenRaw);
$accName = $settings['account_name'] ?? 'Atlassian Gebruiker';
if (empty($domain) || empty($email) || empty($apiToken)) {
return [
'is_demo' => false,
'success' => false,
'error' => 'Jira configuratie is onvolledig. Controleer je instellingen.',
'assigned_issues' => [],
'team_issues' => [],
];
}
// Cache controle (60 seconden)
$cacheKey = "jira_dashboard_user_{$userId}";
if (!$forceRefresh) {
$cachedData = fociz_cache_get($cacheKey);
if ($cachedData !== null && is_array($cachedData)) {
return array_merge($cachedData, ['from_cache' => true]);
}
if (session_status() === PHP_SESSION_ACTIVE && !empty($_SESSION[$cacheKey]['data'])) {
if (($_SESSION[$cacheKey]['timestamp'] ?? 0) > (time() - 60)) {
return array_merge($_SESSION[$cacheKey]['data'], ['from_cache' => true]);
}
}
}
// Ophalen van toegewezen taken (max 100)
$assignedRes = jira_fetch_assigned_issues($domain, $email, $apiToken, 100);
// Ophalen van teamtaken (max 100)
$teamRes = jira_fetch_team_issues($domain, $email, $apiToken, 100);
// Ophalen van Jira statussen
$availableStatuses = jira_fetch_statuses($domain, $email, $apiToken);
if (!$assignedRes['success'] && !$teamRes['success']) {
return [
'is_demo' => false,
'success' => false,
'error' => $assignedRes['error'] ?? 'Kan Jira tickets niet ophalen.',
'assigned_issues' => [],
'team_issues' => [],
];
}
$assignedIssues = $assignedRes['success'] ? $assignedRes['issues'] : [];
$teamIssues = $teamRes['success'] ? $teamRes['issues'] : [];
$data = [
'is_demo' => false,
'success' => true,
'domain' => $domain,
'email' => $email,
'account_name' => $accName,
'last_sync_at' => date('Y-m-d H:i:s'),
'assigned_issues' => $assignedIssues,
'team_issues' => $teamIssues,
'queue_filters' => $queueFilters,
'default_queue_filter' => $userQueuePref,
'user_queue_filters' => $userQueuePrefs,
'available_statuses' => $availableStatuses,
];
fociz_cache_set($cacheKey, $data, 60);
if (session_status() === PHP_SESSION_ACTIVE) {
$_SESSION[$cacheKey] = [
'timestamp' => time(),
'data' => $data,
];
}
// Werk last_sync_at bij in database
$stmtUpdate = $db->prepare("UPDATE integrations SET last_sync_at = NOW() WHERE id = ?");
$stmtUpdate->execute([$integration['id']]);
return $data;
}
/**
* Haalt de Jira tickets op voor een Fociz gebruiker (backwards compatibility)
*/
function jira_get_user_issues(int $userId, bool $forceRefresh = false): array {
$data = jira_get_dashboard_data($userId, $forceRefresh);
return [
'is_demo' => $data['is_demo'] ?? false,
'success' => $data['success'] ?? false,
'error' => $data['error'] ?? null,
'issues' => $data['assigned_issues'] ?? [],
'from_cache' => $data['from_cache'] ?? false,
];
}
/**
* 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}"]);
unset($_SESSION["jira_dashboard_user_{$userId}"]);
fociz_cache_delete("jira_dashboard_user_{$userId}");
return $stmt->execute([$userId]);
}