diff --git a/app/Models/Location.php b/app/Models/Location.php index ecd27ee..7c9a418 100644 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Model; #[Fillable([ 'organization_id', 'name', 'address_line_1', 'address_line_2', 'city', 'region', 'postal_code', 'country', 'phone', 'is_primary', + 'latitude', 'longitude', ])] class Location extends Model { @@ -15,6 +16,8 @@ class Location extends Model { return [ 'is_primary' => 'boolean', + 'latitude' => 'float', + 'longitude' => 'float', ]; } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 8919eca..8433cb8 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Storage; -#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path'])] +#[Fillable(['name', 'slug', 'org_type', 'status', 'description', 'allowed_ip_ranges', 'logo_path', 'glpi_entity_id'])] class Organization extends Model { use SoftDeletes; diff --git a/app/Services/Glpi/GlpiClient.php b/app/Services/Glpi/GlpiClient.php new file mode 100644 index 0000000..ce7a2be --- /dev/null +++ b/app/Services/Glpi/GlpiClient.php @@ -0,0 +1,205 @@ + '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, + ]; + } +} diff --git a/app/Services/Glpi/GlpiException.php b/app/Services/Glpi/GlpiException.php new file mode 100644 index 0000000..4ae35f8 --- /dev/null +++ b/app/Services/Glpi/GlpiException.php @@ -0,0 +1,9 @@ + env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), - 'busy_timeout' => null, - 'journal_mode' => null, + 'busy_timeout' => env('DB_BUSY_TIMEOUT', 5000), + 'journal_mode' => env('DB_JOURNAL_MODE', 'wal'), 'synchronous' => null, 'transaction_mode' => 'DEFERRED', ], diff --git a/config/glpi.php b/config/glpi.php new file mode 100644 index 0000000..94640aa --- /dev/null +++ b/config/glpi.php @@ -0,0 +1,16 @@ + env('GLPI_API_URL'), + + // Identifies this application to GLPI (Setup > General > API > API clients). + 'app_token' => env('GLPI_APP_TOKEN'), + + // Authenticates as a GLPI account (ideally a dedicated, least-privilege + // service account) — generated from that user's "API tokens" tab. + 'user_token' => env('GLPI_USER_TOKEN'), + + // How long ticket listings are cached before hitting the GLPI API again. + 'cache_ttl_seconds' => env('GLPI_CACHE_TTL', 60), +]; diff --git a/database/migrations/2026_08_17_100000_add_coordinates_to_locations_table.php b/database/migrations/2026_08_17_100000_add_coordinates_to_locations_table.php new file mode 100644 index 0000000..6fc3007 --- /dev/null +++ b/database/migrations/2026_08_17_100000_add_coordinates_to_locations_table.php @@ -0,0 +1,23 @@ +decimal('latitude', 10, 7)->nullable()->after('country'); + $table->decimal('longitude', 10, 7)->nullable()->after('latitude'); + }); + } + + public function down(): void + { + Schema::table('locations', function (Blueprint $table) { + $table->dropColumn(['latitude', 'longitude']); + }); + } +}; diff --git a/database/migrations/2026_08_18_090000_add_glpi_entity_id_to_organizations_table.php b/database/migrations/2026_08_18_090000_add_glpi_entity_id_to_organizations_table.php new file mode 100644 index 0000000..63946f5 --- /dev/null +++ b/database/migrations/2026_08_18_090000_add_glpi_entity_id_to_organizations_table.php @@ -0,0 +1,22 @@ +unsignedInteger('glpi_entity_id')->nullable()->after('allowed_ip_ranges'); + }); + } + + public function down(): void + { + Schema::table('organizations', function (Blueprint $table) { + $table->dropColumn('glpi_entity_id'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index df64529..57b03fc 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use App\Models\User; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { @@ -19,10 +20,14 @@ class DatabaseSeeder extends Seeder $this->call(RolesAndPermissionsSeeder::class); - $admin = User::factory()->create([ - 'name' => 'Administrateur', - 'email' => 'administrateur@cabmit.local', - ]); + $admin = User::firstOrCreate( + ['email' => 'administrateur@cabmit.local'], + [ + 'name' => 'Administrateur', + 'email_verified_at' => now(), + 'password' => Hash::make('password'), + ], + ); $admin->assignRole('Administrateur'); $this->call(AssetTypeLookupSeeder::class); diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index 186e3d7..45357e0 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Role; +use Spatie\Permission\PermissionRegistrar; class RolesAndPermissionsSeeder extends Seeder { @@ -22,6 +23,12 @@ class RolesAndPermissionsSeeder extends Seeder Permission::findOrCreate($permission); } + // DatabaseSeeder uses WithoutModelEvents, which silences the + // saved/deleted events Spatie relies on to invalidate its + // permission cache. Reset it manually so newly created + // permissions are visible below. + app(PermissionRegistrar::class)->forgetCachedPermissions(); + $admin = Role::findOrCreate('Administrateur'); $admin->syncPermissions($permissions); diff --git a/resources/views/components/assets/⚡index.blade.php b/resources/views/components/assets/⚡index.blade.php index a6f195b..660b55d 100644 --- a/resources/views/components/assets/⚡index.blade.php +++ b/resources/views/components/assets/⚡index.blade.php @@ -25,6 +25,22 @@ new class extends Component session()->flash('status', "\"{$asset->name}\" supprimé."); } + public function duplicate(int $assetId): void + { + $asset = $this->organization->flexibleAssets()->findOrFail($assetId); + + $copy = $this->organization->flexibleAssets()->create([ + 'flexible_asset_type_id' => $asset->flexible_asset_type_id, + 'name' => "{$asset->name} (copie)", + 'data' => $asset->data, + 'created_by' => auth()->id(), + ]); + + session()->flash('status', "\"{$asset->name}\" dupliqué."); + + $this->redirectRoute('organizations.assets.edit', [$this->organization, $this->type, $copy], navigate: true); + } + public function with(): array { $listFields = $this->type->fields()->where('show_in_list', true)->get(); @@ -94,6 +110,11 @@ new class extends Component @endforeach Modifier + - + @@ -189,12 +202,26 @@ new class extends Component

Laisser vide pour autoriser l'accès depuis n'importe quelle IP.

+
+ + +

Lie cette organisation à une entité GLPI pour afficher ses tickets sur le tableau de bord. Laisser vide pour désactiver.

+ @error('glpi_entity_id')

{{ $message }}

@enderror +
+
- - Annuler + Annuler
@@ -218,6 +245,8 @@ new class extends Component dragOffsetStartY: 0, fileName: 'logo.png', fileType: 'image/png', + uploading: false, + uploadError: null, onFileSelected(event) { const file = event.target.files[0]; @@ -311,6 +340,8 @@ new class extends Component canvas.height = viewport; const ctx = canvas.getContext('2d'); + this.uploadError = null; + const img = new Image(); img.onload = () => { ctx.drawImage( @@ -324,10 +355,15 @@ new class extends Component canvas.toBlob((blob) => { const croppedFile = new File([blob], this.fileName, { type: this.fileType }); + this.uploading = true; wire.$upload('logo', croppedFile, () => { + this.uploading = false; this.cropping = false; this.imageSrc = null; - }); + }, () => { + this.uploading = false; + this.uploadError = "Échec de l'envoi de l'image. Réessayez."; + }, () => {}); }, this.fileType); }; img.src = this.imageSrc;