'Nouveau', 2 => 'En cours (assigné)', 3 => 'En cours (planifié)', 4 => 'En attente', 5 => 'Résolu', 6 => 'Clos', ]; public const STATUS_CLOSED = 6; public const PRIORITIES = [ 1 => 'Très basse', 2 => 'Basse', 3 => 'Moyenne', 4 => 'Haute', 5 => 'Très haute', 6 => 'Majeure', ]; protected string $baseUrl; protected string $appToken; protected string $userToken; public function __construct() { $this->baseUrl = rtrim((string) config('glpi.api_url'), '/'); $this->appToken = (string) config('glpi.app_token'); $this->userToken = (string) config('glpi.user_token'); } public function isConfigured(): bool { return $this->baseUrl !== '' && $this->appToken !== '' && $this->userToken !== ''; } /** * The GLPI front-end base URL (for building links to tickets), derived * from the REST API URL by dropping the trailing "apirest.php". */ public function frontUrl(): string { return preg_replace('#/apirest\.php$#', '', $this->baseUrl); } public function ticketUrl(int $ticketId): string { return $this->frontUrl()."/front/ticket.form.php?id={$ticketId}"; } /** * Recent tickets for a GLPI entity, most recently modified first. * * @return array{tickets: array>, total: int, error: ?string} */ public function recentTicketsForEntity(int $entityId, int $limit = 8): array { if (! $this->isConfigured()) { return ['tickets' => [], 'total' => 0, 'error' => null]; } return Cache::remember( "glpi.tickets.entity.{$entityId}.{$limit}", (int) config('glpi.cache_ttl_seconds', 60), function () use ($entityId, $limit) { try { return $this->fetchRecentTickets($entityId, $limit); } catch (Throwable $e) { Log::warning('GLPI: échec de récupération des tickets', [ 'entity_id' => $entityId, 'message' => $e->getMessage(), ]); return ['tickets' => [], 'total' => 0, 'error' => "Impossible de joindre GLPI pour le moment."]; } } ); } /** * @return array{tickets: array>, total: int, error: ?string} */ protected function fetchRecentTickets(int $entityId, int $limit): array { return $this->withSession(function (string $sessionToken) use ($entityId, $limit) { $response = Http::withHeaders($this->headers($sessionToken)) ->get("{$this->baseUrl}/search/Ticket", [ 'criteria' => [ ['field' => 80, 'searchtype' => 'equals', 'value' => $entityId], // GLPI's "notequals" searchtype is silently ignored by the search // engine; excluding a value only works via a negated AND link. ['link' => 'AND NOT', 'field' => 12, 'searchtype' => 'equals', 'value' => self::STATUS_CLOSED], ], 'forcedisplay' => [2, 1, 12, 3, 7, 21, 19], 'sort' => 19, 'order' => 'DESC', 'range' => '0-'.($limit - 1), ]); if ($response->failed()) { throw new GlpiException("GLPI a répondu avec une erreur (HTTP {$response->status()})."); } $rows = $response->json('data') ?? []; $tickets = collect($rows)->map(fn (array $row) => [ 'id' => $row['2'], 'title' => $this->decodeText((string) $row['1']), 'status' => (int) $row['12'], 'status_label' => self::STATUSES[(int) $row['12']] ?? 'Inconnu', 'category' => filled($row['7'] ?? null) ? $this->decodeText((string) $row['7']) : null, 'excerpt' => $this->excerpt($row['21'] ?? null), 'priority' => (int) $row['3'], 'priority_label' => self::PRIORITIES[(int) $row['3']] ?? 'Inconnue', 'date_mod' => $row['19'], 'url' => $this->ticketUrl((int) $row['2']), ])->all(); return [ 'tickets' => $tickets, 'total' => (int) ($response->json('totalcount') ?? count($tickets)), 'error' => null, ]; }); } /** * GLPI's ticket description comes back as HTML, itself HTML-entity-encoded * (e.g. "<p>" for "

"). Strip markup and trim to a short * plain-text preview. */ protected function excerpt(?string $html, int $length = 160): ?string { if (blank($html)) { return null; } $text = strip_tags($this->decodeText($html)); $text = trim(preg_replace('/\s+/', ' ', $text)); return $text === '' ? null : \Illuminate\Support\Str::limit($text, $length); } /** * Some GLPI fields (rich-text description, titles typed via a rich editor) * are HTML-entity-encoded twice over: once because the stored content is * itself escaped HTML (e.g. a literal "&"), and again because the * search API encodes the whole field for its JSON response. A single * decode leaves artifacts like "&" in the output, so decode twice — * harmless no-op on text that was only encoded once. */ protected function decodeText(string $value): string { return html_entity_decode(html_entity_decode($value, ENT_QUOTES), ENT_QUOTES); } /** * Opens a GLPI session, runs the callback with the session token, and * always closes the session afterwards — GLPI has no stateless auth mode * for the REST API, so every call pays this round trip. */ protected function withSession(\Closure $callback): mixed { $session = Http::withHeaders([ 'App-Token' => $this->appToken, 'Authorization' => 'user_token '.$this->userToken, ])->get("{$this->baseUrl}/initSession"); if ($session->failed()) { throw new GlpiException("Échec de connexion à GLPI (HTTP {$session->status()})."); } $sessionToken = $session->json('session_token'); try { return $callback($sessionToken); } finally { Http::withHeaders($this->headers($sessionToken))->get("{$this->baseUrl}/killSession"); } } /** * @return array */ protected function headers(string $sessionToken): array { return [ 'App-Token' => $this->appToken, 'Session-Token' => $sessionToken, ]; } }