diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b15fe63de014..0f711b41ffdc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,7 @@ # GitHub Copilot Custom Instructions for Snipe-IT -These instructions guide Copilot to generate code that aligns with modern Laravel 11 standards, PHP 8.2/8.4 features, software engineering principles, and industry best practices to improve software quality, maintainability, and security. +These instructions guide Copilot to generate code that aligns with modern Laravel 12 standards, PHP 8.2/8.4 features, +software engineering principles, and industry best practices to improve software quality, maintainability, and security. ## ✅ General Coding Standards @@ -22,7 +23,7 @@ These instructions guide Copilot to generate code that aligns with modern Larave - Adopt **final classes** where extension is not intended. - Use **Named Arguments** for improved clarity when calling functions with multiple parameters. -## ✅ Laravel 11 Project Structure & Conventions +## ✅ Laravel 12 Project Structure & Conventions - Follow the official Laravel project structure: - `app/Http/Controllers` - Controllers @@ -32,6 +33,7 @@ These instructions guide Copilot to generate code that aligns with modern Larave - `app/Enums` - Enums - `app/Actions` - Single-responsibility action classes - `app/Policies` - Authorization logic + - `app/Models/Builders` - Query scoping logic - Controllers must: - Use dependency injection. diff --git a/app/Mcp/Prompts/AuditLocationPrompt.php b/app/Mcp/Prompts/AuditLocationPrompt.php new file mode 100644 index 000000000000..276f92bee954 --- /dev/null +++ b/app/Mcp/Prompts/AuditLocationPrompt.php @@ -0,0 +1,45 @@ +get('location'); + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('location', 'Name or ID of the location to audit', required: true), + ]; + } +} diff --git a/app/Mcp/Prompts/EndOfLifeReviewPrompt.php b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php new file mode 100644 index 000000000000..cd756494228b --- /dev/null +++ b/app/Mcp/Prompts/EndOfLifeReviewPrompt.php @@ -0,0 +1,54 @@ +get('department'); + $category = $request->get('category'); + + $scope = collect([ + $department ? "department: {$department}" : null, + $category ? "category: {$category}" : null, + ])->filter()->implode(' and '); + + $scopeLine = $scope + ? "Limit the review to assets in {$scope}." + : 'Review assets across the entire organisation.'; + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('department', 'Limit review to a specific department', required: false), + new Argument('category', 'Limit review to a specific asset category', required: false), + ]; + } +} diff --git a/app/Mcp/Prompts/ExpiringLicensesPrompt.php b/app/Mcp/Prompts/ExpiringLicensesPrompt.php new file mode 100644 index 000000000000..ac556e4286d4 --- /dev/null +++ b/app/Mcp/Prompts/ExpiringLicensesPrompt.php @@ -0,0 +1,43 @@ +get('days', 30)); + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('days', 'Number of days ahead to check for expiring licenses (default: 30)', required: false), + ]; + } +} diff --git a/app/Mcp/Prompts/FindAvailableAssetPrompt.php b/app/Mcp/Prompts/FindAvailableAssetPrompt.php new file mode 100644 index 000000000000..1c81cb0fdee9 --- /dev/null +++ b/app/Mcp/Prompts/FindAvailableAssetPrompt.php @@ -0,0 +1,56 @@ +get('category'); + $model = $request->get('model'); + $assignTo = $request->get('assign_to'); + + $assetDescription = collect([ + $category ? "category: {$category}" : null, + $model ? "model: {$model}" : null, + ])->filter()->implode(' / '); + + $assignLine = $assignTo + ? "If a suitable asset is found, check it out to the user: {$assignTo}." + : 'Ask whether the found asset should be checked out to a specific user before proceeding.'; + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('category', 'Asset category to search (e.g. Laptop, Monitor)', required: false), + new Argument('model', 'Specific model name to search for', required: false), + new Argument('assign_to', 'Username to check the asset out to once found', required: false), + ]; + } +} diff --git a/app/Mcp/Prompts/InventorySummaryPrompt.php b/app/Mcp/Prompts/InventorySummaryPrompt.php new file mode 100644 index 000000000000..3904b472d751 --- /dev/null +++ b/app/Mcp/Prompts/InventorySummaryPrompt.php @@ -0,0 +1,54 @@ +get('location'); + $department = $request->get('department'); + + $scope = collect([ + $location ? "location: {$location}" : null, + $department ? "department: {$department}" : null, + ])->filter()->implode(' and '); + + $scopeLine = $scope + ? "Scope the report to {$scope}." + : 'Report across the entire organisation.'; + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('location', 'Limit report to a specific location', required: false), + new Argument('department', 'Limit report to a specific department', required: false), + ]; + } +} diff --git a/app/Mcp/Prompts/OffboardEmployeePrompt.php b/app/Mcp/Prompts/OffboardEmployeePrompt.php new file mode 100644 index 000000000000..8201172d041c --- /dev/null +++ b/app/Mcp/Prompts/OffboardEmployeePrompt.php @@ -0,0 +1,45 @@ +get('username'); + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('username', 'Username of the departing employee', required: true), + ]; + } +} diff --git a/app/Mcp/Prompts/OnboardEmployeePrompt.php b/app/Mcp/Prompts/OnboardEmployeePrompt.php new file mode 100644 index 000000000000..dcf65c5bf57e --- /dev/null +++ b/app/Mcp/Prompts/OnboardEmployeePrompt.php @@ -0,0 +1,64 @@ +get('first_name'); + $lastName = $request->get('last_name'); + $department = $request->get('department'); + $location = $request->get('location'); + $title = $request->get('title'); + + $fullName = trim("{$firstName} {$lastName}"); + + $context = collect([ + $department ? "Department: {$department}" : null, + $location ? "Location: {$location}" : null, + $title ? "Job title: {$title}" : null, + ])->filter()->implode("\n"); + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('first_name', 'First name of the new employee', required: true), + new Argument('last_name', 'Last name of the new employee', required: false), + new Argument('department', 'Department the employee will join', required: false), + new Argument('location', 'Primary office location', required: false), + new Argument('title', 'Job title', required: false), + ]; + } +} diff --git a/app/Mcp/Prompts/SnipePrompt.php b/app/Mcp/Prompts/SnipePrompt.php new file mode 100644 index 000000000000..36976f07c323 --- /dev/null +++ b/app/Mcp/Prompts/SnipePrompt.php @@ -0,0 +1,24 @@ +user()?->locale ?? app()->getLocale(); + + if (str_starts_with($locale, 'en')) { + return ''; + } + + return "\n\nPlease respond in the language that corresponds to locale: {$locale}."; + } +} diff --git a/app/Mcp/Prompts/UserInventoryPrompt.php b/app/Mcp/Prompts/UserInventoryPrompt.php new file mode 100644 index 000000000000..633d513266fe --- /dev/null +++ b/app/Mcp/Prompts/UserInventoryPrompt.php @@ -0,0 +1,44 @@ +get('username'); + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('username', 'Username of the user to review', required: true), + ]; + } +} diff --git a/app/Mcp/Prompts/WarrantyExpiringPrompt.php b/app/Mcp/Prompts/WarrantyExpiringPrompt.php new file mode 100644 index 000000000000..b18fdc6529dc --- /dev/null +++ b/app/Mcp/Prompts/WarrantyExpiringPrompt.php @@ -0,0 +1,42 @@ +get('days', 90)); + + $prompt = <<localeInstruction()); + } + + public function arguments(): array + { + return [ + new Argument('days', 'Number of days ahead to check for warranty expiry (default: 90)', required: false), + ]; + } +} diff --git a/app/Mcp/README.md b/app/Mcp/README.md new file mode 100644 index 000000000000..d5bb1c520d38 --- /dev/null +++ b/app/Mcp/README.md @@ -0,0 +1,1066 @@ +# Snipe-IT MCP Server + +This directory contains the Model Context Protocol (MCP) server implementation for Snipe-IT. It exposes the Snipe-IT asset management database to AI assistants through a standard interface, allowing them to look up assets, manage users, process checkouts and check-ins, and run common IT workflows — all in plain language. + +## Table of Contents + +- [Connecting to an AI Service](#connecting-to-an-ai-service) +- [Authentication](#authentication) +- [Prompts](#prompts) +- [Tools Reference](#tools-reference) + - [Assets](#assets) + - [Users](#users) + - [Accessories](#accessories) + - [Components](#components) + - [Consumables](#consumables) + - [Licenses](#licenses) + - [Departments](#departments) + - [Companies](#companies) + - [Categories](#categories) + - [Manufacturers](#manufacturers) + - [Suppliers](#suppliers) + - [Status Labels](#status-labels) + - [Locations](#locations) + - [Asset Models](#asset-models) + - [Depreciations](#depreciations) + - [Groups](#groups) + - [Maintenance](#maintenance) + - [Activity Log](#activity-log) + +--- + +## Connecting to an AI Service + +The MCP server is available at: + +``` +https://your-snipeit-domain.com/mcp/snipe-it +``` + +It uses **OAuth 2.0** for authentication (see [Authentication](#authentication) below). Any MCP-compatible client that supports OAuth and the Streamable HTTP transport can connect to it. + +### Claude Desktop + +Add the server to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "snipe-it": { + "url": "https://your-snipeit-domain/mcp/snipe-it" + } + } +} +``` + +Claude Desktop will initiate the OAuth flow on first connection. Once authorised, you can use tools and prompts directly in conversation: + +> "Check out asset LAPTOP-0042 to jsmith." + +> "What's assigned to sarah.chen right now?" + +### MCP Inspector + +The [MCP Inspector](https://laravel.com/docs/12.x/mcp#mcp-inspector) is useful for exploring and testing tools before +integrating with a client: + +1. Run `php artisan mcp:inspector SnipeITMcpServer` in your terminal +2. Open the provided URL in your browser + +### Cursor / VS Code / Other MCP Clients + +Any client that supports the MCP Streamable HTTP transport and OAuth can connect using the same URL. Refer to your client's documentation for how to add a remote MCP server. + +--- + +## Authentication + +The server uses **OAuth 2.0 with dynamic client registration**. Clients that support OAuth will handle this automatically. On first connection: + +1. The client discovers the OAuth server via `/.well-known/oauth-authorization-server` +2. It registers itself dynamically via `/oauth/register` +3. The user is redirected to Snipe-IT's login page to authorise access +4. The client receives a bearer token and uses it for all subsequent requests + +The authenticated user's Snipe-IT permissions apply — a user without `delete assets` permission cannot call `delete_asset`, for example. + +--- + +## Prompts + +Prompts are pre-built conversation starters for common multi-step workflows. In clients that support them (such as Claude Desktop), prompts appear in a slash-command menu or prompt picker. Select a prompt, fill in any arguments, and the AI will walk through the workflow using the available tools automatically. + +--- + +### `onboard_employee` + +**Guide through creating a new employee account and assigning appropriate equipment and licenses.** + +Creates the user account, finds available assets suitable for their role, checks equipment out to them, and assigns any relevant license seats. + +| Argument | Required | Description | +|----------|----------|-------------| +| `first_name` | Yes | First name of the new employee | +| `last_name` | No | Last name of the new employee | +| `department` | No | Department the employee will join | +| `location` | No | Primary office location | +| `title` | No | Job title | + +**Example usage in Claude:** +> Use the `onboard_employee` prompt → first_name: "Marcus", last_name: "Webb", department: "Engineering", location: "Austin HQ" + +--- + +### `offboard_employee` + +**Check in all equipment and licenses from a departing employee and deactivate their account.** + +Looks up everything assigned to the user, checks in all assets and accessories, revokes license seats, and deactivates the account. + +| Argument | Required | Description | +|----------|----------|-------------| +| `username` | Yes | Username of the departing employee | + +**Example usage in Claude:** +> Use the `offboard_employee` prompt → username: "marcus.webb" + +--- + +### `audit_location` + +**Review all assets at a location, flag overdue audits and status anomalies.** + +Lists all assets at the location, identifies overdue audit dates, flags unexpected statuses, and produces a summary with recommended actions. + +| Argument | Required | Description | +|----------|----------|-------------| +| `location` | Yes | Name or ID of the location to audit | + +**Example usage in Claude:** +> Use the `audit_location` prompt → location: "Austin HQ" + +--- + +### `find_available_asset` + +**Find an undeployed asset by category or model and optionally check it out to a user.** + +Searches for Ready-to-Deploy assets matching the criteria, lists options if multiple are available, and can check out the selected asset immediately. + +| Argument | Required | Description | +|----------|----------|-------------| +| `category` | No | Asset category to search (e.g. Laptop, Monitor) | +| `model` | No | Specific model name to search for | +| `assign_to` | No | Username to check the asset out to once found | + +**Example usage in Claude:** +> Use the `find_available_asset` prompt → category: "Laptop", assign_to: "marcus.webb" + +--- + +### `expiring_licenses` + +**Review license seat usage and flag licenses expiring within a given number of days.** + +Lists all licenses, identifies those expiring soon, flags over-deployed and under-used licenses, and produces a prioritised action list. + +| Argument | Required | Description | +|----------|----------|-------------| +| `days` | No | Days ahead to check for expiry (default: 30) | + +**Example usage in Claude:** +> Use the `expiring_licenses` prompt → days: 60 + +--- + +### `end_of_life_review` + +**Identify assets that have passed their EOL date or are fully depreciated, and recommend disposition actions.** + +Can be scoped to a department or category. Groups findings and recommends retirement, redeployment, repair, or archival. + +| Argument | Required | Description | +|----------|----------|-------------| +| `department` | No | Limit review to a specific department | +| `category` | No | Limit review to a specific asset category | + +**Example usage in Claude:** +> Use the `end_of_life_review` prompt → category: "Laptop" + +--- + +### `warranty_expiring` + +**List assets whose warranty expires within a given number of days.** + +Groups findings by urgency (within 30 days, 31–60, 61–N), flags assets in critical roles, and recommends extensions or replacements. + +| Argument | Required | Description | +|----------|----------|-------------| +| `days` | No | Days ahead to check for warranty expiry (default: 90) | + +**Example usage in Claude:** +> Use the `warranty_expiring` prompt → days: 30 + +--- + +### `inventory_summary` + +**Produce a high-level inventory count by category, broken down by deployment status.** + +Can be scoped to a location or department. Shows deployed vs. available counts, top models, total value, and stock-out risks. + +| Argument | Required | Description | +|----------|----------|-------------| +| `location` | No | Limit report to a specific location | +| `department` | No | Limit report to a specific department | + +**Example usage in Claude:** +> Use the `inventory_summary` prompt → location: "Austin HQ" + +--- + +### `user_inventory` + +**List everything currently assigned to a specific user across all asset types.** + +Shows assets, accessories, license seats, and consumables assigned to the user, plus total assigned value if cost data is available. + +| Argument | Required | Description | +|----------|----------|-------------| +| `username` | Yes | Username of the user to review | + +**Example usage in Claude:** +> Use the `user_inventory` prompt → username: "sarah.chen" + +--- + +## Tools Reference + +Tools are individual actions the AI can call directly. They can also be combined freely in conversation without using a prompt — just describe what you want in plain language. + +> "Find the MacBook Pro with serial C02XL0AAJGH5 and check it in." + +> "Create a new location called 'Denver Office' in Colorado." + +> "List all licenses expiring before the end of the year." + +--- + +### Assets + +#### `show_asset` +Look up a single asset by asset tag, serial number, or numeric ID. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Asset tag | +| `serial` | string | No | Serial number | +| `id` | number | No | Numeric ID | + +#### `list_assets` +Search and list assets with optional filters. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across tag, serial, name, model | +| `status_type` | string | No | `RTD`, `Deployed`, `Archived`, `Pending`, or `Undeployable` | +| `company_id` | number | No | Filter by company | +| `location_id` | number | No | Filter by location | +| `category_id` | number | No | Filter by category | +| `model_id` | number | No | Filter by model | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip for pagination | + +#### `create_asset` +Create a new asset. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `model_id` | number | **Yes** | Asset model ID | +| `status_id` | number | **Yes** | Status label ID | +| `asset_tag` | string | **Yes** | Asset tag | +| `name` | string | No | Display name | +| `serial` | string | No | Serial number | +| `company_id` | number | No | Company ID | +| `location_id` | number | No | Current location ID | +| `rtd_location_id` | number | No | Default RTD location ID | +| `supplier_id` | number | No | Supplier ID | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `purchase_cost` | number | No | Purchase cost | +| `order_number` | string | No | Order number | +| `warranty_months` | number | No | Warranty length in months (0–240) | +| `requestable` | boolean | No | Whether users can request this asset | +| `notes` | string | No | Notes | + +#### `update_asset` +Update fields on an asset. Identify it by `asset_tag`, `serial`, or `id`. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | New display name | +| `new_asset_tag` | string | No | Rename the asset tag | +| `new_serial` | string | No | New serial number | +| `status_id` | number | No | Status label ID | +| `model_id` | number | No | Model ID | +| `notes` | string | No | Notes | +| `order_number` | string | No | Order number | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `purchase_cost` | number | No | Purchase cost | +| `warranty_months` | number | No | Warranty length in months | +| `location_id` | number | No | Current location ID | +| `rtd_location_id` | number | No | Default RTD location ID | +| `supplier_id` | number | No | Supplier ID | +| `requestable` | boolean | No | User-requestable flag | +| `byod` | boolean | No | Bring-your-own-device flag | +| `asset_eol_date` | string | No | End-of-life date (YYYY-MM-DD) | +| `expected_checkin` | string | No | Expected check-in date (YYYY-MM-DD) | +| `next_audit_date` | string | No | Next audit date (YYYY-MM-DD) | + +#### `delete_asset` +Soft-delete an asset. If checked out, it will be checked in first. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | + +#### `restore_asset` +Restore a soft-deleted asset. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric ID of the asset to restore | + +#### `checkout_asset` +Check out an asset to a user, location, or another asset. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify asset by tag | +| `id` | number | No | Identify asset by numeric ID | +| `checkout_to_type` | string | **Yes** | `user`, `location`, or `asset` | +| `assigned_user` | number | No | User ID (when checking out to a user) | +| `assigned_location` | number | No | Location ID (when checking out to a location) | +| `assigned_asset` | number | No | Asset ID (when checking out to an asset) | +| `note` | string | No | Checkout note | +| `checkout_at` | string | No | Checkout date (YYYY-MM-DD, defaults to now) | +| `expected_checkin` | string | No | Expected check-in date (YYYY-MM-DD) | + +#### `checkin_asset` +Check a currently checked-out asset back in. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `id` | number | No | Identify by numeric ID | +| `note` | string | No | Check-in note | + +#### `audit_asset` +Record an audit for an asset, updating the last audit date and optionally the location. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | +| `note` | string | No | Audit note | +| `location_id` | number | No | Location where the asset was found | +| `next_audit_date` | string | No | Override the next audit date (YYYY-MM-DD) | + +#### `add_asset_note` +Add a manual note to an asset. The note is recorded in the asset's activity log. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_tag` | string | No | Identify by asset tag | +| `serial` | string | No | Identify by serial number | +| `id` | number | No | Identify by numeric ID | +| `note` | string | **Yes** | Note text to add | + +--- + +### Users + +#### `list_users` +Search and list users. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across name, username, email, employee number | +| `company_id` | number | No | Filter by company | +| `department_id` | number | No | Filter by department | +| `location_id` | number | No | Filter by location | +| `activated` | boolean | No | Filter by account activated status | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip for pagination | + +#### `show_user` +Look up a single user by numeric ID, username, or email address. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Numeric user ID | +| `username` | string | No | Username | +| `email` | string | No | Email address | + +#### `create_user` +Create a new user account. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `first_name` | string | **Yes** | First name | +| `username` | string | **Yes** | Username (must be unique) | +| `last_name` | string | No | Last name | +| `email` | string | No | Email address | +| `password` | string | No | Password (min 8 characters) | +| `employee_num` | string | No | Employee number | +| `jobtitle` | string | No | Job title | +| `phone` | string | No | Phone number | +| `company_id` | number | No | Company ID | +| `department_id` | number | No | Department ID | +| `location_id` | number | No | Location ID | +| `manager_id` | number | No | Manager user ID | +| `activated` | boolean | No | Whether the account is active (default: true) | +| `notes` | string | No | Notes | +| `start_date` | string | No | Employment start date (YYYY-MM-DD) | +| `end_date` | string | No | Employment end date (YYYY-MM-DD) | +| `vip` | boolean | No | Mark as VIP | +| `remote` | boolean | No | Mark as remote worker | +| `address` | string | No | Street address | +| `city` | string | No | City | +| `state` | string | No | State/province | +| `country` | string | No | Country | +| `zip` | string | No | Postal/ZIP code | + +#### `update_user` +Update fields on a user. Identify by `id`, `username`, or `email`. Use `new_username` or `new_email` to change those values. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `username` | string | No | Identify by username | +| `email` | string | No | Identify by email | +| `new_username` | string | No | Rename the username | +| `new_email` | string | No | New email address | +| `first_name` | string | No | First name | +| `last_name` | string | No | Last name | +| `password` | string | No | New password (min 8 characters) | +| `employee_num` | string | No | Employee number | +| `jobtitle` | string | No | Job title | +| `phone` | string | No | Phone number | +| `company_id` | number | No | Company ID | +| `department_id` | number | No | Department ID | +| `location_id` | number | No | Location ID | +| `manager_id` | number | No | Manager user ID | +| `activated` | boolean | No | Account active status | +| `notes` | string | No | Notes | +| `start_date` | string | No | Employment start date (YYYY-MM-DD) | +| `end_date` | string | No | Employment end date (YYYY-MM-DD) | +| `vip` | boolean | No | VIP flag | +| `remote` | boolean | No | Remote worker flag | + +#### `delete_user` +Soft-delete a user. The user must have no assets, licenses, accessories, or consumables assigned. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Numeric user ID | +| `username` | string | No | Username | +| `email` | string | No | Email address | + +#### `restore_user` +Restore a soft-deleted user. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric ID of the user to restore | + +#### `get_current_user` +Return information about the currently authenticated user. No parameters. + +#### `update_profile` +Update the authenticated user's own profile. Fields protected by the `self.profile` gate (`first_name`, `last_name`, `phone`, `website`, `gravatar`) require profile editing to be enabled in Snipe-IT settings. `location_id` requires the `self.edit_location` permission. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `first_name` | string | No | First name | +| `last_name` | string | No | Last name | +| `phone` | string | No | Phone number | +| `website` | string | No | Personal website URL | +| `gravatar` | string | No | Gravatar email or hash | +| `locale` | string | No | Locale/language code (e.g. `en-US`) | +| `two_factor_optin` | boolean | No | Opt in to 2FA (requires `self.two_factor` permission and 2FA enabled in settings) | +| `location_id` | number | No | Default location ID (requires `self.edit_location` permission) | + +#### `get_user_assets` +Return all assets currently checked out to a user. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric user ID | + +#### `reset_2fa` +Reset two-factor authentication for a user (requires admin permission). + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | **Yes** | Numeric user ID | + +--- + +### Accessories + +#### `create_accessory` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Accessory name | +| `category_id` | number | **Yes** | Category ID (must be an accessory category) | +| `qty` | number | No | Total quantity in stock | +| `model_number` | string | No | Model number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `location_id` | number | No | Location ID | +| `company_id` | number | No | Company ID | +| `order_number` | string | No | Order number | +| `purchase_cost` | number | No | Purchase cost per unit | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `requestable` | boolean | No | User-requestable flag | +| `notes` | string | No | Notes | + +#### `update_accessory` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_accessory` +The accessory must have no units currently checked out. Identify by `id` or `name`. + +#### `checkout_accessory` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `checkout_to_type` | string | **Yes** | `user`, `location`, or `asset` | +| `assigned_user` | number | No | User ID to check out to | +| `assigned_location` | number | No | Location ID to check out to | +| `assigned_asset` | number | No | Asset ID to check out to | +| `note` | string | No | Checkout note | + +#### `checkin_accessory` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `checkout_id` | number | **Yes** | ID of the checkout record to check in | +| `note` | string | No | Check-in note | + +--- + +### Components + +#### `create_component` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Component name | +| `category_id` | number | **Yes** | Category ID (must be a component category) | +| `qty` | number | **Yes** | Total quantity in stock (min 1) | +| `serial` | string | No | Serial number | +| `model_number` | string | No | Model number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `location_id` | number | No | Location ID | +| `company_id` | number | No | Company ID | +| `order_number` | string | No | Order number | +| `purchase_cost` | number | No | Purchase cost per unit | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `notes` | string | No | Notes | + +#### `update_component` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_component` +The component must have no units checked out to assets. Identify by `id` or `name`. + +#### `checkout_component` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `asset_id` | number | **Yes** | Asset ID to check the component out to | +| `assigned_qty` | number | No | Number of units to check out (default: 1) | +| `note` | string | No | Checkout note | + +#### `checkin_component` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `component_asset_id` | number | **Yes** | ID of the checkout record to check in | +| `checkin_qty` | number | No | Units to check in (default: all) | +| `note` | string | No | Check-in note | + +--- + +### Consumables + +#### `list_consumables` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `company_id` | number | No | Filter by company | +| `category_id` | number | No | Filter by category | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `location_id` | number | No | Filter by location | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_consumable` +Look up by `id` or `name`. + +#### `create_consumable` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Consumable name | +| `qty` | number | **Yes** | Quantity in stock | +| `category_id` | number | **Yes** | Category ID (must be a consumable category) | +| `company_id` | number | No | Company ID | +| `location_id` | number | No | Location ID | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `item_no` | string | No | Item number | +| `order_number` | string | No | Order number | +| `model_number` | string | No | Model number | +| `purchase_cost` | number | No | Purchase cost per unit | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `requestable` | boolean | No | User-requestable flag | +| `notes` | string | No | Notes | + +#### `update_consumable` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_consumable` +The consumable must have no units currently checked out. Identify by `id` or `name`. + +#### `checkout_consumable` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `assigned_to` | number | **Yes** | User ID to check out to | +| `note` | string | No | Checkout note | + +--- + +### Licenses + +#### `list_licenses` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across name, serial, notes, order number | +| `company_id` | number | No | Filter by company | +| `category_id` | number | No | Filter by category | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `supplier_id` | number | No | Filter by supplier | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_license` +Look up by `id` or `name`. Returns seat counts. + +#### `create_license` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | License name | +| `seats` | number | **Yes** | Number of seats (min 1) | +| `category_id` | number | **Yes** | Category ID (must be a license category) | +| `serial` | string | No | Product key / serial number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `supplier_id` | number | No | Supplier ID | +| `company_id` | number | No | Company ID | +| `purchase_date` | string | No | Purchase date (YYYY-MM-DD) | +| `purchase_cost` | number | No | Purchase cost | +| `expiration_date` | string | No | Expiration date (YYYY-MM-DD) | +| `license_name` | string | No | Name of the licensed user/organisation | +| `license_email` | string | No | Email of the licensed user/organisation | +| `maintained` | boolean | No | Whether the license is under maintenance | +| `reassignable` | boolean | No | Whether seats can be reassigned after check-in | +| `notes` | string | No | Notes | +| `min_amt` | number | No | Minimum seat threshold for alerts | + +#### `update_license` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_license` +The license must have no seats currently assigned. Identify by `id` or `name`. + +#### `checkout_license` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | number | No | Identify by numeric ID | +| `name` | string | No | Identify by name | +| `assigned_to` | number | No | User ID to assign the seat to | +| `asset_id` | number | No | Asset ID to assign the seat to | +| `note` | string | No | Checkout note | + +#### `checkin_license` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `seat_id` | number | **Yes** | ID of the license seat to check in | +| `note` | string | No | Check-in note | + +--- + +### Departments + +#### `create_department` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Department name | +| `location_id` | number | No | Location ID | +| `company_id` | number | No | Company ID | +| `manager_id` | number | No | Manager user ID | +| `phone` | string | No | Department phone number | +| `fax` | string | No | Department fax number | +| `notes` | string | No | Notes | + +#### `update_department` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_department` +The department must have no users assigned. Identify by `id` or `name`. + +--- + +### Companies + +#### `list_companies` +Search with optional `search`, `limit`, `offset`. + +#### `show_company` +Look up by `id` or `name`. + +#### `create_company` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Company name | +| `phone` | string | No | Phone number | +| `fax` | string | No | Fax number | +| `email` | string | No | Email address | +| `notes` | string | No | Notes | + +#### `update_company` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_company` +Identify by `id` or `name`. + +--- + +### Categories + +#### `list_categories` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `category_type` | string | No | `asset`, `accessory`, `consumable`, `component`, or `license` | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_category` +Look up by `id` or `name`. + +#### `create_category` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Category name | +| `category_type` | string | **Yes** | `asset`, `accessory`, `consumable`, `component`, or `license` | +| `checkin_email` | boolean | No | Send check-in email | +| `require_acceptance` | boolean | No | Require user acceptance on checkout | +| `use_default_eula` | boolean | No | Use the default EULA | +| `notes` | string | No | Notes | + +#### `update_category` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_category` +The category must have no items assigned. Identify by `id` or `name`. + +--- + +### Manufacturers + +#### `list_manufacturers` +Search with optional `search`, `limit`, `offset`. + +#### `show_manufacturer` +Look up by `id` or `name`. + +#### `create_manufacturer` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Manufacturer name | +| `url` | string | No | Website URL | +| `support_url` | string | No | Support website URL | +| `support_email` | string | No | Support email | +| `support_phone` | string | No | Support phone | +| `warranty_lookup_url` | string | No | Warranty lookup URL | +| `notes` | string | No | Notes | + +#### `update_manufacturer` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_manufacturer` +Identify by `id` or `name`. + +--- + +### Suppliers + +#### `list_suppliers` +Search with optional `search`, `limit`, `offset`. + +#### `show_supplier` +Look up by `id` or `name`. + +#### `create_supplier` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Supplier name | +| `address` | string | No | Address line 1 | +| `address2` | string | No | Address line 2 | +| `city` | string | No | City | +| `state` | string | No | State/province | +| `country` | string | No | Country | +| `zip` | string | No | Postal code | +| `phone` | string | No | Phone number | +| `fax` | string | No | Fax number | +| `email` | string | No | Email address | +| `url` | string | No | Website URL | +| `contact` | string | No | Contact name | +| `notes` | string | No | Notes | + +#### `update_supplier` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_supplier` +Identify by `id` or `name`. + +--- + +### Status Labels + +#### `list_status_labels` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `status_type` | string | No | `deployable`, `pending`, `archived`, or `undeployable` | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_status_label` +Look up by `id` or `name`. + +#### `create_status_label` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Status label name | +| `type` | string | **Yes** | `deployable`, `pending`, `archived`, or `undeployable` | +| `color` | string | No | Display colour in `#RRGGBB` format | +| `notes` | string | No | Notes | +| `default_label` | boolean | No | Set as default label | +| `show_in_nav` | boolean | No | Show in navigation | + +#### `update_status_label` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_status_label` +Identify by `id` or `name`. + +--- + +### Locations + +#### `list_locations` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search | +| `parent_id` | number | No | Filter by parent location ID | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_location` +Look up by `id` or `name`. + +#### `create_location` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Location name | +| `address` | string | No | Street address | +| `address2` | string | No | Address line 2 | +| `city` | string | No | City | +| `state` | string | No | State/province | +| `country` | string | No | Country | +| `zip` | string | No | Postal code | +| `phone` | string | No | Phone number | +| `fax` | string | No | Fax number | +| `currency` | string | No | Currency code | +| `parent_id` | number | No | Parent location ID | +| `manager_id` | number | No | Manager user ID | + +#### `update_location` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_location` +Identify by `id` or `name`. + +--- + +### Asset Models + +#### `list_asset_models` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `search` | string | No | Keyword search across name, model number | +| `category_id` | number | No | Filter by category | +| `manufacturer_id` | number | No | Filter by manufacturer | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `show_asset_model` +Look up by `id` or `name`. + +#### `create_asset_model` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Model name | +| `category_id` | number | **Yes** | Category ID | +| `model_number` | string | No | Model number | +| `manufacturer_id` | number | No | Manufacturer ID | +| `depreciation_id` | number | No | Depreciation schedule ID | +| `eol` | number | No | End of life in months (0–240) | +| `min_amt` | number | No | Minimum quantity alert threshold | +| `notes` | string | No | Notes | +| `requestable` | boolean | No | Whether the model can be requested | +| `require_serial` | boolean | No | Whether serial numbers are required | + +#### `update_asset_model` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_asset_model` +Identify by `id` or `name`. + +--- + +### Depreciations + +#### `list_depreciations` +Search with optional `search`, `limit`, `offset`. + +#### `show_depreciation` +Look up by `id` or `name`. + +#### `create_depreciation` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Depreciation schedule name | +| `months` | number | **Yes** | Depreciation period in months (1–3600) | + +#### `update_depreciation` +Identify by `id` or `name`. Use `new_name` to rename, `months` to change the period. + +#### `delete_depreciation` +Identify by `id` or `name`. + +--- + +### Groups + +#### `list_groups` +Search with optional `search`, `limit`, `offset`. + +#### `show_group` +Look up by `id` or `name`. + +#### `create_group` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | **Yes** | Group name (must be unique) | +| `notes` | string | No | Notes | + +#### `update_group` +Identify by `id` or `name`. Use `new_name` to rename. + +#### `delete_group` +The group must have no users assigned. Identify by `id` or `name`. + +--- + +### Maintenance + +#### `list_maintenances` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_id` | number | No | Filter by asset ID | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | + +#### `create_maintenance` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `asset_id` | number | **Yes** | Asset ID the maintenance is for | +| `title` | string | **Yes** | Maintenance title | +| `asset_maintenance_type` | string | No | Type (e.g. `maintenance`, `repair`, `upgrade`) | +| `supplier_id` | number | No | Supplier ID | +| `is_warranty` | boolean | No | Whether this is a warranty repair | +| `cost` | number | No | Cost of the maintenance | +| `start_date` | string | No | Start date (YYYY-MM-DD, defaults to today) | +| `completion_date` | string | No | Completion date (YYYY-MM-DD) | +| `notes` | string | No | Notes | +| `user_id` | number | No | Technician user ID | + +--- + +### Activity Log + +#### `get_activity_log` + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `item_type` | string | No | Filter by item type (e.g. `App\Models\Asset`) | +| `item_id` | number | No | Filter by item ID | +| `user_id` | number | No | Filter by user ID | +| `action_type` | string | No | Filter by action (e.g. `checkout`, `checkin`, `update`) | +| `limit` | number | No | Results to return (default: 25, max: 500) | +| `offset` | number | No | Results to skip | diff --git a/app/Mcp/Servers/SnipeMCPServer.php b/app/Mcp/Servers/SnipeMCPServer.php new file mode 100644 index 000000000000..a2760245b7e9 --- /dev/null +++ b/app/Mcp/Servers/SnipeMCPServer.php @@ -0,0 +1,275 @@ +validate([ + 'asset_tag' => 'nullable|string|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'note' => 'required|string|max:50000', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('update', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $logaction = new Actionlog; + $logaction->item_type = Asset::class; + $logaction->item_id = $asset->id; + $logaction->note = $request->get('note'); + $logaction->created_by = auth()->id(); + + if ($logaction->logaction('note added')) { + return Response::make( + Response::text(trans('mcp.note_added_to_asset', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.note_added_successfully'), + 'asset_tag' => $asset->asset_tag, + 'asset_id' => $asset->id, + 'note' => $logaction->note, + ]); + } + + return Response::make(Response::error(trans('mcp.note_save_failed'))); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset'), + 'serial' => $schema->string()->description('Serial number of the asset'), + 'id' => $schema->number()->description('Numeric ID of the asset'), + 'note' => $schema->string()->description('Note text to add to the asset'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the note was saved'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the asset'), + 'asset_id' => $schema->number()->description('Numeric ID of the asset'), + 'note' => $schema->string()->description('The note that was saved'), + ]; + } +} diff --git a/app/Mcp/Tools/AuditAssetTool.php b/app/Mcp/Tools/AuditAssetTool.php new file mode 100644 index 000000000000..59489d4d43ba --- /dev/null +++ b/app/Mcp/Tools/AuditAssetTool.php @@ -0,0 +1,120 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'note' => 'nullable|string|max:1000', + 'location_id' => 'nullable|integer|exists:locations,id', + 'next_audit_date' => 'nullable|date', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('audit', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $originalValues = $asset->getRawOriginal(); + $settings = Setting::getSettings(); + + $asset->last_audit_date = date('Y-m-d H:i:s'); + + if ($request->filled('next_audit_date')) { + $asset->next_audit_date = $request->get('next_audit_date'); + } elseif (! is_null($settings->audit_interval)) { + $asset->next_audit_date = Carbon::now()->addMonths($settings->audit_interval)->toDateString(); + } + + if ($request->filled('location_id')) { + $asset->location_id = $request->get('location_id'); + } + + // Bypass the observer to avoid logging a spurious asset-update entry + // alongside the audit log entry created by logAudit() below + $asset->unsetEventDispatcher(); + + if ($asset->isValid() && $asset->save()) { + $asset->logAudit($request->get('note'), $request->get('location_id'), null, $originalValues); + + return Response::make( + Response::text(trans('mcp.asset_audited', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_audited', ['asset_tag' => $asset->asset_tag]), + 'asset_tag' => $asset->asset_tag, + 'last_audit_date' => $asset->last_audit_date, + 'next_audit_date' => $asset->next_audit_date, + 'location' => $asset->location?->name, + ]); + } + + return Response::make(Response::error(trans('mcp.audit_failed', ['error' => $asset->getErrors()->first()]))); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->with('location')->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->with('location')->first(); + } + if ($request->filled('id')) { + return Asset::with('location')->find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset to audit'), + 'serial' => $schema->string()->description('Serial number of the asset to audit'), + 'id' => $schema->number()->description('Numeric ID of the asset to audit'), + 'note' => $schema->string()->description('Optional audit note'), + 'location_id' => $schema->number()->description('Location ID where the asset was found (also updates the asset location)'), + 'next_audit_date' => $schema->string()->description('Override the next audit date (YYYY-MM-DD); defaults to now plus the audit_interval from settings'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the audit succeeded'), + 'error' => $schema->boolean()->description('True if the audit failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the audited asset'), + 'last_audit_date' => $schema->string()->description('Timestamp of the audit just recorded'), + 'next_audit_date' => $schema->string()->description('Date of the next scheduled audit'), + 'location' => $schema->string()->description('Location name where the asset was found'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckinAccessoryTool.php b/app/Mcp/Tools/CheckinAccessoryTool.php new file mode 100644 index 000000000000..00dab7c603e6 --- /dev/null +++ b/app/Mcp/Tools/CheckinAccessoryTool.php @@ -0,0 +1,82 @@ +validate([ + 'checkout_id' => 'required|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $checkout = AccessoryCheckout::find($request->get('checkout_id')); + + if (! $checkout) { + return Response::make(Response::error(trans('mcp.accessory_checkout_not_found'))); + } + + $accessory = Accessory::find($checkout->accessory_id); + + if (! $accessory) { + return Response::make(Response::error(trans('mcp.accessory_not_found'))); + } + + if (! Gate::allows('checkin', $accessory)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $target = $checkout->assigned_type && $checkout->assigned_to + ? $checkout->assigned_type::find($checkout->assigned_to) + : null; + + $accessory->logCheckin($target, $request->get('note')); + + if ($checkout->delete()) { + return Response::make( + Response::text(trans('mcp.accessory_checked_in', ['name' => $accessory->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.accessory_checked_in', ['name' => $accessory->name]), + 'accessory_id' => $accessory->id, + 'accessory_name' => $accessory->name, + ]); + } + + return Response::make(Response::error(trans('mcp.checkin_failed'))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'checkout_id' => $schema->number()->description('ID of the checkout record to check in (returned by checkout_accessory)'), + 'note' => $schema->string()->description('Optional checkin note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkin succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'accessory_id' => $schema->number()->description('Numeric ID of the accessory'), + 'accessory_name' => $schema->string()->description('Name of the accessory'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckinAssetTool.php b/app/Mcp/Tools/CheckinAssetTool.php new file mode 100644 index 000000000000..c6ca06ac53b4 --- /dev/null +++ b/app/Mcp/Tools/CheckinAssetTool.php @@ -0,0 +1,110 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'id' => 'nullable|integer', + 'note' => 'nullable|string|max:1000', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('checkin', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $target = $asset->assignedTo; + + if (is_null($target)) { + return Response::make(Response::error(trans('mcp.asset_not_checked_out', ['asset_tag' => $asset->asset_tag]))); + } + + $originalValues = $asset->getRawOriginal(); + $checkinAt = date('Y-m-d H:i:s'); + + $asset->expected_checkin = null; + $asset->last_checkin = now(); + $asset->assignedTo()->disassociate($asset); + $asset->accepted = null; + $asset->location_id = $asset->rtd_location_id; + + if ($asset->save()) { + event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->get('note'), $checkinAt, $originalValues)); + + return Response::make( + Response::text(trans('mcp.asset_checked_in', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_checked_in', ['asset_tag' => $asset->asset_tag]), + 'asset_tag' => $asset->asset_tag, + 'model' => $asset->model?->name, + 'location' => $asset->location?->name, + ]); + } + + return Response::make(Response::error(trans('mcp.checkin_failed_error', ['error' => $asset->getErrors()->first()]))); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag')) + ->with('model', 'location') + ->first(); + } + + if ($request->filled('id')) { + return Asset::with('model', 'location')->find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string() + ->description('Asset tag of the asset to check in'), + 'id' => $schema->number() + ->description('Numeric ID of the asset to check in'), + 'note' => $schema->string() + ->description('Optional note to attach to this checkin'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->string()->description('True if the checkin succeeded'), + 'error' => $schema->string()->description('True if the checkin failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the checked-in asset'), + 'model' => $schema->string()->description('Model name of the checked-in asset'), + 'location' => $schema->string()->description('Location the asset returned to'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckinComponentTool.php b/app/Mcp/Tools/CheckinComponentTool.php new file mode 100644 index 000000000000..b76f29de33f3 --- /dev/null +++ b/app/Mcp/Tools/CheckinComponentTool.php @@ -0,0 +1,102 @@ +validate([ + 'component_asset_id' => 'required|integer', + 'checkin_qty' => 'nullable|integer|min:1', + 'note' => 'nullable|string|max:65535', + ]); + + $componentAsset = DB::table('components_assets')->find($request->get('component_asset_id')); + + if (! $componentAsset) { + return Response::make(Response::error(trans('mcp.component_checkout_not_found'))); + } + + $component = Component::find($componentAsset->component_id); + + if (! $component) { + return Response::make(Response::error(trans('mcp.component_not_found'))); + } + + if (! Gate::allows('checkin', $component)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $maxCheckin = $componentAsset->assigned_qty ?? 1; + $checkinQty = (int) $request->get('checkin_qty', $maxCheckin); + + if ($checkinQty > $maxCheckin) { + return Response::make(Response::error( + 'Checkin quantity ('.$checkinQty.') exceeds assigned quantity ('.$maxCheckin.')' + )); + } + + $remaining = $maxCheckin - $checkinQty; + + if ($remaining === 0) { + DB::table('components_assets')->where('id', $componentAsset->id)->delete(); + } else { + DB::table('components_assets')->where('id', $componentAsset->id)->update(['assigned_qty' => $remaining]); + } + + $asset = Asset::find($componentAsset->asset_id); + + event(new CheckoutableCheckedIn($component, $asset, auth()->user(), $request->get('note'), Carbon::now())); + + return Response::make( + Response::text(trans('mcp.component_checked_in', ['name' => $component->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.component_checked_in', ['name' => $component->name]), + 'component_id' => $component->id, + 'component_name' => $component->name, + 'checkin_qty' => $checkinQty, + 'qty_still_checked_out' => $remaining, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'component_asset_id' => $schema->number()->description('ID of the checkout record to check in (returned by checkout_component)'), + 'checkin_qty' => $schema->number()->description('Number of units to check in (default: all assigned units)'), + 'note' => $schema->string()->description('Optional checkin note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkin succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'component_id' => $schema->number()->description('Numeric ID of the component'), + 'component_name' => $schema->string()->description('Name of the component'), + 'checkin_qty' => $schema->number()->description('Number of units checked in'), + 'qty_still_checked_out' => $schema->number()->description('Units remaining checked out on this record (0 means fully returned)'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckinLicenseTool.php b/app/Mcp/Tools/CheckinLicenseTool.php new file mode 100644 index 000000000000..4ec5ba5c220a --- /dev/null +++ b/app/Mcp/Tools/CheckinLicenseTool.php @@ -0,0 +1,105 @@ +validate([ + 'seat_id' => 'required|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $seat = LicenseSeat::with('license')->find($request->get('seat_id')); + + if (! $seat) { + return Response::make(Response::error(trans('mcp.license_seat_not_found'))); + } + + if (is_null($seat->assigned_to) && is_null($seat->asset_id)) { + return Response::make(Response::error(trans('mcp.seat_not_checked_out'))); + } + + $license = $seat->license; + + if (! $license) { + return Response::make(Response::error(trans('mcp.license_not_found'))); + } + + // License checkin uses the checkout gate (matching application behavior) + if (! Gate::allows('checkout', $license)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $returnTo = null; + if ($seat->assigned_to) { + $returnTo = User::withTrashed()->find($seat->assigned_to); + } elseif ($seat->asset_id) { + $returnTo = Asset::find($seat->asset_id); + } + + $note = $request->get('note'); + + $seat->assigned_to = null; + $seat->asset_id = null; + $seat->notes = $note; + + if (! $license->reassignable) { + $seat->unreassignable_seat = true; + } + + if ($seat->save()) { + event(new CheckoutableCheckedIn($seat, $returnTo, auth()->user(), $note)); + + return Response::make( + Response::text(trans('mcp.license_seat_checked_in', ['id' => $seat->id])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.license_seat_checked_in', ['id' => $seat->id]), + 'seat_id' => $seat->id, + 'license_id' => $license->id, + 'license_name' => $license->name, + ]); + } + + return Response::make(Response::error(trans('mcp.checkin_failed'))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'seat_id' => $schema->number()->description('ID of the license seat to check in (returned by checkout_license)'), + 'note' => $schema->string()->description('Optional checkin note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkin succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'seat_id' => $schema->number()->description('ID of the seat that was checked in'), + 'license_id' => $schema->number()->description('Numeric ID of the license'), + 'license_name' => $schema->string()->description('Name of the license'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutAccessoryTool.php b/app/Mcp/Tools/CheckoutAccessoryTool.php new file mode 100644 index 000000000000..996802831af4 --- /dev/null +++ b/app/Mcp/Tools/CheckoutAccessoryTool.php @@ -0,0 +1,134 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'checkout_to_type' => 'required|in:user,location,asset', + 'assigned_user' => 'nullable|integer', + 'assigned_location' => 'nullable|integer', + 'assigned_asset' => 'nullable|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $accessory = $this->resolveAccessory($request); + + if (! $accessory) { + return Response::make(Response::error(trans('mcp.accessory_not_found'))); + } + + if (! Gate::allows('checkout', $accessory)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($accessory->numRemaining() < 1) { + return Response::make(Response::error(trans('mcp.no_units_available'))); + } + + $checkoutType = $request->get('checkout_to_type'); + + $target = match ($checkoutType) { + 'user' => User::find($request->get('assigned_user')), + 'location' => Location::find($request->get('assigned_location')), + 'asset' => Asset::find($request->get('assigned_asset')), + }; + + if (! $target) { + return Response::make(Response::error(trans('mcp.checkout_target_not_found', ['type' => $checkoutType]))); + } + + $checkout = new AccessoryCheckout([ + 'accessory_id' => $accessory->id, + 'created_at' => Carbon::now(), + 'assigned_to' => $target->id, + 'assigned_type' => $target::class, + 'note' => $request->get('note'), + ]); + $checkout->created_by = auth()->id(); + $checkout->save(); + + event(new CheckoutableCheckedOut( + $accessory, + $target, + auth()->user(), + $request->get('note'), + [], + 1, + )); + + return Response::make( + Response::text(trans('mcp.accessory_checked_out', ['name' => $accessory->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.accessory_checked_out', ['name' => $accessory->name]), + 'accessory_id' => $accessory->id, + 'accessory_name' => $accessory->name, + 'checkout_id' => $checkout->id, + 'checked_out_to_type' => $checkoutType, + 'checked_out_to_id' => $target->id, + ]); + } + + private function resolveAccessory(Request $request): ?Accessory + { + if ($request->filled('id')) { + return Accessory::withCount('checkouts as checkouts_count')->find($request->get('id')); + } + if ($request->filled('name')) { + return Accessory::withCount('checkouts as checkouts_count')->where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the accessory to check out'), + 'name' => $schema->string()->description('Name of the accessory to check out'), + 'checkout_to_type' => $schema->string()->description('Target type: user, location, or asset (required)'), + 'assigned_user' => $schema->number()->description('User ID to check out to'), + 'assigned_location' => $schema->number()->description('Location ID to check out to'), + 'assigned_asset' => $schema->number()->description('Asset ID to check out to'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'accessory_id' => $schema->number()->description('Numeric ID of the accessory'), + 'accessory_name' => $schema->string()->description('Name of the accessory'), + 'checkout_id' => $schema->number()->description('ID of the checkout record (use this for checkin)'), + 'checked_out_to_type' => $schema->string()->description('Type of target: user, location, or asset'), + 'checked_out_to_id' => $schema->number()->description('ID of the target'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutAssetTool.php b/app/Mcp/Tools/CheckoutAssetTool.php new file mode 100644 index 000000000000..4bd31441b509 --- /dev/null +++ b/app/Mcp/Tools/CheckoutAssetTool.php @@ -0,0 +1,145 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'id' => 'nullable|integer', + 'checkout_to_type' => 'required|string|in:user,location,asset', + 'assigned_user' => 'nullable|integer', + 'assigned_location' => 'nullable|integer', + 'assigned_asset' => 'nullable|integer', + 'note' => 'nullable|string|max:1000', + 'checkout_at' => 'nullable|date', + 'expected_checkin' => 'nullable|date', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('checkout', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if (! $asset->availableForCheckout()) { + return Response::make(Response::error(trans('mcp.asset_not_available', ['asset_tag' => $asset->asset_tag]))); + } + + $checkoutType = $request->get('checkout_to_type'); + $target = null; + + if ($checkoutType === 'user') { + $target = User::find($request->get('assigned_user')); + if ($target) { + $asset->location_id = $target->location_id ?? $asset->location_id; + } + } elseif ($checkoutType === 'location') { + $target = Location::find($request->get('assigned_location')); + if ($target) { + $asset->location_id = $target->id; + } + } elseif ($checkoutType === 'asset') { + $target = Asset::where('id', '!=', $asset->id)->find($request->get('assigned_asset')); + if ($target) { + $asset->location_id = $target->location_id ?? $asset->location_id; + } + } + + if (! $target) { + return Response::make(Response::error(trans('mcp.checkout_target_not_found', ['type' => $checkoutType]))); + } + + $checkoutAt = $request->filled('checkout_at') ? $request->get('checkout_at') : date('Y-m-d H:i:s'); + $expectedCheckin = $request->filled('expected_checkin') ? $request->get('expected_checkin') : null; + $note = $request->filled('note') ? $request->get('note') : null; + + if ($asset->checkOut($target, auth()->user(), $checkoutAt, $expectedCheckin, $note, $asset->name, $asset->location_id)) { + return Response::make( + Response::text(trans('mcp.asset_checked_out', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_checked_out', ['asset_tag' => $asset->asset_tag]), + 'asset_tag' => $asset->asset_tag, + 'checked_out_to_type' => $checkoutType, + 'checked_out_to_id' => $target->id, + ]); + } + + return Response::make(Response::error(trans('mcp.checkout_failed'))); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag')) + ->with('status') + ->first(); + } + + if ($request->filled('id')) { + return Asset::with('status')->find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string() + ->description('Asset tag of the asset to check out'), + 'id' => $schema->number() + ->description('Numeric ID of the asset to check out'), + 'checkout_to_type' => $schema->string() + ->description('What to check the asset out to: user, location, or asset') + ->required(), + 'assigned_user' => $schema->number() + ->description('ID of the user to check the asset out to (when checkout_to_type is user)'), + 'assigned_location' => $schema->number() + ->description('ID of the location to check the asset out to (when checkout_to_type is location)'), + 'assigned_asset' => $schema->number() + ->description('ID of the asset to check the asset out to (when checkout_to_type is asset)'), + 'note' => $schema->string() + ->description('Optional note to attach to this checkout'), + 'checkout_at' => $schema->string() + ->description('Checkout date/time (defaults to now, format: YYYY-MM-DD)'), + 'expected_checkin' => $schema->string() + ->description('Expected checkin date (format: YYYY-MM-DD)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->string()->description('True if the checkout succeeded'), + 'error' => $schema->string()->description('True if the checkout failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the checked-out asset'), + 'checked_out_to_type' => $schema->string()->description('Type of entity the asset was checked out to'), + 'checked_out_to_id' => $schema->number()->description('ID of the entity the asset was checked out to'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutComponentTool.php b/app/Mcp/Tools/CheckoutComponentTool.php new file mode 100644 index 000000000000..56d27caac200 --- /dev/null +++ b/app/Mcp/Tools/CheckoutComponentTool.php @@ -0,0 +1,121 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:191', + 'asset_id' => 'required|integer|exists:assets,id', + 'assigned_qty' => 'nullable|integer|min:1', + 'note' => 'nullable|string|max:65535', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $component = $this->resolveComponent($request); + + if (! $component) { + return Response::make(Response::error(trans('mcp.component_not_found'))); + } + + if (! Gate::allows('checkout', $component)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $qty = (int) $request->get('assigned_qty', 1); + + if ($component->numRemaining() < $qty) { + return Response::make(Response::error( + 'Not enough units available. Requested: '.$qty.', remaining: '.$component->numRemaining() + )); + } + + $asset = Asset::find($request->get('asset_id')); + + $component->assets()->attach($component->id, [ + 'component_id' => $component->id, + 'created_at' => Carbon::now(), + 'assigned_qty' => $qty, + 'created_by' => auth()->id(), + 'asset_id' => $asset->id, + 'note' => $request->get('note'), + ]); + + $pivotId = $component->assets()->wherePivot('asset_id', $asset->id)->latest('components_assets.created_at')->first()?->pivot->id; + + $component->logCheckout($request->get('note'), $asset, null, [], $qty); + + return Response::make( + Response::text(trans('mcp.component_checked_out', ['name' => $component->name, 'asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.component_checked_out', ['name' => $component->name, 'asset_tag' => $asset->asset_tag]), + 'component_id' => $component->id, + 'component_name' => $component->name, + 'asset_id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'assigned_qty' => $qty, + 'component_asset_id' => $pivotId, + ]); + } + + private function resolveComponent(Request $request): ?Component + { + if ($request->filled('id')) { + return Component::find($request->get('id')); + } + if ($request->filled('name')) { + return Component::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the component to check out'), + 'name' => $schema->string()->description('Name of the component to check out'), + 'asset_id' => $schema->number()->description('Asset ID to check the component out to (required)'), + 'assigned_qty' => $schema->number()->description('Number of units to check out (default: 1)'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'component_id' => $schema->number()->description('Numeric ID of the component'), + 'component_name' => $schema->string()->description('Name of the component'), + 'asset_id' => $schema->number()->description('ID of the asset checked out to'), + 'asset_tag' => $schema->string()->description('Asset tag of the asset checked out to'), + 'assigned_qty' => $schema->number()->description('Number of units checked out'), + 'component_asset_id' => $schema->number()->description('ID of the checkout record (use this for checkin)'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutConsumableTool.php b/app/Mcp/Tools/CheckoutConsumableTool.php new file mode 100644 index 000000000000..5bd834b5751f --- /dev/null +++ b/app/Mcp/Tools/CheckoutConsumableTool.php @@ -0,0 +1,113 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'assigned_to' => 'required|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $consumable = $this->resolveConsumable($request); + + if (! $consumable) { + return Response::make(Response::error(trans('mcp.consumable_not_found'))); + } + + if (! Gate::allows('checkout', $consumable)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($consumable->numRemaining() <= 0) { + return Response::make(Response::error(trans('mcp.no_units_remaining'))); + } + + $user = User::find($request->get('assigned_to')); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + $consumable->users()->attach($consumable->id, [ + 'consumable_id' => $consumable->id, + 'created_by' => auth()->id(), + 'assigned_to' => $user->id, + 'note' => $request->get('note'), + ]); + + event(new CheckoutableCheckedOut( + $consumable, + $user, + auth()->user(), + $request->get('note'), + [], + 1, + )); + + return Response::make( + Response::text(trans('mcp.consumable_checked_out', ['name' => $consumable->name, 'username' => $user->username])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.consumable_checked_out', ['name' => $consumable->name, 'username' => $user->username]), + 'consumable_id' => $consumable->id, + 'consumable_name' => $consumable->name, + 'assigned_to_id' => $user->id, + 'assigned_to_username' => $user->username, + ]); + } + + private function resolveConsumable(Request $request): ?Consumable + { + if ($request->filled('id')) { + return Consumable::find($request->get('id')); + } + if ($request->filled('name')) { + return Consumable::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the consumable to check out'), + 'name' => $schema->string()->description('Name of the consumable to check out'), + 'assigned_to' => $schema->number()->description('User ID to check out to (required)'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'consumable_id' => $schema->number()->description('Numeric ID of the consumable'), + 'consumable_name' => $schema->string()->description('Name of the consumable'), + 'assigned_to_id' => $schema->number()->description('ID of the user the consumable was checked out to'), + 'assigned_to_username' => $schema->string()->description('Username of the user the consumable was checked out to'), + ]; + } +} diff --git a/app/Mcp/Tools/CheckoutLicenseTool.php b/app/Mcp/Tools/CheckoutLicenseTool.php new file mode 100644 index 000000000000..256b3ec5018a --- /dev/null +++ b/app/Mcp/Tools/CheckoutLicenseTool.php @@ -0,0 +1,149 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'assigned_to' => 'nullable|integer', + 'asset_id' => 'nullable|integer', + 'note' => 'nullable|string|max:65535', + ]); + + $license = $this->resolveLicense($request); + + if (! $license) { + return Response::make(Response::error(trans('mcp.license_not_found'))); + } + + if (! Gate::allows('checkout', $license)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($license->numRemaining() < 1) { + return Response::make(Response::error(trans('mcp.no_available_seats'))); + } + + if (! $request->filled('assigned_to') && ! $request->filled('asset_id')) { + return Response::make(Response::error(trans('mcp.provide_user_or_asset'))); + } + + $seat = $license->freeSeat(); + + if (! $seat) { + return Response::make(Response::error(trans('mcp.no_free_seat'))); + } + + $note = $request->get('note'); + + if ($request->filled('assigned_to')) { + $target = User::find($request->get('assigned_to')); + if (! $target) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + $seat->assigned_to = $target->id; + $seat->notes = $note; + + if ($seat->save()) { + event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1)); + + return Response::make( + Response::text(trans('mcp.license_seat_checked_out_user', ['username' => $target->username])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.license_seat_checked_out_user', ['username' => $target->username]), + 'license_id' => $license->id, + 'license_name' => $license->name, + 'seat_id' => $seat->id, + 'assigned_to_type' => 'user', + 'assigned_to_id' => $target->id, + ]); + } + } elseif ($request->filled('asset_id')) { + $target = Asset::find($request->get('asset_id')); + if (! $target) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + $seat->asset_id = $target->id; + if ($target->checkedOutToUser()) { + $seat->assigned_to = $target->assigned_to; + } + $seat->notes = $note; + + if ($seat->save()) { + event(new CheckoutableCheckedOut($seat, $target, auth()->user(), $note, [], 1)); + + return Response::make( + Response::text(trans('mcp.license_seat_checked_out_asset', ['asset_tag' => $target->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.license_seat_checked_out_asset', ['asset_tag' => $target->asset_tag]), + 'license_id' => $license->id, + 'license_name' => $license->name, + 'seat_id' => $seat->id, + 'assigned_to_type' => 'asset', + 'assigned_to_id' => $target->id, + ]); + } + } + + return Response::make(Response::error(trans('mcp.checkout_failed'))); + } + + private function resolveLicense(Request $request): ?License + { + if ($request->filled('id')) { + return License::find($request->get('id')); + } + if ($request->filled('name')) { + return License::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the license to check out'), + 'name' => $schema->string()->description('Name of the license to check out'), + 'assigned_to' => $schema->number()->description('User ID to assign the seat to'), + 'asset_id' => $schema->number()->description('Asset ID to assign the seat to'), + 'note' => $schema->string()->description('Optional checkout note'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the checkout succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'license_id' => $schema->number()->description('Numeric ID of the license'), + 'license_name' => $schema->string()->description('Name of the license'), + 'seat_id' => $schema->number()->description('ID of the seat record (use this for checkin)'), + 'assigned_to_type' => $schema->string()->description('Type of entity checked out to: user or asset'), + 'assigned_to_id' => $schema->number()->description('ID of the entity checked out to'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateAccessoryTool.php b/app/Mcp/Tools/CreateAccessoryTool.php new file mode 100644 index 000000000000..e93b94f7121a --- /dev/null +++ b/app/Mcp/Tools/CreateAccessoryTool.php @@ -0,0 +1,107 @@ +validate([ + 'name' => 'required|string|max:255', + 'category_id' => 'required|integer|exists:categories,id', + 'qty' => 'nullable|integer|min:0', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $accessory = new Accessory; + $accessory->fill($request->only([ + 'name', 'category_id', 'qty', 'model_number', 'manufacturer_id', + 'supplier_id', 'location_id', 'order_number', 'purchase_cost', + 'purchase_date', 'min_amt', 'requestable', 'notes', + ])); + + $accessory->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $accessory->created_by = auth()->id(); + + if ($accessory->save()) { + return Response::make( + Response::text(trans('mcp.accessory_created', ['name' => $accessory->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.accessory_created', ['name' => $accessory->name]), + 'id' => $accessory->id, + 'name' => $accessory->name, + 'qty' => $accessory->qty, + 'category_id' => $accessory->category_id, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $accessory->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Accessory name (required)'), + 'category_id' => $schema->number()->description('Category ID — must be an accessory category (required)'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity threshold for alerts'), + 'requestable' => $schema->boolean()->description('Whether users can request this accessory'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the accessory was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new accessory'), + 'name' => $schema->string()->description('Name of the new accessory'), + 'qty' => $schema->number()->description('Total quantity'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateAssetModelTool.php b/app/Mcp/Tools/CreateAssetModelTool.php new file mode 100644 index 000000000000..10372f815572 --- /dev/null +++ b/app/Mcp/Tools/CreateAssetModelTool.php @@ -0,0 +1,97 @@ +validate([ + 'name' => 'required|string|max:255', + 'category_id' => 'required|integer|exists:categories,id', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'depreciation_id' => 'nullable|integer|exists:depreciations,id', + 'eol' => 'nullable|integer|min:0|max:240', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + 'requestable' => 'nullable|boolean', + 'require_serial' => 'nullable|boolean', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $assetModel = new AssetModel; + $assetModel->name = $request->get('name'); + $assetModel->category_id = $request->get('category_id'); + $assetModel->created_by = auth()->id(); + + foreach (['model_number', 'manufacturer_id', 'depreciation_id', 'eol', 'min_amt', 'notes', 'requestable', 'require_serial'] as $f) { + if ($request->filled($f)) { + $assetModel->{$f} = $request->get($f); + } + } + + if ($assetModel->save()) { + return Response::make( + Response::text(trans('mcp.asset_model_created', ['name' => $assetModel->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_model_created', ['name' => $assetModel->name]), + 'id' => $assetModel->id, + 'name' => $assetModel->name, + 'category_id' => $assetModel->category_id, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $assetModel->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Asset model name (required)'), + 'category_id' => $schema->number()->description('Category ID (required)'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'depreciation_id' => $schema->number()->description('Depreciation schedule ID'), + 'eol' => $schema->number()->description('End of life in months (0-240)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + 'requestable' => $schema->boolean()->description('Whether the model can be requested'), + 'require_serial' => $schema->boolean()->description('Whether serial numbers are required'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the asset model was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new asset model'), + 'name' => $schema->string()->description('Name of the new asset model'), + 'category_id' => $schema->number()->description('Category ID of the new asset model'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateAssetTool.php b/app/Mcp/Tools/CreateAssetTool.php new file mode 100644 index 000000000000..3f0101739212 --- /dev/null +++ b/app/Mcp/Tools/CreateAssetTool.php @@ -0,0 +1,108 @@ +validate([ + 'model_id' => 'required|integer|exists:models,id', + 'status_id' => 'required|integer|exists:status_labels,id', + 'asset_tag' => 'required|string|max:255', + 'name' => 'nullable|string|max:255', + 'serial' => 'nullable|string', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'rtd_location_id' => 'nullable|integer|exists:locations,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'purchase_cost' => 'nullable|numeric', + 'order_number' => 'nullable|string|max:191', + 'warranty_months' => 'nullable|integer|min:0|max:240', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string|max:65535', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $asset = new Asset; + $asset->model_id = $request->get('model_id'); + $asset->status_id = $request->get('status_id'); + $asset->asset_tag = $request->get('asset_tag'); + $asset->created_by = auth()->id(); + + foreach (['name', 'serial', 'company_id', 'location_id', 'rtd_location_id', 'supplier_id', 'purchase_date', 'purchase_cost', 'order_number', 'warranty_months', 'requestable', 'notes'] as $field) { + if ($request->filled($field)) { + $asset->{$field} = $request->get($field); + } + } + + if ($asset->save()) { + return Response::make( + Response::text(trans('mcp.asset_created', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_created', ['asset_tag' => $asset->asset_tag]), + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $asset->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'model_id' => $schema->number()->description('Asset model ID (required)'), + 'status_id' => $schema->number()->description('Status label ID (required)'), + 'asset_tag' => $schema->string()->description('Asset tag (required)'), + 'name' => $schema->string()->description('Display name for the asset'), + 'serial' => $schema->string()->description('Serial number'), + 'company_id' => $schema->number()->description('Company ID'), + 'location_id' => $schema->number()->description('Current location ID'), + 'rtd_location_id' => $schema->number()->description('Default RTD location ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'order_number' => $schema->string()->description('Order number'), + 'warranty_months' => $schema->number()->description('Warranty length in months (0-240)'), + 'requestable' => $schema->boolean()->description('Whether the asset is user-requestable'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the asset was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new asset'), + 'asset_tag' => $schema->string()->description('Asset tag of the new asset'), + 'name' => $schema->string()->description('Display name of the new asset'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateCategoryTool.php b/app/Mcp/Tools/CreateCategoryTool.php new file mode 100644 index 000000000000..51d516192bcc --- /dev/null +++ b/app/Mcp/Tools/CreateCategoryTool.php @@ -0,0 +1,89 @@ +validate([ + 'name' => 'required|string|max:255', + 'category_type' => 'required|string|in:asset,accessory,consumable,component,license', + 'checkin_email' => 'nullable|boolean', + 'require_acceptance' => 'nullable|boolean', + 'use_default_eula' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $category = new Category; + $category->name = $request->get('name'); + $category->category_type = $request->get('category_type'); + $category->created_by = auth()->id(); + + foreach (['checkin_email', 'require_acceptance', 'use_default_eula', 'notes'] as $field) { + if ($request->filled($field)) { + $category->{$field} = $request->get($field); + } + } + + if ($category->save()) { + return Response::make( + Response::text(trans('mcp.category_created', ['name' => $category->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.category_created', ['name' => $category->name]), + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $category->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Category name (required)'), + 'category_type' => $schema->string()->description('Category type (required): asset, accessory, consumable, component, or license'), + 'checkin_email' => $schema->boolean()->description('Send checkin email when items are checked in'), + 'require_acceptance' => $schema->boolean()->description('Require user acceptance when checking out'), + 'use_default_eula' => $schema->boolean()->description('Use the default EULA'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the category was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new category'), + 'name' => $schema->string()->description('Name of the new category'), + 'category_type' => $schema->string()->description('Type of the new category'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateCompanyTool.php b/app/Mcp/Tools/CreateCompanyTool.php new file mode 100644 index 000000000000..c4737cc2ed1a --- /dev/null +++ b/app/Mcp/Tools/CreateCompanyTool.php @@ -0,0 +1,90 @@ +validate([ + 'name' => 'required|string|max:255', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $company = new Company; + $company->name = $request->get('name'); + if ($request->filled('phone')) { + $company->phone = $request->get('phone'); + } + if ($request->filled('fax')) { + $company->fax = $request->get('fax'); + } + if ($request->filled('email')) { + $company->email = $request->get('email'); + } + if ($request->filled('notes')) { + $company->notes = $request->get('notes'); + } + $company->created_by = auth()->id(); + + if ($company->save()) { + return Response::make( + Response::text(trans('mcp.company_created', ['name' => $company->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.company_created', ['name' => $company->name]), + 'id' => $company->id, + 'name' => $company->name, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $company->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Company name (required)'), + 'phone' => $schema->string()->description('Company phone number'), + 'fax' => $schema->string()->description('Company fax number'), + 'email' => $schema->string()->description('Company email address'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the company was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new company'), + 'name' => $schema->string()->description('Name of the new company'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateComponentTool.php b/app/Mcp/Tools/CreateComponentTool.php new file mode 100644 index 000000000000..b0d5d3bb5810 --- /dev/null +++ b/app/Mcp/Tools/CreateComponentTool.php @@ -0,0 +1,107 @@ +validate([ + 'name' => 'required|string|max:191', + 'category_id' => 'required|integer|exists:categories,id', + 'qty' => 'required|integer|min:1', + 'serial' => 'nullable|string|max:255', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $component = new Component; + $component->fill($request->only([ + 'name', 'category_id', 'qty', 'serial', 'model_number', + 'manufacturer_id', 'supplier_id', 'location_id', + 'order_number', 'purchase_cost', 'purchase_date', 'min_amt', 'notes', + ])); + + $component->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $component->created_by = auth()->id(); + + if ($component->save()) { + return Response::make( + Response::text(trans('mcp.component_created', ['name' => $component->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.component_created', ['name' => $component->name]), + 'id' => $component->id, + 'name' => $component->name, + 'qty' => $component->qty, + 'category_id' => $component->category_id, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $component->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Component name (required)'), + 'category_id' => $schema->number()->description('Category ID — must be a component category (required)'), + 'qty' => $schema->number()->description('Total quantity in stock (required, min 1)'), + 'serial' => $schema->string()->description('Serial number'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity threshold for alerts'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the component was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new component'), + 'name' => $schema->string()->description('Name of the new component'), + 'qty' => $schema->number()->description('Total quantity'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateConsumableTool.php b/app/Mcp/Tools/CreateConsumableTool.php new file mode 100644 index 000000000000..402825505b4c --- /dev/null +++ b/app/Mcp/Tools/CreateConsumableTool.php @@ -0,0 +1,106 @@ +validate([ + 'name' => 'required|string|max:255', + 'qty' => 'required|integer|min:0', + 'category_id' => 'required|integer|exists:categories,id', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'item_no' => 'nullable|string|max:255', + 'order_number' => 'nullable|string|max:255', + 'model_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $consumable = new Consumable; + $consumable->fill($request->only([ + 'name', 'qty', 'category_id', 'company_id', 'location_id', 'manufacturer_id', + 'supplier_id', 'item_no', 'order_number', 'model_number', 'purchase_cost', + 'purchase_date', 'min_amt', 'requestable', 'notes', + ])); + $consumable->created_by = auth()->id(); + + if ($consumable->save()) { + return Response::make( + Response::text(trans('mcp.consumable_created', ['name' => $consumable->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.consumable_created', ['name' => $consumable->name]), + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + 'category_id' => $consumable->category_id, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $consumable->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Consumable name (required)'), + 'qty' => $schema->number()->description('Total quantity in stock (required)'), + 'category_id' => $schema->number()->description('Category ID — must be a consumable category (required)'), + 'company_id' => $schema->number()->description('Company ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'item_no' => $schema->string()->description('Item number'), + 'order_number' => $schema->string()->description('Order number'), + 'model_number' => $schema->string()->description('Model number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity threshold for alerts'), + 'requestable' => $schema->boolean()->description('Whether users can request this consumable'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the consumable was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new consumable'), + 'name' => $schema->string()->description('Name of the new consumable'), + 'qty' => $schema->number()->description('Total quantity'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateDepartmentTool.php b/app/Mcp/Tools/CreateDepartmentTool.php new file mode 100644 index 000000000000..ec4c439bf158 --- /dev/null +++ b/app/Mcp/Tools/CreateDepartmentTool.php @@ -0,0 +1,87 @@ +validate([ + 'name' => 'required|string|max:255', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'phone' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:255', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $department = new Department; + $department->fill($request->only([ + 'name', 'location_id', 'manager_id', 'phone', 'fax', 'notes', + ])); + + $department->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $department->created_by = auth()->id(); + + if ($department->save()) { + return Response::make( + Response::text(trans('mcp.department_created', ['name' => $department->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.department_created', ['name' => $department->name]), + 'id' => $department->id, + 'name' => $department->name, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $department->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Department name (required)'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'manager_id' => $schema->number()->description('User ID of the department manager'), + 'phone' => $schema->string()->description('Department phone number'), + 'fax' => $schema->string()->description('Department fax number'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the department was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new department'), + 'name' => $schema->string()->description('Name of the new department'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateDepreciationTool.php b/app/Mcp/Tools/CreateDepreciationTool.php new file mode 100644 index 000000000000..c7f75a115093 --- /dev/null +++ b/app/Mcp/Tools/CreateDepreciationTool.php @@ -0,0 +1,74 @@ +validate([ + 'name' => 'required|string|max:255', + 'months' => 'required|integer|min:1|max:3600', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $depreciation = new Depreciation; + $depreciation->name = $request->get('name'); + $depreciation->months = $request->get('months'); + + if ($depreciation->save()) { + return Response::make( + Response::text(trans('mcp.depreciation_created', ['name' => $depreciation->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.depreciation_created', ['name' => $depreciation->name]), + 'id' => $depreciation->id, + 'name' => $depreciation->name, + 'months' => $depreciation->months, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $depreciation->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Depreciation name (required)'), + 'months' => $schema->number()->description('Depreciation period in months (required, 1-3600)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the depreciation was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new depreciation'), + 'name' => $schema->string()->description('Name of the new depreciation'), + 'months' => $schema->number()->description('Depreciation period in months'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateGroupTool.php b/app/Mcp/Tools/CreateGroupTool.php new file mode 100644 index 000000000000..c932cf8ad3fa --- /dev/null +++ b/app/Mcp/Tools/CreateGroupTool.php @@ -0,0 +1,130 @@ +validate([ + 'name' => 'required|string|max:255', + 'permissions' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $permissions = null; + if ($request->filled('permissions')) { + $result = $this->parseAndValidatePermissions($request->get('permissions')); + if (is_string($result)) { + return Response::make(Response::error($result)); + } + $permissions = $result; + } + + $group = new Group; + $group->name = $request->get('name'); + if ($permissions !== null) { + $group->permissions = json_encode($permissions); + } + if ($request->filled('notes')) { + $group->notes = $request->get('notes'); + } + $group->created_by = auth()->id(); + + if ($group->save()) { + return Response::make( + Response::text(trans('mcp.group_created', ['name' => $group->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.group_created', ['name' => $group->name]), + 'id' => $group->id, + 'name' => $group->name, + 'permissions' => $group->decodePermissions(), + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $group->getErrors()->first()]))); + } + + /** + * Parse a JSON permissions string and validate all keys against config('permissions'). + * Returns the decoded array on success, or an error string on failure. + */ + private function parseAndValidatePermissions(string $raw): array|string + { + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + return trans('mcp.invalid_permissions_format'); + } + + $validKeys = collect(config('permissions')) + ->flatMap(fn ($perms) => collect($perms)->pluck('permission')) + ->unique() + ->flip() + ->all(); + + foreach (array_keys($decoded) as $key) { + if (! isset($validKeys[$key])) { + return trans('mcp.invalid_permission_key', ['key' => $key]); + } + if (! in_array((int) $decoded[$key], [1, -1], true)) { + return trans('mcp.invalid_permission_value', ['key' => $key]); + } + } + + return array_map('intval', $decoded); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Group name (required, must be unique)'), + 'permissions' => $schema->string()->description( + 'JSON object mapping permission keys to 1 (grant) or -1 (deny). '. + 'Valid keys include: superuser, admin, import, reports.view, '. + 'assets.view, assets.create, assets.edit, assets.delete, assets.checkout, assets.checkin, assets.audit, '. + 'users.view, users.create, users.edit, users.delete, '. + 'licenses.view, licenses.create, licenses.edit, licenses.delete, licenses.checkout, licenses.checkin, '. + 'accessories.view, accessories.create, accessories.edit, accessories.delete, accessories.checkout, accessories.checkin, '. + 'components.view, components.create, components.edit, components.delete, components.checkout, components.checkin, '. + 'consumables.view, consumables.create, consumables.edit, consumables.delete, consumables.checkout, '. + 'and many more. Example: {"assets.view":1,"assets.create":1,"assets.edit":-1}' + ), + 'notes' => $schema->string()->description('Notes about the group'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the group was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new group'), + 'name' => $schema->string()->description('Name of the new group'), + 'permissions' => $schema->object()->description('Permissions set on the group'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateLicenseTool.php b/app/Mcp/Tools/CreateLicenseTool.php new file mode 100644 index 000000000000..14f0ad0e3fef --- /dev/null +++ b/app/Mcp/Tools/CreateLicenseTool.php @@ -0,0 +1,119 @@ +validate([ + 'name' => 'required|string|max:255', + 'seats' => 'required|integer|min:1', + 'category_id' => 'required|integer|exists:categories,id', + 'serial' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_order' => 'nullable|string|max:255', + 'order_number' => 'nullable|string|max:255', + 'expiration_date' => 'nullable|date_format:Y-m-d', + 'termination_date' => 'nullable|date_format:Y-m-d', + 'license_name' => 'nullable|string|max:255', + 'license_email' => 'nullable|email|max:255', + 'maintained' => 'nullable|boolean', + 'reassignable' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'min_amt' => 'nullable|integer|min:0', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $license = new License; + $license->fill($request->only([ + 'name', 'seats', 'category_id', 'serial', 'manufacturer_id', + 'supplier_id', 'purchase_date', 'purchase_cost', 'purchase_order', + 'order_number', 'expiration_date', 'termination_date', + 'license_name', 'license_email', 'maintained', 'reassignable', + 'notes', 'min_amt', + ])); + + $license->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $license->created_by = auth()->id(); + + if ($license->save()) { + return Response::make( + Response::text(trans('mcp.license_created', ['name' => $license->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.license_created', ['name' => $license->name]), + 'id' => $license->id, + 'name' => $license->name, + 'seats' => $license->seats, + 'category_id' => $license->category_id, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $license->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('License name (required)'), + 'seats' => $schema->number()->description('Number of seats (required, min 1)'), + 'category_id' => $schema->number()->description('Category ID — must be a license category (required)'), + 'serial' => $schema->string()->description('Product key / serial number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'purchase_order' => $schema->string()->description('Purchase order number'), + 'order_number' => $schema->string()->description('Order number'), + 'expiration_date' => $schema->string()->description('License expiration date (YYYY-MM-DD)'), + 'termination_date' => $schema->string()->description('License termination date (YYYY-MM-DD)'), + 'license_name' => $schema->string()->description('Name of the licensed user/organization'), + 'license_email' => $schema->string()->description('Email of the licensed user/organization'), + 'maintained' => $schema->boolean()->description('Whether the license is under maintenance'), + 'reassignable' => $schema->boolean()->description('Whether seats can be reassigned after checkin'), + 'notes' => $schema->string()->description('Notes'), + 'min_amt' => $schema->number()->description('Minimum seat threshold for alerts'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the license was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new license'), + 'name' => $schema->string()->description('Name of the new license'), + 'seats' => $schema->number()->description('Total seat count'), + 'category_id' => $schema->number()->description('Category ID'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateLocationTool.php b/app/Mcp/Tools/CreateLocationTool.php new file mode 100644 index 000000000000..81f43893dfb6 --- /dev/null +++ b/app/Mcp/Tools/CreateLocationTool.php @@ -0,0 +1,97 @@ +validate([ + 'name' => 'required|string|max:255', + 'address' => 'nullable|string', + 'address2' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:255', + 'currency' => 'nullable|string', + 'parent_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $location = new Location; + $location->name = $request->get('name'); + + foreach (['address', 'address2', 'city', 'state', 'country', 'zip', 'phone', 'fax', 'currency', 'parent_id', 'manager_id'] as $field) { + if ($request->filled($field)) { + $location->{$field} = $request->get($field); + } + } + + if ($location->save()) { + return Response::make( + Response::text(trans('mcp.location_created', ['name' => $location->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.location_created', ['name' => $location->name]), + 'id' => $location->id, + 'name' => $location->name, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $location->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Location name (required)'), + 'address' => $schema->string()->description('Street address'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Zip code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'currency' => $schema->string()->description('Currency code'), + 'parent_id' => $schema->number()->description('Parent location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the location was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new location'), + 'name' => $schema->string()->description('Name of the new location'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateMaintenanceTool.php b/app/Mcp/Tools/CreateMaintenanceTool.php new file mode 100644 index 000000000000..4d2157fab515 --- /dev/null +++ b/app/Mcp/Tools/CreateMaintenanceTool.php @@ -0,0 +1,105 @@ +validate([ + 'asset_id' => 'required|integer|exists:assets,id', + 'title' => 'required|string|max:255', + 'asset_maintenance_type' => 'nullable|string|max:255', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'is_warranty' => 'nullable|boolean', + 'cost' => 'nullable|numeric|min:0', + 'start_date' => 'nullable|date_format:Y-m-d', + 'completion_date' => 'nullable|date_format:Y-m-d', + 'notes' => 'nullable|string', + 'user_id' => 'nullable|integer|exists:users,id', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $maintenance = new Maintenance; + $maintenance->asset_id = $request->get('asset_id'); + $maintenance->name = $request->get('title'); + $maintenance->asset_maintenance_type = $request->get('asset_maintenance_type', 'Maintenance'); + $maintenance->start_date = $request->filled('start_date') ? $request->get('start_date') : now()->format('Y-m-d'); + $maintenance->created_by = auth()->id(); + $maintenance->is_warranty = 0; + + foreach (['supplier_id', 'is_warranty', 'cost', 'completion_date', 'notes', 'user_id'] as $field) { + if ($request->filled($field)) { + $maintenance->{$field} = $request->get($field); + } + } + + if ($maintenance->save()) { + $maintenance->load('asset'); + + return Response::make( + Response::text(trans('mcp.maintenance_created', ['name' => $maintenance->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.maintenance_created', ['name' => $maintenance->name]), + 'id' => $maintenance->id, + 'title' => $maintenance->name, + 'asset_id' => $maintenance->asset_id, + 'asset_tag' => $maintenance->asset?->asset_tag, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $maintenance->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->number()->description('Asset ID the maintenance is for (required)'), + 'title' => $schema->string()->description('Maintenance title/name (required)'), + 'asset_maintenance_type' => $schema->string()->description('Type of maintenance (e.g. maintenance, repair, upgrade)'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'is_warranty' => $schema->boolean()->description('Whether this is a warranty maintenance'), + 'cost' => $schema->number()->description('Cost of the maintenance'), + 'start_date' => $schema->string()->description('Start date (YYYY-MM-DD, defaults to today)'), + 'completion_date' => $schema->string()->description('Completion date (YYYY-MM-DD)'), + 'notes' => $schema->string()->description('Notes about the maintenance'), + 'user_id' => $schema->number()->description('Technician user ID'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the maintenance was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new maintenance record'), + 'title' => $schema->string()->description('Title of the maintenance'), + 'asset_id' => $schema->number()->description('Asset ID'), + 'asset_tag' => $schema->string()->description('Asset tag'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateManufacturerTool.php b/app/Mcp/Tools/CreateManufacturerTool.php new file mode 100644 index 000000000000..bcbd2cf3541c --- /dev/null +++ b/app/Mcp/Tools/CreateManufacturerTool.php @@ -0,0 +1,83 @@ +validate([ + 'name' => 'required|string|max:255', + 'url' => 'nullable|string|max:255', + 'support_url' => 'nullable|string|max:255', + 'support_email' => 'nullable|email|max:191', + 'support_phone' => 'nullable|string|max:191', + 'warranty_lookup_url' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $manufacturer = new Manufacturer; + $manufacturer->fill($request->only([ + 'name', 'url', 'support_url', 'support_email', 'support_phone', 'warranty_lookup_url', 'notes', + ])); + + if ($manufacturer->save()) { + return Response::make( + Response::text(trans('mcp.manufacturer_created', ['name' => $manufacturer->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.manufacturer_created', ['name' => $manufacturer->name]), + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $manufacturer->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Manufacturer name (required)'), + 'url' => $schema->string()->description('Manufacturer website URL'), + 'support_url' => $schema->string()->description('Support website URL'), + 'support_email' => $schema->string()->description('Support email address'), + 'support_phone' => $schema->string()->description('Support phone number'), + 'warranty_lookup_url' => $schema->string()->description('Warranty lookup URL'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the manufacturer was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new manufacturer'), + 'name' => $schema->string()->description('Name of the new manufacturer'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateStatusLabelTool.php b/app/Mcp/Tools/CreateStatusLabelTool.php new file mode 100644 index 000000000000..81b2bea47f5e --- /dev/null +++ b/app/Mcp/Tools/CreateStatusLabelTool.php @@ -0,0 +1,95 @@ +validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|string|in:deployable,pending,archived,undeployable', + 'color' => 'nullable|string', + 'notes' => 'nullable|string', + 'default_label' => 'nullable|boolean', + 'show_in_nav' => 'nullable|boolean', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $statuslabel = new Statuslabel; + $statuslabel->name = $request->get('name'); + + $statusType = Statuslabel::getStatuslabelTypesForDB($request->get('type')); + $statuslabel->deployable = $statusType['deployable']; + $statuslabel->pending = $statusType['pending']; + $statuslabel->archived = $statusType['archived']; + + if ($request->filled('color')) { + $statuslabel->color = $request->get('color'); + } + if ($request->filled('notes')) { + $statuslabel->notes = $request->get('notes'); + } + $statuslabel->default_label = $request->get('default_label', 0); + $statuslabel->show_in_nav = $request->get('show_in_nav', 0); + + if ($statuslabel->save()) { + return Response::make( + Response::text(trans('mcp.status_label_created', ['name' => $statuslabel->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.status_label_created', ['name' => $statuslabel->name]), + 'id' => $statuslabel->id, + 'name' => $statuslabel->name, + 'type' => $statuslabel->getStatuslabelType(), + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $statuslabel->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Status label name (required)'), + 'type' => $schema->string()->description('Status label type: deployable, pending, archived, or undeployable (required)'), + 'color' => $schema->string()->description('Display color in #RRGGBB format'), + 'notes' => $schema->string()->description('Notes'), + 'default_label' => $schema->boolean()->description('Whether this is the default label'), + 'show_in_nav' => $schema->boolean()->description('Whether to show in navigation'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the status label was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new status label'), + 'name' => $schema->string()->description('Name of the new status label'), + 'type' => $schema->string()->description('Type of the new status label'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateSupplierTool.php b/app/Mcp/Tools/CreateSupplierTool.php new file mode 100644 index 000000000000..6fcb47dbef62 --- /dev/null +++ b/app/Mcp/Tools/CreateSupplierTool.php @@ -0,0 +1,96 @@ +validate([ + 'name' => 'required|string|max:255', + 'address' => 'nullable|string', + 'address2' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|email', + 'url' => 'nullable|string', + 'contact' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + $supplier = new Supplier; + $supplier->fill($request->only([ + 'name', 'address', 'address2', 'city', 'state', 'country', 'zip', + 'phone', 'fax', 'email', 'url', 'contact', 'notes', + ])); + + if ($supplier->save()) { + return Response::make( + Response::text(trans('mcp.supplier_created', ['name' => $supplier->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.supplier_created', ['name' => $supplier->name]), + 'id' => $supplier->id, + 'name' => $supplier->name, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $supplier->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Supplier name (required)'), + 'address' => $schema->string()->description('Address line 1'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'email' => $schema->string()->description('Email address'), + 'url' => $schema->string()->description('Website URL'), + 'contact' => $schema->string()->description('Contact name'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the supplier was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new supplier'), + 'name' => $schema->string()->description('Name of the new supplier'), + ]; + } +} diff --git a/app/Mcp/Tools/CreateUserTool.php b/app/Mcp/Tools/CreateUserTool.php new file mode 100644 index 000000000000..19831be842d0 --- /dev/null +++ b/app/Mcp/Tools/CreateUserTool.php @@ -0,0 +1,155 @@ +validate([ + 'first_name' => 'required|string|max:191', + 'last_name' => 'nullable|string|max:191', + 'username' => 'required|string|max:191', + 'email' => 'nullable|email|max:191', + 'password' => 'nullable|string|min:8', + 'employee_num' => 'nullable|string|max:191', + 'jobtitle' => 'nullable|string|max:191', + 'phone' => 'nullable|string|max:35', + 'mobile' => 'nullable|string|max:35', + 'company_id' => 'nullable|integer|exists:companies,id', + 'department_id' => 'nullable|integer|exists:departments,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'activated' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'vip' => 'nullable|boolean', + 'remote' => 'nullable|boolean', + 'website' => 'nullable|url|max:191', + 'address' => 'nullable|string|max:191', + 'city' => 'nullable|string|max:191', + 'state' => 'nullable|string|max:191', + 'country' => 'nullable|string|max:191', + 'zip' => 'nullable|string|max:10', + 'group_ids' => 'nullable|array', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + if (User::where('username', $request->get('username'))->exists()) { + return Response::make(Response::error(trans('mcp.username_taken', ['username' => $request->get('username')]))); + } + + $user = new User; + $user->fill($request->only([ + 'first_name', 'last_name', 'username', 'email', 'employee_num', + 'jobtitle', 'phone', 'mobile', 'department_id', 'location_id', + 'manager_id', 'notes', 'start_date', 'end_date', 'vip', 'remote', + 'website', 'address', 'city', 'state', 'country', 'zip', + ])); + + $user->activated = $request->filled('activated') ? (bool) $request->get('activated') : true; + $user->company_id = Company::getIdForCurrentUser($request->get('company_id')); + $user->created_by = auth()->id(); + + if ($request->filled('password')) { + $user->password = bcrypt($request->get('password')); + } else { + $user->password = $user->noPassword(); + } + + if ($user->save()) { + $groupIds = []; + if ($request->filled('group_ids') && auth()->user()->isSuperUser()) { + $groupIds = Group::whereIn('id', $request->get('group_ids'))->pluck('id')->all(); + $user->groups()->sync($groupIds); + } elseif ($request->filled('group_ids')) { + return Response::make(Response::error(trans('mcp.superadmin_required_for_groups'))); + } + + return Response::make( + Response::text(trans('mcp.user_created', ['username' => $user->username])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.user_created', ['username' => $user->username]), + 'id' => $user->id, + 'username' => $user->username, + 'email' => $user->email, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'group_ids' => $groupIds, + ]); + } + + return Response::make(Response::error(trans('mcp.create_failed', ['error' => $user->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'first_name' => $schema->string()->description('First name (required)'), + 'last_name' => $schema->string()->description('Last name'), + 'username' => $schema->string()->description('Username (required, must be unique)'), + 'email' => $schema->string()->description('Email address'), + 'password' => $schema->string()->description('Password (min 8 characters; if omitted, account will have no password set)'), + 'employee_num' => $schema->string()->description('Employee number'), + 'jobtitle' => $schema->string()->description('Job title'), + 'phone' => $schema->string()->description('Phone number'), + 'mobile' => $schema->string()->description('Mobile number'), + 'company_id' => $schema->number()->description('Company ID (defaults to the authenticated user\'s company)'), + 'department_id' => $schema->number()->description('Department ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + 'activated' => $schema->boolean()->description('Whether the account is active (default: true)'), + 'notes' => $schema->string()->description('Notes'), + 'start_date' => $schema->string()->description('Employment start date (YYYY-MM-DD)'), + 'end_date' => $schema->string()->description('Employment end date (YYYY-MM-DD)'), + 'vip' => $schema->boolean()->description('Mark user as VIP'), + 'remote' => $schema->boolean()->description('Mark user as remote'), + 'website' => $schema->string()->description('Website URL'), + 'address' => $schema->string()->description('Street address'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State/province'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal/ZIP code'), + 'group_ids' => $schema->array()->description('Array of permission group IDs to assign (requires superadmin). Example: [1, 3]'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the user was created'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the new user'), + 'username' => $schema->string()->description('Username of the new user'), + 'email' => $schema->string()->description('Email of the new user'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteAccessoryTool.php b/app/Mcp/Tools/DeleteAccessoryTool.php new file mode 100644 index 000000000000..6dd3f5c0d6dc --- /dev/null +++ b/app/Mcp/Tools/DeleteAccessoryTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $accessory = $this->resolveAccessory($request); + + if (! $accessory) { + return Response::make(Response::error(trans('mcp.accessory_not_found'))); + } + + if (! Gate::allows('delete', $accessory)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($accessory->numCheckedOut() > 0) { + return Response::make(Response::error(trans('mcp.accessory_has_checkouts'))); + } + + $name = $accessory->name; + + $accessory->delete(); + + return Response::make( + Response::text(trans('mcp.accessory_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.accessory_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveAccessory(Request $request): ?Accessory + { + if ($request->filled('id')) { + return Accessory::withCount('checkouts as checkouts_count')->find($request->get('id')); + } + if ($request->filled('name')) { + return Accessory::withCount('checkouts as checkouts_count')->where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the accessory to delete'), + 'name' => $schema->string()->description('Name of the accessory to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted accessory'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteAssetModelTool.php b/app/Mcp/Tools/DeleteAssetModelTool.php new file mode 100644 index 000000000000..b57f0b0f4e71 --- /dev/null +++ b/app/Mcp/Tools/DeleteAssetModelTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $model = $this->resolveModel($request); + + if (! $model) { + return Response::make(Response::error(trans('mcp.asset_model_not_found'))); + } + + if (! Gate::allows('delete', $model)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($model->assets()->count() > 0) { + return Response::make(Response::error(trans('mcp.model_has_assets'))); + } + + $name = $model->name; + + $model->delete(); + + return Response::make( + Response::text(trans('mcp.asset_model_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_model_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveModel(Request $request): ?AssetModel + { + if ($request->filled('id')) { + return AssetModel::find($request->get('id')); + } + if ($request->filled('name')) { + return AssetModel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the asset model to delete'), + 'name' => $schema->string()->description('Name of the asset model to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted asset model'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteAssetTool.php b/app/Mcp/Tools/DeleteAssetTool.php new file mode 100644 index 000000000000..644fcdee0449 --- /dev/null +++ b/app/Mcp/Tools/DeleteAssetTool.php @@ -0,0 +1,94 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('delete', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $assetTag = $asset->asset_tag; + + if ($asset->assignedTo) { + $target = $asset->assignedTo; + $originalValues = $asset->getRawOriginal(); + event(new CheckoutableCheckedIn($asset, $target, auth()->user(), 'Checked in on delete', date('Y-m-d H:i:s'), $originalValues)); + DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null]); + } + + $asset->delete(); + + return Response::make( + Response::text(trans('mcp.asset_deleted', ['asset_tag' => $assetTag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_deleted', ['asset_tag' => $assetTag]), + 'asset_tag' => $assetTag, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset to delete'), + 'serial' => $schema->string()->description('Serial number of the asset to delete'), + 'id' => $schema->number()->description('Numeric ID of the asset to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'error' => $schema->boolean()->description('True if the deletion failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the deleted asset'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteCategoryTool.php b/app/Mcp/Tools/DeleteCategoryTool.php new file mode 100644 index 000000000000..7d63781aec08 --- /dev/null +++ b/app/Mcp/Tools/DeleteCategoryTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $category = $this->resolveCategory($request); + + if (! $category) { + return Response::make(Response::error(trans('mcp.category_not_found'))); + } + + if (! Gate::allows('delete', $category)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $name = $category->name; + + try { + $category->delete(); + } catch (\Exception $e) { + return Response::make(Response::error(trans('mcp.category_delete_failed', ['error' => $e->getMessage()]))); + } + + return Response::make( + Response::text(trans('mcp.category_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.category_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveCategory(Request $request): ?Category + { + if ($request->filled('id')) { + return Category::find($request->get('id')); + } + if ($request->filled('name')) { + return Category::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the category to delete'), + 'name' => $schema->string()->description('Name of the category to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted category'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteCompanyTool.php b/app/Mcp/Tools/DeleteCompanyTool.php new file mode 100644 index 000000000000..70ab41bad8dc --- /dev/null +++ b/app/Mcp/Tools/DeleteCompanyTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $company = $this->resolveCompany($request); + + if (! $company) { + return Response::make(Response::error(trans('mcp.company_not_found'))); + } + + if (! Gate::allows('delete', $company)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $name = $company->name; + + $company->delete(); + + return Response::make( + Response::text(trans('mcp.company_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.company_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveCompany(Request $request): ?Company + { + if ($request->filled('id')) { + return Company::find($request->get('id')); + } + if ($request->filled('name')) { + return Company::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the company to delete'), + 'name' => $schema->string()->description('Name of the company to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted company'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteComponentTool.php b/app/Mcp/Tools/DeleteComponentTool.php new file mode 100644 index 000000000000..dc4bd2564400 --- /dev/null +++ b/app/Mcp/Tools/DeleteComponentTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:191', + ]); + + $component = $this->resolveComponent($request); + + if (! $component) { + return Response::make(Response::error(trans('mcp.component_not_found'))); + } + + if (! Gate::allows('delete', $component)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($component->numCheckedOut() > 0) { + return Response::make(Response::error(trans('mcp.component_has_checkouts'))); + } + + $name = $component->name; + + $component->delete(); + + return Response::make( + Response::text(trans('mcp.component_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.component_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveComponent(Request $request): ?Component + { + if ($request->filled('id')) { + return Component::find($request->get('id')); + } + if ($request->filled('name')) { + return Component::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the component to delete'), + 'name' => $schema->string()->description('Name of the component to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted component'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteConsumableTool.php b/app/Mcp/Tools/DeleteConsumableTool.php new file mode 100644 index 000000000000..c141a0990dff --- /dev/null +++ b/app/Mcp/Tools/DeleteConsumableTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $consumable = $this->resolveConsumable($request); + + if (! $consumable) { + return Response::make(Response::error(trans('mcp.consumable_not_found'))); + } + + if (! Gate::allows('delete', $consumable)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($consumable->users()->count() > 0) { + return Response::make(Response::error(trans('mcp.consumable_has_checkouts'))); + } + + $name = $consumable->name; + + $consumable->delete(); + + return Response::make( + Response::text(trans('mcp.consumable_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.consumable_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveConsumable(Request $request): ?Consumable + { + if ($request->filled('id')) { + return Consumable::find($request->get('id')); + } + if ($request->filled('name')) { + return Consumable::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the consumable to delete'), + 'name' => $schema->string()->description('Name of the consumable to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted consumable'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteDepartmentTool.php b/app/Mcp/Tools/DeleteDepartmentTool.php new file mode 100644 index 000000000000..8fb08b02829f --- /dev/null +++ b/app/Mcp/Tools/DeleteDepartmentTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $department = $this->resolveDepartment($request); + + if (! $department) { + return Response::make(Response::error(trans('mcp.department_not_found'))); + } + + if (! Gate::allows('delete', $department)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($department->users->count() > 0) { + return Response::make(Response::error(trans('mcp.department_has_users'))); + } + + $name = $department->name; + + $department->delete(); + + return Response::make( + Response::text(trans('mcp.department_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.department_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveDepartment(Request $request): ?Department + { + if ($request->filled('id')) { + return Department::find($request->get('id')); + } + if ($request->filled('name')) { + return Department::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the department to delete'), + 'name' => $schema->string()->description('Name of the department to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted department'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteDepreciationTool.php b/app/Mcp/Tools/DeleteDepreciationTool.php new file mode 100644 index 000000000000..b07a86005c3a --- /dev/null +++ b/app/Mcp/Tools/DeleteDepreciationTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $dep = $this->resolveDepreciation($request); + + if (! $dep) { + return Response::make(Response::error(trans('mcp.depreciation_not_found'))); + } + + if (! Gate::allows('delete', $dep)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $name = $dep->name; + + $dep->delete(); + + return Response::make( + Response::text(trans('mcp.depreciation_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.depreciation_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveDepreciation(Request $request): ?Depreciation + { + if ($request->filled('id')) { + return Depreciation::find($request->get('id')); + } + if ($request->filled('name')) { + return Depreciation::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the depreciation to delete'), + 'name' => $schema->string()->description('Name of the depreciation to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted depreciation'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteGroupTool.php b/app/Mcp/Tools/DeleteGroupTool.php new file mode 100644 index 000000000000..a93586ec21c5 --- /dev/null +++ b/app/Mcp/Tools/DeleteGroupTool.php @@ -0,0 +1,75 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + if ($request->filled('id')) { + $group = Group::find($request->get('id')); + } elseif ($request->filled('name')) { + $group = Group::where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $group) { + return Response::make(Response::error(trans('mcp.group_not_found'))); + } + + $groupName = $group->name; + + if ($group->delete()) { + return Response::make( + Response::text(trans('mcp.group_deleted', ['name' => $groupName])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.group_deleted', ['name' => $groupName]), + 'name' => $groupName, + ]); + } + + return Response::make(Response::error(trans('mcp.delete_failed'))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID to delete'), + 'name' => $schema->string()->description('Group name to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted group'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteLicenseTool.php b/app/Mcp/Tools/DeleteLicenseTool.php new file mode 100644 index 000000000000..128b7f8a9134 --- /dev/null +++ b/app/Mcp/Tools/DeleteLicenseTool.php @@ -0,0 +1,89 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $license = $this->resolveLicense($request); + + if (! $license) { + return Response::make(Response::error(trans('mcp.license_not_found'))); + } + + if (! Gate::allows('delete', $license)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($license->assignedCount()->count() > 0) { + return Response::make(Response::error(trans('mcp.license_has_seats_assigned'))); + } + + $name = $license->name; + + DB::table('license_seats') + ->where('license_id', $license->id) + ->update(['assigned_to' => null, 'asset_id' => null]); + + $license->licenseseats()->delete(); + $license->delete(); + + return Response::make( + Response::text(trans('mcp.license_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.license_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveLicense(Request $request): ?License + { + if ($request->filled('id')) { + return License::find($request->get('id')); + } + if ($request->filled('name')) { + return License::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the license to delete'), + 'name' => $schema->string()->description('Name of the license to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted license'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteLocationTool.php b/app/Mcp/Tools/DeleteLocationTool.php new file mode 100644 index 000000000000..fa915d325c9b --- /dev/null +++ b/app/Mcp/Tools/DeleteLocationTool.php @@ -0,0 +1,87 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $location = $this->resolveLocation($request); + + if (! $location) { + return Response::make(Response::error(trans('mcp.location_not_found'))); + } + + if (! Gate::allows('delete', $location)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($location->users()->count() > 0) { + return Response::make(Response::error(trans('mcp.location_has_users'))); + } + + if ($location->children()->count() > 0) { + return Response::make(Response::error(trans('mcp.location_has_child_locations'))); + } + + $name = $location->name; + + $location->delete(); + + return Response::make( + Response::text(trans('mcp.location_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.location_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveLocation(Request $request): ?Location + { + if ($request->filled('id')) { + return Location::find($request->get('id')); + } + if ($request->filled('name')) { + return Location::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the location to delete'), + 'name' => $schema->string()->description('Name of the location to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted location'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteManufacturerTool.php b/app/Mcp/Tools/DeleteManufacturerTool.php new file mode 100644 index 000000000000..feb96383b6fe --- /dev/null +++ b/app/Mcp/Tools/DeleteManufacturerTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $manufacturer = $this->resolveManufacturer($request); + + if (! $manufacturer) { + return Response::make(Response::error(trans('mcp.manufacturer_not_found'))); + } + + if (! Gate::allows('delete', $manufacturer)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $name = $manufacturer->name; + + $manufacturer->delete(); + + return Response::make( + Response::text(trans('mcp.manufacturer_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.manufacturer_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveManufacturer(Request $request): ?Manufacturer + { + if ($request->filled('id')) { + return Manufacturer::find($request->get('id')); + } + if ($request->filled('name')) { + return Manufacturer::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the manufacturer to delete'), + 'name' => $schema->string()->description('Name of the manufacturer to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted manufacturer'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteStatusLabelTool.php b/app/Mcp/Tools/DeleteStatusLabelTool.php new file mode 100644 index 000000000000..a4670ff40c81 --- /dev/null +++ b/app/Mcp/Tools/DeleteStatusLabelTool.php @@ -0,0 +1,83 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $label = $this->resolveStatusLabel($request); + + if (! $label) { + return Response::make(Response::error(trans('mcp.status_label_not_found'))); + } + + if (! Gate::allows('delete', $label)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($label->assets()->count() > 0) { + return Response::make(Response::error(trans('mcp.status_label_has_assets'))); + } + + $name = $label->name; + + $label->delete(); + + return Response::make( + Response::text(trans('mcp.status_label_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.status_label_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveStatusLabel(Request $request): ?Statuslabel + { + if ($request->filled('id')) { + return Statuslabel::find($request->get('id')); + } + if ($request->filled('name')) { + return Statuslabel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the status label to delete'), + 'name' => $schema->string()->description('Name of the status label to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted status label'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteSupplierTool.php b/app/Mcp/Tools/DeleteSupplierTool.php new file mode 100644 index 000000000000..bf3790d1815d --- /dev/null +++ b/app/Mcp/Tools/DeleteSupplierTool.php @@ -0,0 +1,79 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $supplier = $this->resolveSupplier($request); + + if (! $supplier) { + return Response::make(Response::error(trans('mcp.supplier_not_found'))); + } + + if (! Gate::allows('delete', $supplier)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $name = $supplier->name; + + $supplier->delete(); + + return Response::make( + Response::text(trans('mcp.supplier_deleted', ['name' => $name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.supplier_deleted', ['name' => $name]), + 'name' => $name, + ]); + } + + private function resolveSupplier(Request $request): ?Supplier + { + if ($request->filled('id')) { + return Supplier::find($request->get('id')); + } + if ($request->filled('name')) { + return Supplier::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the supplier to delete'), + 'name' => $schema->string()->description('Name of the supplier to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'name' => $schema->string()->description('Name of the deleted supplier'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteUserTool.php b/app/Mcp/Tools/DeleteUserTool.php new file mode 100644 index 000000000000..cbaa4d8f6fe8 --- /dev/null +++ b/app/Mcp/Tools/DeleteUserTool.php @@ -0,0 +1,94 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + ]); + + $user = $this->resolveUser($request); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + if (! Gate::allows('delete', $user)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($user->id === auth()->id()) { + return Response::make(Response::error(trans('mcp.user_cannot_delete_self'))); + } + + if ($user->allAssignedCount() > 0) { + return Response::make(Response::error(trans('mcp.user_has_items'))); + } + + $username = $user->username; + + if ($user->delete()) { + return Response::make( + Response::text(trans('mcp.user_deleted', ['username' => $username])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.user_deleted', ['username' => $username]), + 'username' => $username, + ]); + } + + return Response::make(Response::error(trans('mcp.delete_failed_error', ['error' => $user->getErrors()->first()]))); + } + + private function resolveUser(Request $request): ?User + { + if ($request->filled('id')) { + return User::find($request->get('id')); + } + if ($request->filled('username')) { + return User::where('username', $request->get('username'))->first(); + } + if ($request->filled('email')) { + return User::where('email', $request->get('email'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID to delete'), + 'username' => $schema->string()->description('Username of the user to delete'), + 'email' => $schema->string()->description('Email address of the user to delete'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the deletion succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'username' => $schema->string()->description('Username of the deleted user'), + ]; + } +} diff --git a/app/Mcp/Tools/GetActivityLogTool.php b/app/Mcp/Tools/GetActivityLogTool.php new file mode 100644 index 000000000000..6876c25d5469 --- /dev/null +++ b/app/Mcp/Tools/GetActivityLogTool.php @@ -0,0 +1,102 @@ +validate([ + 'item_type' => 'nullable|string|max:255', + 'item_id' => 'nullable|integer', + 'user_id' => 'nullable|integer', + 'action_type' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $logs = Actionlog::with('user', 'item')->orderBy('created_at', 'desc'); + + if ($request->filled('item_type')) { + $logs->where('item_type', $request->get('item_type')); + } + + if ($request->filled('item_id')) { + $logs->where('item_id', $request->get('item_id')); + } + + if ($request->filled('user_id')) { + $logs->where('user_id', $request->get('user_id')); + } + + if ($request->filled('action_type')) { + $logs->where('action_type', $request->get('action_type')); + } + + $total = $logs->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $logs->skip($offset)->take($limit)->get(); + + $activityData = $results->map(fn (Actionlog $log) => [ + 'id' => $log->id, + 'action_type' => $log->action_type, + 'item_type' => $log->item_type, + 'item_id' => $log->item_id, + 'user_id' => $log->user_id, + 'user' => $log->user?->username, + 'note' => $log->note, + 'created_at' => $log->created_at?->toDateTimeString(), + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_activity', ['total' => $total, 'count' => count($activityData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'activity' => $activityData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'item_type' => $schema->string()->description('Filter by item type (e.g. App\\Models\\Asset)'), + 'item_id' => $schema->number()->description('Filter by item ID'), + 'user_id' => $schema->number()->description('Filter by user ID'), + 'action_type' => $schema->string()->description('Filter by action type (e.g. checkout, checkin, update)'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching log entries')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'activity' => $schema->array()->description('List of activity log entries')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/GetCurrentUserTool.php b/app/Mcp/Tools/GetCurrentUserTool.php new file mode 100644 index 000000000000..06a11d91d3e0 --- /dev/null +++ b/app/Mcp/Tools/GetCurrentUserTool.php @@ -0,0 +1,72 @@ +check()) { + return Response::make(Response::error(trans('mcp.not_authenticated'))); + } + + $user = User::with('company', 'department', 'userloc')->find(auth()->id()); + + if (! $user) { + return Response::make(Response::error(trans('mcp.not_authenticated'))); + } + + return Response::make( + Response::text(trans('mcp.current_user', ['username' => $user->username])) + )->withStructuredContent([ + 'id' => $user->id, + 'username' => $user->username, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'email' => $user->email, + 'company' => $user->company?->name, + 'department' => $user->department?->name, + 'location' => $user->userloc?->name, + 'employee_num' => $user->employee_num, + 'title' => $user->jobtitle, + 'phone' => $user->phone, + 'activated' => (bool) $user->activated, + ]); + } + + public function schema(JsonSchema $schema): array + { + return []; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID')->required(), + 'username' => $schema->string()->description('Username')->required(), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'email' => $schema->string()->description('Email address'), + 'company' => $schema->string()->description('Company name'), + 'department' => $schema->string()->description('Department name'), + 'location' => $schema->string()->description('Default location name'), + 'employee_num' => $schema->string()->description('Employee number'), + 'title' => $schema->string()->description('Job title'), + 'phone' => $schema->string()->description('Phone number'), + 'activated' => $schema->boolean()->description('Whether the account is activated'), + ]; + } +} diff --git a/app/Mcp/Tools/GetUserAssetsTool.php b/app/Mcp/Tools/GetUserAssetsTool.php new file mode 100644 index 000000000000..67812b157949 --- /dev/null +++ b/app/Mcp/Tools/GetUserAssetsTool.php @@ -0,0 +1,82 @@ +validate([ + 'id' => 'required|integer', + ]); + + $user = User::find($request->get('id')); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + $assets = Asset::where('assigned_to', $user->id) + ->where('assigned_type', User::class) + ->with('model', 'status', 'location') + ->get(); + + $data = $assets->map(fn ($asset) => [ + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + 'serial' => $asset->serial, + 'model' => $asset->model?->name, + 'status' => $asset->status?->name, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.user_assets_found', ['count' => count($data), 'username' => $user->username])) + )->withStructuredContent([ + 'user_id' => $user->id, + 'username' => $user->username, + 'total' => count($data), + 'assets' => $data, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user whose assets should be listed (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'user_id' => $schema->number()->description('Numeric ID of the user')->required(), + 'username' => $schema->string()->description('Username of the user')->required(), + 'total' => $schema->number()->description('Total number of assets checked out to the user')->required(), + 'assets' => $schema->array()->description('List of checked-out assets'), + ]; + } +} diff --git a/app/Mcp/Tools/ListAssetModelsTool.php b/app/Mcp/Tools/ListAssetModelsTool.php new file mode 100644 index 000000000000..b8e9cc4f8b51 --- /dev/null +++ b/app/Mcp/Tools/ListAssetModelsTool.php @@ -0,0 +1,101 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $models = AssetModel::with('category', 'manufacturer', 'depreciation') + ->withCount('assets as assets_count'); + + if ($request->filled('search')) { + $models->TextSearch($request->get('search')); + } + + if ($request->filled('category_id')) { + $models->where('category_id', $request->get('category_id')); + } + + if ($request->filled('manufacturer_id')) { + $models->where('manufacturer_id', $request->get('manufacturer_id')); + } + + $models->orderBy('models.created_at', 'desc'); + + $total = $models->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $models->skip($offset)->take($limit)->get(); + + $modelsData = $results->map(fn (AssetModel $model) => [ + 'id' => $model->id, + 'name' => $model->name, + 'model_number' => $model->model_number, + 'category_id' => $model->category_id, + 'category' => $model->category?->name, + 'manufacturer_id' => $model->manufacturer_id, + 'manufacturer' => $model->manufacturer?->name, + 'assets_count' => $model->assets_count, + 'eol' => $model->eol, + 'min_amt' => $model->min_amt, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_asset_models', ['total' => $total, 'count' => count($modelsData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'models' => $modelsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across model name, model number'), + 'category_id' => $schema->number()->description('Filter by category ID'), + 'manufacturer_id' => $schema->number()->description('Filter by manufacturer ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching asset models')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'models' => $schema->array()->description('List of asset models')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListAssetNotesTool.php b/app/Mcp/Tools/ListAssetNotesTool.php new file mode 100644 index 000000000000..beaa861ba747 --- /dev/null +++ b/app/Mcp/Tools/ListAssetNotesTool.php @@ -0,0 +1,116 @@ +validate([ + 'asset_tag' => 'nullable|string|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('view', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $query = Actionlog::with('adminuser:id,username') + ->where('item_type', Asset::class) + ->where('item_id', $asset->id) + ->where('action_type', 'note added') + ->orderBy('created_at', 'desc'); + + $total = (clone $query)->count(); + $records = $query->skip($offset)->take($limit) + ->get(['id', 'created_at', 'note', 'created_by', 'item_id', 'action_type']); + + $notes = $records->map(fn ($n) => [ + 'id' => $n->id, + 'created_at' => $n->created_at?->toISOString(), + 'note' => $n->note, + 'created_by_id' => $n->created_by, + 'created_by_username' => $n->adminuser?->username, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_asset_notes', [ + 'asset_tag' => $asset->asset_tag, + 'total' => $total, + 'count' => count($notes), + ])) + )->withStructuredContent([ + 'asset_id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'notes' => $notes, + ]); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag of the asset'), + 'serial' => $schema->string()->description('Serial number of the asset'), + 'id' => $schema->number()->description('Numeric ID of the asset'), + 'limit' => $schema->number()->description('Number of notes to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of notes to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->number()->description('Numeric ID of the asset')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the asset')->required(), + 'total' => $schema->number()->description('Total number of notes on this asset')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'notes' => $schema->array()->description('List of notes'), + ]; + } +} diff --git a/app/Mcp/Tools/ListAssetsTool.php b/app/Mcp/Tools/ListAssetsTool.php new file mode 100644 index 000000000000..745ad0787618 --- /dev/null +++ b/app/Mcp/Tools/ListAssetsTool.php @@ -0,0 +1,141 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'status_type' => 'nullable|string|in:RTD,Deployed,Archived,Pending,Undeployable', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer', + 'category_id' => 'nullable|integer', + 'model_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $assets = Asset::select('assets.*') + ->with('status', 'assignedTo', 'model.category', 'model.manufacturer', 'location', 'company'); + + match ($request->filled('status_type') ? $request->get('status_type') : null) { + 'RTD' => $assets->rtd(), + 'Deployed' => $assets->deployed(), + 'Archived' => $assets->archived(), + 'Pending' => $assets->pending(), + 'Undeployable' => $assets->undeployable(), + default => $assets->notArchived(), + }; + + if ($request->filled('search')) { + $assets->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $assets->where('assets.company_id', '=', $request->get('company_id')); + } + + if ($request->filled('location_id')) { + $assets->where('assets.location_id', '=', $request->get('location_id')); + } + + if ($request->filled('category_id')) { + $assets->inCategory($request->get('category_id')); + } + + if ($request->filled('model_id')) { + $assets->inModels([$request->get('model_id')]); + } + + if ($request->filled('manufacturer_id')) { + $assets->byManufacturer($request->get('manufacturer_id')); + } + + $assets->orderBy('assets.created_at', 'desc'); + + $total = $assets->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $assets->skip($offset)->take($limit)->get(); + + $assetsData = $results->map(fn (Asset $asset) => [ + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + 'serial' => $asset->serial, + 'status' => $asset->status?->name, + 'status_type' => $asset->status?->getStatuslabelType(), + 'model' => $asset->model?->name, + 'category' => $asset->model?->category?->name, + 'manufacturer' => $asset->model?->manufacturer?->name, + 'company' => $asset->company?->name, + 'location' => $asset->location?->name, + 'assigned_to_id' => $asset->assigned_to, + 'assigned_to_type' => $asset->assigned_type ? class_basename($asset->assigned_type) : null, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_assets', ['total' => $total, 'count' => count($assetsData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'assets' => $assetsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string() + ->description('Keyword to search across asset tag, serial, name, and model'), + 'status_type' => $schema->string() + ->description('Filter by status type: RTD (ready to deploy), Deployed, Archived, Pending, or Undeployable'), + 'company_id' => $schema->number() + ->description('Filter by company ID'), + 'location_id' => $schema->number() + ->description('Filter by location ID'), + 'category_id' => $schema->number() + ->description('Filter by category ID'), + 'model_id' => $schema->number() + ->description('Filter by model ID'), + 'manufacturer_id' => $schema->number() + ->description('Filter by manufacturer ID'), + 'limit' => $schema->number() + ->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number() + ->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching assets')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListCategoriesTool.php b/app/Mcp/Tools/ListCategoriesTool.php new file mode 100644 index 000000000000..2ca979d127bc --- /dev/null +++ b/app/Mcp/Tools/ListCategoriesTool.php @@ -0,0 +1,98 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'category_type' => 'nullable|string|in:asset,accessory,consumable,component,license', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $categories = Category::withCount( + 'showableAssets as assets_count', + 'accessories as accessories_count', + 'consumables as consumables_count', + 'components as components_count', + 'licenses as licenses_count' + ); + + if ($request->filled('search')) { + $categories->TextSearch($request->get('search')); + } + + if ($request->filled('category_type')) { + $categories->where('category_type', $request->get('category_type')); + } + + $categories->orderBy('created_at', 'desc'); + + $total = $categories->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $categories->skip($offset)->take($limit)->get(); + + $categoriesData = $results->map(fn (Category $category) => [ + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + 'assets_count' => $category->assets_count, + 'accessories_count' => $category->accessories_count, + 'consumables_count' => $category->consumables_count, + 'components_count' => $category->components_count, + 'licenses_count' => $category->licenses_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_categories', ['total' => $total, 'count' => count($categoriesData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'categories' => $categoriesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across category name, type, notes'), + 'category_type' => $schema->string()->description('Filter by type: asset, accessory, consumable, component, or license'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching categories')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'categories' => $schema->array()->description('List of categories')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListCompaniesTool.php b/app/Mcp/Tools/ListCompaniesTool.php new file mode 100644 index 000000000000..dbdb319b1239 --- /dev/null +++ b/app/Mcp/Tools/ListCompaniesTool.php @@ -0,0 +1,88 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $companies = Company::withCount([ + 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), + ])->withCount('licenses as licenses_count', 'users as users_count'); + + if ($request->filled('search')) { + $companies->TextSearch($request->get('search')); + } + + $companies->orderBy('created_at', 'desc'); + + $total = $companies->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $companies->skip($offset)->take($limit)->get(); + + $companiesData = $results->map(fn (Company $company) => [ + 'id' => $company->id, + 'name' => $company->name, + 'phone' => $company->phone, + 'fax' => $company->fax, + 'email' => $company->email, + 'assets_count' => $company->assets_count, + 'licenses_count' => $company->licenses_count, + 'users_count' => $company->users_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_companies', ['total' => $total, 'count' => count($companiesData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'companies' => $companiesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across company name, phone, fax, email'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching companies')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'companies' => $schema->array()->description('List of companies')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListConsumablesTool.php b/app/Mcp/Tools/ListConsumablesTool.php new file mode 100644 index 000000000000..95b84f0faa95 --- /dev/null +++ b/app/Mcp/Tools/ListConsumablesTool.php @@ -0,0 +1,110 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'company_id' => 'nullable|integer', + 'category_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'location_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $consumables = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location') + ->withCount('users as users_count'); + + if ($request->filled('search')) { + $consumables->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $consumables->where('consumables.company_id', $request->get('company_id')); + } + + if ($request->filled('category_id')) { + $consumables->where('category_id', $request->get('category_id')); + } + + if ($request->filled('manufacturer_id')) { + $consumables->where('manufacturer_id', $request->get('manufacturer_id')); + } + + if ($request->filled('location_id')) { + $consumables->where('location_id', $request->get('location_id')); + } + + $total = $consumables->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $consumables->orderBy('consumables.created_at', 'desc')->skip($offset)->take($limit)->get(); + + $consumablesData = $results->map(fn (Consumable $consumable) => [ + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + 'users_count' => $consumable->users_count, + 'category' => $consumable->category?->name, + 'manufacturer' => $consumable->manufacturer?->name, + 'company' => $consumable->company?->name, + 'location' => $consumable->location?->name, + 'purchase_cost' => $consumable->purchase_cost, + 'purchase_date' => $consumable->purchase_date?->format('Y-m-d'), + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_consumables', ['total' => $total, 'count' => count($consumablesData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'consumables' => $consumablesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across consumable name and other fields'), + 'company_id' => $schema->number()->description('Filter by company ID'), + 'category_id' => $schema->number()->description('Filter by category ID'), + 'manufacturer_id' => $schema->number()->description('Filter by manufacturer ID'), + 'location_id' => $schema->number()->description('Filter by location ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching consumables')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListDepreciationsTool.php b/app/Mcp/Tools/ListDepreciationsTool.php new file mode 100644 index 000000000000..f2ee43a9f144 --- /dev/null +++ b/app/Mcp/Tools/ListDepreciationsTool.php @@ -0,0 +1,82 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $depreciations = Depreciation::withCount('models as models_count'); + + if ($request->filled('search')) { + $depreciations->TextSearch($request->get('search')); + } + + $depreciations->orderBy('created_at', 'desc'); + + $total = $depreciations->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $depreciations->skip($offset)->take($limit)->get(); + + $depreciationsData = $results->map(fn (Depreciation $dep) => [ + 'id' => $dep->id, + 'name' => $dep->name, + 'months' => $dep->months, + 'models_count' => $dep->models_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_depreciations', ['total' => $total, 'count' => count($depreciationsData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'depreciations' => $depreciationsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across depreciation name'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching depreciations')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'depreciations' => $schema->array()->description('List of depreciation schedules')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListGroupsTool.php b/app/Mcp/Tools/ListGroupsTool.php new file mode 100644 index 000000000000..dfc6fdfd52b4 --- /dev/null +++ b/app/Mcp/Tools/ListGroupsTool.php @@ -0,0 +1,81 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $groups = Group::withCount('users as users_count') + ->orderBy('created_at', 'desc'); + + if ($request->filled('search')) { + $groups->TextSearch($request->get('search')); + } + + $total = $groups->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $groups->skip($offset)->take($limit)->get(); + + $groupsData = $results->map(fn (Group $group) => [ + 'id' => $group->id, + 'name' => $group->name, + 'notes' => $group->notes, + 'users_count' => $group->users_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_groups', ['total' => $total, 'count' => count($groupsData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'groups' => $groupsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search groups by name or notes'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching groups')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'groups' => $schema->array()->description('List of groups')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListHistoryTool.php b/app/Mcp/Tools/ListHistoryTool.php new file mode 100644 index 000000000000..1943e20b87a6 --- /dev/null +++ b/app/Mcp/Tools/ListHistoryTool.php @@ -0,0 +1,152 @@ + Accessory::class, + 'asset' => Asset::class, + 'asset_model' => AssetModel::class, + 'component' => Component::class, + 'consumable' => Consumable::class, + 'license' => License::class, + 'location' => Location::class, + 'maintenance' => Maintenance::class, + 'user' => User::class, + ]; + + public function handle(Request $request): ResponseFactory + { + $validTypes = implode(',', array_keys(self::TYPE_MAP)); + + $request->validate([ + 'object_type' => 'required|string|in:'.$validTypes, + 'id' => 'required|integer|min:1', + 'search' => 'nullable|string|max:255', + 'action_type' => 'nullable|string|max:100', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $objectType = $request->get('object_type'); + $modelClass = self::TYPE_MAP[$objectType]; + + $object = $modelClass::withTrashed()->find($request->get('id')); + + if (! $object) { + return Response::make(Response::error(trans('mcp.object_not_found', ['type' => $objectType]))); + } + + if (! Gate::allows('history', $object)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $modelClass = get_class($object); + $modelId = $object->getKey(); + + // Wrap the item/target OR in a subquery so additional filters apply to both sides. + $history = Actionlog::where(function ($q) use ($modelClass, $modelId) { + $q->where('item_type', $modelClass) + ->where('item_id', $modelId) + ->orWhere(function ($q2) use ($modelClass, $modelId) { + $q2->where('target_type', $modelClass) + ->where('target_id', $modelId); + }); + }); + + if ($request->filled('search')) { + $history->TextSearch(e($request->get('search'))); + } + + if ($request->filled('action_type')) { + $history->where('action_type', $request->get('action_type')); + } + + $history->orderBy('action_logs.created_at', 'desc'); + + $total = (clone $history)->count(); + $records = $history->skip($offset)->take($limit)->forApiHistory()->get(); + + $entries = $records->map(fn ($log) => [ + 'id' => $log->id, + 'action_type' => $log->action_type, + 'created_at' => $log->created_at?->toISOString(), + 'note' => $log->note, + 'created_by' => $log->adminuser ? [ + 'id' => $log->adminuser->id, + 'username' => $log->adminuser->username, + ] : null, + 'target' => $log->target ? [ + 'id' => $log->target->getKey(), + 'type' => class_basename($log->target_type), + 'name' => $log->target->present()->name() ?? null, + ] : null, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_history', [ + 'total' => $total, + 'count' => count($entries), + 'type' => $objectType, + ])) + )->withStructuredContent([ + 'object_type' => $objectType, + 'object_id' => $object->id, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'history' => $entries, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object: accessory, asset, asset_model, component, consumable, license, location, maintenance, user'), + 'id' => $schema->number()->description('Numeric ID of the object'), + 'search' => $schema->string()->description('Filter history by keyword'), + 'action_type' => $schema->string()->description('Filter by action type (e.g. checkout, checkin, update, note added, uploaded)'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object queried')->required(), + 'object_id' => $schema->number()->description('ID of the object queried')->required(), + 'total' => $schema->number()->description('Total number of history entries')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'history' => $schema->array()->description('List of history entries'), + ]; + } +} diff --git a/app/Mcp/Tools/ListLicensesTool.php b/app/Mcp/Tools/ListLicensesTool.php new file mode 100644 index 000000000000..507a27ab578c --- /dev/null +++ b/app/Mcp/Tools/ListLicensesTool.php @@ -0,0 +1,113 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'company_id' => 'nullable|integer', + 'category_id' => 'nullable|integer', + 'manufacturer_id' => 'nullable|integer', + 'supplier_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $licenses = License::with('company', 'manufacturer', 'supplier', 'category') + ->withCount('freeSeats as free_seats_count'); + + if ($request->filled('search')) { + $licenses->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $licenses->where('licenses.company_id', '=', $request->get('company_id')); + } + + if ($request->filled('category_id')) { + $licenses->where('category_id', '=', $request->get('category_id')); + } + + if ($request->filled('manufacturer_id')) { + $licenses->where('manufacturer_id', '=', $request->get('manufacturer_id')); + } + + if ($request->filled('supplier_id')) { + $licenses->where('supplier_id', '=', $request->get('supplier_id')); + } + + $licenses->orderBy('licenses.created_at', 'desc'); + + $total = $licenses->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $licenses->skip($offset)->take($limit)->get(); + + $licensesData = $results->map(fn (License $license) => [ + 'id' => $license->id, + 'name' => $license->name, + 'serial' => $license->serial, + 'seats' => $license->seats, + 'free_seats' => $license->free_seats_count, + 'category' => $license->category?->name, + 'manufacturer' => $license->manufacturer?->name, + 'company' => $license->company?->name, + 'supplier' => $license->supplier?->name, + 'expiration_date' => $license->expiration_date?->format('Y-m-d'), + 'purchase_date' => $license->purchase_date?->format('Y-m-d'), + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_licenses', ['total' => $total, 'count' => count($licensesData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'licenses' => $licensesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across name, serial, notes, and order number'), + 'company_id' => $schema->number()->description('Filter by company ID'), + 'category_id' => $schema->number()->description('Filter by category ID'), + 'manufacturer_id' => $schema->number()->description('Filter by manufacturer ID'), + 'supplier_id' => $schema->number()->description('Filter by supplier ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching licenses')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListLocationsTool.php b/app/Mcp/Tools/ListLocationsTool.php new file mode 100644 index 000000000000..2b12ce4c7c74 --- /dev/null +++ b/app/Mcp/Tools/ListLocationsTool.php @@ -0,0 +1,101 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'parent_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $locations = Location::with('parent')->withCount( + 'assets as assets_count', + 'users as users_count', + 'children as children_count' + ); + + if ($request->filled('search')) { + $locations->TextSearch($request->get('search')); + } + + if ($request->filled('parent_id')) { + $locations->where('parent_id', $request->get('parent_id')); + } + + $locations->orderBy('created_at', 'desc'); + + $total = $locations->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $locations->skip($offset)->take($limit)->get(); + + $locationsData = $results->map(fn (Location $location) => [ + 'id' => $location->id, + 'name' => $location->name, + 'address' => $location->address, + 'city' => $location->city, + 'state' => $location->state, + 'country' => $location->country, + 'zip' => $location->zip, + 'phone' => $location->phone, + 'parent_id' => $location->parent_id, + 'parent' => $location->parent?->name, + 'assets_count' => $location->assets_count, + 'users_count' => $location->users_count, + 'children_count' => $location->children_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_locations', ['total' => $total, 'count' => count($locationsData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'locations' => $locationsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across location name, city, state, country'), + 'parent_id' => $schema->number()->description('Filter by parent location ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching locations')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'locations' => $schema->array()->description('List of locations')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListMaintenancesTool.php b/app/Mcp/Tools/ListMaintenancesTool.php new file mode 100644 index 000000000000..5b27ce686469 --- /dev/null +++ b/app/Mcp/Tools/ListMaintenancesTool.php @@ -0,0 +1,89 @@ +validate([ + 'asset_id' => 'nullable|integer', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $maintenances = Maintenance::with('asset', 'supplier'); + + if ($request->filled('asset_id')) { + $maintenances->where('asset_id', $request->get('asset_id')); + } + + $maintenances->orderBy('created_at', 'desc'); + + $total = $maintenances->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $maintenances->skip($offset)->take($limit)->get(); + + $maintenancesData = $results->map(fn (Maintenance $maintenance) => [ + 'id' => $maintenance->id, + 'title' => $maintenance->name, + 'asset_id' => $maintenance->asset_id, + 'asset_tag' => $maintenance->asset?->asset_tag, + 'is_warranty' => (bool) $maintenance->is_warranty, + 'cost' => $maintenance->cost, + 'start_date' => $maintenance->start_date, + 'completion_date' => $maintenance->completion_date, + 'supplier' => $maintenance->supplier?->name, + 'notes' => $maintenance->notes, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_maintenances', ['total' => $total, 'count' => count($maintenancesData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'maintenances' => $maintenancesData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_id' => $schema->number()->description('Filter by asset ID'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching maintenances')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'maintenances' => $schema->array()->description('List of maintenances')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListManufacturersTool.php b/app/Mcp/Tools/ListManufacturersTool.php new file mode 100644 index 000000000000..425bb08afda5 --- /dev/null +++ b/app/Mcp/Tools/ListManufacturersTool.php @@ -0,0 +1,93 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $manufacturers = Manufacturer::withCount( + 'assets as assets_count', + 'licenses as licenses_count', + 'accessories as accessories_count', + 'components as components_count' + ); + + if ($request->filled('search')) { + $manufacturers->TextSearch($request->get('search')); + } + + $manufacturers->orderBy('created_at', 'desc'); + + $total = $manufacturers->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $manufacturers->skip($offset)->take($limit)->get(); + + $manufacturersData = $results->map(fn (Manufacturer $manufacturer) => [ + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + 'url' => $manufacturer->url, + 'support_url' => $manufacturer->support_url, + 'support_email' => $manufacturer->support_email, + 'support_phone' => $manufacturer->support_phone, + 'assets_count' => $manufacturer->assets_count, + 'licenses_count' => $manufacturer->licenses_count, + 'accessories_count' => $manufacturer->accessories_count, + 'components_count' => $manufacturer->components_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_manufacturers', ['total' => $total, 'count' => count($manufacturersData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'manufacturers' => $manufacturersData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across manufacturer name and notes'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching manufacturers')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'manufacturers' => $schema->array()->description('List of manufacturers')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListStatusLabelsTool.php b/app/Mcp/Tools/ListStatusLabelsTool.php new file mode 100644 index 000000000000..62da66cf7cff --- /dev/null +++ b/app/Mcp/Tools/ListStatusLabelsTool.php @@ -0,0 +1,99 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'status_type' => 'nullable|string|in:deployable,pending,archived,undeployable', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $labels = Statuslabel::withCount('assets as assets_count'); + + if ($request->filled('search')) { + $labels->TextSearch($request->get('search')); + } + + if ($request->filled('status_type')) { + $type = $request->get('status_type'); + if ($type === 'deployable') { + $labels->Deployable(); + } elseif ($type === 'pending') { + $labels->Pending(); + } elseif ($type === 'archived') { + $labels->Archived(); + } elseif ($type === 'undeployable') { + $labels->Undeployable(); + } + } + + $total = $labels->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $labels->skip($offset)->take($limit)->get(); + + $labelsData = $results->map(fn (Statuslabel $label) => [ + 'id' => $label->id, + 'name' => $label->name, + 'type' => $label->getStatuslabelType(), + 'color' => $label->color, + 'assets_count' => $label->assets_count, + 'deployable' => $label->deployable, + 'pending' => $label->pending, + 'archived' => $label->archived, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_status_labels', ['total' => $total, 'count' => count($labelsData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'status_labels' => $labelsData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across status label name and notes'), + 'status_type' => $schema->string()->description('Filter by type: deployable, pending, archived, undeployable'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching status labels')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'status_labels' => $schema->array()->description('List of status labels')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListSuppliersTool.php b/app/Mcp/Tools/ListSuppliersTool.php new file mode 100644 index 000000000000..38d62557c20a --- /dev/null +++ b/app/Mcp/Tools/ListSuppliersTool.php @@ -0,0 +1,92 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $suppliers = Supplier::withCount( + 'assets as assets_count', + 'licenses as licenses_count' + ); + + if ($request->filled('search')) { + $suppliers->TextSearch($request->get('search')); + } + + $suppliers->orderBy('created_at', 'desc'); + + $total = $suppliers->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $suppliers->skip($offset)->take($limit)->get(); + + $suppliersData = $results->map(fn (Supplier $supplier) => [ + 'id' => $supplier->id, + 'name' => $supplier->name, + 'address' => $supplier->address, + 'city' => $supplier->city, + 'state' => $supplier->state, + 'country' => $supplier->country, + 'phone' => $supplier->phone, + 'email' => $supplier->email, + 'url' => $supplier->url, + 'assets_count' => $supplier->assets_count, + 'licenses_count' => $supplier->licenses_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_suppliers', ['total' => $total, 'count' => count($suppliersData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'suppliers' => $suppliersData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across supplier fields'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching suppliers')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'suppliers' => $schema->array()->description('List of suppliers')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListUploadsTool.php b/app/Mcp/Tools/ListUploadsTool.php new file mode 100644 index 000000000000..479c8fd1c1b4 --- /dev/null +++ b/app/Mcp/Tools/ListUploadsTool.php @@ -0,0 +1,130 @@ + Accessory::class, + 'assets' => Asset::class, + 'companies' => Company::class, + 'components' => Component::class, + 'consumables' => Consumable::class, + 'departments' => Department::class, + 'licenses' => License::class, + 'locations' => Location::class, + 'maintenances' => Maintenance::class, + 'models' => AssetModel::class, + 'suppliers' => Supplier::class, + 'users' => User::class, + ]; + + public function handle(Request $request): ResponseFactory + { + $validTypes = implode(',', array_keys(self::TYPE_MAP)); + + $request->validate([ + 'object_type' => 'required|string|in:'.$validTypes, + 'id' => 'required|integer|min:1', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $objectType = $request->get('object_type'); + $modelClass = self::TYPE_MAP[$objectType]; + + $object = $modelClass::withTrashed()->find($request->get('id')); + + if (! $object) { + return Response::make(Response::error(trans('mcp.object_not_found', ['type' => $objectType]))); + } + + if (! Gate::allows('files', $object)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $query = $object->uploads()->with('adminuser'); + + $total = (clone $query)->count(); + $uploads = $query->skip($offset)->take($limit)->orderBy('created_at', 'desc')->get(); + + $files = $uploads->map(fn ($file) => [ + 'id' => $file->id, + 'filename' => $file->filename, + 'url' => $file->uploads_file_url(), + 'note' => $file->note, + 'created_by' => $file->adminuser ? [ + 'id' => $file->adminuser->id, + 'username' => $file->adminuser->username, + ] : null, + 'created_at' => $file->created_at?->toISOString(), + 'exists_on_disk' => Storage::exists($file->uploads_file_path()), + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_uploads', [ + 'total' => $total, + 'count' => count($files), + 'type' => $objectType, + ])) + )->withStructuredContent([ + 'object_type' => $objectType, + 'object_id' => $object->id, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'files' => $files, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object: accessories, assets, companies, components, consumables, departments, licenses, locations, maintenances, models, suppliers, users'), + 'id' => $schema->number()->description('Numeric ID of the object'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'object_type' => $schema->string()->description('Type of object queried')->required(), + 'object_id' => $schema->number()->description('ID of the object queried')->required(), + 'total' => $schema->number()->description('Total number of uploaded files')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + 'files' => $schema->array()->description('List of uploaded files'), + ]; + } +} diff --git a/app/Mcp/Tools/ListUsersTool.php b/app/Mcp/Tools/ListUsersTool.php new file mode 100644 index 000000000000..ccdafa7248df --- /dev/null +++ b/app/Mcp/Tools/ListUsersTool.php @@ -0,0 +1,115 @@ +validate([ + 'search' => 'nullable|string|max:255', + 'company_id' => 'nullable|integer', + 'department_id' => 'nullable|integer', + 'location_id' => 'nullable|integer', + 'activated' => 'nullable|boolean', + 'limit' => 'nullable|integer|min:1|max:500', + 'offset' => 'nullable|integer|min:0', + ]); + + $users = User::with('company', 'department', 'userloc', 'manager') + ->withCount(['assets as assets_count', 'licenses as licenses_count']); + + if ($request->filled('search')) { + $users->TextSearch($request->get('search')); + } + + if ($request->filled('company_id')) { + $users->where('users.company_id', '=', $request->get('company_id')); + } + + if ($request->filled('department_id')) { + $users->where('users.department_id', '=', $request->get('department_id')); + } + + if ($request->filled('location_id')) { + $users->where('users.location_id', '=', $request->get('location_id')); + } + + if ($request->has('activated')) { + $users->where('users.activated', '=', $request->get('activated')); + } + + $users->orderBy('users.last_name', 'asc')->orderBy('users.first_name', 'asc'); + + $total = $users->count(); + $limit = $request->filled('limit') ? (int) $request->get('limit') : 25; + $offset = $request->filled('offset') ? (int) $request->get('offset') : 0; + + $results = $users->skip($offset)->take($limit)->get(); + + $usersData = $results->map(fn (User $user) => [ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'username' => $user->username, + 'email' => $user->email, + 'jobtitle' => $user->jobtitle, + 'company' => $user->company?->name, + 'department' => $user->department?->name, + 'location' => $user->userloc?->name, + 'manager' => $user->manager ? trim($user->manager->first_name.' '.$user->manager->last_name) : null, + 'activated' => (bool) $user->activated, + 'assets_count' => $user->assets_count, + 'licenses_count' => $user->licenses_count, + ])->values()->all(); + + return Response::make( + Response::text(trans('mcp.list_users', ['total' => $total, 'count' => count($usersData)])) + )->withStructuredContent([ + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'users' => $usersData, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'search' => $schema->string()->description('Keyword to search across name, username, email, and employee number'), + 'company_id' => $schema->number()->description('Filter by company ID'), + 'department_id' => $schema->number()->description('Filter by department ID'), + 'location_id' => $schema->number()->description('Filter by location ID'), + 'activated' => $schema->boolean()->description('Filter by account activated status'), + 'limit' => $schema->number()->description('Number of results to return (default: 25, max: 500)'), + 'offset' => $schema->number()->description('Number of results to skip for pagination (default: 0)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'total' => $schema->number()->description('Total number of matching users')->required(), + 'offset' => $schema->number()->description('Current pagination offset')->required(), + 'limit' => $schema->number()->description('Results per page')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/Reset2FATool.php b/app/Mcp/Tools/Reset2FATool.php new file mode 100644 index 000000000000..838ec5297d8d --- /dev/null +++ b/app/Mcp/Tools/Reset2FATool.php @@ -0,0 +1,68 @@ +validate([ + 'id' => 'required|integer', + ]); + + $user = User::find($request->get('id')); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + $user->two_factor_secret = null; + $user->two_factor_enrolled = 0; + $user->two_factor_optin = 0; + $user->save(); + + return Response::make( + Response::text(trans('mcp.two_factor_reset', ['username' => $user->username])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.two_factor_reset', ['username' => $user->username]), + 'id' => $user->id, + 'username' => $user->username, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user whose 2FA should be reset (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the reset succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the user'), + 'username' => $schema->string()->description('Username of the user'), + ]; + } +} diff --git a/app/Mcp/Tools/RestoreAssetTool.php b/app/Mcp/Tools/RestoreAssetTool.php new file mode 100644 index 000000000000..2e62ae99bda7 --- /dev/null +++ b/app/Mcp/Tools/RestoreAssetTool.php @@ -0,0 +1,69 @@ +validate([ + 'id' => 'required|integer', + ]); + + $asset = Asset::withTrashed()->find($request->get('id')); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! $asset->deleted_at) { + return Response::make(Response::error(trans('mcp.asset_not_deleted'))); + } + + if (! Gate::allows('delete', Asset::class)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $asset->restore(); + + return Response::make( + Response::text(trans('mcp.asset_restored', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_restored', ['asset_tag' => $asset->asset_tag]), + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the asset to restore (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the restore succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the restored asset'), + 'asset_tag' => $schema->string()->description('Asset tag of the restored asset'), + ]; + } +} diff --git a/app/Mcp/Tools/RestoreUserTool.php b/app/Mcp/Tools/RestoreUserTool.php new file mode 100644 index 000000000000..013a8df29eb2 --- /dev/null +++ b/app/Mcp/Tools/RestoreUserTool.php @@ -0,0 +1,73 @@ +validate([ + 'id' => 'required|integer', + ]); + + $user = User::withTrashed()->find($request->get('id')); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + if (! $user->deleted_at) { + return Response::make(Response::error(trans('mcp.user_not_deleted'))); + } + + if (! Gate::allows('delete', User::class)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $user->restore(); + + return Response::make( + Response::text(trans('mcp.user_restored', ['username' => $user->username])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.user_restored', ['username' => $user->username]), + 'id' => $user->id, + 'username' => $user->username, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user to restore (required)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the restore succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the restored user'), + 'username' => $schema->string()->description('Username of the restored user'), + ]; + } +} diff --git a/app/Mcp/Tools/SendPasswordResetTool.php b/app/Mcp/Tools/SendPasswordResetTool.php new file mode 100644 index 000000000000..5e6e193a2c53 --- /dev/null +++ b/app/Mcp/Tools/SendPasswordResetTool.php @@ -0,0 +1,105 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + ]); + + $user = $this->resolveUser($request); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + if (! Gate::allows('view', $user)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if (! $user->activated) { + return Response::make(Response::error(trans('mcp.password_reset_user_inactive', ['username' => $user->username]))); + } + + if (empty($user->email)) { + return Response::make(Response::error(trans('mcp.password_reset_no_email', ['username' => $user->username]))); + } + + if ($user->ldap_import) { + return Response::make(Response::error(trans('mcp.password_reset_ldap_user', ['username' => $user->username]))); + } + + try { + $result = Password::sendResetLink(['email' => trim($user->email)]); + } catch (\Exception $e) { + return Response::make(Response::error(trans('mcp.password_reset_send_failed', ['error' => $e->getMessage()]))); + } + + if ($result === Password::RESET_LINK_SENT) { + return Response::make( + Response::text(trans('mcp.password_reset_sent', ['email' => $user->email])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.password_reset_sent', ['email' => $user->email]), + 'username' => $user->username, + 'email' => $user->email, + ]); + } + + return Response::make(Response::error(trans('mcp.password_reset_send_failed', ['error' => $result]))); + } + + private function resolveUser(Request $request): ?User + { + if ($request->filled('id')) { + return User::find($request->get('id')); + } + if ($request->filled('username')) { + return User::where('username', $request->get('username'))->first(); + } + if ($request->filled('email')) { + return User::where('email', $request->get('email'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the user'), + 'username' => $schema->string()->description('Username of the user'), + 'email' => $schema->string()->description('Email address of the user'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the reset email was sent'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'username' => $schema->string()->description('Username of the user'), + 'email' => $schema->string()->description('Email address the reset link was sent to'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowAssetModelTool.php b/app/Mcp/Tools/ShowAssetModelTool.php new file mode 100644 index 000000000000..690bfdc4c9df --- /dev/null +++ b/app/Mcp/Tools/ShowAssetModelTool.php @@ -0,0 +1,105 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $model = $this->resolveModel($request); + + if ($model === false) { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $model) { + return Response::make(Response::error(trans('mcp.asset_model_not_found'))); + } + + if (! Gate::allows('view', $model)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $model->loadCount('assets as assets_count'); + + return Response::make( + Response::text(trans('mcp.asset_model_found', ['name' => $model->name])) + )->withStructuredContent([ + 'id' => $model->id, + 'name' => $model->name, + 'model_number' => $model->model_number, + 'category_id' => $model->category_id, + 'category' => $model->category?->name, + 'manufacturer_id' => $model->manufacturer_id, + 'manufacturer' => $model->manufacturer?->name, + 'depreciation_id' => $model->depreciation_id, + 'depreciation' => $model->depreciation?->name, + 'assets_count' => $model->assets_count, + 'eol' => $model->eol, + 'min_amt' => $model->min_amt, + 'notes' => $model->notes, + 'created_at' => $model->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $model->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + private function resolveModel(Request $request): AssetModel|false|null + { + if ($request->filled('id')) { + return AssetModel::with('category', 'manufacturer', 'depreciation')->find($request->get('id')); + } + if ($request->filled('name')) { + return AssetModel::with('category', 'manufacturer', 'depreciation')->where('name', $request->get('name'))->first(); + } + + return false; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the asset model to look up'), + 'name' => $schema->string()->description('Name of the asset model to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric asset model ID'), + 'name' => $schema->string()->description('Asset model name'), + 'model_number' => $schema->string()->description('Model number'), + 'category_id' => $schema->number()->description('Category ID'), + 'category' => $schema->string()->description('Category name'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'manufacturer' => $schema->string()->description('Manufacturer name'), + 'depreciation_id' => $schema->number()->description('Depreciation schedule ID'), + 'depreciation' => $schema->string()->description('Depreciation schedule name'), + 'assets_count' => $schema->number()->description('Number of assets using this model'), + 'eol' => $schema->number()->description('End of life in months'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowAssetTool.php b/app/Mcp/Tools/ShowAssetTool.php new file mode 100644 index 000000000000..3c96e1125013 --- /dev/null +++ b/app/Mcp/Tools/ShowAssetTool.php @@ -0,0 +1,124 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + ]); + + $with = ['status', 'model.category', 'model.manufacturer', 'location', 'defaultLoc', 'company', 'supplier', 'adminuser']; + $asset = null; + + if ($request->filled('asset_tag')) { + $asset = Asset::where('asset_tag', $request->get('asset_tag'))->with($with)->first(); + } elseif ($request->filled('serial')) { + $asset = Asset::where('serial', $request->get('serial'))->with($with)->first(); + } elseif ($request->filled('id')) { + $asset = Asset::with($with)->find($request->get('id')); + } + + if (! $asset) { + return Response::make( + Response::error(trans('mcp.asset_not_found')) + ); + } + + if (! Gate::allows('view', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + return Response::make( + Response::text(trans('mcp.asset_found', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent($this->formatAsset($asset)); + } + + private function formatAsset(Asset $asset): array + { + return [ + 'id' => $asset->id, + 'asset_tag' => $asset->asset_tag, + 'name' => $asset->name, + 'serial' => $asset->serial, + 'status' => $asset->status?->name, + 'status_type' => $asset->status?->getStatuslabelType(), + 'model' => $asset->model?->name, + 'model_number' => $asset->model?->model_number, + 'category' => $asset->model?->category?->name, + 'manufacturer' => $asset->model?->manufacturer?->name, + 'company' => $asset->company?->name, + 'location' => $asset->location?->name, + 'rtd_location' => $asset->defaultLoc?->name, + 'supplier' => $asset->supplier?->name, + 'assigned_to_id' => $asset->assigned_to, + 'assigned_to_type' => $asset->assigned_type ? class_basename($asset->assigned_type) : null, + 'notes' => $asset->notes, + 'order_number' => $asset->order_number, + 'purchase_date' => $asset->purchase_date?->format('Y-m-d'), + 'purchase_cost' => $asset->purchase_cost, + 'warranty_months' => $asset->warranty_months, + 'last_checkout' => $asset->last_checkout, + 'last_checkin' => $asset->last_checkin, + 'expected_checkin' => $asset->expected_checkin, + 'last_audit_date' => $asset->last_audit_date, + 'created_at' => $asset->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $asset->updated_at?->format('Y-m-d H:i:s'), + ]; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string() + ->description('The asset tag of the asset to look up'), + 'serial' => $schema->string() + ->description('The serial number of the asset to look up'), + 'id' => $schema->number() + ->description('The numeric ID of the asset to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric asset ID'), + 'asset_tag' => $schema->string()->description('Asset tag'), + 'name' => $schema->string()->description('Asset name'), + 'serial' => $schema->string()->description('Serial number'), + 'status' => $schema->string()->description('Status label name'), + 'status_type' => $schema->string()->description('Status type: deployable, pending, or archived'), + 'model' => $schema->string()->description('Asset model name'), + 'model_number' => $schema->string()->description('Model number'), + 'category' => $schema->string()->description('Category name'), + 'manufacturer' => $schema->string()->description('Manufacturer name'), + 'company' => $schema->string()->description('Company name'), + 'location' => $schema->string()->description('Current location name'), + 'rtd_location' => $schema->string()->description('Default return-to-deploy location name'), + 'assigned_to_id' => $schema->number()->description('ID of the entity this asset is currently assigned to'), + 'assigned_to_type' => $schema->string()->description('Type of entity assigned to: User, Asset, or Location'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->string()->description('Purchase cost'), + 'last_checkout' => $schema->string()->description('Date of last checkout'), + 'last_checkin' => $schema->string()->description('Date of last checkin'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowCategoryTool.php b/app/Mcp/Tools/ShowCategoryTool.php new file mode 100644 index 000000000000..4a02fc86fa8b --- /dev/null +++ b/app/Mcp/Tools/ShowCategoryTool.php @@ -0,0 +1,95 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $withCounts = [ + 'showableAssets as assets_count', + 'accessories as accessories_count', + 'consumables as consumables_count', + 'components as components_count', + 'licenses as licenses_count', + ]; + + $category = null; + + if ($request->filled('id')) { + $category = Category::withCount($withCounts)->find($request->get('id')); + } elseif ($request->filled('name')) { + $category = Category::withCount($withCounts)->where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $category) { + return Response::make(Response::error(trans('mcp.category_not_found'))); + } + + if (! Gate::allows('view', $category)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + return Response::make( + Response::text(trans('mcp.category_found', ['name' => $category->name])) + )->withStructuredContent([ + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + 'assets_count' => $category->assets_count, + 'accessories_count' => $category->accessories_count, + 'consumables_count' => $category->consumables_count, + 'components_count' => $category->components_count, + 'licenses_count' => $category->licenses_count, + 'notes' => $category->notes, + 'created_at' => $category->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $category->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the category to look up'), + 'name' => $schema->string()->description('Name of the category to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric category ID'), + 'name' => $schema->string()->description('Category name'), + 'category_type' => $schema->string()->description('Category type: asset, accessory, consumable, component, or license'), + 'assets_count' => $schema->number()->description('Number of assets in this category'), + 'accessories_count' => $schema->number()->description('Number of accessories in this category'), + 'consumables_count' => $schema->number()->description('Number of consumables in this category'), + 'components_count' => $schema->number()->description('Number of components in this category'), + 'licenses_count' => $schema->number()->description('Number of licenses in this category'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowCompanyTool.php b/app/Mcp/Tools/ShowCompanyTool.php new file mode 100644 index 000000000000..be620f09ab08 --- /dev/null +++ b/app/Mcp/Tools/ShowCompanyTool.php @@ -0,0 +1,89 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $company = null; + + if ($request->filled('id')) { + $company = Company::withCount([ + 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), + ])->withCount('users as users_count')->find($request->get('id')); + } elseif ($request->filled('name')) { + $company = Company::withCount([ + 'assets as assets_count' => fn ($q) => $q->AssetsForShow(), + ])->withCount('users as users_count')->where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $company) { + return Response::make(Response::error(trans('mcp.company_not_found'))); + } + + if (! Gate::allows('view', $company)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + return Response::make( + Response::text(trans('mcp.company_found', ['name' => $company->name])) + )->withStructuredContent([ + 'id' => $company->id, + 'name' => $company->name, + 'phone' => $company->phone, + 'fax' => $company->fax, + 'email' => $company->email, + 'assets_count' => $company->assets_count, + 'users_count' => $company->users_count, + 'notes' => $company->notes, + 'created_at' => $company->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $company->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the company to look up'), + 'name' => $schema->string()->description('Name of the company to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric company ID'), + 'name' => $schema->string()->description('Company name'), + 'phone' => $schema->string()->description('Company phone number'), + 'fax' => $schema->string()->description('Company fax number'), + 'email' => $schema->string()->description('Company email address'), + 'assets_count' => $schema->number()->description('Number of assets belonging to this company'), + 'users_count' => $schema->number()->description('Number of users belonging to this company'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowConsumableTool.php b/app/Mcp/Tools/ShowConsumableTool.php new file mode 100644 index 000000000000..26a58527112e --- /dev/null +++ b/app/Mcp/Tools/ShowConsumableTool.php @@ -0,0 +1,107 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $consumable = null; + + if ($request->filled('id')) { + $consumable = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location')->find($request->get('id')); + } elseif ($request->filled('name')) { + $consumable = Consumable::with('company', 'category', 'manufacturer', 'supplier', 'location')->where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $consumable) { + return Response::make(Response::error(trans('mcp.consumable_not_found'))); + } + + if (! Gate::allows('view', $consumable)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $usersCount = $consumable->users()->count(); + + return Response::make( + Response::text(trans('mcp.consumable_found', ['name' => $consumable->name])) + )->withStructuredContent([ + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + 'users_count' => $usersCount, + 'min_amt' => $consumable->min_amt, + 'category_id' => $consumable->category_id, + 'category' => $consumable->category?->name, + 'manufacturer_id' => $consumable->manufacturer_id, + 'manufacturer' => $consumable->manufacturer?->name, + 'company_id' => $consumable->company_id, + 'company' => $consumable->company?->name, + 'location_id' => $consumable->location_id, + 'location' => $consumable->location?->name, + 'purchase_cost' => $consumable->purchase_cost, + 'purchase_date' => $consumable->purchase_date?->format('Y-m-d'), + 'order_number' => $consumable->order_number, + 'model_number' => $consumable->model_number, + 'notes' => $consumable->notes, + 'created_at' => $consumable->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $consumable->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the consumable to look up'), + 'name' => $schema->string()->description('Name of the consumable to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric consumable ID'), + 'name' => $schema->string()->description('Consumable name'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'users_count' => $schema->number()->description('Number of units checked out'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'category_id' => $schema->number()->description('Category ID'), + 'category' => $schema->string()->description('Category name'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'manufacturer' => $schema->string()->description('Manufacturer name'), + 'company_id' => $schema->number()->description('Company ID'), + 'company' => $schema->string()->description('Company name'), + 'location_id' => $schema->number()->description('Location ID'), + 'location' => $schema->string()->description('Location name'), + 'purchase_cost' => $schema->string()->description('Purchase cost'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'order_number' => $schema->string()->description('Order number'), + 'model_number' => $schema->string()->description('Model number'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last updated timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowDepreciationTool.php b/app/Mcp/Tools/ShowDepreciationTool.php new file mode 100644 index 000000000000..65340929b177 --- /dev/null +++ b/app/Mcp/Tools/ShowDepreciationTool.php @@ -0,0 +1,87 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $depreciation = $this->resolveDepreciation($request); + + if ($depreciation === false) { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $depreciation) { + return Response::make(Response::error(trans('mcp.depreciation_not_found'))); + } + + if (! Gate::allows('view', $depreciation)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $depreciation->loadCount('models as models_count'); + + return Response::make( + Response::text(trans('mcp.depreciation_found', ['name' => $depreciation->name])) + )->withStructuredContent([ + 'id' => $depreciation->id, + 'name' => $depreciation->name, + 'months' => $depreciation->months, + 'models_count' => $depreciation->models_count, + 'created_at' => $depreciation->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $depreciation->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + private function resolveDepreciation(Request $request): Depreciation|false|null + { + if ($request->filled('id')) { + return Depreciation::find($request->get('id')); + } + if ($request->filled('name')) { + return Depreciation::where('name', $request->get('name'))->first(); + } + + return false; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the depreciation to look up'), + 'name' => $schema->string()->description('Name of the depreciation to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric depreciation ID'), + 'name' => $schema->string()->description('Depreciation name'), + 'months' => $schema->number()->description('Depreciation period in months'), + 'models_count' => $schema->number()->description('Number of asset models using this depreciation'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowGroupTool.php b/app/Mcp/Tools/ShowGroupTool.php new file mode 100644 index 000000000000..e75c08945594 --- /dev/null +++ b/app/Mcp/Tools/ShowGroupTool.php @@ -0,0 +1,75 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + if ($request->filled('id')) { + $group = Group::withCount('users as users_count')->find($request->get('id')); + } elseif ($request->filled('name')) { + $group = Group::withCount('users as users_count') + ->where('name', $request->get('name')) + ->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $group) { + return Response::make(Response::error(trans('mcp.group_not_found'))); + } + + return Response::make( + Response::text(trans('mcp.group_found', ['name' => $group->name])) + )->withStructuredContent([ + 'id' => $group->id, + 'name' => $group->name, + 'notes' => $group->notes, + 'permissions' => $group->decodePermissions(), + 'users_count' => $group->users_count, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID'), + 'name' => $schema->string()->description('Group name to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID')->required(), + 'name' => $schema->string()->description('Group name')->required(), + 'notes' => $schema->string()->description('Notes about the group'), + 'permissions' => $schema->object()->description('Decoded permissions array'), + 'users_count' => $schema->number()->description('Number of users in this group'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowLicenseTool.php b/app/Mcp/Tools/ShowLicenseTool.php new file mode 100644 index 000000000000..2aa648283b6c --- /dev/null +++ b/app/Mcp/Tools/ShowLicenseTool.php @@ -0,0 +1,102 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + if ($request->filled('id')) { + $license = License::with('company', 'manufacturer', 'supplier', 'category') + ->withCount('freeSeats as free_seats_count') + ->find($request->get('id')); + } elseif ($request->filled('name')) { + $license = License::with('company', 'manufacturer', 'supplier', 'category') + ->withCount('freeSeats as free_seats_count') + ->where('name', $request->get('name')) + ->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $license) { + return Response::make(Response::error(trans('mcp.license_not_found'))); + } + + if (! Gate::allows('view', $license)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $assignedCount = $license->assignedCount()->count(); + + return Response::make( + Response::text(trans('mcp.license_found', ['name' => $license->name])) + )->withStructuredContent([ + 'id' => $license->id, + 'name' => $license->name, + 'serial' => $license->serial, + 'seats' => $license->seats, + 'free_seats' => $license->free_seats_count, + 'assigned_seats' => $assignedCount, + 'category' => $license->category?->name, + 'category_id' => $license->category_id, + 'manufacturer' => $license->manufacturer?->name, + 'manufacturer_id' => $license->manufacturer_id, + 'company' => $license->company?->name, + 'company_id' => $license->company_id, + 'supplier' => $license->supplier?->name, + 'supplier_id' => $license->supplier_id, + 'license_name' => $license->license_name, + 'license_email' => $license->license_email, + 'maintained' => (bool) $license->maintained, + 'reassignable' => (bool) $license->reassignable, + 'purchase_date' => $license->purchase_date?->format('Y-m-d'), + 'purchase_cost' => $license->purchase_cost, + 'purchase_order' => $license->purchase_order, + 'order_number' => $license->order_number, + 'expiration_date' => $license->expiration_date?->format('Y-m-d'), + 'termination_date' => $license->termination_date?->format('Y-m-d'), + 'notes' => $license->notes, + 'created_at' => $license->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $license->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric license ID'), + 'name' => $schema->string()->description('License name to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric license ID')->required(), + 'name' => $schema->string()->description('License name')->required(), + 'seats' => $schema->number()->description('Total seat count'), + 'free_seats' => $schema->number()->description('Number of available (unassigned) seats'), + 'assigned_seats' => $schema->number()->description('Number of currently assigned seats'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowLocationTool.php b/app/Mcp/Tools/ShowLocationTool.php new file mode 100644 index 000000000000..e5d0359b7cfb --- /dev/null +++ b/app/Mcp/Tools/ShowLocationTool.php @@ -0,0 +1,109 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $location = $this->resolveLocation($request); + + if ($location === false) { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $location) { + return Response::make(Response::error(trans('mcp.location_not_found'))); + } + + if (! Gate::allows('view', $location)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $location->loadCount('assets as assets_count', 'users as users_count', 'children as children_count'); + + return Response::make( + Response::text(trans('mcp.location_found', ['name' => $location->name])) + )->withStructuredContent([ + 'id' => $location->id, + 'name' => $location->name, + 'address' => $location->address, + 'address2' => $location->address2, + 'city' => $location->city, + 'state' => $location->state, + 'country' => $location->country, + 'zip' => $location->zip, + 'phone' => $location->phone, + 'fax' => $location->fax, + 'parent_id' => $location->parent_id, + 'parent' => $location->parent?->name, + 'assets_count' => $location->assets_count, + 'users_count' => $location->users_count, + 'children_count' => $location->children_count, + 'created_at' => $location->created_at?->format('Y-m-d H:i:s'), + 'updated_at' => $location->updated_at?->format('Y-m-d H:i:s'), + ]); + } + + private function resolveLocation(Request $request): Location|false|null + { + if ($request->filled('id')) { + return Location::with('parent')->find($request->get('id')); + } + if ($request->filled('name')) { + return Location::with('parent')->where('name', $request->get('name'))->first(); + } + + return false; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the location to look up'), + 'name' => $schema->string()->description('Name of the location to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric location ID'), + 'name' => $schema->string()->description('Location name'), + 'address' => $schema->string()->description('Street address'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Zip code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'parent_id' => $schema->number()->description('Parent location ID'), + 'parent' => $schema->string()->description('Parent location name'), + 'assets_count' => $schema->number()->description('Number of assets at this location'), + 'users_count' => $schema->number()->description('Number of users at this location'), + 'children_count' => $schema->number()->description('Number of child locations'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowManufacturerTool.php b/app/Mcp/Tools/ShowManufacturerTool.php new file mode 100644 index 000000000000..88344eb3327b --- /dev/null +++ b/app/Mcp/Tools/ShowManufacturerTool.php @@ -0,0 +1,97 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $manufacturer = $this->resolveManufacturer($request); + + if (! $manufacturer) { + if (! $request->filled('id') && ! $request->filled('name')) { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + return Response::make(Response::error(trans('mcp.manufacturer_not_found'))); + } + + if (! Gate::allows('view', $manufacturer)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $manufacturer->loadCount('assets as assets_count'); + + return Response::make( + Response::text(trans('mcp.manufacturer_found', ['name' => $manufacturer->name])) + )->withStructuredContent([ + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + 'url' => $manufacturer->url, + 'support_url' => $manufacturer->support_url, + 'support_email' => $manufacturer->support_email, + 'support_phone' => $manufacturer->support_phone, + 'warranty_lookup_url' => $manufacturer->warranty_lookup_url, + 'assets_count' => $manufacturer->assets_count, + 'notes' => $manufacturer->notes, + 'created_at' => $manufacturer->created_at?->toISOString(), + 'updated_at' => $manufacturer->updated_at?->toISOString(), + ]); + } + + private function resolveManufacturer(Request $request): ?Manufacturer + { + if ($request->filled('id')) { + return Manufacturer::find($request->get('id')); + } + if ($request->filled('name')) { + return Manufacturer::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the manufacturer to show'), + 'name' => $schema->string()->description('Name of the manufacturer to show'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the manufacturer'), + 'name' => $schema->string()->description('Manufacturer name')->required(), + 'url' => $schema->string()->description('Manufacturer website URL'), + 'support_url' => $schema->string()->description('Support website URL'), + 'support_email' => $schema->string()->description('Support email address'), + 'support_phone' => $schema->string()->description('Support phone number'), + 'warranty_lookup_url' => $schema->string()->description('Warranty lookup URL'), + 'assets_count' => $schema->number()->description('Number of assets from this manufacturer'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowStatusLabelTool.php b/app/Mcp/Tools/ShowStatusLabelTool.php new file mode 100644 index 000000000000..90953431d09b --- /dev/null +++ b/app/Mcp/Tools/ShowStatusLabelTool.php @@ -0,0 +1,101 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $label = $this->resolveStatusLabel($request); + + if (! $label) { + if (! $request->filled('id') && ! $request->filled('name')) { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + return Response::make(Response::error(trans('mcp.status_label_not_found'))); + } + + if (! Gate::allows('view', $label)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $label->loadCount('assets as assets_count'); + + return Response::make( + Response::text(trans('mcp.status_label_found', ['name' => $label->name])) + )->withStructuredContent([ + 'id' => $label->id, + 'name' => $label->name, + 'type' => $label->getStatuslabelType(), + 'color' => $label->color, + 'deployable' => $label->deployable, + 'pending' => $label->pending, + 'archived' => $label->archived, + 'assets_count' => $label->assets_count, + 'default_label' => $label->default_label, + 'show_in_nav' => $label->show_in_nav, + 'notes' => $label->notes, + 'created_at' => $label->created_at?->toISOString(), + 'updated_at' => $label->updated_at?->toISOString(), + ]); + } + + private function resolveStatusLabel(Request $request): ?Statuslabel + { + if ($request->filled('id')) { + return Statuslabel::find($request->get('id')); + } + if ($request->filled('name')) { + return Statuslabel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the status label to show'), + 'name' => $schema->string()->description('Name of the status label to show'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the status label'), + 'name' => $schema->string()->description('Status label name')->required(), + 'type' => $schema->string()->description('Status label type (deployable, pending, archived, undeployable)'), + 'color' => $schema->string()->description('Display color'), + 'deployable' => $schema->boolean()->description('Whether status is deployable'), + 'pending' => $schema->boolean()->description('Whether status is pending'), + 'archived' => $schema->boolean()->description('Whether status is archived'), + 'assets_count' => $schema->number()->description('Number of assets with this status'), + 'default_label' => $schema->boolean()->description('Whether this is the default label'), + 'show_in_nav' => $schema->boolean()->description('Whether to show in navigation'), + 'notes' => $schema->string()->description('Notes'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowSupplierTool.php b/app/Mcp/Tools/ShowSupplierTool.php new file mode 100644 index 000000000000..4871a568a1ac --- /dev/null +++ b/app/Mcp/Tools/ShowSupplierTool.php @@ -0,0 +1,111 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + ]); + + $supplier = $this->resolveSupplier($request); + + if (! $supplier) { + if (! $request->filled('id') && ! $request->filled('name')) { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + return Response::make(Response::error(trans('mcp.supplier_not_found'))); + } + + if (! Gate::allows('view', $supplier)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $supplier->loadCount('assets as assets_count', 'licenses as licenses_count'); + + return Response::make( + Response::text(trans('mcp.supplier_found', ['name' => $supplier->name])) + )->withStructuredContent([ + 'id' => $supplier->id, + 'name' => $supplier->name, + 'address' => $supplier->address, + 'address2' => $supplier->address2, + 'city' => $supplier->city, + 'state' => $supplier->state, + 'country' => $supplier->country, + 'zip' => $supplier->zip, + 'phone' => $supplier->phone, + 'fax' => $supplier->fax, + 'email' => $supplier->email, + 'url' => $supplier->url, + 'contact' => $supplier->contact, + 'notes' => $supplier->notes, + 'assets_count' => $supplier->assets_count, + 'licenses_count' => $supplier->licenses_count, + 'created_at' => $supplier->created_at?->toISOString(), + 'updated_at' => $supplier->updated_at?->toISOString(), + ]); + } + + private function resolveSupplier(Request $request): ?Supplier + { + if ($request->filled('id')) { + return Supplier::find($request->get('id')); + } + if ($request->filled('name')) { + return Supplier::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the supplier to show'), + 'name' => $schema->string()->description('Name of the supplier to show'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID of the supplier'), + 'name' => $schema->string()->description('Supplier name')->required(), + 'address' => $schema->string()->description('Address line 1'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'email' => $schema->string()->description('Email address'), + 'url' => $schema->string()->description('Website URL'), + 'contact' => $schema->string()->description('Contact name'), + 'notes' => $schema->string()->description('Notes'), + 'assets_count' => $schema->number()->description('Number of assets from this supplier'), + 'licenses_count' => $schema->number()->description('Number of licenses from this supplier'), + 'created_at' => $schema->string()->description('Creation timestamp'), + 'updated_at' => $schema->string()->description('Last update timestamp'), + ]; + } +} diff --git a/app/Mcp/Tools/ShowUserTool.php b/app/Mcp/Tools/ShowUserTool.php new file mode 100644 index 000000000000..bd320fb0ca4e --- /dev/null +++ b/app/Mcp/Tools/ShowUserTool.php @@ -0,0 +1,116 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + ]); + + $with = ['company', 'department', 'userloc', 'manager', 'groups']; + + if ($request->filled('id')) { + $user = User::with($with) + ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) + ->find($request->get('id')); + } elseif ($request->filled('username')) { + $user = User::where('username', $request->get('username'))->with($with) + ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) + ->first(); + } elseif ($request->filled('email')) { + $user = User::where('email', $request->get('email'))->with($with) + ->withCount(['assets as assets_count', 'licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count']) + ->first(); + } else { + return Response::make(Response::error(trans('mcp.id_username_or_email_required'))); + } + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + if (! Gate::allows('view', $user)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + return Response::make( + Response::text(trans('mcp.user_found', ['username' => $user->username])) + )->withStructuredContent([ + 'id' => $user->id, + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'username' => $user->username, + 'email' => $user->email, + 'employee_num' => $user->employee_num, + 'jobtitle' => $user->jobtitle, + 'phone' => $user->phone, + 'mobile' => $user->mobile, + 'company' => $user->company?->name, + 'company_id' => $user->company_id, + 'department' => $user->department?->name, + 'department_id' => $user->department_id, + 'location' => $user->userloc?->name, + 'location_id' => $user->location_id, + 'manager' => $user->manager ? trim($user->manager->first_name.' '.$user->manager->last_name) : null, + 'manager_id' => $user->manager_id, + 'activated' => (bool) $user->activated, + 'notes' => $user->notes, + 'start_date' => $user->start_date?->toDateString(), + 'end_date' => $user->end_date?->toDateString(), + 'vip' => (bool) $user->vip, + 'remote' => (bool) $user->remote, + 'website' => $user->website, + 'address' => $user->address, + 'city' => $user->city, + 'state' => $user->state, + 'country' => $user->country, + 'zip' => $user->zip, + 'assets_count' => $user->assets_count, + 'licenses_count' => $user->licenses_count, + 'accessories_count' => $user->accessories_count, + 'consumables_count' => $user->consumables_count, + 'last_login' => $user->last_login?->toDateTimeString(), + 'created_at' => $user->created_at?->toDateTimeString(), + 'updated_at' => $user->updated_at?->toDateTimeString(), + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID'), + 'username' => $schema->string()->description('Username to look up'), + 'email' => $schema->string()->description('Email address to look up'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID')->required(), + 'username' => $schema->string()->description('Username')->required(), + 'email' => $schema->string()->description('Email address'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAccessoryTool.php b/app/Mcp/Tools/UpdateAccessoryTool.php new file mode 100644 index 000000000000..c462fbfaf5ac --- /dev/null +++ b/app/Mcp/Tools/UpdateAccessoryTool.php @@ -0,0 +1,130 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer|exists:categories,id', + 'qty' => 'nullable|integer|min:0', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'requestable' => 'nullable|boolean', + 'notes' => 'nullable|string', + ]); + + $accessory = $this->resolveAccessory($request); + + if (! $accessory) { + return Response::make(Response::error(trans('mcp.accessory_not_found'))); + } + + if (! Gate::allows('update', $accessory)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = [ + 'category_id', 'qty', 'model_number', 'manufacturer_id', + 'supplier_id', 'location_id', 'order_number', 'purchase_cost', + 'purchase_date', 'min_amt', 'requestable', 'notes', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $accessory->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $accessory->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $accessory->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($accessory->save()) { + return Response::make( + Response::text(trans('mcp.accessory_updated', ['name' => $accessory->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.accessory_updated', ['name' => $accessory->name]), + 'id' => $accessory->id, + 'name' => $accessory->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $accessory->getErrors()->first()]))); + } + + private function resolveAccessory(Request $request): ?Accessory + { + if ($request->filled('id')) { + return Accessory::find($request->get('id')); + } + if ($request->filled('name')) { + return Accessory::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the accessory'), + 'name' => $schema->string()->description('Name to identify the accessory'), + 'new_name' => $schema->string()->description('New name (renames the accessory)'), + 'category_id' => $schema->number()->description('Category ID'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'requestable' => $schema->boolean()->description('Whether users can request this accessory'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the accessory'), + 'name' => $schema->string()->description('Name of the accessory'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAssetModelTool.php b/app/Mcp/Tools/UpdateAssetModelTool.php new file mode 100644 index 000000000000..cdb7a09214c2 --- /dev/null +++ b/app/Mcp/Tools/UpdateAssetModelTool.php @@ -0,0 +1,111 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer|exists:categories,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'depreciation_id' => 'nullable|integer|exists:depreciations,id', + 'model_number' => 'nullable|string|max:255', + 'eol' => 'nullable|integer|min:0|max:240', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + 'requestable' => 'nullable|boolean', + 'require_serial' => 'nullable|boolean', + ]); + + $model = $this->resolveModel($request); + + if (! $model) { + return Response::make(Response::error(trans('mcp.asset_model_not_found'))); + } + + if (! Gate::allows('update', $model)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $model->name = $request->get('new_name'); + } + + foreach (['category_id', 'manufacturer_id', 'depreciation_id', 'model_number', 'eol', 'min_amt', 'notes', 'requestable', 'require_serial'] as $field) { + if ($request->filled($field)) { + $model->{$field} = $request->get($field); + } + } + + if ($model->save()) { + return Response::make( + Response::text(trans('mcp.asset_model_updated', ['name' => $model->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_model_updated', ['name' => $model->name]), + 'id' => $model->id, + 'name' => $model->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $model->getErrors()->first()]))); + } + + private function resolveModel(Request $request): ?AssetModel + { + if ($request->filled('id')) { + return AssetModel::find($request->get('id')); + } + if ($request->filled('name')) { + return AssetModel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the asset model'), + 'name' => $schema->string()->description('Name to identify the asset model'), + 'new_name' => $schema->string()->description('New name (renames the asset model)'), + 'category_id' => $schema->number()->description('Category ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'depreciation_id' => $schema->number()->description('Depreciation schedule ID'), + 'model_number' => $schema->string()->description('Model number'), + 'eol' => $schema->number()->description('End of life in months (0-240)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + 'requestable' => $schema->boolean()->description('Whether the model can be requested'), + 'require_serial' => $schema->boolean()->description('Whether serial numbers are required'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the asset model'), + 'name' => $schema->string()->description('Name of the asset model'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateAssetTool.php b/app/Mcp/Tools/UpdateAssetTool.php new file mode 100644 index 000000000000..d693a5ea053f --- /dev/null +++ b/app/Mcp/Tools/UpdateAssetTool.php @@ -0,0 +1,145 @@ +validate([ + 'asset_tag' => 'nullable|max:100', + 'serial' => 'nullable|string|max:255', + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_asset_tag' => 'nullable|string|max:255', + 'new_serial' => 'nullable|string|max:255', + 'status_id' => 'nullable|integer|exists:status_labels,id', + 'model_id' => 'nullable|integer|exists:models,id', + 'notes' => 'nullable|string|max:65535', + 'order_number' => 'nullable|string|max:255', + 'purchase_date' => 'nullable|date', + 'purchase_cost' => 'nullable|numeric', + 'warranty_months' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'rtd_location_id' => 'nullable|integer|exists:locations,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'requestable' => 'nullable|boolean', + 'byod' => 'nullable|boolean', + 'asset_eol_date' => 'nullable|date', + 'expected_checkin' => 'nullable|date', + 'next_audit_date' => 'nullable|date', + ]); + + $asset = $this->resolveAsset($request); + + if (! $asset) { + return Response::make(Response::error(trans('mcp.asset_not_found'))); + } + + if (! Gate::allows('update', $asset)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = [ + 'name', 'status_id', 'model_id', 'notes', 'order_number', + 'purchase_date', 'purchase_cost', 'warranty_months', + 'location_id', 'rtd_location_id', 'supplier_id', + 'requestable', 'byod', 'asset_eol_date', 'expected_checkin', 'next_audit_date', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $asset->{$field} = $request->get($field); + } + } + + // new_asset_tag / new_serial let callers change the identifiers without + // conflicting with the asset_tag/serial used to look up the asset + if ($request->filled('new_asset_tag')) { + $asset->asset_tag = $request->get('new_asset_tag'); + } + if ($request->filled('new_serial')) { + $asset->serial = $request->get('new_serial'); + } + + if ($asset->save()) { + return Response::make( + Response::text(trans('mcp.asset_updated', ['asset_tag' => $asset->asset_tag])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.asset_updated', ['asset_tag' => $asset->asset_tag]), + 'asset_tag' => $asset->asset_tag, + 'id' => $asset->id, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $asset->getErrors()->first()]))); + } + + private function resolveAsset(Request $request): ?Asset + { + if ($request->filled('asset_tag')) { + return Asset::where('asset_tag', $request->get('asset_tag'))->first(); + } + if ($request->filled('serial')) { + return Asset::where('serial', $request->get('serial'))->first(); + } + if ($request->filled('id')) { + return Asset::find($request->get('id')); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'asset_tag' => $schema->string()->description('Asset tag to identify the asset'), + 'serial' => $schema->string()->description('Serial number to identify the asset'), + 'id' => $schema->number()->description('Numeric ID to identify the asset'), + 'name' => $schema->string()->description('New display name'), + 'new_asset_tag' => $schema->string()->description('New asset tag (renames the asset tag itself)'), + 'new_serial' => $schema->string()->description('New serial number'), + 'status_id' => $schema->number()->description('Status label ID'), + 'model_id' => $schema->number()->description('Model ID'), + 'notes' => $schema->string()->description('Notes'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'warranty_months' => $schema->number()->description('Warranty length in months'), + 'location_id' => $schema->number()->description('Current location ID'), + 'rtd_location_id' => $schema->number()->description('Default RTD location ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'requestable' => $schema->boolean()->description('Whether the asset is user-requestable'), + 'byod' => $schema->boolean()->description('Bring-your-own-device flag'), + 'asset_eol_date' => $schema->string()->description('Asset end-of-life date (YYYY-MM-DD)'), + 'expected_checkin' => $schema->string()->description('Expected check-in date (YYYY-MM-DD)'), + 'next_audit_date' => $schema->string()->description('Next scheduled audit date (YYYY-MM-DD)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'error' => $schema->boolean()->description('True if the update failed'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'asset_tag' => $schema->string()->description('Asset tag of the updated asset'), + 'id' => $schema->number()->description('Numeric ID of the updated asset'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateCategoryTool.php b/app/Mcp/Tools/UpdateCategoryTool.php new file mode 100644 index 000000000000..42ea3c378930 --- /dev/null +++ b/app/Mcp/Tools/UpdateCategoryTool.php @@ -0,0 +1,105 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'category_type' => 'nullable|string|in:asset,accessory,consumable,component,license', + 'notes' => 'nullable|string', + 'checkin_email' => 'nullable|boolean', + 'require_acceptance' => 'nullable|boolean', + 'use_default_eula' => 'nullable|boolean', + ]); + + $category = $this->resolveCategory($request); + + if (! $category) { + return Response::make(Response::error(trans('mcp.category_not_found'))); + } + + if (! Gate::allows('update', $category)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $category->name = $request->get('new_name'); + } + + foreach (['category_type', 'notes', 'checkin_email', 'require_acceptance', 'use_default_eula'] as $field) { + if ($request->filled($field)) { + $category->{$field} = $request->get($field); + } + } + + if ($category->save()) { + return Response::make( + Response::text(trans('mcp.category_updated', ['name' => $category->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.category_updated', ['name' => $category->name]), + 'id' => $category->id, + 'name' => $category->name, + 'category_type' => $category->category_type, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $category->getErrors()->first()]))); + } + + private function resolveCategory(Request $request): ?Category + { + if ($request->filled('id')) { + return Category::find($request->get('id')); + } + if ($request->filled('name')) { + return Category::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the category'), + 'name' => $schema->string()->description('Name to identify the category'), + 'new_name' => $schema->string()->description('New name (renames the category)'), + 'category_type' => $schema->string()->description('Category type: asset, accessory, consumable, component, or license'), + 'checkin_email' => $schema->boolean()->description('Send checkin email when items are checked in'), + 'require_acceptance' => $schema->boolean()->description('Require user acceptance when checking out'), + 'use_default_eula' => $schema->boolean()->description('Use the default EULA'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the category'), + 'name' => $schema->string()->description('Name of the category'), + 'category_type' => $schema->string()->description('Type of the category'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateCompanyTool.php b/app/Mcp/Tools/UpdateCompanyTool.php new file mode 100644 index 000000000000..1a742f74a688 --- /dev/null +++ b/app/Mcp/Tools/UpdateCompanyTool.php @@ -0,0 +1,101 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $company = $this->resolveCompany($request); + + if (! $company) { + return Response::make(Response::error(trans('mcp.company_not_found'))); + } + + if (! Gate::allows('update', $company)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $company->name = $request->get('new_name'); + } + + foreach (['phone', 'fax', 'email', 'notes'] as $field) { + if ($request->filled($field)) { + $company->{$field} = $request->get($field); + } + } + + if ($company->save()) { + return Response::make( + Response::text(trans('mcp.company_updated', ['name' => $company->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.company_updated', ['name' => $company->name]), + 'id' => $company->id, + 'name' => $company->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $company->getErrors()->first()]))); + } + + private function resolveCompany(Request $request): ?Company + { + if ($request->filled('id')) { + return Company::find($request->get('id')); + } + if ($request->filled('name')) { + return Company::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the company'), + 'name' => $schema->string()->description('Name to identify the company'), + 'new_name' => $schema->string()->description('New name (renames the company)'), + 'phone' => $schema->string()->description('Company phone number'), + 'fax' => $schema->string()->description('Company fax number'), + 'email' => $schema->string()->description('Company email address'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the company'), + 'name' => $schema->string()->description('Name of the company'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateComponentTool.php b/app/Mcp/Tools/UpdateComponentTool.php new file mode 100644 index 000000000000..2ae0e3a827a6 --- /dev/null +++ b/app/Mcp/Tools/UpdateComponentTool.php @@ -0,0 +1,130 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:191', + 'new_name' => 'nullable|string|max:191', + 'category_id' => 'nullable|integer|exists:categories,id', + 'qty' => 'nullable|integer|min:1', + 'serial' => 'nullable|string|max:255', + 'model_number' => 'nullable|string|max:255', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'order_number' => 'nullable|string|max:255', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $component = $this->resolveComponent($request); + + if (! $component) { + return Response::make(Response::error(trans('mcp.component_not_found'))); + } + + if (! Gate::allows('update', $component)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = [ + 'category_id', 'qty', 'serial', 'model_number', 'manufacturer_id', + 'supplier_id', 'location_id', 'order_number', + 'purchase_cost', 'purchase_date', 'min_amt', 'notes', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $component->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $component->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $component->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($component->save()) { + return Response::make( + Response::text(trans('mcp.component_updated', ['name' => $component->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.component_updated', ['name' => $component->name]), + 'id' => $component->id, + 'name' => $component->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $component->getErrors()->first()]))); + } + + private function resolveComponent(Request $request): ?Component + { + if ($request->filled('id')) { + return Component::find($request->get('id')); + } + if ($request->filled('name')) { + return Component::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the component'), + 'name' => $schema->string()->description('Name to identify the component'), + 'new_name' => $schema->string()->description('New name (renames the component)'), + 'category_id' => $schema->number()->description('Category ID'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'serial' => $schema->string()->description('Serial number'), + 'model_number' => $schema->string()->description('Model number'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'order_number' => $schema->string()->description('Order number'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the component'), + 'name' => $schema->string()->description('Name of the component'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateConsumableTool.php b/app/Mcp/Tools/UpdateConsumableTool.php new file mode 100644 index 000000000000..92d31c23cb8e --- /dev/null +++ b/app/Mcp/Tools/UpdateConsumableTool.php @@ -0,0 +1,120 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'qty' => 'nullable|integer|min:0', + 'category_id' => 'nullable|integer|exists:categories,id', + 'company_id' => 'nullable|integer', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'min_amt' => 'nullable|integer|min:0', + 'notes' => 'nullable|string', + ]); + + $consumable = $this->resolveConsumable($request); + + if (! $consumable) { + return Response::make(Response::error(trans('mcp.consumable_not_found'))); + } + + if (! Gate::allows('update', $consumable)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = [ + 'qty', 'category_id', 'company_id', 'location_id', 'manufacturer_id', + 'supplier_id', 'purchase_cost', 'purchase_date', 'min_amt', 'notes', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $consumable->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $consumable->name = $request->get('new_name'); + } + + if ($consumable->save()) { + return Response::make( + Response::text(trans('mcp.consumable_updated', ['name' => $consumable->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.consumable_updated', ['name' => $consumable->name]), + 'id' => $consumable->id, + 'name' => $consumable->name, + 'qty' => $consumable->qty, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $consumable->getErrors()->first()]))); + } + + private function resolveConsumable(Request $request): ?Consumable + { + if ($request->filled('id')) { + return Consumable::find($request->get('id')); + } + if ($request->filled('name')) { + return Consumable::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the consumable'), + 'name' => $schema->string()->description('Name to identify the consumable'), + 'new_name' => $schema->string()->description('New name (renames the consumable)'), + 'qty' => $schema->number()->description('Total quantity in stock'), + 'category_id' => $schema->number()->description('Category ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'purchase_cost' => $schema->number()->description('Purchase cost per unit'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'min_amt' => $schema->number()->description('Minimum quantity alert threshold'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the consumable'), + 'name' => $schema->string()->description('Name of the consumable'), + 'qty' => $schema->number()->description('Total quantity'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateDepartmentTool.php b/app/Mcp/Tools/UpdateDepartmentTool.php new file mode 100644 index 000000000000..75cdb77386bc --- /dev/null +++ b/app/Mcp/Tools/UpdateDepartmentTool.php @@ -0,0 +1,112 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'location_id' => 'nullable|integer|exists:locations,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'phone' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:255', + 'notes' => 'nullable|string|max:255', + ]); + + $department = $this->resolveDepartment($request); + + if (! $department) { + return Response::make(Response::error(trans('mcp.department_not_found'))); + } + + if (! Gate::allows('update', $department)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = ['location_id', 'manager_id', 'phone', 'fax', 'notes']; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $department->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $department->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $department->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($department->save()) { + return Response::make( + Response::text(trans('mcp.department_updated', ['name' => $department->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.department_updated', ['name' => $department->name]), + 'id' => $department->id, + 'name' => $department->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $department->getErrors()->first()]))); + } + + private function resolveDepartment(Request $request): ?Department + { + if ($request->filled('id')) { + return Department::find($request->get('id')); + } + if ($request->filled('name')) { + return Department::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the department'), + 'name' => $schema->string()->description('Name to identify the department'), + 'new_name' => $schema->string()->description('New name (renames the department)'), + 'location_id' => $schema->number()->description('Location ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'manager_id' => $schema->number()->description('User ID of the department manager'), + 'phone' => $schema->string()->description('Department phone number'), + 'fax' => $schema->string()->description('Department fax number'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the department'), + 'name' => $schema->string()->description('Name of the department'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateDepreciationTool.php b/app/Mcp/Tools/UpdateDepreciationTool.php new file mode 100644 index 000000000000..53b3cade5453 --- /dev/null +++ b/app/Mcp/Tools/UpdateDepreciationTool.php @@ -0,0 +1,95 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'months' => 'nullable|integer|min:1|max:3600', + ]); + + $dep = $this->resolveDepreciation($request); + + if (! $dep) { + return Response::make(Response::error(trans('mcp.depreciation_not_found'))); + } + + if (! Gate::allows('update', $dep)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $dep->name = $request->get('new_name'); + } + + if ($request->filled('months')) { + $dep->months = $request->get('months'); + } + + if ($dep->save()) { + return Response::make( + Response::text(trans('mcp.depreciation_updated', ['name' => $dep->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.depreciation_updated', ['name' => $dep->name]), + 'id' => $dep->id, + 'name' => $dep->name, + 'months' => $dep->months, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $dep->getErrors()->first()]))); + } + + private function resolveDepreciation(Request $request): ?Depreciation + { + if ($request->filled('id')) { + return Depreciation::find($request->get('id')); + } + if ($request->filled('name')) { + return Depreciation::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the depreciation'), + 'name' => $schema->string()->description('Name to identify the depreciation'), + 'new_name' => $schema->string()->description('New name (renames the depreciation)'), + 'months' => $schema->number()->description('Depreciation period in months (1-3600)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the depreciation'), + 'name' => $schema->string()->description('Name of the depreciation'), + 'months' => $schema->number()->description('Depreciation period in months'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateGroupTool.php b/app/Mcp/Tools/UpdateGroupTool.php new file mode 100644 index 000000000000..c2bf538c3aca --- /dev/null +++ b/app/Mcp/Tools/UpdateGroupTool.php @@ -0,0 +1,138 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'permissions' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + } catch (ValidationException $e) { + return Response::make(Response::error($e->validator->errors()->first())); + } + + if ($request->filled('id')) { + $group = Group::find($request->get('id')); + } elseif ($request->filled('name')) { + $group = Group::where('name', $request->get('name'))->first(); + } else { + return Response::make(Response::error(trans('mcp.id_or_name_required'))); + } + + if (! $group) { + return Response::make(Response::error(trans('mcp.group_not_found'))); + } + + if ($request->filled('new_name')) { + $group->name = $request->get('new_name'); + } + + if ($request->filled('notes')) { + $group->notes = $request->get('notes'); + } + + if ($request->filled('permissions')) { + $result = $this->parseAndValidatePermissions($request->get('permissions')); + if (is_string($result)) { + return Response::make(Response::error($result)); + } + $group->permissions = json_encode($result); + } + + if ($group->save()) { + return Response::make( + Response::text(trans('mcp.group_updated', ['name' => $group->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.group_updated', ['name' => $group->name]), + 'id' => $group->id, + 'name' => $group->name, + 'permissions' => $group->decodePermissions(), + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $group->getErrors()->first()]))); + } + + /** + * Parse a JSON permissions string and validate all keys against config('permissions'). + * Returns the decoded array on success, or an error string on failure. + */ + private function parseAndValidatePermissions(string $raw): array|string + { + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + return trans('mcp.invalid_permissions_format'); + } + + $validKeys = collect(config('permissions')) + ->flatMap(fn ($perms) => collect($perms)->pluck('permission')) + ->unique() + ->flip() + ->all(); + + foreach (array_keys($decoded) as $key) { + if (! isset($validKeys[$key])) { + return trans('mcp.invalid_permission_key', ['key' => $key]); + } + if (! in_array((int) $decoded[$key], [1, -1], true)) { + return trans('mcp.invalid_permission_value', ['key' => $key]); + } + } + + return array_map('intval', $decoded); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric group ID to update'), + 'name' => $schema->string()->description('Group name to look up for updating'), + 'new_name' => $schema->string()->description('New name to rename the group to'), + 'permissions' => $schema->string()->description( + 'JSON object replacing the group\'s entire permission set. '. + 'Map permission keys to 1 (grant) or -1 (deny). '. + 'Example: {"assets.view":1,"assets.create":1,"assets.edit":-1}. '. + 'Omit to leave existing permissions unchanged.' + ), + 'notes' => $schema->string()->description('Updated notes for the group'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the updated group'), + 'name' => $schema->string()->description('Name of the updated group'), + 'permissions' => $schema->object()->description('Permissions now set on the group'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateLicenseTool.php b/app/Mcp/Tools/UpdateLicenseTool.php new file mode 100644 index 000000000000..fe8cdb9492c3 --- /dev/null +++ b/app/Mcp/Tools/UpdateLicenseTool.php @@ -0,0 +1,143 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'seats' => 'nullable|integer|min:1', + 'serial' => 'nullable|string|max:255', + 'category_id' => 'nullable|integer|exists:categories,id', + 'manufacturer_id' => 'nullable|integer|exists:manufacturers,id', + 'supplier_id' => 'nullable|integer|exists:suppliers,id', + 'company_id' => 'nullable|integer|exists:companies,id', + 'purchase_date' => 'nullable|date_format:Y-m-d', + 'purchase_cost' => 'nullable|numeric|min:0', + 'purchase_order' => 'nullable|string|max:255', + 'order_number' => 'nullable|string|max:255', + 'expiration_date' => 'nullable|date_format:Y-m-d', + 'termination_date' => 'nullable|date_format:Y-m-d', + 'license_name' => 'nullable|string|max:255', + 'license_email' => 'nullable|email|max:255', + 'maintained' => 'nullable|boolean', + 'reassignable' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'min_amt' => 'nullable|integer|min:0', + ]); + + $license = $this->resolveLicense($request); + + if (! $license) { + return Response::make(Response::error(trans('mcp.license_not_found'))); + } + + if (! Gate::allows('update', $license)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = [ + 'seats', 'serial', 'category_id', 'manufacturer_id', 'supplier_id', + 'purchase_date', 'purchase_cost', 'purchase_order', 'order_number', + 'expiration_date', 'termination_date', 'license_name', 'license_email', + 'maintained', 'reassignable', 'notes', 'min_amt', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $license->{$field} = $request->get($field); + } + } + + if ($request->filled('new_name')) { + $license->name = $request->get('new_name'); + } + + if ($request->filled('company_id')) { + $license->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($license->save()) { + return Response::make( + Response::text(trans('mcp.license_updated', ['name' => $license->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.license_updated', ['name' => $license->name]), + 'id' => $license->id, + 'name' => $license->name, + 'seats' => $license->seats, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $license->getErrors()->first()]))); + } + + private function resolveLicense(Request $request): ?License + { + if ($request->filled('id')) { + return License::find($request->get('id')); + } + if ($request->filled('name')) { + return License::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the license'), + 'name' => $schema->string()->description('Name to identify the license'), + 'new_name' => $schema->string()->description('New name (renames the license)'), + 'seats' => $schema->number()->description('Total seat count'), + 'serial' => $schema->string()->description('Product key / serial number'), + 'category_id' => $schema->number()->description('Category ID'), + 'manufacturer_id' => $schema->number()->description('Manufacturer ID'), + 'supplier_id' => $schema->number()->description('Supplier ID'), + 'company_id' => $schema->number()->description('Company ID'), + 'purchase_date' => $schema->string()->description('Purchase date (YYYY-MM-DD)'), + 'purchase_cost' => $schema->number()->description('Purchase cost'), + 'purchase_order' => $schema->string()->description('Purchase order number'), + 'order_number' => $schema->string()->description('Order number'), + 'expiration_date' => $schema->string()->description('Expiration date (YYYY-MM-DD)'), + 'termination_date' => $schema->string()->description('Termination date (YYYY-MM-DD)'), + 'license_name' => $schema->string()->description('Name of the licensed user/organization'), + 'license_email' => $schema->string()->description('Email of the licensed user/organization'), + 'maintained' => $schema->boolean()->description('Whether the license is under maintenance'), + 'reassignable' => $schema->boolean()->description('Whether seats can be reassigned after checkin'), + 'notes' => $schema->string()->description('Notes'), + 'min_amt' => $schema->number()->description('Minimum seat threshold for alerts'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the license'), + 'name' => $schema->string()->description('Name of the license'), + 'seats' => $schema->number()->description('Total seat count'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateLocationTool.php b/app/Mcp/Tools/UpdateLocationTool.php new file mode 100644 index 000000000000..8d891164798b --- /dev/null +++ b/app/Mcp/Tools/UpdateLocationTool.php @@ -0,0 +1,113 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'address' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'currency' => 'nullable|string', + 'parent_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + ]); + + $location = $this->resolveLocation($request); + + if (! $location) { + return Response::make(Response::error(trans('mcp.location_not_found'))); + } + + if (! Gate::allows('update', $location)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $location->name = $request->get('new_name'); + } + + foreach (['address', 'city', 'state', 'country', 'zip', 'phone', 'fax', 'currency', 'parent_id', 'manager_id'] as $field) { + if ($request->filled($field)) { + $location->{$field} = $request->get($field); + } + } + + if ($location->save()) { + return Response::make( + Response::text(trans('mcp.location_updated', ['name' => $location->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.location_updated', ['name' => $location->name]), + 'id' => $location->id, + 'name' => $location->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $location->getErrors()->first()]))); + } + + private function resolveLocation(Request $request): ?Location + { + if ($request->filled('id')) { + return Location::find($request->get('id')); + } + if ($request->filled('name')) { + return Location::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the location'), + 'name' => $schema->string()->description('Name to identify the location'), + 'new_name' => $schema->string()->description('New name (renames the location)'), + 'address' => $schema->string()->description('Street address'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Zip code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'currency' => $schema->string()->description('Currency code'), + 'parent_id' => $schema->number()->description('Parent location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the location'), + 'name' => $schema->string()->description('Name of the location'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateManufacturerTool.php b/app/Mcp/Tools/UpdateManufacturerTool.php new file mode 100644 index 000000000000..f2fce4085b8b --- /dev/null +++ b/app/Mcp/Tools/UpdateManufacturerTool.php @@ -0,0 +1,107 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'url' => 'nullable|string|max:255', + 'support_url' => 'nullable|string|max:255', + 'support_email' => 'nullable|email|max:191', + 'support_phone' => 'nullable|string|max:191', + 'warranty_lookup_url' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + + $manufacturer = $this->resolveManufacturer($request); + + if (! $manufacturer) { + return Response::make(Response::error(trans('mcp.manufacturer_not_found'))); + } + + if (! Gate::allows('update', $manufacturer)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $manufacturer->name = $request->get('new_name'); + } + + $updatable = ['url', 'support_url', 'support_email', 'support_phone', 'warranty_lookup_url', 'notes']; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $manufacturer->{$field} = $request->get($field); + } + } + + if ($manufacturer->save()) { + return Response::make( + Response::text(trans('mcp.manufacturer_updated', ['name' => $manufacturer->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.manufacturer_updated', ['name' => $manufacturer->name]), + 'id' => $manufacturer->id, + 'name' => $manufacturer->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $manufacturer->getErrors()->first()]))); + } + + private function resolveManufacturer(Request $request): ?Manufacturer + { + if ($request->filled('id')) { + return Manufacturer::find($request->get('id')); + } + if ($request->filled('name')) { + return Manufacturer::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the manufacturer'), + 'name' => $schema->string()->description('Name to identify the manufacturer'), + 'new_name' => $schema->string()->description('New name (renames the manufacturer)'), + 'url' => $schema->string()->description('Manufacturer website URL'), + 'support_url' => $schema->string()->description('Support website URL'), + 'support_email' => $schema->string()->description('Support email address'), + 'support_phone' => $schema->string()->description('Support phone number'), + 'warranty_lookup_url' => $schema->string()->description('Warranty lookup URL'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the manufacturer'), + 'name' => $schema->string()->description('Name of the manufacturer'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateProfileTool.php b/app/Mcp/Tools/UpdateProfileTool.php new file mode 100644 index 000000000000..dcdc32592518 --- /dev/null +++ b/app/Mcp/Tools/UpdateProfileTool.php @@ -0,0 +1,106 @@ +validate([ + 'first_name' => 'nullable|string|max:255', + 'last_name' => 'nullable|string|max:255', + 'phone' => 'nullable|string|max:35', + 'website' => 'nullable|url|max:255', + 'gravatar' => 'nullable|string|max:255', + 'locale' => 'nullable|string|max:10', + 'two_factor_optin' => 'nullable|boolean', + 'location_id' => 'nullable|integer|exists:locations,id', + ]); + + $user = auth()->user(); + + if (Gate::allows('self.profile') && ! config('app.lock_passwords')) { + foreach (['first_name', 'last_name', 'phone', 'website', 'gravatar'] as $field) { + if ($request->filled($field)) { + $user->{$field} = $request->get($field); + } + } + } + + if ($request->filled('locale')) { + $user->locale = $request->get('locale'); + } + + if ( + $request->filled('two_factor_optin') && + Gate::allows('self.two_factor') && + Setting::getSettings()->two_factor_enabled == '1' && + ! config('app.lock_passwords') + ) { + $user->two_factor_optin = $request->get('two_factor_optin'); + } + + if ($request->filled('location_id') && Gate::allows('self.edit_location') && ! config('app.lock_passwords')) { + $user->location_id = $request->get('location_id'); + } + + if ($user->save()) { + return Response::make( + Response::text(trans('mcp.profile_updated')) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.profile_updated'), + 'first_name' => $user->first_name, + 'last_name' => $user->last_name, + 'phone' => $user->phone, + 'website' => $user->website, + 'locale' => $user->locale, + 'location_id' => $user->location_id, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $user->getErrors()->first()]))); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'phone' => $schema->string()->description('Phone number'), + 'website' => $schema->string()->description('Personal website URL'), + 'gravatar' => $schema->string()->description('Gravatar email or hash'), + 'locale' => $schema->string()->description('Locale/language code (e.g. en-US)'), + 'two_factor_optin' => $schema->boolean()->description('Opt in to two-factor authentication (requires self.two_factor permission and 2FA enabled in settings)'), + 'location_id' => $schema->number()->description('Default location ID (requires self.edit_location permission)'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'first_name' => $schema->string()->description('Updated first name'), + 'last_name' => $schema->string()->description('Updated last name'), + 'phone' => $schema->string()->description('Updated phone number'), + 'website' => $schema->string()->description('Updated website URL'), + 'locale' => $schema->string()->description('Updated locale'), + 'location_id' => $schema->number()->description('Updated location ID'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateStatusLabelTool.php b/app/Mcp/Tools/UpdateStatusLabelTool.php new file mode 100644 index 000000000000..0b44ae82b910 --- /dev/null +++ b/app/Mcp/Tools/UpdateStatusLabelTool.php @@ -0,0 +1,122 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'type' => 'nullable|string|in:deployable,pending,archived,undeployable', + 'color' => 'nullable|string', + 'notes' => 'nullable|string', + 'default_label' => 'nullable|boolean', + 'show_in_nav' => 'nullable|boolean', + ]); + + $label = $this->resolveStatusLabel($request); + + if (! $label) { + return Response::make(Response::error(trans('mcp.status_label_not_found'))); + } + + if (! Gate::allows('update', $label)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $label->name = $request->get('new_name'); + } + + if ($request->filled('type')) { + $statusType = Statuslabel::getStatuslabelTypesForDB($request->get('type')); + $label->deployable = $statusType['deployable']; + $label->pending = $statusType['pending']; + $label->archived = $statusType['archived']; + } + + if ($request->filled('color')) { + $label->color = $request->get('color'); + } + + if ($request->filled('notes')) { + $label->notes = $request->get('notes'); + } + + if ($request->has('default_label')) { + $label->default_label = $request->get('default_label'); + } + + if ($request->has('show_in_nav')) { + $label->show_in_nav = $request->get('show_in_nav'); + } + + if ($label->save()) { + return Response::make( + Response::text(trans('mcp.status_label_updated', ['name' => $label->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.status_label_updated', ['name' => $label->name]), + 'id' => $label->id, + 'name' => $label->name, + 'type' => $label->getStatuslabelType(), + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $label->getErrors()->first()]))); + } + + private function resolveStatusLabel(Request $request): ?Statuslabel + { + if ($request->filled('id')) { + return Statuslabel::find($request->get('id')); + } + if ($request->filled('name')) { + return Statuslabel::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the status label'), + 'name' => $schema->string()->description('Name to identify the status label'), + 'new_name' => $schema->string()->description('New name (renames the status label)'), + 'type' => $schema->string()->description('New type: deployable, pending, archived, or undeployable'), + 'color' => $schema->string()->description('Display color in #RRGGBB format'), + 'notes' => $schema->string()->description('Notes'), + 'default_label' => $schema->boolean()->description('Whether this is the default label'), + 'show_in_nav' => $schema->boolean()->description('Whether to show in navigation'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the status label'), + 'name' => $schema->string()->description('Name of the status label'), + 'type' => $schema->string()->description('Type of the status label'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateSupplierTool.php b/app/Mcp/Tools/UpdateSupplierTool.php new file mode 100644 index 000000000000..bb845bac8404 --- /dev/null +++ b/app/Mcp/Tools/UpdateSupplierTool.php @@ -0,0 +1,119 @@ +validate([ + 'id' => 'nullable|integer', + 'name' => 'nullable|string|max:255', + 'new_name' => 'nullable|string|max:255', + 'address' => 'nullable|string', + 'address2' => 'nullable|string', + 'city' => 'nullable|string', + 'state' => 'nullable|string', + 'country' => 'nullable|string', + 'zip' => 'nullable|string', + 'phone' => 'nullable|string', + 'fax' => 'nullable|string', + 'email' => 'nullable|email', + 'url' => 'nullable|string', + 'contact' => 'nullable|string', + 'notes' => 'nullable|string', + ]); + + $supplier = $this->resolveSupplier($request); + + if (! $supplier) { + return Response::make(Response::error(trans('mcp.supplier_not_found'))); + } + + if (! Gate::allows('update', $supplier)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + if ($request->filled('new_name')) { + $supplier->name = $request->get('new_name'); + } + + $updatable = ['address', 'address2', 'city', 'state', 'country', 'zip', 'phone', 'fax', 'email', 'url', 'contact', 'notes']; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $supplier->{$field} = $request->get($field); + } + } + + if ($supplier->save()) { + return Response::make( + Response::text(trans('mcp.supplier_updated', ['name' => $supplier->name])) + )->withStructuredContent([ + 'success' => true, + 'message' => trans('mcp.supplier_updated', ['name' => $supplier->name]), + 'id' => $supplier->id, + 'name' => $supplier->name, + ]); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $supplier->getErrors()->first()]))); + } + + private function resolveSupplier(Request $request): ?Supplier + { + if ($request->filled('id')) { + return Supplier::find($request->get('id')); + } + if ($request->filled('name')) { + return Supplier::where('name', $request->get('name'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric ID to identify the supplier'), + 'name' => $schema->string()->description('Name to identify the supplier'), + 'new_name' => $schema->string()->description('New name (renames the supplier)'), + 'address' => $schema->string()->description('Address line 1'), + 'address2' => $schema->string()->description('Address line 2'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal code'), + 'phone' => $schema->string()->description('Phone number'), + 'fax' => $schema->string()->description('Fax number'), + 'email' => $schema->string()->description('Email address'), + 'url' => $schema->string()->description('Website URL'), + 'contact' => $schema->string()->description('Contact name'), + 'notes' => $schema->string()->description('Notes'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the supplier'), + 'name' => $schema->string()->description('Name of the supplier'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateUserTool.php b/app/Mcp/Tools/UpdateUserTool.php new file mode 100644 index 000000000000..914c4bf7929d --- /dev/null +++ b/app/Mcp/Tools/UpdateUserTool.php @@ -0,0 +1,198 @@ +validate([ + 'id' => 'nullable|integer', + 'username' => 'nullable|string|max:191', + 'email' => 'nullable|string|max:191', + 'new_username' => 'nullable|string|max:191', + 'new_email' => 'nullable|email|max:191', + 'first_name' => 'nullable|string|max:191', + 'last_name' => 'nullable|string|max:191', + 'password' => 'nullable|string|min:8', + 'employee_num' => 'nullable|string|max:191', + 'jobtitle' => 'nullable|string|max:191', + 'phone' => 'nullable|string|max:35', + 'mobile' => 'nullable|string|max:35', + 'company_id' => 'nullable|integer|exists:companies,id', + 'department_id' => 'nullable|integer|exists:departments,id', + 'location_id' => 'nullable|integer|exists:locations,id', + 'manager_id' => 'nullable|integer|exists:users,id', + 'activated' => 'nullable|boolean', + 'notes' => 'nullable|string', + 'start_date' => 'nullable|date_format:Y-m-d', + 'end_date' => 'nullable|date_format:Y-m-d', + 'vip' => 'nullable|boolean', + 'remote' => 'nullable|boolean', + 'website' => 'nullable|url|max:191', + 'address' => 'nullable|string|max:191', + 'city' => 'nullable|string|max:191', + 'state' => 'nullable|string|max:191', + 'country' => 'nullable|string|max:191', + 'zip' => 'nullable|string|max:10', + 'group_ids' => 'nullable|array', + ]); + + $user = $this->resolveUser($request); + + if (! $user) { + return Response::make(Response::error(trans('mcp.user_not_found'))); + } + + if (! Gate::allows('update', $user)) { + return Response::make(Response::error(trans('mcp.unauthorized'))); + } + + $updatable = [ + 'first_name', 'last_name', 'employee_num', 'jobtitle', + 'phone', 'mobile', 'department_id', 'location_id', 'manager_id', + 'notes', 'start_date', 'end_date', + 'vip', 'remote', 'website', 'address', 'city', 'state', 'country', 'zip', + ]; + + foreach ($updatable as $field) { + if ($request->filled($field)) { + $user->{$field} = $request->get($field); + } + } + + // Sensitive auth fields require elevated permission over the target user + $canEditAuthFields = Gate::allows('canEditAuthFields', $user); + + if ($request->filled('new_username') || $request->filled('new_email') || + $request->filled('password') || $request->has('activated')) { + if (! $canEditAuthFields) { + return Response::make(Response::error(trans('mcp.cannot_edit_auth_fields'))); + } + + if ($request->filled('new_username')) { + $user->username = $request->get('new_username'); + } + + if ($request->filled('new_email')) { + $user->email = $request->get('new_email'); + } + + if ($request->filled('password')) { + $user->password = bcrypt($request->get('password')); + } + + if ($request->has('activated')) { + $user->activated = $request->get('activated'); + } + } + + if ($request->filled('company_id')) { + $user->company_id = Company::getIdForCurrentUser($request->get('company_id')); + } + + if ($user->save()) { + $groupIds = null; + if ($request->filled('group_ids') && auth()->user()->isSuperUser()) { + $groupIds = Group::whereIn('id', $request->get('group_ids'))->pluck('id')->all(); + $user->groups()->sync($groupIds); + } elseif ($request->filled('group_ids')) { + return Response::make(Response::error(trans('mcp.superadmin_required_for_groups'))); + } + + $content = [ + 'success' => true, + 'message' => trans('mcp.user_updated', ['username' => $user->username]), + 'id' => $user->id, + 'username' => $user->username, + 'email' => $user->email, + ]; + if ($groupIds !== null) { + $content['group_ids'] = $groupIds; + } + + return Response::make( + Response::text(trans('mcp.user_updated', ['username' => $user->username])) + )->withStructuredContent($content); + } + + return Response::make(Response::error(trans('mcp.update_failed', ['error' => $user->getErrors()->first()]))); + } + + private function resolveUser(Request $request): ?User + { + if ($request->filled('id')) { + return User::find($request->get('id')); + } + if ($request->filled('username')) { + return User::where('username', $request->get('username'))->first(); + } + if ($request->filled('email')) { + return User::where('email', $request->get('email'))->first(); + } + + return null; + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->number()->description('Numeric user ID to identify the user'), + 'username' => $schema->string()->description('Username to identify the user'), + 'email' => $schema->string()->description('Email address to identify the user'), + 'new_username' => $schema->string()->description('New username (renames the username itself)'), + 'new_email' => $schema->string()->description('New email address'), + 'first_name' => $schema->string()->description('First name'), + 'last_name' => $schema->string()->description('Last name'), + 'password' => $schema->string()->description('New password (min 8 characters)'), + 'employee_num' => $schema->string()->description('Employee number'), + 'jobtitle' => $schema->string()->description('Job title'), + 'phone' => $schema->string()->description('Phone number'), + 'mobile' => $schema->string()->description('Mobile number'), + 'company_id' => $schema->number()->description('Company ID'), + 'department_id' => $schema->number()->description('Department ID'), + 'location_id' => $schema->number()->description('Location ID'), + 'manager_id' => $schema->number()->description('Manager user ID'), + 'activated' => $schema->boolean()->description('Whether the account is active'), + 'notes' => $schema->string()->description('Notes'), + 'start_date' => $schema->string()->description('Employment start date (YYYY-MM-DD)'), + 'end_date' => $schema->string()->description('Employment end date (YYYY-MM-DD)'), + 'vip' => $schema->boolean()->description('Mark user as VIP'), + 'remote' => $schema->boolean()->description('Mark user as remote'), + 'website' => $schema->string()->description('Website URL'), + 'address' => $schema->string()->description('Street address'), + 'city' => $schema->string()->description('City'), + 'state' => $schema->string()->description('State/province'), + 'country' => $schema->string()->description('Country'), + 'zip' => $schema->string()->description('Postal/ZIP code'), + 'group_ids' => $schema->array()->description('Array of permission group IDs to assign (requires superadmin). Replaces all existing group memberships. Example: [1, 3]'), + ]; + } + + public function outputSchema(JsonSchema $schema): array + { + return [ + 'success' => $schema->boolean()->description('True if the update succeeded'), + 'message' => $schema->string()->description('Human-readable result message')->required(), + 'id' => $schema->number()->description('Numeric ID of the updated user'), + 'username' => $schema->string()->description('Username of the updated user'), + 'email' => $schema->string()->description('Email of the updated user'), + ]; + } +} diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 3d68d2136abf..07fb549c7e46 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -6,6 +6,7 @@ use App\Exceptions\CheckoutNotAllowed; use App\Helpers\Helper; use App\Http\Traits\UniqueUndeletedTrait; +use App\Models\Builders\AssetBuilder; use App\Models\Traits\Acceptable; use App\Models\Traits\CompanyableTrait; use App\Models\Traits\HasUploads; @@ -232,6 +233,18 @@ public function declinedCheckout(User $declinedBy, $signature) 'rtd_location' => 'defaultLoc', ]; + /** + * Create a new Eloquent query builder for the model. + * (We will be moving the query scopes below to the new builders eventually) + * + * @param \Illuminate\Database\Query\Builder $query + * @return AssetBuilder + */ + public function newEloquentBuilder($query) + { + return new AssetBuilder($query); + } + protected static function booted(): void { static::forceDeleted(function (Asset $asset) { diff --git a/app/Models/Builders/AssetBuilder.php b/app/Models/Builders/AssetBuilder.php new file mode 100644 index 000000000000..e9a7a2aa8628 --- /dev/null +++ b/app/Models/Builders/AssetBuilder.php @@ -0,0 +1,700 @@ +whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 0) + ->where('pending', '=', 1) + ->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for searching location + * + * @return AssetBuilder Modified query builder + */ + public function byLocation($location) + { + return $this->where( + function ($query) use ($location) { + $query->whereHas( + 'assignedTo', function ($query) use ($location) { + $query->where( + [ + ['users.location_id', '=', $location->id], + ['assets.assigned_type', '=', User::class], + ] + )->orWhere( + [ + ['locations.id', '=', $location->id], + ['assets.assigned_type', '=', Location::class], + ] + )->orWhere( + [ + ['assets.rtd_location_id', '=', $location->id], + ['assets.assigned_type', '=', self::class], + ] + ); + } + )->orWhere( + function ($query) use ($location) { + $query->where('assets.rtd_location_id', '=', $location->id); + $query->whereNull('assets.assigned_to'); + } + ); + } + ); + } + + /** + * Query builder scope for RTD assets + * + * @return AssetBuilder Modified query builder + */ + public function rtd() + { + return $this->whereNull('assets.assigned_to') + ->whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 1) + ->where('pending', '=', 0) + ->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for Undeployable assets + * + * @return AssetBuilder Modified query builder + */ + public function undeployable() + { + return $this->whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 0) + ->where('pending', '=', 0) + ->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for non-Archived assets + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @return AssetBuilder Modified query builder + */ + public function notArchived() + { + return $this->whereHas( + 'status', function ($query) { + $query->where('archived', '=', 0); + } + ); + } + + /** + * Query builder scope for Assets that are due for auditing, based on the assets.next_audit_date + * and settings.audit_warning_days. + * + * This is/will be used in the artisan command snipeit:upcoming-audits and also + * for an upcoming API call for retrieving a report on assets that will need to be audited. + * + * Due for audit soon: + * next_audit_date greater than or equal to now (must be in the future) + * and (next_audit_date - threshold days) <= now () + * + * Example: + * next_audit_date = May 4, 2025 + * threshold for alerts = 30 days + * now = May 4, 2019 + * + * @param Setting $settings + * @return AssetBuilder Modified query builder + * + * @author A. Gianotto + * + * @since v4.6.16 + */ + public function dueForAudit($settings) + { + $interval = (int) $settings->audit_warning_days ?? 0; + $today = Carbon::now(); + $interval_date = $today->copy()->addDays($interval)->format('Y-m-d'); + + return $this->whereNotNull('assets.next_audit_date') + ->whereBetween('assets.next_audit_date', [$today->format('Y-m-d'), $interval_date]) + ->where('assets.archived', '=', 0) + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are OVERDUE for auditing, based on the assets.next_audit_date + * and settings.audit_warning_days. It checks to see if assets.next audit_date is before now + * + * This is/will be used in the artisan command snipeit:upcoming-audits and also + * for an upcoming API call for retrieving a report on overdue assets. + * + * @param Setting $settings + * @return AssetBuilder Modified query builder + * + * @author A. Gianotto + * + * @since v4.6.16 + */ + public function overdueForAudit() + { + return $this->whereNotNull('assets.next_audit_date') + ->where('assets.next_audit_date', '<', Carbon::now()->format('Y-m-d')) + ->where('assets.archived', '=', 0) + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are due for auditing OR overdue, based on the assets.next_audit_date + * and settings.audit_warning_days. + * + * This is/will be used in the artisan command snipeit:upcoming-audits and also + * for an upcoming API call for retrieving a report on assets that will need to be audited. + * + * @param Setting $settings + * @return AssetBuilder Modified query builder + * + * @author A. Gianotto + * + * @since v4.6.16 + */ + public function dueOrOverdueForAudit($settings) + { + + return $this->where( + function ($query) { + $query->OverdueForAudit(); + } + )->orWhere( + function ($query) use ($settings) { + $query->DueForAudit($settings); + } + ); + } + + /** + * Query builder scope for Assets that are DUE for checkin, based on the assets.expected_checkin + * and settings.audit_warning_days. It checks to see if assets.expected_checkin is now + * + * @return AssetBuilder Modified query builder + * + * @since v6.4.0 + * + * @author A. Gianotto + */ + public function dueForCheckin($settings) + { + $interval = (int) $settings->due_checkin_days ?? 0; + $today = Carbon::now(); + $interval_date = $today->copy()->addDays($interval)->format('Y-m-d'); + + return $this->whereNotNull('assets.expected_checkin') + ->whereBetween('assets.expected_checkin', [$today->format('Y-m-d'), $interval_date]) + ->where('assets.archived', '=', 0) + ->whereNotNull('assets.assigned_to') + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are overdue for checkin OR overdue + * + * @return AssetBuilder Modified query builder + * + * @since v6.4.0 + * + * @author A. Gianotto + */ + public function overdueForCheckin() + { + return $this->whereNotNull('assets.expected_checkin') + ->where('assets.expected_checkin', '<', Carbon::now()->format('Y-m-d')) + ->where('assets.archived', '=', 0) + ->whereNotNull('assets.assigned_to') + ->NotArchived(); + } + + /** + * Query builder scope for Assets that are due for checkin OR overdue + * + * @return AssetBuilder Modified query builder + * + * @since v6.4.0 + * + * @author A. Gianotto + */ + public function dueOrOverdueForCheckin($settings) + { + return $this->where( + function ($query) { + $query->OverdueForCheckin(); + } + )->orWhere( + function ($query) use ($settings) { + $query->DueForCheckin($settings); + } + ); + } + + /** + * Query builder scope for Archived assets counting + * + * This is primarily used for the tab counters so that IF the admin + * has chosen to not display archived assets in their regular lists + * and views, it will return the correct number. + * + * @return AssetBuilder Modified query builder + */ + public function forShow() + { + + if (Setting::getSettings()->show_archived_in_list != 1) { + return $this->whereHas( + 'status', function ($query) { + $query->where('archived', '=', 0); + } + ); + } else { + return $this; + } + + } + + /** + * Query builder scope for Archived assets + * + * @return AssetBuilder Modified query builder + */ + public function archived() + { + return $this->whereHas( + 'status', function ($query) { + $query->where('deployable', '=', 0) + ->where('pending', '=', 0) + ->where('archived', '=', 1); + } + ); + } + + /** + * Query builder scope for Deployed assets + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @return AssetBuilder Modified query builder + */ + public function deployed() + { + return $this->whereNotNull('assets.assigned_to'); + } + + /** + * Query builder scope for Requestable assets + * + * @return AssetBuilder Modified query builder + * + * @todo probably refactor? This is allowing table names for some reason + */ + public function requestable(): Builder + { + $table = $this->getModel()->getTable(); + + return Company::scopeCompanyables($this->where($table.'.requestable', '=', 1)) + ->whereHas( + 'status', function ($query) { + $query->where( + function ($query) { + $query->where('deployable', '=', 1) + ->where('archived', '=', 0); // you definitely can't request something that's archived + } + )->orWhere('pending', '=', 1); // we've decided that even though an asset may be 'pending', you can still request it + } + ); + } + + /** + * scopeInModelList + * Get all assets in the provided listing of model ids + * + * @return AssetBuilder + * + * @author Vincent Sposato + * + * @version v1.0 + */ + public function inModels(array $modelIdListing) + { + return $this->whereIn('assets.model_id', $modelIdListing); + } + + /** + * Query builder scope to get not-yet-accepted assets + * + * @return AssetBuilder Modified query builder + */ + public function unaccepted() + { + return $this->where('accepted', '=', 'pending'); + } + + /** + * Query builder scope to get rejected assets + * + * @return AssetBuilder Modified query builder + */ + public function rejected() + { + return $this->where('accepted', '=', 'rejected'); + } + + /** + * Query builder scope to get accepted assets + * + * @return AssetBuilder Modified query builder + */ + public function accepted() + { + return $this->where('accepted', '=', 'accepted'); + } + + /** + * Query builder scope to search on text for complex Bootstrap Tables API. + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $search Search term + * @return AssetBuilder Modified query builder + */ + public function assignedSearch($search) + { + $search = explode(' OR ', $search); + + return $this->leftJoin( + 'users as assets_users', function ($leftJoin) { + $leftJoin->on('assets_users.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', User::class); + } + )->leftJoin( + 'locations as assets_locations', function ($leftJoin) { + $leftJoin->on('assets_locations.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', Location::class); + } + )->leftJoin( + 'assets as assigned_assets', function ($leftJoin) { + $leftJoin->on('assigned_assets.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', self::class); + } + )->where( + function ($query) use ($search) { + foreach ($search as $search) { + $query->whereHas( + 'model', function ($query) use ($search) { + $query->whereHas( + 'category', function ($query) use ($search) { + $query->where( + function ($query) use ($search) { + $query->where('categories.name', 'LIKE', '%'.$search.'%') + ->orWhere('models.name', 'LIKE', '%'.$search.'%') + ->orWhere('models.model_number', 'LIKE', '%'.$search.'%'); + } + ); + } + ); + } + )->orWhereHas( + 'model', function ($query) use ($search) { + $query->whereHas( + 'manufacturer', function ($query) use ($search) { + $query->where( + function ($query) use ($search) { + $query->where('manufacturers.name', 'LIKE', '%'.$search.'%'); + } + ); + } + ); + } + )->orWhere( + function ($query) use ($search) { + $query->where('assets_users.first_name', 'LIKE', '%'.$search.'%') + ->orWhere('assets_users.last_name', 'LIKE', '%'.$search.'%') + ->orWhere('assets_users.username', 'LIKE', '%'.$search.'%') + ->orWhere('assets_users.jobtitle', 'LIKE', '%'.$search.'%') + ->orWhereMultipleColumns( + [ + 'assets_users.first_name', + 'assets_users.last_name', + 'assets_users.jobtitle', + ], $search + ) + ->orWhere('assets_locations.name', 'LIKE', '%'.$search.'%') + ->orWhere('assigned_assets.name', 'LIKE', '%'.$search.'%'); + } + )->orWhere('assets.name', 'LIKE', '%'.$search.'%') + ->orWhere('assets.asset_tag', 'LIKE', '%'.$search.'%') + ->orWhere('assets.serial', 'LIKE', '%'.$search.'%') + ->orWhere('assets.order_number', 'LIKE', '%'.$search.'%') + ->orWhere('assets.notes', 'LIKE', '%'.$search.'%'); + } + + } + )->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug + } + + /** + * Query builder scope to search the department ID of users assigned to assets + * + * @return string | false + * @return AssetBuilder Modified query builder + * + * @author [A. Gianotto] [] + * + * @since [v5.0] + */ + public function inDepartment($search) + { + return $this->leftJoin( + 'users as assets_dept_users', function ($leftJoin) { + $leftJoin->on('assets_dept_users.id', '=', 'assets.assigned_to') + ->where('assets.assigned_type', '=', User::class); + } + )->where( + function ($query) use ($search) { + $query->whereIn('assets_dept_users.department_id', $search); + + } + )->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug + } + + /** + * Query builder scope to order on model + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByModel($order) + { + return $this->join('models as asset_models', 'assets.model_id', '=', 'asset_models.id')->orderBy('asset_models.name', $order); + } + + /** + * Query builder scope to order on model number + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByModelNumber($order) + { + return $this->leftJoin('models as model_number_sort', 'assets.model_id', '=', 'model_number_sort.id')->orderBy('model_number_sort.model_number', $order); + } + + /** + * Query builder scope to order on created_by name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByCreatedByName($order) + { + return $this->leftJoin('users as admin_sort', 'assets.created_by', '=', 'admin_sort.id')->select('assets.*')->orderBy('admin_sort.first_name', $order)->orderBy('admin_sort.last_name', $order); + } + + /** + * Query builder scope to order on assigned user + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByAssigned($order) + { + return $this->leftJoin('users as users_sort', 'assets.assigned_to', '=', 'users_sort.id')->select('assets.*')->orderBy('users_sort.first_name', $order)->orderBy('users_sort.last_name', $order); + } + + /** + * Query builder scope to order on status + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByStatus($order) + { + return $this->join('status_labels as status_sort', 'assets.status_id', '=', 'status_sort.id')->orderBy('status_sort.name', $order); + } + + /** + * Query builder scope to order on company + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByCompany($order) + { + return $this->leftJoin('companies as company_sort', 'assets.company_id', '=', 'company_sort.id')->orderBy('company_sort.name', $order); + } + + /** + * Query builder scope to return results of a category + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function inCategory($category_id) + { + return $this->join('models as category_models', 'assets.model_id', '=', 'category_models.id') + ->join('categories', 'category_models.category_id', '=', 'categories.id') + ->whereIn('category_models.category_id', (! is_array($category_id) ? explode(',', $category_id) : $category_id)); + // ->whereIn('category_models.category_id', $category_id); + } + + /** + * Query builder scope to return results of a manufacturer + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function byManufacturer($manufacturer_id) + { + return $this->join('models', 'assets.model_id', '=', 'models.id') + ->join('manufacturers', 'models.manufacturer_id', '=', 'manufacturers.id')->whereIn('models.manufacturer_id', (! is_array($manufacturer_id) ? explode(',', $manufacturer_id) : $manufacturer_id)); + } + + /** + * Query builder scope to order on category + * + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByCategoryName($order) + { + return $this->join('models as order_model_category', 'assets.model_id', '=', 'order_model_category.id') + ->join('categories as category_order', 'order_model_category.category_id', '=', 'category_order.id') + ->orderBy('category_order.name', $order); + } + + /** + * Query builder scope to order on manufacturer + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByManufacturerName($query, $order) + { + return $this->join('models as order_asset_model', 'assets.model_id', '=', 'order_asset_model.id') + ->leftjoin('manufacturers as manufacturer_order', 'order_asset_model.manufacturer_id', '=', 'manufacturer_order.id') + ->orderBy('manufacturer_order.name', $order); + } + + /** + * Query builder scope to order on location + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByLocationName($query, $order) + { + return $this->leftJoin('locations as asset_locations', 'asset_locations.id', '=', 'assets.location_id')->orderBy('asset_locations.name', $order); + } + + /** + * Query builder scope to order on default + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByRTDLocationName($query, $order) + { + return $this->leftJoin('locations as rtd_asset_locations', 'rtd_asset_locations.id', '=', 'assets.rtd_location_id')->orderBy('rtd_asset_locations.name', $order); + } + + /** + * Query builder scope to order on supplier name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderBySupplierName($query, $order) + { + return $this->leftJoin('suppliers as suppliers_assets', 'assets.supplier_id', '=', 'suppliers_assets.id')->orderBy('suppliers_assets.name', $order); + } + + /** + * Query builder scope to order on supplier name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $order Order + * @return AssetBuilder Modified query builder + */ + public function orderByJobTitle($query, $order) + { + return $this->leftJoin('users as users_sort', 'assets.assigned_to', '=', 'users_sort.id')->select('assets.*')->orderBy('users_sort.jobtitle', $order); + } + + /** + * Query builder scope to search on location ID + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $search Search term + * @return AssetBuilder Modified query builder + */ + public function inLocation($query, $search) + { + return $this->where( + function ($query) use ($search) { + $query->whereHas( + 'location', function ($query) use ($search) { + $query->where('locations.id', '=', $search); + } + ); + } + ); + + } + + /** + * Query builder scope to search on depreciation name + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param string $search Search term + * @return AssetBuilder Modified query builder + */ + public function orderByDepreciationName($query, $search) + { + return $this->join('models', 'assets.model_id', '=', 'models.id') + ->join('depreciations', 'models.depreciation_id', '=', 'depreciations.id')->where('models.depreciation_id', '=', $search); + + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b4fff56ae854..53cf430199e8 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -30,6 +30,8 @@ use Laravel\Passport\HasApiTokens; use Watson\Validating\ValidatingTrait; +// use Laravel\Passport\Contracts\OAuthenticatable; + class User extends SnipeModel implements AuthenticatableContract, AuthorizableContract, CanResetPasswordContract, HasLocalePreference { use CompanyableTrait; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a6a4e7912780..9dd0d665c77c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -29,6 +29,7 @@ use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; +use Laravel\Passport\Passport; use Rollbar\Laravel\RollbarServiceProvider; /** @@ -63,6 +64,12 @@ public function boot(UrlGenerator $url) $url->forceScheme('https'); } + Passport::enablePasswordGrant(); + + Passport::authorizationView(function ($parameters) { + return view('mcp.authorize', $parameters); + }); + // TODO - isn't it somehow 'gauche' to check the environment directly; shouldn't we be using config() somehow? if (! env('APP_ALLOW_INSECURE_HOSTS')) { // unless you set APP_ALLOW_INSECURE_HOSTS, you should PROHIBIT forging domain parts of URL via Host: headers $url_parts = parse_url(config('app.url')); diff --git a/composer.json b/composer.json index d363d4519870..66c21ac6e877 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "laravel-notification-channels/microsoft-teams": "^1.2", "laravel/framework": "^12.0", "laravel/helpers": "^1.4", + "laravel/mcp": "^0.7.0", "laravel/passport": "^12.0", "laravel/slack-notification-channel": "^3.4", "laravel/socialite": "^5.6", diff --git a/composer.lock b/composer.lock index edc5657c78ec..e54ff4794e2c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6dbe361f8555681a027fca2d2c2e61fd", + "content-hash": "dfc5c42eac938218211469b8dab29892", "packages": [ { "name": "alek13/slack", @@ -2861,6 +2861,79 @@ }, "time": "2025-09-02T15:31:25+00:00" }, + { + "name": "laravel/mcp", + "version": "v0.7.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/3513b4feca5f1678be4d2261dcfa8e456436d02a", + "reference": "3513b4feca5f1678be4d2261dcfa8e456436d02a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-04-21T10:23:03+00:00" + }, { "name": "laravel/passport", "version": "v12.4.2", diff --git a/config/cors.php b/config/cors.php index d84570195621..a3fb72eee87d 100644 --- a/config/cors.php +++ b/config/cors.php @@ -16,7 +16,7 @@ * list of urls, explode that out into an array to whitelist just those urls. */ $allowed_origins = env('CORS_ALLOWED_ORIGINS') !== null ? - explode(',', env('CORS_ALLOWED_ORIGINS')) : []; + explode(',', env('CORS_ALLOWED_ORIGINS')) : ['*']; /** * Original Laravel CORS package config file modifications end here @@ -41,6 +41,6 @@ 'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 'exposed_headers' => [], 'max_age' => 0, - 'paths' => ['api/*', 'sanctum/csrf-cookie'], + 'paths' => ['api/*', 'sanctum/csrf-cookie', '.well-known/*', 'oauth/*', 'mcp/*'], ]; diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 5e9dd8cf9cb2..a6ba7e40b032 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -180,14 +180,24 @@ public function viewRequestableAssets() return $this->appendPermission(['assets.view.requestable' => '1']); } - public function deleteAssetModels() + public function viewAssetModels() { - return $this->appendPermission(['models.delete' => '1']); + return $this->appendPermission(['models.view' => '1']); } - public function viewAssetModels() + public function createAssetModels() { - return $this->appendPermission(['models.view' => '1']); + return $this->appendPermission(['models.create' => '1']); + } + + public function editAssetModels() + { + return $this->appendPermission(['models.edit' => '1']); + } + + public function deleteAssetModels() + { + return $this->appendPermission(['models.delete' => '1']); } public function viewAccessories() @@ -250,6 +260,16 @@ public function checkoutConsumables() return $this->appendPermission(['consumables.checkout' => '1']); } + public function createDepartments() + { + return $this->appendPermission(['departments.create' => '1']); + } + + public function editDepartments() + { + return $this->appendPermission(['departments.edit' => '1']); + } + public function deleteDepartments() { return $this->appendPermission(['departments.delete' => '1']); @@ -360,11 +380,41 @@ public function deleteUsers() return $this->appendPermission(['users.delete' => '1']); } + public function viewCategories() + { + return $this->appendPermission(['categories.view' => '1']); + } + + public function createCategories() + { + return $this->appendPermission(['categories.create' => '1']); + } + + public function editCategories() + { + return $this->appendPermission(['categories.edit' => '1']); + } + public function deleteCategories() { return $this->appendPermission(['categories.delete' => '1']); } + public function viewLocations() + { + return $this->appendPermission(['locations.view' => '1']); + } + + public function createLocations() + { + return $this->appendPermission(['locations.create' => '1']); + } + + public function editLocations() + { + return $this->appendPermission(['locations.edit' => '1']); + } + public function deleteLocations() { return $this->appendPermission(['locations.delete' => '1']); @@ -405,11 +455,41 @@ public function deleteCustomFieldsets() return $this->appendPermission(['customfields.delete' => '1']); } + public function viewDepreciations() + { + return $this->appendPermission(['depreciations.view' => '1']); + } + + public function createDepreciations() + { + return $this->appendPermission(['depreciations.create' => '1']); + } + + public function editDepreciations() + { + return $this->appendPermission(['depreciations.edit' => '1']); + } + public function deleteDepreciations() { return $this->appendPermission(['depreciations.delete' => '1']); } + public function viewManufacturers() + { + return $this->appendPermission(['manufacturers.view' => '1']); + } + + public function createManufacturers() + { + return $this->appendPermission(['manufacturers.create' => '1']); + } + + public function editManufacturers() + { + return $this->appendPermission(['manufacturers.edit' => '1']); + } + public function deleteManufacturers() { return $this->appendPermission(['manufacturers.delete' => '1']); @@ -425,11 +505,41 @@ public function viewPredefinedKits() return $this->appendPermission(['kits.view' => '1']); } + public function viewStatusLabels() + { + return $this->appendPermission(['statuslabels.view' => '1']); + } + + public function createStatusLabels() + { + return $this->appendPermission(['statuslabels.create' => '1']); + } + + public function editStatusLabels() + { + return $this->appendPermission(['statuslabels.edit' => '1']); + } + public function deleteStatusLabels() { return $this->appendPermission(['statuslabels.delete' => '1']); } + public function viewSuppliers() + { + return $this->appendPermission(['suppliers.view' => '1']); + } + + public function createSuppliers() + { + return $this->appendPermission(['suppliers.create' => '1']); + } + + public function editSuppliers() + { + return $this->appendPermission(['suppliers.edit' => '1']); + } + public function deleteSuppliers() { return $this->appendPermission(['suppliers.delete' => '1']); diff --git a/resources/lang/en-US/mcp.php b/resources/lang/en-US/mcp.php new file mode 100644 index 000000000000..8621d8b7eb10 --- /dev/null +++ b/resources/lang/en-US/mcp.php @@ -0,0 +1,259 @@ + 'Not authenticated', + 'unauthorized' => 'Unauthorized', + 'id_or_name_required' => 'Either id or name is required', + 'id_username_or_email_required' => 'Please provide an id, username, or email', + 'provide_user_or_asset' => 'Please provide either assigned_to (user ID) or asset_id', + + // ----------------------------------------------------------------- + // "Not found" errors + // ----------------------------------------------------------------- + 'object_not_found' => 'The specified :type was not found', + 'asset_not_found' => 'Asset not found', + 'user_not_found' => 'User not found', + 'accessory_not_found' => 'Accessory not found', + 'accessory_checkout_not_found' => 'Accessory checkout record not found', + 'component_not_found' => 'Component not found', + 'component_checkout_not_found' => 'Component checkout record not found', + 'consumable_not_found' => 'Consumable not found', + 'license_not_found' => 'License not found', + 'license_seat_not_found' => 'License seat not found', + 'department_not_found' => 'Department not found', + 'company_not_found' => 'Company not found', + 'category_not_found' => 'Category not found', + 'manufacturer_not_found' => 'Manufacturer not found', + 'supplier_not_found' => 'Supplier not found', + 'status_label_not_found' => 'Status label not found', + 'location_not_found' => 'Location not found', + 'asset_model_not_found' => 'Asset model not found', + 'depreciation_not_found' => 'Depreciation not found', + 'group_not_found' => 'Group not found', + 'checkout_target_not_found' => 'The specified :type was not found', + + // ----------------------------------------------------------------- + // Business rule errors + // ----------------------------------------------------------------- + 'asset_not_available' => 'Asset :asset_tag is not available for checkout', + 'asset_not_checked_out' => 'Asset :asset_tag is not currently checked out', + 'asset_not_deleted' => 'Asset is not deleted', + 'user_not_deleted' => 'User is not deleted', + 'user_cannot_delete_self' => 'You cannot delete your own account', + 'user_has_items' => 'User has assigned items and cannot be deleted. Check in all items first.', + 'cannot_edit_auth_fields' => 'You do not have permission to edit auth fields (username, email, password, activated) for this user', + 'username_taken' => 'Username :username is already taken. Please choose a different username.', + 'password_reset_sent' => 'Password reset link sent to :email', + 'password_reset_user_inactive' => 'User :username is inactive and cannot receive a password reset email', + 'password_reset_no_email' => 'User :username has no email address on file', + 'password_reset_ldap_user' => 'User :username is managed by LDAP and cannot use password reset', + 'password_reset_send_failed' => 'Failed to send password reset email: :error', + 'invalid_permissions_format' => 'Permissions must be a valid JSON object', + 'invalid_permission_key' => 'Invalid permission key: :key', + 'invalid_permission_value' => 'Permission value for :key must be 1 (grant) or -1 (deny)', + 'superadmin_required_for_groups' => 'Only superadmins can assign permission groups', + 'no_available_seats' => 'No available seats for this license', + 'no_free_seat' => 'No free seat found for this license', + 'seat_not_checked_out' => 'This seat is not currently checked out', + 'no_units_available' => 'No units of this accessory are available for checkout', + 'no_units_remaining' => 'No units remaining', + 'accessory_has_checkouts' => 'Accessory has units checked out and cannot be deleted. Check them in first.', + 'component_has_checkouts' => 'Component has units checked out and cannot be deleted. Check them in first.', + 'consumable_has_checkouts' => 'Consumable has items checked out and cannot be deleted', + 'license_has_seats_assigned' => 'License has seats currently assigned and cannot be deleted. Check in all seats first.', + 'department_has_users' => 'Department has users assigned and cannot be deleted. Reassign all users first.', + 'location_has_child_locations' => 'Location has child locations and cannot be deleted', + 'location_has_users' => 'Location has users assigned and cannot be deleted', + 'status_label_has_assets' => 'Status label has assets assigned and cannot be deleted', + 'model_has_assets' => 'Model has assets and cannot be deleted', + + // ----------------------------------------------------------------- + // Operation failures + // ----------------------------------------------------------------- + 'create_failed' => 'Create failed: :error', + 'update_failed' => 'Update failed: :error', + 'delete_failed' => 'Delete failed', + 'delete_failed_error' => 'Delete failed: :error', + 'checkin_failed' => 'Checkin failed', + 'checkin_failed_error' => 'Checkin failed: :error', + 'checkout_failed' => 'Checkout failed', + 'audit_failed' => 'Audit failed: :error', + 'note_save_failed' => 'Failed to save note', + + // ----------------------------------------------------------------- + // Asset messages + // ----------------------------------------------------------------- + 'asset_found' => 'Asset :asset_tag found', + 'asset_created' => 'Asset :asset_tag created successfully', + 'asset_updated' => 'Asset :asset_tag updated successfully', + 'asset_deleted' => 'Asset :asset_tag deleted successfully', + 'asset_restored' => 'Asset :asset_tag restored successfully', + 'asset_checked_out' => 'Asset :asset_tag checked out successfully', + 'asset_checked_in' => 'Asset :asset_tag checked in successfully', + 'asset_audited' => 'Audit recorded for asset :asset_tag', + 'note_added_to_asset' => 'Note added to asset :asset_tag', + 'note_added_successfully' => 'Note added successfully', + + // ----------------------------------------------------------------- + // User messages + // ----------------------------------------------------------------- + 'current_user' => 'Current user: :username', + 'user_found' => 'User :username found', + 'user_created' => 'User :username created successfully', + 'user_updated' => 'User :username updated successfully', + 'user_deleted' => 'User :username deleted successfully', + 'user_restored' => 'User :username restored successfully', + 'two_factor_reset' => 'Two-factor authentication reset for :username', + 'user_assets_found' => 'Found :count assets for user :username', + 'profile_updated' => 'Profile updated successfully', + + // ----------------------------------------------------------------- + // Accessory messages + // ----------------------------------------------------------------- + 'accessory_created' => 'Accessory :name created successfully', + 'accessory_updated' => 'Accessory :name updated successfully', + 'accessory_deleted' => 'Accessory :name deleted successfully', + 'accessory_checked_out' => 'Accessory :name checked out successfully', + 'accessory_checked_in' => 'Accessory :name checked in successfully', + + // ----------------------------------------------------------------- + // Component messages + // ----------------------------------------------------------------- + 'component_created' => 'Component :name created successfully', + 'component_updated' => 'Component :name updated successfully', + 'component_deleted' => 'Component :name deleted successfully', + 'component_checked_out' => 'Component :name checked out to asset :asset_tag', + 'component_checked_in' => 'Component :name checked in successfully', + + // ----------------------------------------------------------------- + // Consumable messages + // ----------------------------------------------------------------- + 'consumable_found' => 'Consumable :name found', + 'consumable_created' => 'Consumable :name created successfully', + 'consumable_updated' => 'Consumable :name updated successfully', + 'consumable_deleted' => 'Consumable :name deleted successfully', + 'consumable_checked_out' => 'Consumable :name checked out to :username', + + // ----------------------------------------------------------------- + // License messages + // ----------------------------------------------------------------- + 'license_found' => 'License :name found', + 'license_created' => 'License :name created successfully', + 'license_updated' => 'License :name updated successfully', + 'license_deleted' => 'License :name deleted successfully', + 'license_seat_checked_out_user' => 'License seat checked out to user :username', + 'license_seat_checked_out_asset' => 'License seat checked out to asset :asset_tag', + 'license_seat_checked_in' => 'License seat :id checked in successfully', + + // ----------------------------------------------------------------- + // Department messages + // ----------------------------------------------------------------- + 'department_created' => 'Department :name created successfully', + 'department_updated' => 'Department :name updated successfully', + 'department_deleted' => 'Department :name deleted successfully', + + // ----------------------------------------------------------------- + // Company messages + // ----------------------------------------------------------------- + 'company_found' => 'Company :name found', + 'company_created' => 'Company :name created successfully', + 'company_updated' => 'Company :name updated successfully', + 'company_deleted' => 'Company :name deleted successfully', + + // ----------------------------------------------------------------- + // Category messages + // ----------------------------------------------------------------- + 'category_found' => 'Category :name found', + 'category_created' => 'Category :name created successfully', + 'category_updated' => 'Category :name updated successfully', + 'category_deleted' => 'Category :name deleted successfully', + 'category_delete_failed' => 'Category cannot be deleted: :error', + + // ----------------------------------------------------------------- + // Manufacturer messages + // ----------------------------------------------------------------- + 'manufacturer_found' => 'Manufacturer :name found', + 'manufacturer_created' => 'Manufacturer :name created successfully', + 'manufacturer_updated' => 'Manufacturer :name updated successfully', + 'manufacturer_deleted' => 'Manufacturer :name deleted successfully', + + // ----------------------------------------------------------------- + // Supplier messages + // ----------------------------------------------------------------- + 'supplier_found' => 'Supplier :name found', + 'supplier_created' => 'Supplier :name created successfully', + 'supplier_updated' => 'Supplier :name updated successfully', + 'supplier_deleted' => 'Supplier :name deleted successfully', + + // ----------------------------------------------------------------- + // Status label messages + // ----------------------------------------------------------------- + 'status_label_found' => 'Status label :name found', + 'status_label_created' => 'Status label :name created successfully', + 'status_label_updated' => 'Status label :name updated successfully', + 'status_label_deleted' => 'Status label :name deleted successfully', + + // ----------------------------------------------------------------- + // Location messages + // ----------------------------------------------------------------- + 'location_found' => 'Location :name found', + 'location_created' => 'Location :name created successfully', + 'location_updated' => 'Location :name updated successfully', + 'location_deleted' => 'Location :name deleted successfully', + + // ----------------------------------------------------------------- + // Asset model messages + // ----------------------------------------------------------------- + 'asset_model_found' => 'Asset model :name found', + 'asset_model_created' => 'Asset model :name created successfully', + 'asset_model_updated' => 'Asset model :name updated successfully', + 'asset_model_deleted' => 'Asset model :name deleted successfully', + + // ----------------------------------------------------------------- + // Depreciation messages + // ----------------------------------------------------------------- + 'depreciation_found' => 'Depreciation :name found', + 'depreciation_created' => 'Depreciation :name created successfully', + 'depreciation_updated' => 'Depreciation :name updated successfully', + 'depreciation_deleted' => 'Depreciation :name deleted successfully', + + // ----------------------------------------------------------------- + // Group messages + // ----------------------------------------------------------------- + 'group_found' => 'Group :name found', + 'group_created' => 'Group :name created successfully', + 'group_updated' => 'Group :name updated successfully', + 'group_deleted' => 'Group :name deleted successfully', + + // ----------------------------------------------------------------- + // Maintenance messages + // ----------------------------------------------------------------- + 'maintenance_created' => 'Maintenance :name created successfully', + + // ----------------------------------------------------------------- + // List messages + // ----------------------------------------------------------------- + 'list_assets' => 'Found :total assets, returning :count', + 'list_users' => 'Found :total users, returning :count', + 'list_consumables' => 'Found :total consumables, returning :count', + 'list_licenses' => 'Found :total licenses, returning :count', + 'list_companies' => 'Found :total companies, returning :count', + 'list_categories' => 'Found :total categories, returning :count', + 'list_manufacturers' => 'Found :total manufacturers, returning :count', + 'list_suppliers' => 'Found :total suppliers, returning :count', + 'list_status_labels' => 'Found :total status labels, returning :count', + 'list_locations' => 'Found :total locations, returning :count', + 'list_asset_models' => 'Found :total asset models, returning :count', + 'list_depreciations' => 'Found :total depreciations, returning :count', + 'list_groups' => 'Found :total groups, returning :count', + 'list_maintenances' => 'Found :total maintenances, returning :count', + 'list_activity' => 'Found :total activity log entries, returning :count', + 'list_asset_notes' => 'Found :total notes for asset :asset_tag, returning :count', + 'list_uploads' => 'Found :total uploaded files for :type, returning :count', + 'list_history' => 'Found :total history entries for :type, returning :count', + +]; diff --git a/resources/views/mcp/authorize.blade.php b/resources/views/mcp/authorize.blade.php new file mode 100644 index 000000000000..9f8a7ab4f1f5 --- /dev/null +++ b/resources/views/mcp/authorize.blade.php @@ -0,0 +1,146 @@ +@extends('layouts/basic') + + +{{-- Page content --}} +@section('content') + + + + + + +
+
+ +
+ +
+
+

Authorize {{ $client->name }}

+
+ +
+
+ + + @include('notifications') + +

+ This application will be able to use available MCP functionality. +

+ + +

Logged in as: {{ $user->username }}

+ + + + @if(count($scopes) > 0) + +

Permissions:

+ +
    + @foreach($scopes as $scope) +
  • + {{ $scope->description }} +
  • + @endforeach +
+ + @endif + + +
+ +
+ + + +
+ + +
+ +
+
+ + + +@stop + + diff --git a/resources/views/vendor/mcp/components/app.blade.php b/resources/views/vendor/mcp/components/app.blade.php new file mode 100644 index 000000000000..48a0874c958f --- /dev/null +++ b/resources/views/vendor/mcp/components/app.blade.php @@ -0,0 +1,21 @@ +@props(['title' => null]) +@php + $mcpSdk = app('mcp.sdk'); + $libraryScripts = app()->bound('mcp.library_scripts') ? app('mcp.library_scripts') : ''; +@endphp + + + + + + @if($title) + {{ $title }} + @endif + + {!! $libraryScripts !!} + {{ $head ?? '' }} + + + {{ $slot }} + + diff --git a/routes/ai.php b/routes/ai.php new file mode 100644 index 000000000000..63c26a1ca3c1 --- /dev/null +++ b/routes/ai.php @@ -0,0 +1,7 @@ +middleware(['auth:api', 'api-throttle:api']); diff --git a/tests/Feature/Mcp/AddAssetNoteToolTest.php b/tests/Feature/Mcp/AddAssetNoteToolTest.php new file mode 100644 index 000000000000..e7208f0b0841 --- /dev/null +++ b/tests/Feature/Mcp/AddAssetNoteToolTest.php @@ -0,0 +1,89 @@ +actingAs(User::factory()->editAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new AddAssetNoteTool)->handle(new Request($args)); + } + + public function test_adds_note_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => (string) $asset->asset_tag, + 'note' => 'This is a test note.', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('This is a test note.', $content['note']); + $this->assertDatabaseHas('action_logs', [ + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'This is a test note.', + ]); + } + + public function test_adds_note_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $asset->id, + 'note' => 'Note via ID.', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('action_logs', [ + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'Note via ID.', + ]); + } + + public function test_adds_note_by_serial() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'serial' => $asset->serial, + 'note' => 'Note via serial.', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('action_logs', [ + 'item_id' => $asset->id, + 'action_type' => 'note added', + ]); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'note' => 'test'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['id' => $asset->id, 'note' => 'test'])->responses()->first()->isError()); + $this->assertDatabaseMissing('action_logs', ['item_id' => $asset->id, 'action_type' => 'note added']); + } +} diff --git a/tests/Feature/Mcp/AuditAssetToolTest.php b/tests/Feature/Mcp/AuditAssetToolTest.php new file mode 100644 index 000000000000..d58b7465eb14 --- /dev/null +++ b/tests/Feature/Mcp/AuditAssetToolTest.php @@ -0,0 +1,149 @@ +actingAs(User::factory()->auditAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new AuditAssetTool)->handle(new Request($args)); + } + + public function test_records_audit_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_records_audit_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_records_audit_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-AUDIT-001']); + + $content = $this->handle(['serial' => 'SN-AUDIT-001'])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->responses()->first()->isError()); + } + + public function test_sets_last_audit_date_to_now() + { + $asset = Asset::factory()->create(['last_audit_date' => null]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertNotNull($asset->fresh()->last_audit_date); + } + + public function test_respects_explicit_next_audit_date() + { + $asset = Asset::factory()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'next_audit_date' => '2027-01-15', + ]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'next_audit_date' => '2027-01-15', + ]); + } + + public function test_updates_location_when_provided() + { + $location = Location::factory()->create(); + $asset = Asset::factory()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'location_id' => $location->id, + ]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'location_id' => $location->id, + ]); + } + + public function test_does_not_change_location_when_not_provided() + { + $location = Location::factory()->create(); + $asset = Asset::factory()->create(['location_id' => $location->id]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'location_id' => $location->id, + ]); + } + + public function test_response_includes_audit_dates_and_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertArrayHasKey('last_audit_date', $content); + $this->assertArrayHasKey('next_audit_date', $content); + $this->assertNotNull($content['last_audit_date']); + } + + public function test_creates_audit_log_entry() + { + $asset = Asset::factory()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'note' => 'MCP audit note', + ]); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'audit', + ]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $response = $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNull($asset->fresh()->last_audit_date); + } +} diff --git a/tests/Feature/Mcp/CheckinAccessoryToolTest.php b/tests/Feature/Mcp/CheckinAccessoryToolTest.php new file mode 100644 index 000000000000..d991c3b72698 --- /dev/null +++ b/tests/Feature/Mcp/CheckinAccessoryToolTest.php @@ -0,0 +1,108 @@ +actingAs(User::factory()->checkoutAccessories()->checkinAccessories()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinAccessoryTool)->handle(new Request($args)); + } + + private function checkoutToUser(Accessory $accessory, User $user): AccessoryCheckout + { + $response = (new CheckoutAccessoryTool)->handle(new Request([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])); + + $checkoutId = $response->getStructuredContent()['checkout_id']; + + return AccessoryCheckout::find($checkoutId); + } + + public function test_checks_in_accessory_by_checkout_id() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->create(['qty' => 5]); + $checkout = $this->checkoutToUser($accessory, $user); + + $content = $this->handle(['checkout_id' => $checkout->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]); + } + + public function test_response_includes_accessory_name() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->create(['name' => 'Checkin Test Accessory', 'qty' => 5]); + $checkout = $this->checkoutToUser($accessory, $user); + + $content = $this->handle(['checkout_id' => $checkout->id])->getStructuredContent(); + + $this->assertEquals('Checkin Test Accessory', $content['accessory_name']); + $this->assertEquals($accessory->id, $content['accessory_id']); + } + + public function test_returns_error_when_checkout_not_found() + { + $this->assertTrue($this->handle(['checkout_id' => 999999])->responses()->first()->isError()); + } + + public function test_creates_checkin_action_log_entry() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->create(['qty' => 5]); + $checkout = $this->checkoutToUser($accessory, $user); + + $this->handle(['checkout_id' => $checkout->id]); + + $this->assertDatabaseHas('action_logs', [ + 'item_type' => Accessory::class, + 'item_id' => $accessory->id, + 'action_type' => 'checkin from', + ]); + } + + public function test_checkout_record_from_factory_can_be_checked_in() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser($user)->create(); + + $checkout = AccessoryCheckout::where('accessory_id', $accessory->id)->first(); + + $content = $this->handle(['checkout_id' => $checkout->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('accessories_checkout', ['id' => $checkout->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser($user)->create(); + $checkout = AccessoryCheckout::where('accessory_id', $accessory->id)->first(); + + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['checkout_id' => $checkout->id])->responses()->first()->isError()); + $this->assertDatabaseHas('accessories_checkout', ['id' => $checkout->id]); + } +} diff --git a/tests/Feature/Mcp/CheckinAssetToolTest.php b/tests/Feature/Mcp/CheckinAssetToolTest.php new file mode 100644 index 000000000000..4e4d34ab1287 --- /dev/null +++ b/tests/Feature/Mcp/CheckinAssetToolTest.php @@ -0,0 +1,129 @@ +actingAs(User::factory()->checkinAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinAssetTool)->handle(new Request($args)); + } + + public function test_checks_in_asset_by_asset_tag() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => null, + 'assigned_type' => null, + 'accepted' => null, + ]); + } + + public function test_checks_in_asset_by_numeric_id() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_asset_tag_not_found() + { + $this->assertTrue($this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_checked_out() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + } + + public function test_asset_location_resets_to_rtd_location_on_checkin() + { + $rtdLocation = Location::factory()->create(); + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create([ + 'rtd_location_id' => $rtdLocation->id, + ]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'location_id' => $rtdLocation->id, + ]); + } + + public function test_clears_expected_checkin_date_on_checkin() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create([ + 'expected_checkin' => now()->addDays(7), + ]); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + $this->assertNull($asset->fresh()->expected_checkin); + } + + public function test_fires_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + Event::assertDispatched(CheckoutableCheckedIn::class, function (CheckoutableCheckedIn $event) use ($asset) { + return $event->checkoutable->id === $asset->id; + }); + } + + public function test_response_includes_asset_tag_and_location() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertArrayHasKey('model', $content); + $this->assertArrayHasKey('location', $content); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'assigned_to' => $user->id]); + } +} diff --git a/tests/Feature/Mcp/CheckinComponentToolTest.php b/tests/Feature/Mcp/CheckinComponentToolTest.php new file mode 100644 index 000000000000..20d93a4a513a --- /dev/null +++ b/tests/Feature/Mcp/CheckinComponentToolTest.php @@ -0,0 +1,156 @@ +actingAs(User::factory()->checkoutComponents()->checkinComponents()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinComponentTool)->handle(new Request($args)); + } + + private function checkoutToAsset(Component $component, Asset $asset, int $qty = 1): int + { + $response = (new CheckoutComponentTool)->handle(new Request([ + 'id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => $qty, + ])); + + return $response->getStructuredContent()['component_asset_id']; + } + + public function test_checks_in_full_quantity() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $content = $this->handle(['component_asset_id' => $componentAssetId])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(0, $content['qty_still_checked_out']); + $this->assertDatabaseMissing('components_assets', ['id' => $componentAssetId]); + } + + public function test_partial_checkin_leaves_remainder_in_record() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset, 4); + + $content = $this->handle([ + 'component_asset_id' => $componentAssetId, + 'checkin_qty' => 2, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(2, $content['checkin_qty']); + $this->assertEquals(2, $content['qty_still_checked_out']); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId, 'assigned_qty' => 2]); + } + + public function test_full_checkin_deletes_pivot_record() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset, 3); + + $this->handle([ + 'component_asset_id' => $componentAssetId, + 'checkin_qty' => 3, + ]); + + $this->assertDatabaseMissing('components_assets', ['id' => $componentAssetId]); + } + + public function test_response_includes_component_name_and_counts() + { + $component = Component::factory()->create(['name' => 'Checkin Test Component', 'qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $content = $this->handle(['component_asset_id' => $componentAssetId])->getStructuredContent(); + + $this->assertEquals('Checkin Test Component', $content['component_name']); + $this->assertEquals($component->id, $content['component_id']); + } + + public function test_fires_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $this->handle(['component_asset_id' => $componentAssetId]); + + Event::assertDispatched(CheckoutableCheckedIn::class, function (CheckoutableCheckedIn $event) use ($component) { + return $event->checkoutable->id === $component->id; + }); + } + + public function test_returns_error_when_checkout_record_not_found() + { + $this->assertTrue($this->handle(['component_asset_id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_checkin_qty_exceeds_assigned_qty() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset, 2); + + $response = $this->handle([ + 'component_asset_id' => $componentAssetId, + 'checkin_qty' => 5, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId, 'assigned_qty' => 2]); + } + + public function test_factory_checkout_record_can_be_checked_in() + { + $asset = Asset::factory()->create(); + $component = Component::factory()->checkedOutToAsset($asset)->create(); + + $record = DB::table('components_assets')->where('component_id', $component->id)->first(); + + $content = $this->handle(['component_asset_id' => $record->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('components_assets', ['id' => $record->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + $componentAssetId = $this->checkoutToAsset($component, $asset); + + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['component_asset_id' => $componentAssetId])->responses()->first()->isError()); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId]); + } +} diff --git a/tests/Feature/Mcp/CheckinLicenseToolTest.php b/tests/Feature/Mcp/CheckinLicenseToolTest.php new file mode 100644 index 000000000000..54afe683e109 --- /dev/null +++ b/tests/Feature/Mcp/CheckinLicenseToolTest.php @@ -0,0 +1,139 @@ +actingAs(User::factory()->checkoutLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckinLicenseTool)->handle(new Request($args)); + } + + private function checkoutToUser(License $license, User $user): LicenseSeat + { + $response = (new CheckoutLicenseTool)->handle(new Request([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])); + + $seatId = $response->getStructuredContent()['seat_id']; + + return LicenseSeat::find($seatId); + } + + public function test_checks_in_seat_by_seat_id() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $content = $this->handle(['seat_id' => $seat->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('license_seats', [ + 'id' => $seat->id, + 'assigned_to' => null, + 'asset_id' => null, + ]); + } + + public function test_response_includes_license_info() + { + $license = License::factory()->create(['name' => 'Checkin License', 'seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $content = $this->handle(['seat_id' => $seat->id])->getStructuredContent(); + + $this->assertEquals($seat->id, $content['seat_id']); + $this->assertEquals($license->id, $content['license_id']); + $this->assertEquals('Checkin License', $content['license_name']); + } + + public function test_fires_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $this->handle(['seat_id' => $seat->id]); + + Event::assertDispatched(CheckoutableCheckedIn::class); + } + + public function test_returns_error_when_seat_not_found() + { + $this->assertTrue($this->handle(['seat_id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_seat_is_not_checked_out() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $seat = $license->freeSeat(); + + $this->assertTrue($this->handle(['seat_id' => $seat->id])->responses()->first()->isError()); + } + + public function test_sets_unreassignable_flag_when_license_not_reassignable() + { + $license = License::factory()->create(['seats' => 1, 'reassignable' => false]); + $user = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user->id; + $seat->save(); + + $this->handle(['seat_id' => $seat->id]); + + $this->assertDatabaseHas('license_seats', [ + 'id' => $seat->id, + 'unreassignable_seat' => true, + ]); + } + + public function test_does_not_set_unreassignable_flag_when_license_is_reassignable() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $this->handle(['seat_id' => $seat->id]); + + $refreshed = LicenseSeat::find($seat->id); + $this->assertFalse((bool) $refreshed->unreassignable_seat); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + $seat = $this->checkoutToUser($license, $user); + + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['seat_id' => $seat->id])->responses()->first()->isError()); + $this->assertDatabaseHas('license_seats', [ + 'id' => $seat->id, + 'assigned_to' => $user->id, + ]); + } +} diff --git a/tests/Feature/Mcp/CheckoutAccessoryToolTest.php b/tests/Feature/Mcp/CheckoutAccessoryToolTest.php new file mode 100644 index 000000000000..ec8789b8edfa --- /dev/null +++ b/tests/Feature/Mcp/CheckoutAccessoryToolTest.php @@ -0,0 +1,179 @@ +actingAs(User::factory()->checkoutAccessories()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutAccessoryTool)->handle(new Request($args)); + } + + public function test_checks_out_accessory_to_user_by_id() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $user->id, + 'assigned_type' => User::class, + ]); + } + + public function test_checks_out_accessory_to_user_by_name() + { + $accessory = Accessory::factory()->create(['name' => 'Named Checkout Accessory', 'qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'name' => 'Named Checkout Accessory', + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_accessory_to_location() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $location = Location::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'location', + 'assigned_location' => $location->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $location->id, + 'assigned_type' => Location::class, + ]); + } + + public function test_checks_out_accessory_to_asset() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'asset', + 'assigned_asset' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', [ + 'accessory_id' => $accessory->id, + 'assigned_to' => $asset->id, + 'assigned_type' => Asset::class, + ]); + } + + public function test_response_includes_checkout_id_for_checkin() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('checkout_id', $content); + $this->assertIsInt($content['checkout_id']); + } + + public function test_fires_checkout_event() + { + Event::fake([CheckoutableCheckedOut::class]); + + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]); + + Event::assertDispatched(CheckoutableCheckedOut::class, function (CheckoutableCheckedOut $event) use ($accessory) { + return $event->checkoutable->id === $accessory->id; + }); + } + + public function test_returns_error_when_no_units_remaining() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->withoutItemsRemaining()->create(); + + $response = $this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_accessory_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_target_user_not_found() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $accessory = Accessory::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $accessory->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CheckoutAssetToolTest.php b/tests/Feature/Mcp/CheckoutAssetToolTest.php new file mode 100644 index 000000000000..4e53b662d409 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutAssetToolTest.php @@ -0,0 +1,174 @@ +actingAs(User::factory()->checkoutAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutAssetTool)->handle(new Request($args)); + } + + public function test_checks_out_asset_to_user_by_asset_tag() + { + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $user->id, + 'assigned_type' => User::class, + ]); + } + + public function test_checks_out_asset_to_user_by_numeric_id() + { + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $asset->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_asset_to_location() + { + $asset = Asset::factory()->create(); + $location = Location::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'location', + 'assigned_location' => $location->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $location->id, + 'assigned_type' => Location::class, + ]); + } + + public function test_checks_out_asset_to_another_asset() + { + $asset = Asset::factory()->create(); + $target = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'asset', + 'assigned_asset' => $target->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $target->id, + 'assigned_type' => Asset::class, + ]); + } + + public function test_returns_error_when_asset_tag_not_found() + { + $this->assertTrue($this->handle([ + 'asset_tag' => 'DOES-NOT-EXIST', + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create()->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_already_checked_out() + { + $existingUser = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($existingUser)->create(); + $newUser = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $newUser->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'assigned_to' => $existingUser->id, + ]); + } + + public function test_returns_error_when_target_user_not_found() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => 99999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_target_location_not_found() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'location', + 'assigned_location' => 99999, + ])->responses()->first()->isError()); + } + + public function test_response_includes_asset_tag_and_target_info() + { + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals('user', $content['checked_out_to_type']); + $this->assertEquals($user->id, $content['checked_out_to_id']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CheckoutComponentToolTest.php b/tests/Feature/Mcp/CheckoutComponentToolTest.php new file mode 100644 index 000000000000..5956f626fa87 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutComponentToolTest.php @@ -0,0 +1,149 @@ +actingAs(User::factory()->checkoutComponents()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutComponentTool)->handle(new Request($args)); + } + + public function test_checks_out_component_to_asset_by_id() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components_assets', [ + 'component_id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 1, + ]); + } + + public function test_checks_out_component_to_asset_by_name() + { + $component = Component::factory()->create(['name' => 'Named Checkout Component', 'qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'name' => 'Named Checkout Component', + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_specified_quantity() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 3, + ]); + + $this->assertDatabaseHas('components_assets', [ + 'component_id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 3, + ]); + } + + public function test_response_includes_component_asset_id_for_checkin() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('component_asset_id', $content); + $this->assertIsInt($content['component_asset_id']); + $this->assertEquals(1, $content['assigned_qty']); + } + + public function test_response_includes_asset_tag() + { + $component = Component::factory()->create(['qty' => 10]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals($asset->id, $content['asset_id']); + } + + public function test_returns_error_when_not_enough_units_remaining() + { + $component = Component::factory()->create(['qty' => 2]); + $asset = Asset::factory()->create(); + + $response = $this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 5, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_component_not_found() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => 999999, + 'asset_id' => $asset->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_found() + { + $component = Component::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $component->id, + 'asset_id' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $component = Component::factory()->create(['qty' => 5]); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $component->id, + 'asset_id' => $asset->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CheckoutConsumableToolTest.php b/tests/Feature/Mcp/CheckoutConsumableToolTest.php new file mode 100644 index 000000000000..f1c9213ef804 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutConsumableToolTest.php @@ -0,0 +1,86 @@ +actingAs(User::factory()->checkoutConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CheckoutConsumableTool)->handle(new Request($args)); + } + + public function test_checks_out_consumable_to_user() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $consumable->id, + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('consumables_users', [ + 'consumable_id' => $consumable->id, + 'assigned_to' => $user->id, + ]); + } + + public function test_returns_error_when_no_units_remaining() + { + $consumable = Consumable::factory()->withoutItemsRemaining()->create(); + $user = User::factory()->create(); + + $response = $this->handle([ + 'id' => $consumable->id, + 'assigned_to' => $user->id, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_consumable_not_found() + { + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => 999999, + 'assigned_to' => $user->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $consumable->id, + 'assigned_to' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(['qty' => 5]); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $consumable->id, + 'assigned_to' => $user->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CheckoutLicenseToolTest.php b/tests/Feature/Mcp/CheckoutLicenseToolTest.php new file mode 100644 index 000000000000..f53642835052 --- /dev/null +++ b/tests/Feature/Mcp/CheckoutLicenseToolTest.php @@ -0,0 +1,164 @@ +actingAs(User::factory()->checkoutLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CheckoutLicenseTool)->handle(new Request($args)); + } + + public function test_checks_out_license_to_user_by_id() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('user', $content['assigned_to_type']); + $this->assertEquals($user->id, $content['assigned_to_id']); + $this->assertDatabaseHas('license_seats', [ + 'license_id' => $license->id, + 'assigned_to' => $user->id, + ]); + } + + public function test_checks_out_license_to_user_by_name() + { + $license = License::factory()->create(['name' => 'Named Checkout License', 'seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'name' => 'Named Checkout License', + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + } + + public function test_checks_out_license_to_asset() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'id' => $license->id, + 'asset_id' => $asset->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('asset', $content['assigned_to_type']); + $this->assertEquals($asset->id, $content['assigned_to_id']); + $this->assertDatabaseHas('license_seats', [ + 'license_id' => $license->id, + 'asset_id' => $asset->id, + ]); + } + + public function test_response_includes_seat_id_for_checkin() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('seat_id', $content); + $this->assertIsInt($content['seat_id']); + $this->assertNotNull(LicenseSeat::find($content['seat_id'])); + } + + public function test_fires_checkout_event() + { + Event::fake([CheckoutableCheckedOut::class]); + + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ]); + + Event::assertDispatched(CheckoutableCheckedOut::class); + } + + public function test_returns_error_when_no_seats_remaining() + { + $license = License::factory()->create(['seats' => 1, 'reassignable' => true]); + $user1 = User::factory()->create(); + $user2 = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user1->id; + $seat->save(); + + $response = $this->handle([ + 'id' => $license->id, + 'assigned_to' => $user2->id, + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_neither_assignee_provided() + { + $license = License::factory()->create(['seats' => 3]); + + $this->assertTrue($this->handle(['id' => $license->id])->responses()->first()->isError()); + } + + public function test_returns_error_when_license_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'assigned_to' => User::factory()->create()->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found() + { + $license = License::factory()->create(['seats' => 3]); + + $this->assertTrue($this->handle([ + 'id' => $license->id, + 'assigned_to' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $license->id, + 'assigned_to' => $user->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateAccessoryToolTest.php b/tests/Feature/Mcp/CreateAccessoryToolTest.php new file mode 100644 index 000000000000..8b865ef8aca7 --- /dev/null +++ b/tests/Feature/Mcp/CreateAccessoryToolTest.php @@ -0,0 +1,116 @@ +actingAs(User::factory()->createAccessories()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateAccessoryTool)->handle(new Request($args)); + } + + public function test_creates_accessory_with_required_fields() + { + $category = Category::factory()->forAccessories()->create(); + + $content = $this->handle([ + 'name' => 'Test Keyboard', + 'category_id' => $category->id, + 'qty' => 5, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['name' => 'Test Keyboard', 'qty' => 5]); + } + + public function test_response_includes_id_name_and_qty() + { + $category = Category::factory()->forAccessories()->create(); + + $content = $this->handle([ + 'name' => 'Response Check Accessory', + 'category_id' => $category->id, + 'qty' => 3, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Accessory', $content['name']); + $this->assertEquals(3, $content['qty']); + } + + public function test_creates_accessory_with_optional_fields() + { + $category = Category::factory()->forAccessories()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'name' => 'Full Accessory', + 'category_id' => $category->id, + 'qty' => 10, + 'model_number' => 'MN-1234', + 'location_id' => $location->id, + 'order_number' => 'ORD-001', + 'purchase_cost' => 29.99, + 'purchase_date' => '2024-01-15', + 'min_amt' => 2, + 'notes' => 'Test notes', + ]); + + $this->assertDatabaseHas('accessories', [ + 'name' => 'Full Accessory', + 'model_number' => 'MN-1234', + 'location_id' => $location->id, + 'order_number' => 'ORD-001', + 'min_amt' => 2, + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->forAccessories()->create(); + + $this->assertTrue($this->handle([ + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Category', + 'category_id' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'accessory']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Accessory', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateAssetModelToolTest.php b/tests/Feature/Mcp/CreateAssetModelToolTest.php new file mode 100644 index 000000000000..327dc7b988e2 --- /dev/null +++ b/tests/Feature/Mcp/CreateAssetModelToolTest.php @@ -0,0 +1,79 @@ +actingAs(User::factory()->createAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateAssetModelTool)->handle(new Request($args)); + } + + public function test_creates_model_with_required_fields() + { + $category = Category::factory()->create(['category_type' => 'asset']); + + $content = $this->handle([ + 'name' => 'Test Model', + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('models', [ + 'name' => 'Test Model', + 'category_id' => $category->id, + ]); + } + + public function test_response_includes_id_name_and_category() + { + $category = Category::factory()->create(['category_type' => 'asset']); + + $content = $this->handle([ + 'name' => 'Response Model', + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Model', $content['name']); + $this->assertEquals($category->id, $content['category_id']); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->create(['category_type' => 'asset']); + + $this->assertTrue($this->handle(['category_id' => $category->id])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle(['name' => 'No Category Model'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'asset']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Model', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('models', ['name' => 'Blocked Model']); + } +} diff --git a/tests/Feature/Mcp/CreateAssetToolTest.php b/tests/Feature/Mcp/CreateAssetToolTest.php new file mode 100644 index 000000000000..bd59641c7933 --- /dev/null +++ b/tests/Feature/Mcp/CreateAssetToolTest.php @@ -0,0 +1,106 @@ +actingAs(User::factory()->createAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateAssetTool)->handle(new Request($args)); + } + + public function test_creates_asset_with_required_fields() + { + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $content = $this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TEST-001', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', [ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TEST-001', + ]); + } + + public function test_response_includes_id_and_asset_tag() + { + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $content = $this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TAG-RESP-001', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('TAG-RESP-001', $content['asset_tag']); + } + + public function test_returns_error_when_model_id_missing() + { + $status = Statuslabel::factory()->create(); + + $this->assertTrue($this->handle([ + 'status_id' => $status->id, + 'asset_tag' => 'TAG-NO-MODEL', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_status_id_missing() + { + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle([ + 'model_id' => $model->id, + 'asset_tag' => 'TAG-NO-STATUS', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_tag_missing() + { + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $this->assertTrue($this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $model = AssetModel::factory()->create(); + $status = Statuslabel::factory()->create(); + + $this->assertTrue($this->handle([ + 'model_id' => $model->id, + 'status_id' => $status->id, + 'asset_tag' => 'TAG-BLOCKED', + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('assets', ['asset_tag' => 'TAG-BLOCKED']); + } +} diff --git a/tests/Feature/Mcp/CreateCategoryToolTest.php b/tests/Feature/Mcp/CreateCategoryToolTest.php new file mode 100644 index 000000000000..783a5f384ff2 --- /dev/null +++ b/tests/Feature/Mcp/CreateCategoryToolTest.php @@ -0,0 +1,87 @@ +actingAs(User::factory()->createCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateCategoryTool)->handle(new Request($args)); + } + + public function test_creates_asset_category() + { + $content = $this->handle([ + 'name' => 'Test Asset Category', + 'category_type' => 'asset', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('categories', ['name' => 'Test Asset Category', 'category_type' => 'asset']); + } + + public function test_creates_license_category() + { + $content = $this->handle([ + 'name' => 'Test License Category', + 'category_type' => 'license', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('categories', ['name' => 'Test License Category', 'category_type' => 'license']); + } + + public function test_response_includes_id_name_and_type() + { + $content = $this->handle([ + 'name' => 'Response Category', + 'category_type' => 'accessory', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Category', $content['name']); + $this->assertEquals('accessory', $content['category_type']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle(['category_type' => 'asset'])->responses()->first()->isError()); + } + + public function test_returns_error_when_type_missing() + { + $this->assertTrue($this->handle(['name' => 'No Type Category'])->responses()->first()->isError()); + } + + public function test_returns_error_when_invalid_type() + { + $this->assertTrue($this->handle([ + 'name' => 'Invalid Type Category', + 'category_type' => 'invalid', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Category', + 'category_type' => 'asset', + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('categories', ['name' => 'Blocked Category']); + } +} diff --git a/tests/Feature/Mcp/CreateCompanyToolTest.php b/tests/Feature/Mcp/CreateCompanyToolTest.php new file mode 100644 index 000000000000..c448df246715 --- /dev/null +++ b/tests/Feature/Mcp/CreateCompanyToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->createCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateCompanyTool)->handle(new Request($args)); + } + + public function test_creates_company_with_required_fields() + { + $content = $this->handle(['name' => 'Test Company LLC'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('companies', ['name' => 'Test Company LLC']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Company Inc'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Company Inc', $content['name']); + } + + public function test_creates_with_optional_fields() + { + $this->handle([ + 'name' => 'Full Company Corp', + 'phone' => '555-1234567', + 'fax' => '555-7654321', + 'email' => 'company@example.com', + ]); + + $this->assertDatabaseHas('companies', [ + 'name' => 'Full Company Corp', + 'phone' => '555-1234567', + 'fax' => '555-7654321', + 'email' => 'company@example.com', + ]); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Company'])->responses()->first()->isError()); + $this->assertDatabaseMissing('companies', ['name' => 'Blocked Company']); + } +} diff --git a/tests/Feature/Mcp/CreateComponentToolTest.php b/tests/Feature/Mcp/CreateComponentToolTest.php new file mode 100644 index 000000000000..58f5a6c85eda --- /dev/null +++ b/tests/Feature/Mcp/CreateComponentToolTest.php @@ -0,0 +1,133 @@ +actingAs(User::factory()->createComponents()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateComponentTool)->handle(new Request($args)); + } + + public function test_creates_component_with_required_fields() + { + $category = Category::factory()->forComponents()->create(); + + $content = $this->handle([ + 'name' => 'Test RAM', + 'category_id' => $category->id, + 'qty' => 10, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['name' => 'Test RAM', 'qty' => 10]); + } + + public function test_response_includes_id_name_qty_and_category() + { + $category = Category::factory()->forComponents()->create(); + + $content = $this->handle([ + 'name' => 'Response Check Component', + 'category_id' => $category->id, + 'qty' => 5, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Component', $content['name']); + $this->assertEquals(5, $content['qty']); + $this->assertEquals($category->id, $content['category_id']); + } + + public function test_creates_component_with_optional_fields() + { + $category = Category::factory()->forComponents()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'name' => 'Full Component', + 'category_id' => $category->id, + 'qty' => 20, + 'serial' => 'SN-COMP-001', + 'model_number' => 'MN-5678', + 'location_id' => $location->id, + 'order_number' => 'ORD-COMP-001', + 'purchase_cost' => 49.99, + 'purchase_date' => '2024-03-01', + 'min_amt' => 3, + 'notes' => 'Component notes', + ]); + + $this->assertDatabaseHas('components', [ + 'name' => 'Full Component', + 'serial' => 'SN-COMP-001', + 'model_number' => 'MN-5678', + 'location_id' => $location->id, + 'order_number' => 'ORD-COMP-001', + 'min_amt' => 3, + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->forComponents()->create(); + + $this->assertTrue($this->handle([ + 'category_id' => $category->id, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category', + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_qty_missing() + { + $category = Category::factory()->forComponents()->create(); + + $this->assertTrue($this->handle([ + 'name' => 'No Qty', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Category', + 'category_id' => 999999, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'component']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Component', + 'category_id' => $category->id, + 'qty' => 1, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateConsumableToolTest.php b/tests/Feature/Mcp/CreateConsumableToolTest.php new file mode 100644 index 000000000000..48ae352048cd --- /dev/null +++ b/tests/Feature/Mcp/CreateConsumableToolTest.php @@ -0,0 +1,126 @@ +actingAs(User::factory()->createConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateConsumableTool)->handle(new Request($args)); + } + + public function test_creates_consumable_with_required_fields() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $content = $this->handle([ + 'name' => 'Test Toner Cartridge', + 'qty' => 10, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('consumables', [ + 'name' => 'Test Toner Cartridge', + 'qty' => 10, + 'category_id' => $category->id, + ]); + } + + public function test_response_includes_id_name_and_qty() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $content = $this->handle([ + 'name' => 'Response Check Consumable', + 'qty' => 5, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Consumable', $content['name']); + $this->assertEquals(5, $content['qty']); + } + + public function test_creates_with_optional_fields() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + $location = Location::factory()->create(); + + $this->handle([ + 'name' => 'Full Consumable', + 'qty' => 20, + 'category_id' => $category->id, + 'location_id' => $location->id, + 'order_number' => 'ORD-CONS-001', + 'purchase_cost' => 9.99, + 'purchase_date' => '2024-06-01', + 'min_amt' => 5, + 'notes' => 'Test consumable notes', + ]); + + $this->assertDatabaseHas('consumables', [ + 'name' => 'Full Consumable', + 'qty' => 20, + 'location_id' => $location->id, + 'order_number' => 'ORD-CONS-001', + 'min_amt' => 5, + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $this->assertTrue($this->handle([ + 'qty' => 5, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_qty_missing() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + + $this->assertTrue($this->handle([ + 'name' => 'No Qty Consumable', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category Consumable', + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'consumable']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked Consumable', + 'qty' => 1, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('consumables', ['name' => 'Blocked Consumable']); + } +} diff --git a/tests/Feature/Mcp/CreateDepartmentToolTest.php b/tests/Feature/Mcp/CreateDepartmentToolTest.php new file mode 100644 index 000000000000..dd32646a4348 --- /dev/null +++ b/tests/Feature/Mcp/CreateDepartmentToolTest.php @@ -0,0 +1,84 @@ +actingAs(User::factory()->createDepartments()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateDepartmentTool)->handle(new Request($args)); + } + + public function test_creates_department_with_required_fields() + { + $content = $this->handle(['name' => 'Test Department'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('departments', ['name' => 'Test Department']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Department'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Department', $content['name']); + } + + public function test_creates_department_with_optional_fields() + { + $location = Location::factory()->create(); + $manager = User::factory()->create(); + + $this->handle([ + 'name' => 'Full Department', + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-1234', + 'fax' => '555-5678', + 'notes' => 'Department notes', + ]); + + $this->assertDatabaseHas('departments', [ + 'name' => 'Full Department', + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-1234', + 'fax' => '555-5678', + ]); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_location_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Location Dept', + 'location_id' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Department'])->responses()->first()->isError()); + $this->assertDatabaseMissing('departments', ['name' => 'Blocked Department']); + } +} diff --git a/tests/Feature/Mcp/CreateDepreciationToolTest.php b/tests/Feature/Mcp/CreateDepreciationToolTest.php new file mode 100644 index 000000000000..34a0f9049b59 --- /dev/null +++ b/tests/Feature/Mcp/CreateDepreciationToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->createDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateDepreciationTool)->handle(new Request($args)); + } + + public function test_creates_depreciation() + { + $content = $this->handle(['name' => 'Test Depreciation', 'months' => 36])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('depreciations', [ + 'name' => 'Test Depreciation', + 'months' => 36, + ]); + } + + public function test_response_includes_id_name_and_months() + { + $content = $this->handle(['name' => 'Response Depreciation', 'months' => 24])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Depreciation', $content['name']); + $this->assertEquals(24, $content['months']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle(['months' => 36])->responses()->first()->isError()); + } + + public function test_returns_error_when_months_missing() + { + $this->assertTrue($this->handle(['name' => 'No Months'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Depreciation', 'months' => 12])->responses()->first()->isError()); + $this->assertDatabaseMissing('depreciations', ['name' => 'Blocked Depreciation']); + } +} diff --git a/tests/Feature/Mcp/CreateGroupToolTest.php b/tests/Feature/Mcp/CreateGroupToolTest.php new file mode 100644 index 000000000000..49c18dccbd76 --- /dev/null +++ b/tests/Feature/Mcp/CreateGroupToolTest.php @@ -0,0 +1,109 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateGroupTool)->handle(new Request($args)); + } + + public function test_creates_group() + { + $name = 'Test MCP Group '.uniqid(); + + $this->handle(['name' => $name]); + + $this->assertDatabaseHas('permission_groups', ['name' => $name]); + } + + public function test_response_includes_id_and_name() + { + $name = 'Test MCP Group '.uniqid(); + + $content = $this->handle(['name' => $name])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertArrayHasKey('name', $content); + $this->assertEquals($name, $content['name']); + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $name = 'Unauthorized Group '.uniqid(); + + $this->handle(['name' => $name]); + + $this->assertDatabaseMissing('permission_groups', ['name' => $name]); + } + + public function test_creates_group_with_valid_permissions() + { + $name = 'Permissions Group '.uniqid(); + + $content = $this->handle([ + 'name' => $name, + 'permissions' => json_encode(['assets.view' => 1, 'assets.create' => -1]), + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertArrayHasKey('permissions', $content); + $group = Group::where('name', $name)->first(); + $this->assertNotNull($group); + $decoded = json_decode($group->permissions, true); + $this->assertEquals(1, $decoded['assets.view']); + $this->assertEquals(-1, $decoded['assets.create']); + } + + public function test_returns_error_for_invalid_permission_key() + { + $response = $this->handle([ + 'name' => 'Bad Perms Group '.uniqid(), + 'permissions' => json_encode(['not.a.real.key' => 1]), + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_for_invalid_permission_value() + { + $response = $this->handle([ + 'name' => 'Bad Value Group '.uniqid(), + 'permissions' => json_encode(['assets.view' => 0]), + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_for_malformed_permissions_json() + { + $response = $this->handle([ + 'name' => 'Malformed Group '.uniqid(), + 'permissions' => 'not valid json', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateLicenseToolTest.php b/tests/Feature/Mcp/CreateLicenseToolTest.php new file mode 100644 index 000000000000..2db0e9478239 --- /dev/null +++ b/tests/Feature/Mcp/CreateLicenseToolTest.php @@ -0,0 +1,147 @@ +actingAs(User::factory()->createLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateLicenseTool)->handle(new Request($args)); + } + + public function test_creates_license_with_required_fields() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $content = $this->handle([ + 'name' => 'Test License', + 'seats' => 5, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('licenses', ['name' => 'Test License', 'seats' => 5]); + } + + public function test_response_includes_id_name_seats_and_category() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $content = $this->handle([ + 'name' => 'Response License', + 'seats' => 10, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response License', $content['name']); + $this->assertEquals(10, $content['seats']); + $this->assertEquals($category->id, $content['category_id']); + } + + public function test_creates_license_seats_automatically() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $content = $this->handle([ + 'name' => 'Seat Auto License', + 'seats' => 3, + 'category_id' => $category->id, + ])->getStructuredContent(); + + $licenseId = $content['id']; + $this->assertEquals(3, LicenseSeat::where('license_id', $licenseId)->count()); + } + + public function test_creates_license_with_optional_fields() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $this->handle([ + 'name' => 'Full License', + 'seats' => 2, + 'category_id' => $category->id, + 'serial' => 'SN-FULL-001', + 'license_name' => 'Acme Corp', + 'license_email' => 'admin@acme.com', + 'purchase_date' => '2024-01-15', + 'purchase_cost' => 299.99, + 'expiration_date' => '2025-01-15', + 'maintained' => true, + 'reassignable' => false, + 'notes' => 'Important license', + ]); + + $this->assertDatabaseHas('licenses', [ + 'name' => 'Full License', + 'serial' => 'SN-FULL-001', + 'license_name' => 'Acme Corp', + 'license_email' => 'admin@acme.com', + ]); + } + + public function test_returns_error_when_name_missing() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $this->assertTrue($this->handle([ + 'seats' => 5, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_seats_missing() + { + $category = Category::factory()->create(['category_type' => 'license']); + + $this->assertTrue($this->handle([ + 'name' => 'No Seats', + 'category_id' => $category->id, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_missing() + { + $this->assertTrue($this->handle([ + 'name' => 'No Category', + 'seats' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_category_does_not_exist() + { + $this->assertTrue($this->handle([ + 'name' => 'Bad Category', + 'seats' => 5, + 'category_id' => 999999, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $category = Category::factory()->create(['category_type' => 'license']); + + $this->assertTrue($this->handle([ + 'name' => 'Blocked License', + 'seats' => 5, + 'category_id' => $category->id, + ])->responses()->first()->isError()); + + $this->assertDatabaseMissing('licenses', ['name' => 'Blocked License']); + } +} diff --git a/tests/Feature/Mcp/CreateLocationToolTest.php b/tests/Feature/Mcp/CreateLocationToolTest.php new file mode 100644 index 000000000000..56fb9991b298 --- /dev/null +++ b/tests/Feature/Mcp/CreateLocationToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->createLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateLocationTool)->handle(new Request($args)); + } + + public function test_creates_location_with_required_fields() + { + $content = $this->handle(['name' => 'Test Location'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('locations', ['name' => 'Test Location']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Location With ID'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Location With ID', $content['name']); + } + + public function test_creates_child_location() + { + $parent = Location::factory()->create(); + + $content = $this->handle([ + 'name' => 'Child Location', + 'parent_id' => $parent->id, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('locations', [ + 'name' => 'Child Location', + 'parent_id' => $parent->id, + ]); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Location'])->responses()->first()->isError()); + $this->assertDatabaseMissing('locations', ['name' => 'Blocked Location']); + } +} diff --git a/tests/Feature/Mcp/CreateMaintenanceToolTest.php b/tests/Feature/Mcp/CreateMaintenanceToolTest.php new file mode 100644 index 000000000000..06a4e0b25832 --- /dev/null +++ b/tests/Feature/Mcp/CreateMaintenanceToolTest.php @@ -0,0 +1,88 @@ +actingAs(User::factory()->editAssets()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateMaintenanceTool)->handle(new Request($args)); + } + + public function test_creates_maintenance_for_asset() + { + $asset = Asset::factory()->laptopZenbook()->create(); + + $this->handle([ + 'asset_id' => $asset->id, + 'title' => 'Test Maintenance', + 'start_date' => '2024-01-01', + ]); + + $this->assertDatabaseHas('maintenances', [ + 'asset_id' => $asset->id, + 'name' => 'Test Maintenance', + ]); + } + + public function test_response_includes_id_title_and_asset_id() + { + $asset = Asset::factory()->laptopZenbook()->create(); + + $content = $this->handle([ + 'asset_id' => $asset->id, + 'title' => 'Test Maintenance Response', + 'start_date' => '2024-01-01', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertArrayHasKey('title', $content); + $this->assertArrayHasKey('asset_id', $content); + $this->assertEquals($asset->id, $content['asset_id']); + $this->assertTrue($content['success']); + } + + public function test_returns_error_when_asset_id_missing() + { + $this->assertTrue($this->handle(['title' => 'Test'])->responses()->first()->isError()); + } + + public function test_returns_error_when_title_missing() + { + $asset = Asset::factory()->laptopZenbook()->create(); + + $this->assertTrue($this->handle(['asset_id' => $asset->id])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle([ + 'asset_id' => 999999, + 'title' => 'Test', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->laptopZenbook()->create(); + + $this->assertTrue($this->handle([ + 'asset_id' => $asset->id, + 'title' => 'Unauthorized Maintenance', + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/CreateManufacturerToolTest.php b/tests/Feature/Mcp/CreateManufacturerToolTest.php new file mode 100644 index 000000000000..606b77082994 --- /dev/null +++ b/tests/Feature/Mcp/CreateManufacturerToolTest.php @@ -0,0 +1,52 @@ +actingAs(User::factory()->createManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateManufacturerTool)->handle(new Request($args)); + } + + public function test_creates_manufacturer_with_required_fields() + { + $content = $this->handle(['name' => 'Test Manufacturer'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('manufacturers', ['name' => 'Test Manufacturer']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Manufacturer'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Manufacturer', $content['name']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Manufacturer'])->responses()->first()->isError()); + $this->assertDatabaseMissing('manufacturers', ['name' => 'Blocked Manufacturer']); + } +} diff --git a/tests/Feature/Mcp/CreateStatusLabelToolTest.php b/tests/Feature/Mcp/CreateStatusLabelToolTest.php new file mode 100644 index 000000000000..259a1fd2c1a8 --- /dev/null +++ b/tests/Feature/Mcp/CreateStatusLabelToolTest.php @@ -0,0 +1,76 @@ +actingAs(User::factory()->createStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateStatusLabelTool)->handle(new Request($args)); + } + + public function test_creates_deployable_status_label() + { + $content = $this->handle(['name' => 'Test Deployable', 'type' => 'deployable'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('status_labels', [ + 'name' => 'Test Deployable', + 'deployable' => 1, + 'pending' => 0, + 'archived' => 0, + ]); + } + + public function test_creates_pending_status_label() + { + $content = $this->handle(['name' => 'Test Pending', 'type' => 'pending'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('status_labels', [ + 'name' => 'Test Pending', + 'deployable' => 0, + 'pending' => 1, + 'archived' => 0, + ]); + } + + public function test_response_includes_id_name_and_type() + { + $content = $this->handle(['name' => 'Response Status Label', 'type' => 'deployable'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Status Label', $content['name']); + $this->assertEquals('deployable', $content['type']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle(['type' => 'deployable'])->responses()->first()->isError()); + } + + public function test_returns_error_when_type_missing() + { + $this->assertTrue($this->handle(['name' => 'No Type Label'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Label', 'type' => 'deployable'])->responses()->first()->isError()); + $this->assertDatabaseMissing('status_labels', ['name' => 'Blocked Label']); + } +} diff --git a/tests/Feature/Mcp/CreateSupplierToolTest.php b/tests/Feature/Mcp/CreateSupplierToolTest.php new file mode 100644 index 000000000000..7f0626cfc437 --- /dev/null +++ b/tests/Feature/Mcp/CreateSupplierToolTest.php @@ -0,0 +1,52 @@ +actingAs(User::factory()->createSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new CreateSupplierTool)->handle(new Request($args)); + } + + public function test_creates_supplier_with_required_fields() + { + $content = $this->handle(['name' => 'Test Supplier'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('suppliers', ['name' => 'Test Supplier']); + } + + public function test_response_includes_id_and_name() + { + $content = $this->handle(['name' => 'Response Supplier'])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Supplier', $content['name']); + } + + public function test_returns_error_when_name_missing() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['name' => 'Blocked Supplier'])->responses()->first()->isError()); + $this->assertDatabaseMissing('suppliers', ['name' => 'Blocked Supplier']); + } +} diff --git a/tests/Feature/Mcp/CreateUserToolTest.php b/tests/Feature/Mcp/CreateUserToolTest.php new file mode 100644 index 000000000000..1deb7c561cdd --- /dev/null +++ b/tests/Feature/Mcp/CreateUserToolTest.php @@ -0,0 +1,202 @@ +actingAs(User::factory()->createUsers()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new CreateUserTool)->handle(new Request($args)); + } + + public function test_creates_user_with_required_fields() + { + $content = $this->handle([ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'username' => 'jane.doe.mcp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['username' => 'jane.doe.mcp', 'first_name' => 'Jane']); + } + + public function test_response_includes_new_user_id_and_username() + { + $content = $this->handle([ + 'first_name' => 'Test', + 'username' => 'new.user.mcp', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertArrayHasKey('username', $content); + $this->assertEquals('new.user.mcp', $content['username']); + $this->assertIsInt($content['id']); + } + + public function test_hashes_password_when_provided() + { + $this->handle([ + 'first_name' => 'Secure', + 'username' => 'secure.user.mcp', + 'password' => 'secret1234', + ]); + + $user = User::where('username', 'secure.user.mcp')->first(); + $this->assertNotNull($user); + $this->assertTrue(Hash::check('secret1234', $user->password)); + } + + public function test_creates_user_with_no_password_when_omitted() + { + $this->handle([ + 'first_name' => 'Nopass', + 'username' => 'nopass.user.mcp', + ]); + + $user = User::where('username', 'nopass.user.mcp')->first(); + $this->assertNotNull($user); + $this->assertFalse(Hash::check('', $user->password)); + } + + public function test_creates_user_with_email_and_contact_fields() + { + $this->handle([ + 'first_name' => 'Contact', + 'username' => 'contact.user.mcp', + 'email' => 'contact@example.com', + 'phone' => '555-1234', + 'jobtitle' => 'Engineer', + ]); + + $this->assertDatabaseHas('users', [ + 'username' => 'contact.user.mcp', + 'email' => 'contact@example.com', + 'phone' => '555-1234', + 'jobtitle' => 'Engineer', + ]); + } + + public function test_creates_user_with_department_and_location() + { + $department = Department::factory()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'first_name' => 'Located', + 'username' => 'located.user.mcp', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + + $this->assertDatabaseHas('users', [ + 'username' => 'located.user.mcp', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + } + + public function test_returns_error_when_username_missing() + { + $this->assertTrue($this->handle([ + 'first_name' => 'No Username', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_first_name_missing() + { + $this->assertTrue($this->handle([ + 'username' => 'no.firstname.mcp', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_username_already_exists() + { + User::factory()->create(['username' => 'duplicate.mcp']); + + $response = $this->handle([ + 'first_name' => 'Dupe', + 'username' => 'duplicate.mcp', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_activates_user_by_default() + { + $this->handle([ + 'first_name' => 'Active', + 'username' => 'default.active.mcp', + ]); + + $this->assertDatabaseHas('users', ['username' => 'default.active.mcp', 'activated' => 1]); + } + + public function test_can_create_deactivated_user() + { + $this->handle([ + 'first_name' => 'Inactive', + 'username' => 'inactive.user.mcp', + 'activated' => false, + ]); + + $this->assertDatabaseHas('users', ['username' => 'inactive.user.mcp', 'activated' => 0]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'first_name' => 'Blocked', + 'username' => 'blocked.user.mcp', + ])->responses()->first()->isError()); + $this->assertDatabaseMissing('users', ['username' => 'blocked.user.mcp']); + } + + public function test_superadmin_can_assign_group_ids() + { + $this->actingAs(User::factory()->superuser()->create()); + $group = Group::factory()->create(); + + $content = $this->handle([ + 'first_name' => 'GroupedUser', + 'username' => 'grouped.user.mcp', + 'group_ids' => [$group->id], + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $user = User::where('username', 'grouped.user.mcp')->first(); + $this->assertNotNull($user); + $this->assertTrue($user->groups->contains($group->id)); + } + + public function test_non_superadmin_cannot_assign_group_ids() + { + $group = Group::factory()->create(); + + $response = $this->handle([ + 'first_name' => 'Denied', + 'username' => 'denied.groups.mcp', + 'group_ids' => [$group->id], + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/DeleteAccessoryToolTest.php b/tests/Feature/Mcp/DeleteAccessoryToolTest.php new file mode 100644 index 000000000000..d78a8c5e966e --- /dev/null +++ b/tests/Feature/Mcp/DeleteAccessoryToolTest.php @@ -0,0 +1,78 @@ +actingAs(User::factory()->deleteAccessories()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteAccessoryTool)->handle(new Request($args)); + } + + public function test_deletes_accessory_by_id() + { + $accessory = Accessory::factory()->create(); + + $content = $this->handle(['id' => $accessory->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('accessories', ['id' => $accessory->id]); + } + + public function test_deletes_accessory_by_name() + { + $accessory = Accessory::factory()->create(['name' => 'Delete By Name']); + + $content = $this->handle(['name' => 'Delete By Name'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('accessories', ['id' => $accessory->id]); + } + + public function test_response_includes_name() + { + $accessory = Accessory::factory()->create(['name' => 'Named Accessory']); + + $content = $this->handle(['id' => $accessory->id])->getStructuredContent(); + + $this->assertEquals('Named Accessory', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_accessory_has_checkouts() + { + $user = User::factory()->create(); + $accessory = Accessory::factory()->checkedOutToUser($user)->create(); + + $response = $this->handle(['id' => $accessory->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('accessories', ['id' => $accessory->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $accessory = Accessory::factory()->create(); + + $this->assertTrue($this->handle(['id' => $accessory->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('accessories', ['id' => $accessory->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteAssetModelToolTest.php b/tests/Feature/Mcp/DeleteAssetModelToolTest.php new file mode 100644 index 000000000000..0cfe151497a6 --- /dev/null +++ b/tests/Feature/Mcp/DeleteAssetModelToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->deleteAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteAssetModelTool)->handle(new Request($args)); + } + + public function test_deletes_model_by_id() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['id' => $model->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('models', ['id' => $model->id]); + } + + public function test_deletes_model_by_name() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['name' => $model->name])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('models', ['id' => $model->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_model_has_assets() + { + $model = AssetModel::factory()->create(); + Asset::factory()->create(['model_id' => $model->id]); + + $response = $this->handle(['id' => $model->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('models', ['id' => $model->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle(['id' => $model->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('models', ['id' => $model->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteAssetToolTest.php b/tests/Feature/Mcp/DeleteAssetToolTest.php new file mode 100644 index 000000000000..347e6c829392 --- /dev/null +++ b/tests/Feature/Mcp/DeleteAssetToolTest.php @@ -0,0 +1,107 @@ +actingAs(User::factory()->deleteAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteAssetTool)->handle(new Request($args)); + } + + public function test_deletes_asset_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_deletes_asset_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_deletes_asset_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-DELETE-001']); + + $content = $this->handle(['serial' => 'SN-DELETE-001'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle(['asset_tag' => 'DOES-NOT-EXIST'])->responses()->first()->isError()); + } + + public function test_checks_in_asset_before_deleting_when_checked_out() + { + Event::fake([CheckoutableCheckedIn::class]); + + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + Event::assertDispatched(CheckoutableCheckedIn::class, function (CheckoutableCheckedIn $event) use ($asset) { + return $event->checkoutable->id === $asset->id; + }); + + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_deletes_unassigned_asset_without_firing_checkin_event() + { + Event::fake([CheckoutableCheckedIn::class]); + + $asset = Asset::factory()->create(); + + $this->handle(['asset_tag' => $asset->asset_tag]); + + Event::assertNotDispatched(CheckoutableCheckedIn::class); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_response_includes_asset_tag() + { + $asset = Asset::factory()->create(['asset_tag' => 'TAG-DEL-001']); + + $content = $this->handle(['asset_tag' => 'TAG-DEL-001'])->getStructuredContent(); + + $this->assertEquals('TAG-DEL-001', $content['asset_tag']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + $this->assertNotSoftDeleted($asset); + } +} diff --git a/tests/Feature/Mcp/DeleteCategoryToolTest.php b/tests/Feature/Mcp/DeleteCategoryToolTest.php new file mode 100644 index 000000000000..886fbef2f03d --- /dev/null +++ b/tests/Feature/Mcp/DeleteCategoryToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteCategoryTool)->handle(new Request($args)); + } + + public function test_deletes_category_by_id() + { + $category = Category::factory()->create(); + + $content = $this->handle(['id' => $category->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('categories', ['id' => $category->id]); + } + + public function test_deletes_category_by_name() + { + $category = Category::factory()->create(['name' => 'Delete By Name Cat']); + + $content = $this->handle(['name' => 'Delete By Name Cat'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('categories', ['id' => $category->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $category = Category::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $category->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('categories', ['id' => $category->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteCompanyToolTest.php b/tests/Feature/Mcp/DeleteCompanyToolTest.php new file mode 100644 index 000000000000..4e06f393416f --- /dev/null +++ b/tests/Feature/Mcp/DeleteCompanyToolTest.php @@ -0,0 +1,67 @@ +actingAs(User::factory()->deleteCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteCompanyTool)->handle(new Request($args)); + } + + public function test_deletes_company_by_id() + { + $company = Company::factory()->create(); + + $content = $this->handle(['id' => $company->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('companies', ['id' => $company->id]); + } + + public function test_deletes_company_by_name() + { + $company = Company::factory()->create(['name' => 'Delete By Name Corp']); + + $content = $this->handle(['name' => 'Delete By Name Corp'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('companies', ['id' => $company->id]); + } + + public function test_response_includes_name() + { + $company = Company::factory()->create(['name' => 'Named Company']); + + $content = $this->handle(['id' => $company->id])->getStructuredContent(); + + $this->assertEquals('Named Company', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $company = Company::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $company->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('companies', ['id' => $company->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteComponentToolTest.php b/tests/Feature/Mcp/DeleteComponentToolTest.php new file mode 100644 index 000000000000..12ff32e9a1ef --- /dev/null +++ b/tests/Feature/Mcp/DeleteComponentToolTest.php @@ -0,0 +1,79 @@ +actingAs(User::factory()->deleteComponents()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteComponentTool)->handle(new Request($args)); + } + + public function test_deletes_component_by_id() + { + $component = Component::factory()->create(); + + $content = $this->handle(['id' => $component->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('components', ['id' => $component->id]); + } + + public function test_deletes_component_by_name() + { + $component = Component::factory()->create(['name' => 'Delete By Name Component']); + + $content = $this->handle(['name' => 'Delete By Name Component'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('components', ['id' => $component->id]); + } + + public function test_response_includes_name() + { + $component = Component::factory()->create(['name' => 'Named Component']); + + $content = $this->handle(['id' => $component->id])->getStructuredContent(); + + $this->assertEquals('Named Component', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_component_has_checkouts() + { + $asset = Asset::factory()->create(); + $component = Component::factory()->checkedOutToAsset($asset)->create(); + + $response = $this->handle(['id' => $component->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('components', ['id' => $component->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $component = Component::factory()->create(); + + $this->assertTrue($this->handle(['id' => $component->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('components', ['id' => $component->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteConsumableToolTest.php b/tests/Feature/Mcp/DeleteConsumableToolTest.php new file mode 100644 index 000000000000..387782aa4774 --- /dev/null +++ b/tests/Feature/Mcp/DeleteConsumableToolTest.php @@ -0,0 +1,87 @@ +actingAs(User::factory()->deleteConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteConsumableTool)->handle(new Request($args)); + } + + public function test_deletes_consumable_by_id() + { + $consumable = Consumable::factory()->create(); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('consumables', ['id' => $consumable->id]); + } + + public function test_deletes_consumable_by_name() + { + $consumable = Consumable::factory()->create(['name' => 'Delete By Name Consumable']); + + $content = $this->handle(['name' => 'Delete By Name Consumable'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('consumables', ['id' => $consumable->id]); + } + + public function test_response_includes_name() + { + $consumable = Consumable::factory()->create(['name' => 'Named Delete Consumable']); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertEquals('Named Delete Consumable', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_consumable_has_checkouts() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + DB::table('consumables_users')->insert([ + 'consumable_id' => $consumable->id, + 'assigned_to' => User::factory()->create()->id, + 'created_at' => now(), + 'updated_at' => now(), + 'note' => null, + 'created_by' => auth()->id(), + ]); + + $response = $this->handle(['id' => $consumable->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('consumables', ['id' => $consumable->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(); + + $this->assertTrue($this->handle(['id' => $consumable->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('consumables', ['id' => $consumable->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteDepartmentToolTest.php b/tests/Feature/Mcp/DeleteDepartmentToolTest.php new file mode 100644 index 000000000000..a192541b5fb0 --- /dev/null +++ b/tests/Feature/Mcp/DeleteDepartmentToolTest.php @@ -0,0 +1,78 @@ +actingAs(User::factory()->deleteDepartments()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteDepartmentTool)->handle(new Request($args)); + } + + public function test_deletes_department_by_id() + { + $department = Department::factory()->create(); + + $content = $this->handle(['id' => $department->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('departments', ['id' => $department->id]); + } + + public function test_deletes_department_by_name() + { + $department = Department::factory()->create(['name' => 'Delete By Name Dept']); + + $content = $this->handle(['name' => 'Delete By Name Dept'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('departments', ['id' => $department->id]); + } + + public function test_response_includes_name() + { + $department = Department::factory()->create(['name' => 'Named Department']); + + $content = $this->handle(['id' => $department->id])->getStructuredContent(); + + $this->assertEquals('Named Department', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_department_has_users() + { + $department = Department::factory()->create(); + User::factory()->create(['department_id' => $department->id]); + + $response = $this->handle(['id' => $department->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('departments', ['id' => $department->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $department = Department::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $department->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('departments', ['id' => $department->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteDepreciationToolTest.php b/tests/Feature/Mcp/DeleteDepreciationToolTest.php new file mode 100644 index 000000000000..f0033dfbaaf0 --- /dev/null +++ b/tests/Feature/Mcp/DeleteDepreciationToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteDepreciationTool)->handle(new Request($args)); + } + + public function test_deletes_depreciation_by_id() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['id' => $dep->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('depreciations', ['id' => $dep->id]); + } + + public function test_deletes_depreciation_by_name() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['name' => $dep->name])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseMissing('depreciations', ['id' => $dep->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $dep = Depreciation::factory()->create(); + + $this->assertTrue($this->handle(['id' => $dep->id])->responses()->first()->isError()); + $this->assertDatabaseHas('depreciations', ['id' => $dep->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteGroupToolTest.php b/tests/Feature/Mcp/DeleteGroupToolTest.php new file mode 100644 index 000000000000..d7c1c8e82d0a --- /dev/null +++ b/tests/Feature/Mcp/DeleteGroupToolTest.php @@ -0,0 +1,55 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteGroupTool)->handle(new Request($args)); + } + + public function test_deletes_group_by_id() + { + $group = Group::factory()->create(); + + $this->handle(['id' => $group->id]); + + $this->assertDatabaseMissing('permission_groups', ['id' => $group->id]); + } + + public function test_deletes_group_by_name() + { + $group = Group::factory()->create(); + + $this->handle(['name' => $group->name]); + + $this->assertDatabaseMissing('permission_groups', ['id' => $group->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $group = Group::factory()->create(); + + $this->assertTrue($this->handle(['id' => $group->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/DeleteLicenseToolTest.php b/tests/Feature/Mcp/DeleteLicenseToolTest.php new file mode 100644 index 000000000000..d844de967db1 --- /dev/null +++ b/tests/Feature/Mcp/DeleteLicenseToolTest.php @@ -0,0 +1,82 @@ +actingAs(User::factory()->deleteLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteLicenseTool)->handle(new Request($args)); + } + + public function test_deletes_license_by_id() + { + $license = License::factory()->create(); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('licenses', ['id' => $license->id]); + } + + public function test_deletes_license_by_name() + { + $license = License::factory()->create(['name' => 'Delete By Name License']); + + $content = $this->handle(['name' => 'Delete By Name License'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('licenses', ['id' => $license->id]); + } + + public function test_response_includes_name() + { + $license = License::factory()->create(['name' => 'Named License']); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertEquals('Named License', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_seats_are_assigned() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user->id; + $seat->save(); + + $response = $this->handle(['id' => $license->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('licenses', ['id' => $license->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $license->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('licenses', ['id' => $license->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteLocationToolTest.php b/tests/Feature/Mcp/DeleteLocationToolTest.php new file mode 100644 index 000000000000..1722a6cf47f5 --- /dev/null +++ b/tests/Feature/Mcp/DeleteLocationToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->deleteLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteLocationTool)->handle(new Request($args)); + } + + public function test_deletes_location_by_id() + { + $location = Location::factory()->create(); + + $content = $this->handle(['id' => $location->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('locations', ['id' => $location->id]); + } + + public function test_deletes_location_by_name() + { + $location = Location::factory()->create(); + + $content = $this->handle(['name' => $location->name])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('locations', ['id' => $location->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_location_has_users() + { + $location = Location::factory()->create(); + User::factory()->create(['location_id' => $location->id]); + + $response = $this->handle(['id' => $location->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('locations', ['id' => $location->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $location = Location::factory()->create(); + + $this->assertTrue($this->handle(['id' => $location->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('locations', ['id' => $location->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteManufacturerToolTest.php b/tests/Feature/Mcp/DeleteManufacturerToolTest.php new file mode 100644 index 000000000000..593d5d7c5f6e --- /dev/null +++ b/tests/Feature/Mcp/DeleteManufacturerToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteManufacturerTool)->handle(new Request($args)); + } + + public function test_deletes_manufacturer_by_id() + { + $manufacturer = Manufacturer::factory()->create(); + + $content = $this->handle(['id' => $manufacturer->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('manufacturers', ['id' => $manufacturer->id]); + } + + public function test_deletes_manufacturer_by_name() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Delete By Name Manufacturer']); + + $content = $this->handle(['name' => 'Delete By Name Manufacturer'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('manufacturers', ['id' => $manufacturer->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $manufacturer = Manufacturer::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $manufacturer->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('manufacturers', ['id' => $manufacturer->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteStatusLabelToolTest.php b/tests/Feature/Mcp/DeleteStatusLabelToolTest.php new file mode 100644 index 000000000000..64ed0891cd2f --- /dev/null +++ b/tests/Feature/Mcp/DeleteStatusLabelToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->deleteStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteStatusLabelTool)->handle(new Request($args)); + } + + public function test_deletes_status_label_by_id() + { + $label = Statuslabel::factory()->create(); + + $content = $this->handle(['id' => $label->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('status_labels', ['id' => $label->id]); + } + + public function test_deletes_status_label_by_name() + { + $label = Statuslabel::factory()->create(['name' => 'Delete By Name Status Label']); + + $content = $this->handle(['name' => 'Delete By Name Status Label'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('status_labels', ['id' => $label->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_status_label_has_assets() + { + $label = Statuslabel::factory()->rtd()->create(); + Asset::factory()->create(['status_id' => $label->id]); + + $response = $this->handle(['id' => $label->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('status_labels', ['id' => $label->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $label = Statuslabel::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $label->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('status_labels', ['id' => $label->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteSupplierToolTest.php b/tests/Feature/Mcp/DeleteSupplierToolTest.php new file mode 100644 index 000000000000..aca7b5d4c46d --- /dev/null +++ b/tests/Feature/Mcp/DeleteSupplierToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->deleteSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new DeleteSupplierTool)->handle(new Request($args)); + } + + public function test_deletes_supplier_by_id() + { + $supplier = Supplier::factory()->create(); + + $content = $this->handle(['id' => $supplier->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('suppliers', ['id' => $supplier->id]); + } + + public function test_deletes_supplier_by_name() + { + $supplier = Supplier::factory()->create(['name' => 'Delete By Name Supplier']); + + $content = $this->handle(['name' => 'Delete By Name Supplier'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('suppliers', ['id' => $supplier->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $supplier = Supplier::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $supplier->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('suppliers', ['id' => $supplier->id]); + } +} diff --git a/tests/Feature/Mcp/DeleteUserToolTest.php b/tests/Feature/Mcp/DeleteUserToolTest.php new file mode 100644 index 000000000000..de94e7789423 --- /dev/null +++ b/tests/Feature/Mcp/DeleteUserToolTest.php @@ -0,0 +1,98 @@ +actingAs(User::factory()->deleteUsers()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new DeleteUserTool)->handle(new Request($args)); + } + + public function test_deletes_user_by_id() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_deletes_user_by_username() + { + $user = User::factory()->create(['username' => 'delete.by.username']); + + $content = $this->handle(['username' => 'delete.by.username'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_deletes_user_by_email() + { + $user = User::factory()->create(['email' => 'delete.by@example.com']); + + $content = $this->handle(['email' => 'delete.by@example.com'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } + + public function test_response_includes_username() + { + $user = User::factory()->create(['username' => 'response.check.mcp']); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals('response.check.mcp', $content['username']); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_deleting_own_account() + { + $authUser = auth()->user(); + + $response = $this->handle(['id' => $authUser->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('users', ['id' => $authUser->id]); + } + + public function test_returns_error_when_user_has_assigned_assets() + { + $user = User::factory()->create(); + Asset::factory()->assignedToUser($user)->create(); + + $response = $this->handle(['id' => $user->id]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted('users', ['id' => $user->id]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + $this->assertNotSoftDeleted('users', ['id' => $user->id]); + } +} diff --git a/tests/Feature/Mcp/FmcsAccessoryScopingTest.php b/tests/Feature/Mcp/FmcsAccessoryScopingTest.php new file mode 100644 index 000000000000..6e59b1fc9918 --- /dev/null +++ b/tests/Feature/Mcp/FmcsAccessoryScopingTest.php @@ -0,0 +1,184 @@ +companyA, $this->companyB] = Company::factory()->count(2)->create(); + + $this->accessoryA = Accessory::factory()->for($this->companyA)->create(['qty' => 5]); + $this->accessoryB = Accessory::factory()->for($this->companyB)->create(['qty' => 5]); + + $this->userInCompanyA = $this->companyA->users()->save( + User::factory()->editAccessories()->deleteAccessories()->checkoutAccessories()->checkinAccessories()->make() + ); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + + $this->settings->enableMultipleFullCompanySupport(); + } + + // --- UpdateAccessoryTool --- + + public function test_update_blocked_for_cross_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $response = (new UpdateAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + 'qty' => 99, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('accessories', ['id' => $this->accessoryB->id, 'qty' => 99]); + } + + public function test_update_allowed_for_same_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryA->id, + 'qty' => 10, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $this->accessoryA->id, 'qty' => 10]); + } + + // --- DeleteAccessoryTool --- + + public function test_delete_blocked_for_cross_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $response = (new DeleteAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted($this->accessoryB); + } + + public function test_delete_allowed_for_same_company_accessory() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryA->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->accessoryA); + } + + // --- CheckoutAccessoryTool --- + + public function test_checkout_blocked_for_cross_company_accessory() + { + $user = $this->companyA->users()->save(User::factory()->make()); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckoutAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('accessories_checkout', ['accessory_id' => $this->accessoryB->id]); + } + + public function test_checkout_allowed_for_same_company_accessory() + { + $user = $this->companyA->users()->save(User::factory()->make()); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryA->id, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories_checkout', ['accessory_id' => $this->accessoryA->id]); + } + + // --- CheckinAccessoryTool --- + + public function test_checkin_blocked_for_cross_company_accessory() + { + $user = $this->companyB->users()->save(User::factory()->make()); + $this->actingAs($this->userInCompanyA); + + // Checkout accessory B as superuser (different company), then try to check it in as userInCompanyA + $checkoutId = AccessoryCheckout::create([ + 'accessory_id' => $this->accessoryB->id, + 'assigned_to' => $user->id, + 'assigned_type' => User::class, + 'created_by' => $this->superUser->id, + ])->id; + + $response = (new CheckinAccessoryTool)->handle(new Request([ + 'checkout_id' => $checkoutId, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('accessories_checkout', ['id' => $checkoutId]); + } + + // --- Superuser bypass --- + + public function test_superuser_can_update_accessory_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new UpdateAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + 'qty' => 20, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $this->accessoryB->id, 'qty' => 20]); + } + + public function test_superuser_can_delete_accessory_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new DeleteAccessoryTool)->handle(new Request([ + 'id' => $this->accessoryB->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->accessoryB); + } +} diff --git a/tests/Feature/Mcp/FmcsCompanyScopingTest.php b/tests/Feature/Mcp/FmcsCompanyScopingTest.php new file mode 100644 index 000000000000..1f3853544b85 --- /dev/null +++ b/tests/Feature/Mcp/FmcsCompanyScopingTest.php @@ -0,0 +1,218 @@ +companyA, $this->companyB] = Company::factory()->count(2)->create(); + + $this->assetA = Asset::factory()->for($this->companyA)->create(); + $this->assetB = Asset::factory()->for($this->companyB)->create(); + + $this->userInCompanyA = $this->companyA->users()->save( + User::factory()->editAssets()->deleteAssets()->auditAssets()->checkinAssets()->checkoutAssets()->make() + ); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + + $this->settings->enableMultipleFullCompanySupport(); + } + + // --- UpdateAssetTool --- + + public function test_update_blocked_for_cross_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $response = (new UpdateAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + 'name' => 'Should Not Apply', + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('assets', ['id' => $this->assetB->id, 'name' => 'Should Not Apply']); + } + + public function test_update_allowed_for_same_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + 'name' => 'Updated Name', + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $this->assetA->id, 'name' => 'Updated Name']); + } + + // --- DeleteAssetTool --- + + public function test_delete_blocked_for_cross_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $response = (new DeleteAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted($this->assetB); + } + + public function test_delete_allowed_for_same_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->assetA); + } + + // --- AuditAssetTool --- + + public function test_audit_blocked_for_cross_company_asset() + { + $this->actingAs($this->userInCompanyA); + + (new AuditAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + ])); + + $this->assertNull($this->assetB->fresh()->last_audit_date); + } + + public function test_audit_allowed_for_same_company_asset() + { + $this->actingAs($this->userInCompanyA); + + $content = (new AuditAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertNotNull($this->assetA->fresh()->last_audit_date); + } + + // --- CheckinAssetTool --- + + public function test_checkin_blocked_for_cross_company_asset() + { + $user = $this->companyB->users()->save(User::factory()->make()); + $checkedOutAsset = Asset::factory()->for($this->companyB)->assignedToUser($user)->create(); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckinAssetTool)->handle(new Request([ + 'asset_tag' => $checkedOutAsset->asset_tag, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('assets', ['id' => $checkedOutAsset->id, 'assigned_to' => $user->id]); + } + + public function test_checkin_allowed_for_same_company_asset() + { + $user = $this->companyA->users()->save(User::factory()->make()); + $asset = Asset::factory()->for($this->companyA)->assignedToUser($user)->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckinAssetTool)->handle(new Request([ + 'asset_tag' => $asset->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'assigned_to' => null]); + } + + // --- CheckoutAssetTool --- + + public function test_checkout_blocked_for_cross_company_asset() + { + $user = $this->companyA->users()->save(User::factory()->make()); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckoutAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('assets', ['id' => $this->assetB->id, 'assigned_to' => null]); + } + + public function test_checkout_allowed_for_same_company_asset() + { + $user = $this->companyA->users()->save(User::factory()->make()); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetA->asset_tag, + 'checkout_to_type' => 'user', + 'assigned_user' => $user->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $this->assetA->id, 'assigned_to' => $user->id]); + } + + // --- Superuser bypass --- + + public function test_superuser_can_update_asset_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new UpdateAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + 'name' => 'Superuser Override', + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $this->assetB->id, 'name' => 'Superuser Override']); + } + + public function test_superuser_can_delete_asset_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new DeleteAssetTool)->handle(new Request([ + 'asset_tag' => $this->assetB->asset_tag, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->assetB); + } +} diff --git a/tests/Feature/Mcp/FmcsComponentScopingTest.php b/tests/Feature/Mcp/FmcsComponentScopingTest.php new file mode 100644 index 000000000000..9ad983533431 --- /dev/null +++ b/tests/Feature/Mcp/FmcsComponentScopingTest.php @@ -0,0 +1,185 @@ +companyA, $this->companyB] = Company::factory()->count(2)->create(); + + $this->componentA = Component::factory()->for($this->companyA)->create(['qty' => 5]); + $this->componentB = Component::factory()->for($this->companyB)->create(['qty' => 5]); + + $this->userInCompanyA = $this->companyA->users()->save( + User::factory()->editComponents()->deleteComponents()->checkoutComponents()->checkinComponents()->make() + ); + $this->superUser = $this->companyA->users()->save(User::factory()->superuser()->make()); + + $this->settings->enableMultipleFullCompanySupport(); + } + + // --- UpdateComponentTool --- + + public function test_update_blocked_for_cross_company_component() + { + $this->actingAs($this->userInCompanyA); + + $response = (new UpdateComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + 'qty' => 99, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('components', ['id' => $this->componentB->id, 'qty' => 99]); + } + + public function test_update_allowed_for_same_company_component() + { + $this->actingAs($this->userInCompanyA); + + $content = (new UpdateComponentTool)->handle(new Request([ + 'id' => $this->componentA->id, + 'qty' => 10, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $this->componentA->id, 'qty' => 10]); + } + + // --- DeleteComponentTool --- + + public function test_delete_blocked_for_cross_company_component() + { + $this->actingAs($this->userInCompanyA); + + $response = (new DeleteComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertNotSoftDeleted($this->componentB); + } + + public function test_delete_allowed_for_same_company_component() + { + $this->actingAs($this->userInCompanyA); + + $content = (new DeleteComponentTool)->handle(new Request([ + 'id' => $this->componentA->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->componentA); + } + + // --- CheckoutComponentTool --- + + public function test_checkout_blocked_for_cross_company_component() + { + $asset = Asset::factory()->for($this->companyA)->create(); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckoutComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + 'asset_id' => $asset->id, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('components_assets', ['component_id' => $this->componentB->id]); + } + + public function test_checkout_allowed_for_same_company_component() + { + $asset = Asset::factory()->for($this->companyA)->create(); + + $this->actingAs($this->userInCompanyA); + + $content = (new CheckoutComponentTool)->handle(new Request([ + 'id' => $this->componentA->id, + 'asset_id' => $asset->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components_assets', ['component_id' => $this->componentA->id, 'asset_id' => $asset->id]); + } + + // --- CheckinComponentTool --- + + public function test_checkin_blocked_for_cross_company_component() + { + $asset = Asset::factory()->for($this->companyB)->create(); + + // Checkout component B to asset B as superuser + $componentAssetId = DB::table('components_assets')->insertGetId([ + 'component_id' => $this->componentB->id, + 'asset_id' => $asset->id, + 'assigned_qty' => 1, + 'created_by' => $this->superUser->id, + 'created_at' => now(), + ]); + + $this->actingAs($this->userInCompanyA); + + $response = (new CheckinComponentTool)->handle(new Request([ + 'component_asset_id' => $componentAssetId, + ])); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseHas('components_assets', ['id' => $componentAssetId]); + } + + // --- Superuser bypass --- + + public function test_superuser_can_update_component_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new UpdateComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + 'qty' => 20, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $this->componentB->id, 'qty' => 20]); + } + + public function test_superuser_can_delete_component_in_any_company() + { + $this->actingAs($this->superUser); + + $content = (new DeleteComponentTool)->handle(new Request([ + 'id' => $this->componentB->id, + ]))->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertSoftDeleted($this->componentB); + } +} diff --git a/tests/Feature/Mcp/GetActivityLogToolTest.php b/tests/Feature/Mcp/GetActivityLogToolTest.php new file mode 100644 index 000000000000..536e2544ce62 --- /dev/null +++ b/tests/Feature/Mcp/GetActivityLogToolTest.php @@ -0,0 +1,88 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new GetActivityLogTool)->handle(new Request($args)); + } + + public function test_returns_activity_log() + { + $asset = Asset::factory()->laptopZenbook()->create(); + $log = new Actionlog; + $log->item_type = Asset::class; + $log->item_id = $asset->id; + $log->action_type = 'update'; + $log->target_id = auth()->id(); + $log->save(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('activity', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(1, $content['total']); + } + + public function test_filters_by_action_type() + { + $asset = Asset::factory()->laptopZenbook()->create(); + $log = new Actionlog; + $log->item_type = Asset::class; + $log->item_id = $asset->id; + $log->action_type = 'checkout'; + $log->target_id = auth()->id(); + $log->save(); + + $content = $this->handle(['action_type' => 'checkout'])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + foreach ($content['activity'] as $entry) { + $this->assertEquals('checkout', $entry['action_type']); + } + } + + public function test_filters_by_item_id() + { + $asset = Asset::factory()->laptopZenbook()->create(); + $log = new Actionlog; + $log->item_type = Asset::class; + $log->item_id = $asset->id; + $log->action_type = 'update'; + $log->target_id = auth()->id(); + $log->save(); + + $content = $this->handle([ + 'item_id' => $asset->id, + 'item_type' => Asset::class, + ])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + foreach ($content['activity'] as $entry) { + $this->assertEquals($asset->id, $entry['item_id']); + } + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/GetCurrentUserToolTest.php b/tests/Feature/Mcp/GetCurrentUserToolTest.php new file mode 100644 index 000000000000..7120e4c8ad34 --- /dev/null +++ b/tests/Feature/Mcp/GetCurrentUserToolTest.php @@ -0,0 +1,42 @@ +actingAs(User::factory()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new GetCurrentUserTool)->handle(new Request($args)); + } + + public function test_returns_current_user_info() + { + $user = auth()->user(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals($user->username, $content['username']); + } + + public function test_response_includes_expected_fields() + { + $content = $this->handle()->getStructuredContent(); + + foreach (['id', 'username', 'first_name', 'last_name', 'email', 'company', 'department', 'location', 'employee_num', 'title', 'phone', 'activated'] as $field) { + $this->assertArrayHasKey($field, $content); + } + } +} diff --git a/tests/Feature/Mcp/GetUserAssetsToolTest.php b/tests/Feature/Mcp/GetUserAssetsToolTest.php new file mode 100644 index 000000000000..900cb94437be --- /dev/null +++ b/tests/Feature/Mcp/GetUserAssetsToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->viewUsers()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new GetUserAssetsTool)->handle(new Request($args)); + } + + public function test_returns_assets_for_user() + { + $user = User::factory()->create(); + Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals($user->id, $content['user_id']); + $this->assertEquals(1, $content['total']); + $this->assertCount(1, $content['assets']); + } + + public function test_returns_empty_array_when_user_has_no_assets() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals(0, $content['total']); + $this->assertCount(0, $content['assets']); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListAssetModelsToolTest.php b/tests/Feature/Mcp/ListAssetModelsToolTest.php new file mode 100644 index 000000000000..7ce33fe0e0ed --- /dev/null +++ b/tests/Feature/Mcp/ListAssetModelsToolTest.php @@ -0,0 +1,71 @@ +actingAs(User::factory()->viewAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListAssetModelsTool)->handle(new Request($args)); + } + + public function test_returns_models_with_pagination() + { + AssetModel::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('models', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_model_has_key_fields() + { + AssetModel::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['models']); + $first = $content['models'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + $this->assertArrayHasKey('category', $first); + } + + public function test_filters_by_category_id() + { + $category = Category::factory()->create(['category_type' => 'asset']); + $model = AssetModel::factory()->create(['category_id' => $category->id]); + AssetModel::factory()->create(); // another model with different category + + $content = $this->handle(['category_id' => $category->id])->getStructuredContent(); + + $ids = array_column($content['models'], 'id'); + $this->assertContains($model->id, $ids); + foreach ($ids as $id) { + $this->assertEquals($category->id, collect($content['models'])->firstWhere('id', $id)['category_id']); + } + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListAssetNotesToolTest.php b/tests/Feature/Mcp/ListAssetNotesToolTest.php new file mode 100644 index 000000000000..b422b653753d --- /dev/null +++ b/tests/Feature/Mcp/ListAssetNotesToolTest.php @@ -0,0 +1,85 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ListAssetNotesTool)->handle(new Request($args)); + } + + public function test_returns_notes_for_asset() + { + $asset = Asset::factory()->create(); + $author = User::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'Test note content', + 'created_by' => $author->id, + ]); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(1, $content['total']); + $this->assertEquals('Test note content', $content['notes'][0]['note']); + } + + public function test_returns_empty_list_when_no_notes() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(0, $content['total']); + $this->assertEmpty($content['notes']); + } + + public function test_resolves_asset_by_asset_tag() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => (string) $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($asset->id, $content['asset_id']); + } + + public function test_resolves_asset_by_serial() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['serial' => $asset->serial])->getStructuredContent(); + + $this->assertEquals($asset->id, $content['asset_id']); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_view_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['id' => $asset->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListAssetsToolTest.php b/tests/Feature/Mcp/ListAssetsToolTest.php new file mode 100644 index 000000000000..1142479ed4e5 --- /dev/null +++ b/tests/Feature/Mcp/ListAssetsToolTest.php @@ -0,0 +1,160 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListAssetsTool)->handle(new Request($args)); + } + + public function test_returns_total_assets_and_pagination_metadata() + { + Asset::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('assets', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_asset_includes_key_fields() + { + Asset::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $asset = $content['assets'][0]; + $this->assertArrayHasKey('id', $asset); + $this->assertArrayHasKey('asset_tag', $asset); + $this->assertArrayHasKey('status', $asset); + $this->assertArrayHasKey('status_type', $asset); + $this->assertArrayHasKey('model', $asset); + $this->assertArrayHasKey('location', $asset); + } + + public function test_excludes_archived_assets_by_default() + { + $rtdAsset = Asset::factory()->create(); + $archivedAsset = Asset::factory()->create([ + 'status_id' => Statuslabel::factory()->archived()->create()->id, + ]); + + $content = $this->handle()->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($rtdAsset->id, $ids); + $this->assertNotContains($archivedAsset->id, $ids); + } + + public function test_rtd_filter_excludes_deployed_assets() + { + $rtdAsset = Asset::factory()->create(); + $deployedAsset = Asset::factory()->assignedToUser()->create(); + + $content = $this->handle(['status_type' => 'RTD'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($rtdAsset->id, $ids); + $this->assertNotContains($deployedAsset->id, $ids); + } + + public function test_deployed_filter_excludes_rtd_assets() + { + $user = User::factory()->create(); + $deployedAsset = Asset::factory()->assignedToUser($user)->create(); + $rtdAsset = Asset::factory()->create(); + + $content = $this->handle(['status_type' => 'Deployed'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($deployedAsset->id, $ids); + $this->assertNotContains($rtdAsset->id, $ids); + } + + public function test_archived_filter_returns_only_archived_assets() + { + $archivedStatus = Statuslabel::factory()->archived()->create(); + $archivedAsset = Asset::factory()->create(['status_id' => $archivedStatus->id]); + $rtdAsset = Asset::factory()->create(); + + $content = $this->handle(['status_type' => 'Archived'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($archivedAsset->id, $ids); + $this->assertNotContains($rtdAsset->id, $ids); + } + + public function test_limit_controls_number_of_results_returned() + { + Asset::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['assets']); + $this->assertGreaterThan(3, $content['total']); + } + + public function test_offset_skips_results_for_pagination() + { + Asset::factory()->count(10)->create(); + + $page1 = $this->handle(['limit' => 4, 'offset' => 0])->getStructuredContent(); + $page2 = $this->handle(['limit' => 4, 'offset' => 4])->getStructuredContent(); + + $ids1 = array_column($page1['assets'], 'id'); + $ids2 = array_column($page2['assets'], 'id'); + + $this->assertEmpty(array_intersect($ids1, $ids2)); + } + + public function test_search_matches_on_asset_tag() + { + $target = Asset::factory()->create(['asset_tag' => 'UNIQ-SEARCH-XYZ']); + Asset::factory()->create(['asset_tag' => 'COMPLETELY-DIFFERENT']); + + $content = $this->handle(['search' => 'UNIQ-SEARCH-XYZ'])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($target->id, $ids); + } + + public function test_filters_by_location_id() + { + $location = Location::factory()->create(); + $matchingAsset = Asset::factory()->create(['location_id' => $location->id]); + $otherAsset = Asset::factory()->create(); + + $content = $this->handle(['location_id' => $location->id])->getStructuredContent(); + $ids = array_column($content['assets'], 'id'); + + $this->assertContains($matchingAsset->id, $ids); + $this->assertNotContains($otherAsset->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListCategoriesToolTest.php b/tests/Feature/Mcp/ListCategoriesToolTest.php new file mode 100644 index 000000000000..2e6b89a1fc64 --- /dev/null +++ b/tests/Feature/Mcp/ListCategoriesToolTest.php @@ -0,0 +1,69 @@ +actingAs(User::factory()->viewCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListCategoriesTool)->handle(new Request($args)); + } + + public function test_returns_categories_with_pagination_metadata() + { + Category::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('categories', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_category_includes_key_fields() + { + Category::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['categories']); + $category = $content['categories'][0]; + $this->assertArrayHasKey('id', $category); + $this->assertArrayHasKey('name', $category); + $this->assertArrayHasKey('category_type', $category); + } + + public function test_filters_by_category_type() + { + $assetCategory = Category::factory()->create(['category_type' => 'asset', 'name' => 'Asset Type Cat '.uniqid()]); + $licenseCategory = Category::factory()->create(['category_type' => 'license', 'name' => 'License Type Cat '.uniqid()]); + + $content = $this->handle(['category_type' => 'asset'])->getStructuredContent(); + + $ids = array_column($content['categories'], 'id'); + $this->assertContains($assetCategory->id, $ids); + $this->assertNotContains($licenseCategory->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListCompaniesToolTest.php b/tests/Feature/Mcp/ListCompaniesToolTest.php new file mode 100644 index 000000000000..6bccce1719e0 --- /dev/null +++ b/tests/Feature/Mcp/ListCompaniesToolTest.php @@ -0,0 +1,65 @@ +actingAs(User::factory()->viewCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListCompaniesTool)->handle(new Request($args)); + } + + public function test_returns_total_and_pagination_metadata() + { + Company::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertGreaterThanOrEqual(3, $content['total']); + $this->assertArrayHasKey('companies', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + } + + public function test_each_company_includes_key_fields() + { + Company::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['companies']); + $company = $content['companies'][0]; + $this->assertArrayHasKey('id', $company); + $this->assertArrayHasKey('name', $company); + } + + public function test_limit_controls_results() + { + Company::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['companies']); + $this->assertGreaterThan(3, $content['total']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListConsumablesToolTest.php b/tests/Feature/Mcp/ListConsumablesToolTest.php new file mode 100644 index 000000000000..87085ffb526b --- /dev/null +++ b/tests/Feature/Mcp/ListConsumablesToolTest.php @@ -0,0 +1,82 @@ +actingAs(User::factory()->viewConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListConsumablesTool)->handle(new Request($args)); + } + + public function test_returns_total_and_pagination_metadata() + { + Consumable::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('consumables', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_consumable_includes_key_fields() + { + Consumable::factory()->create(['name' => 'Test Ink Cartridge']); + + $content = $this->handle(['limit' => 500])->getStructuredContent(); + + $consumable = collect($content['consumables'])->firstWhere('name', 'Test Ink Cartridge'); + + $this->assertNotNull($consumable); + $this->assertArrayHasKey('id', $consumable); + $this->assertArrayHasKey('name', $consumable); + $this->assertArrayHasKey('qty', $consumable); + $this->assertArrayHasKey('category', $consumable); + } + + public function test_limit_controls_results() + { + Consumable::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['consumables']); + $this->assertEquals(3, $content['limit']); + } + + public function test_filters_by_category_id() + { + $category = Category::factory()->create(['category_type' => 'consumable']); + Consumable::factory()->create(['name' => 'Category Match', 'category_id' => $category->id]); + Consumable::factory()->create(['name' => 'Other Consumable']); + + $content = $this->handle(['category_id' => $category->id])->getStructuredContent(); + + $this->assertEquals(1, $content['total']); + $this->assertEquals('Category Match', $content['consumables'][0]['name']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListDepreciationsToolTest.php b/tests/Feature/Mcp/ListDepreciationsToolTest.php new file mode 100644 index 000000000000..683c0f7b74bb --- /dev/null +++ b/tests/Feature/Mcp/ListDepreciationsToolTest.php @@ -0,0 +1,55 @@ +actingAs(User::factory()->viewDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListDepreciationsTool)->handle(new Request($args)); + } + + public function test_returns_depreciations_with_pagination() + { + Depreciation::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('depreciations', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_depreciation_has_key_fields() + { + Depreciation::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['depreciations']); + $first = $content['depreciations'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + $this->assertArrayHasKey('months', $first); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListGroupsToolTest.php b/tests/Feature/Mcp/ListGroupsToolTest.php new file mode 100644 index 000000000000..9fcfbd2b6b4d --- /dev/null +++ b/tests/Feature/Mcp/ListGroupsToolTest.php @@ -0,0 +1,54 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListGroupsTool)->handle(new Request($args)); + } + + public function test_returns_groups_with_pagination() + { + Group::factory()->count(2)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('groups', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(2, $content['total']); + } + + public function test_each_group_has_key_fields() + { + Group::factory()->create(); + + $content = $this->handle(['limit' => 1])->getStructuredContent(); + + $this->assertNotEmpty($content['groups']); + $group = $content['groups'][0]; + $this->assertArrayHasKey('id', $group); + $this->assertArrayHasKey('name', $group); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListHistoryToolTest.php b/tests/Feature/Mcp/ListHistoryToolTest.php new file mode 100644 index 000000000000..91cab3b44e20 --- /dev/null +++ b/tests/Feature/Mcp/ListHistoryToolTest.php @@ -0,0 +1,108 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ListHistoryTool)->handle(new Request($args)); + } + + public function test_returns_history_for_asset() + { + $asset = Asset::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'update', + ]); + + $content = $this->handle(['object_type' => 'asset', 'id' => $asset->id])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + $this->assertEquals('asset', $content['object_type']); + $this->assertEquals($asset->id, $content['object_id']); + } + + public function test_returns_empty_history_for_new_asset() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['object_type' => 'asset', 'id' => $asset->id])->getStructuredContent(); + + $this->assertEquals('asset', $content['object_type']); + $this->assertIsArray($content['history']); + } + + public function test_filters_by_action_type() + { + $asset = Asset::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'update', + ]); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'note added', + 'note' => 'A note', + ]); + + $content = $this->handle([ + 'object_type' => 'asset', + 'id' => $asset->id, + 'action_type' => 'note added', + ])->getStructuredContent(); + + foreach ($content['history'] as $entry) { + $this->assertEquals('note added', $entry['action_type']); + } + } + + public function test_returns_error_when_object_not_found() + { + $this->assertTrue( + $this->handle(['object_type' => 'asset', 'id' => 999999])->responses()->first()->isError() + ); + } + + public function test_returns_error_when_user_lacks_history_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue( + $this->handle(['object_type' => 'asset', 'id' => $asset->id])->responses()->first()->isError() + ); + } + + public function test_supports_user_object_type() + { + $this->actingAs(User::factory()->viewUsers()->create()); + $user = User::factory()->create(); + + $content = $this->handle(['object_type' => 'user', 'id' => $user->id])->getStructuredContent(); + + $this->assertEquals('user', $content['object_type']); + $this->assertEquals($user->id, $content['object_id']); + } +} diff --git a/tests/Feature/Mcp/ListLicensesToolTest.php b/tests/Feature/Mcp/ListLicensesToolTest.php new file mode 100644 index 000000000000..fcd6dc67a4d2 --- /dev/null +++ b/tests/Feature/Mcp/ListLicensesToolTest.php @@ -0,0 +1,120 @@ +actingAs(User::factory()->viewLicenses()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListLicensesTool)->handle(new Request($args)); + } + + public function test_returns_total_licenses_and_pagination_metadata() + { + License::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('licenses', $content); + $this->assertArrayHasKey('limit', $content); + $this->assertArrayHasKey('offset', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_license_includes_key_fields() + { + License::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $license = $content['licenses'][0]; + $this->assertArrayHasKey('id', $license); + $this->assertArrayHasKey('name', $license); + $this->assertArrayHasKey('seats', $license); + $this->assertArrayHasKey('free_seats', $license); + $this->assertArrayHasKey('category', $license); + } + + public function test_limit_controls_number_of_results_returned() + { + License::factory()->count(10)->create(); + + $content = $this->handle(['limit' => 3])->getStructuredContent(); + + $this->assertCount(3, $content['licenses']); + $this->assertGreaterThan(3, $content['total']); + } + + public function test_offset_skips_results_for_pagination() + { + License::factory()->count(10)->create(); + + $page1 = $this->handle(['limit' => 4, 'offset' => 0])->getStructuredContent(); + $page2 = $this->handle(['limit' => 4, 'offset' => 4])->getStructuredContent(); + + $ids1 = array_column($page1['licenses'], 'id'); + $ids2 = array_column($page2['licenses'], 'id'); + + $this->assertEmpty(array_intersect($ids1, $ids2)); + } + + public function test_search_matches_on_name() + { + $target = License::factory()->create(['name' => 'UniqueLicenseXYZ']); + License::factory()->create(['name' => 'Something Else']); + + $content = $this->handle(['search' => 'UniqueLicenseXYZ'])->getStructuredContent(); + $ids = array_column($content['licenses'], 'id'); + + $this->assertContains($target->id, $ids); + } + + public function test_filters_by_category_id() + { + $category = Category::factory()->create(['category_type' => 'license']); + $matching = License::factory()->create(['category_id' => $category->id]); + $other = License::factory()->create(); + + $content = $this->handle(['category_id' => $category->id])->getStructuredContent(); + $ids = array_column($content['licenses'], 'id'); + + $this->assertContains($matching->id, $ids); + $this->assertNotContains($other->id, $ids); + } + + public function test_filters_by_manufacturer_id() + { + $manufacturer = Manufacturer::factory()->create(); + $matching = License::factory()->create(['manufacturer_id' => $manufacturer->id]); + $other = License::factory()->create(['manufacturer_id' => null]); + + $content = $this->handle(['manufacturer_id' => $manufacturer->id])->getStructuredContent(); + $ids = array_column($content['licenses'], 'id'); + + $this->assertContains($matching->id, $ids); + $this->assertNotContains($other->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListLocationsToolTest.php b/tests/Feature/Mcp/ListLocationsToolTest.php new file mode 100644 index 000000000000..d7ef0d9af19c --- /dev/null +++ b/tests/Feature/Mcp/ListLocationsToolTest.php @@ -0,0 +1,67 @@ +actingAs(User::factory()->viewLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListLocationsTool)->handle(new Request($args)); + } + + public function test_returns_locations_with_pagination() + { + Location::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('total', $content); + $this->assertArrayHasKey('locations', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_location_has_key_fields() + { + Location::factory()->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertNotEmpty($content['locations']); + $first = $content['locations'][0]; + $this->assertArrayHasKey('id', $first); + $this->assertArrayHasKey('name', $first); + } + + public function test_filters_by_parent_id() + { + $parent = Location::factory()->create(); + $child = Location::factory()->create(['parent_id' => $parent->id]); + Location::factory()->create(); // another location without parent + + $content = $this->handle(['parent_id' => $parent->id])->getStructuredContent(); + + $ids = array_column($content['locations'], 'id'); + $this->assertContains($child->id, $ids); + $this->assertNotContains($parent->id, $ids); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListMaintenancesToolTest.php b/tests/Feature/Mcp/ListMaintenancesToolTest.php new file mode 100644 index 000000000000..93608135dc7c --- /dev/null +++ b/tests/Feature/Mcp/ListMaintenancesToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListMaintenancesTool)->handle(new Request($args)); + } + + public function test_returns_maintenances_with_pagination() + { + $asset = Asset::factory()->laptopZenbook()->create(); + Maintenance::factory()->create(['asset_id' => $asset->id]); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('maintenances', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(1, $content['total']); + } + + public function test_filters_by_asset_id() + { + $asset1 = Asset::factory()->laptopZenbook()->create(); + $asset2 = Asset::factory()->laptopZenbook()->create(); + + $maintenance1 = Maintenance::factory()->create(['asset_id' => $asset1->id]); + Maintenance::factory()->create(['asset_id' => $asset2->id]); + + $content = $this->handle(['asset_id' => $asset1->id])->getStructuredContent(); + + $this->assertGreaterThanOrEqual(1, $content['total']); + foreach ($content['maintenances'] as $m) { + $this->assertEquals($asset1->id, $m['asset_id']); + } + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListManufacturersToolTest.php b/tests/Feature/Mcp/ListManufacturersToolTest.php new file mode 100644 index 000000000000..2ae42cff304f --- /dev/null +++ b/tests/Feature/Mcp/ListManufacturersToolTest.php @@ -0,0 +1,63 @@ +actingAs(User::factory()->viewManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListManufacturersTool)->handle(new Request($args)); + } + + public function test_returns_manufacturers_with_pagination() + { + Manufacturer::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('manufacturers', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_manufacturer_has_key_fields() + { + Manufacturer::factory()->create(['name' => 'Key Fields Manufacturer']); + + $content = $this->handle(['search' => 'Key Fields Manufacturer'])->getStructuredContent(); + + $this->assertNotEmpty($content['manufacturers']); + $manufacturer = $content['manufacturers'][0]; + $this->assertArrayHasKey('id', $manufacturer); + $this->assertArrayHasKey('name', $manufacturer); + } + + public function test_limit_controls_results() + { + Manufacturer::factory()->count(5)->create(); + + $content = $this->handle(['limit' => 2])->getStructuredContent(); + + $this->assertCount(2, $content['manufacturers']); + $this->assertEquals(2, $content['limit']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListStatusLabelsToolTest.php b/tests/Feature/Mcp/ListStatusLabelsToolTest.php new file mode 100644 index 000000000000..3c6d3a83b528 --- /dev/null +++ b/tests/Feature/Mcp/ListStatusLabelsToolTest.php @@ -0,0 +1,54 @@ +actingAs(User::factory()->viewStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListStatusLabelsTool)->handle(new Request($args)); + } + + public function test_returns_status_labels_with_pagination() + { + Statuslabel::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('status_labels', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_status_label_has_key_fields() + { + Statuslabel::factory()->create(['name' => 'Key Fields Status Label']); + + $content = $this->handle(['search' => 'Key Fields Status Label'])->getStructuredContent(); + + $this->assertNotEmpty($content['status_labels']); + $label = $content['status_labels'][0]; + $this->assertArrayHasKey('id', $label); + $this->assertArrayHasKey('name', $label); + $this->assertArrayHasKey('type', $label); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListSuppliersToolTest.php b/tests/Feature/Mcp/ListSuppliersToolTest.php new file mode 100644 index 000000000000..9c4373e4e361 --- /dev/null +++ b/tests/Feature/Mcp/ListSuppliersToolTest.php @@ -0,0 +1,53 @@ +actingAs(User::factory()->viewSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListSuppliersTool)->handle(new Request($args)); + } + + public function test_returns_suppliers_with_pagination() + { + Supplier::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('suppliers', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_each_supplier_has_key_fields() + { + Supplier::factory()->create(['name' => 'Key Fields Supplier']); + + $content = $this->handle(['search' => 'Key Fields Supplier'])->getStructuredContent(); + + $this->assertNotEmpty($content['suppliers']); + $supplier = $content['suppliers'][0]; + $this->assertArrayHasKey('id', $supplier); + $this->assertArrayHasKey('name', $supplier); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ListUploadsToolTest.php b/tests/Feature/Mcp/ListUploadsToolTest.php new file mode 100644 index 000000000000..0a07ef362cc6 --- /dev/null +++ b/tests/Feature/Mcp/ListUploadsToolTest.php @@ -0,0 +1,79 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ListUploadsTool)->handle(new Request($args)); + } + + public function test_returns_uploads_for_asset() + { + $asset = Asset::factory()->create(); + + Actionlog::factory()->create([ + 'item_type' => Asset::class, + 'item_id' => $asset->id, + 'action_type' => 'uploaded', + 'filename' => 'test-document.pdf', + ]); + + $content = $this->handle(['object_type' => 'assets', 'id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(1, $content['total']); + $this->assertEquals('test-document.pdf', $content['files'][0]['filename']); + } + + public function test_returns_empty_list_when_no_uploads() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['object_type' => 'assets', 'id' => $asset->id])->getStructuredContent(); + + $this->assertEquals(0, $content['total']); + $this->assertEmpty($content['files']); + } + + public function test_returns_error_when_object_not_found() + { + $this->assertTrue( + $this->handle(['object_type' => 'assets', 'id' => 999999])->responses()->first()->isError() + ); + } + + public function test_returns_error_when_user_lacks_files_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue( + $this->handle(['object_type' => 'assets', 'id' => $asset->id])->responses()->first()->isError() + ); + } + + public function test_supports_user_object_type() + { + $user = User::factory()->create(); + + $content = $this->handle(['object_type' => 'users', 'id' => $user->id])->getStructuredContent(); + + $this->assertEquals('users', $content['object_type']); + $this->assertEquals($user->id, $content['object_id']); + } +} diff --git a/tests/Feature/Mcp/ListUsersToolTest.php b/tests/Feature/Mcp/ListUsersToolTest.php new file mode 100644 index 000000000000..36c56080cc3e --- /dev/null +++ b/tests/Feature/Mcp/ListUsersToolTest.php @@ -0,0 +1,139 @@ +actingAs(User::factory()->viewUsers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ListUsersTool)->handle(new Request($args)); + } + + public function test_returns_list_of_users() + { + User::factory()->count(3)->create(); + + $content = $this->handle()->getStructuredContent(); + + $this->assertArrayHasKey('users', $content); + $this->assertArrayHasKey('total', $content); + $this->assertGreaterThanOrEqual(3, $content['total']); + } + + public function test_user_records_contain_expected_fields() + { + User::factory()->create(); + + $content = $this->handle(['limit' => 1])->getStructuredContent(); + + $user = $content['users'][0]; + $this->assertArrayHasKey('id', $user); + $this->assertArrayHasKey('username', $user); + $this->assertArrayHasKey('first_name', $user); + $this->assertArrayHasKey('last_name', $user); + $this->assertArrayHasKey('email', $user); + $this->assertArrayHasKey('activated', $user); + $this->assertArrayHasKey('assets_count', $user); + } + + public function test_filters_by_company_id() + { + $company = Company::factory()->create(); + $user = $company->users()->save(User::factory()->make()); + + $content = $this->handle(['company_id' => $company->id])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($user->id, $ids); + foreach ($content['users'] as $u) { + $this->assertEquals($company->name, $u['company']); + } + } + + public function test_filters_by_department_id() + { + $department = Department::factory()->create(); + $user = User::factory()->create(['department_id' => $department->id]); + + $content = $this->handle(['department_id' => $department->id])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($user->id, $ids); + } + + public function test_filters_by_location_id() + { + $location = Location::factory()->create(); + $user = User::factory()->create(['location_id' => $location->id]); + + $content = $this->handle(['location_id' => $location->id])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($user->id, $ids); + } + + public function test_filters_by_activated_status() + { + $active = User::factory()->create(['activated' => true]); + $inactive = User::factory()->create(['activated' => false]); + + $content = $this->handle(['activated' => false])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($inactive->id, $ids); + $this->assertNotContains($active->id, $ids); + } + + public function test_search_filters_by_name() + { + $target = User::factory()->create(['first_name' => 'Uniquefirstname', 'last_name' => 'McTest']); + + $content = $this->handle(['search' => 'Uniquefirstname'])->getStructuredContent(); + + $ids = array_column($content['users'], 'id'); + $this->assertContains($target->id, $ids); + } + + public function test_pagination_limit_and_offset() + { + User::factory()->count(10)->create(); + + $page1 = $this->handle(['limit' => 3, 'offset' => 0])->getStructuredContent(); + $page2 = $this->handle(['limit' => 3, 'offset' => 3])->getStructuredContent(); + + $this->assertCount(3, $page1['users']); + $this->assertCount(3, $page2['users']); + $this->assertNotEquals($page1['users'][0]['id'], $page2['users'][0]['id']); + } + + public function test_response_contains_pagination_meta() + { + $content = $this->handle(['limit' => 5, 'offset' => 0])->getStructuredContent(); + + $this->assertEquals(5, $content['limit']); + $this->assertEquals(0, $content['offset']); + $this->assertArrayHasKey('total', $content); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle()->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/Reset2FAToolTest.php b/tests/Feature/Mcp/Reset2FAToolTest.php new file mode 100644 index 000000000000..e7cb67ead2a9 --- /dev/null +++ b/tests/Feature/Mcp/Reset2FAToolTest.php @@ -0,0 +1,58 @@ +actingAs(User::factory()->editUsers()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new Reset2FATool)->handle(new Request($args)); + } + + public function test_resets_two_factor_for_user() + { + $user = User::factory()->create([ + 'two_factor_enrolled' => 1, + 'two_factor_optin' => 1, + 'two_factor_secret' => 'somesecret', + ]); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + + $user->refresh(); + $this->assertEquals(0, $user->two_factor_enrolled); + $this->assertEquals(0, $user->two_factor_optin); + $this->assertNull($user->two_factor_secret); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $user = User::factory()->create(['two_factor_enrolled' => 1]); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + + $user->refresh(); + $this->assertEquals(1, $user->two_factor_enrolled); + } +} diff --git a/tests/Feature/Mcp/RestoreAssetToolTest.php b/tests/Feature/Mcp/RestoreAssetToolTest.php new file mode 100644 index 000000000000..650d1f4ba4f0 --- /dev/null +++ b/tests/Feature/Mcp/RestoreAssetToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new RestoreAssetTool)->handle(new Request($args)); + } + + public function test_restores_soft_deleted_asset() + { + $asset = Asset::factory()->create(); + $asset->delete(); + + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertNotSoftDeleted('assets', ['id' => $asset->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_asset_not_deleted() + { + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['id' => $asset->id])->responses()->first()->isError()); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'deleted_at' => null]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $asset = Asset::factory()->create(); + $asset->delete(); + + $this->assertTrue($this->handle(['id' => $asset->id])->responses()->first()->isError()); + $this->assertSoftDeleted('assets', ['id' => $asset->id]); + } +} diff --git a/tests/Feature/Mcp/RestoreUserToolTest.php b/tests/Feature/Mcp/RestoreUserToolTest.php new file mode 100644 index 000000000000..6b7c4ea24ced --- /dev/null +++ b/tests/Feature/Mcp/RestoreUserToolTest.php @@ -0,0 +1,60 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new RestoreUserTool)->handle(new Request($args)); + } + + public function test_restores_soft_deleted_user() + { + $user = User::factory()->create(); + $user->delete(); + + $this->assertSoftDeleted('users', ['id' => $user->id]); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertNotSoftDeleted('users', ['id' => $user->id]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_deleted() + { + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + $this->assertDatabaseHas('users', ['id' => $user->id, 'deleted_at' => null]); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + + $user = User::factory()->create(); + $user->delete(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + $this->assertSoftDeleted('users', ['id' => $user->id]); + } +} diff --git a/tests/Feature/Mcp/ShowAssetModelToolTest.php b/tests/Feature/Mcp/ShowAssetModelToolTest.php new file mode 100644 index 000000000000..5a0f87431712 --- /dev/null +++ b/tests/Feature/Mcp/ShowAssetModelToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowAssetModelTool)->handle(new Request($args)); + } + + public function test_returns_model_by_id() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['id' => $model->id])->getStructuredContent(); + + $this->assertEquals($model->id, $content['id']); + $this->assertEquals($model->name, $content['name']); + } + + public function test_returns_model_by_name() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle(['name' => $model->name])->getStructuredContent(); + + $this->assertEquals($model->id, $content['id']); + $this->assertEquals($model->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle(['id' => $model->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowAssetToolTest.php b/tests/Feature/Mcp/ShowAssetToolTest.php new file mode 100644 index 000000000000..a14300eeec58 --- /dev/null +++ b/tests/Feature/Mcp/ShowAssetToolTest.php @@ -0,0 +1,143 @@ +actingAs(User::factory()->viewAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ShowAssetTool)->handle(new Request($args)); + } + + public function test_returns_error_when_asset_tag_not_found() + { + $response = $this->handle(['asset_tag' => 'DOES-NOT-EXIST']); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_returns_error_when_id_not_found() + { + $response = $this->handle(['id' => 99999]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_finds_asset_by_asset_tag() + { + $asset = Asset::factory()->create(['asset_tag' => 'TAG-FIND-001']); + + $content = $this->handle(['asset_tag' => 'TAG-FIND-001'])->getStructuredContent(); + + $this->assertEquals('TAG-FIND-001', $content['asset_tag']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_finds_asset_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-FIND-001']); + + $content = $this->handle(['serial' => 'SN-FIND-001'])->getStructuredContent(); + + $this->assertEquals('SN-FIND-001', $content['serial']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_returns_error_when_serial_not_found() + { + $response = $this->handle(['serial' => 'DOES-NOT-EXIST']); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_finds_asset_by_numeric_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['id' => $asset->id])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_returns_model_status_and_manufacturer_details() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertArrayHasKey('model', $content); + $this->assertArrayHasKey('model_number', $content); + $this->assertArrayHasKey('category', $content); + $this->assertArrayHasKey('manufacturer', $content); + $this->assertArrayHasKey('status', $content); + $this->assertArrayHasKey('status_type', $content); + } + + public function test_returns_date_and_cost_fields() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertArrayHasKey('purchase_date', $content); + $this->assertArrayHasKey('purchase_cost', $content); + $this->assertArrayHasKey('warranty_months', $content); + $this->assertArrayHasKey('created_at', $content); + $this->assertArrayHasKey('updated_at', $content); + } + + public function test_shows_assignment_info_when_checked_out_to_user() + { + $user = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($user)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($user->id, $content['assigned_to_id']); + $this->assertEquals('User', $content['assigned_to_type']); + } + + public function test_shows_assignment_info_when_checked_out_to_location() + { + $location = Location::factory()->create(); + $asset = Asset::factory()->assignedToLocation($location)->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertEquals($location->id, $content['assigned_to_id']); + $this->assertEquals('Location', $content['assigned_to_type']); + } + + public function test_assignment_info_is_null_when_not_checked_out() + { + $asset = Asset::factory()->create(); + + $content = $this->handle(['asset_tag' => $asset->asset_tag])->getStructuredContent(); + + $this->assertNull($content['assigned_to_id']); + $this->assertNull($content['assigned_to_type']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle(['asset_tag' => $asset->asset_tag])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowCategoryToolTest.php b/tests/Feature/Mcp/ShowCategoryToolTest.php new file mode 100644 index 000000000000..ad9a59f03096 --- /dev/null +++ b/tests/Feature/Mcp/ShowCategoryToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowCategoryTool)->handle(new Request($args)); + } + + public function test_returns_category_by_id() + { + $category = Category::factory()->create(['name' => 'Lookup By ID Cat']); + + $content = $this->handle(['id' => $category->id])->getStructuredContent(); + + $this->assertEquals($category->id, $content['id']); + $this->assertEquals('Lookup By ID Cat', $content['name']); + } + + public function test_returns_category_by_name() + { + $category = Category::factory()->create(['name' => 'Lookup By Name Cat']); + + $content = $this->handle(['name' => 'Lookup By Name Cat'])->getStructuredContent(); + + $this->assertEquals($category->id, $content['id']); + $this->assertEquals('Lookup By Name Cat', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $category = Category::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $category->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowCompanyToolTest.php b/tests/Feature/Mcp/ShowCompanyToolTest.php new file mode 100644 index 000000000000..de76c2c11ecc --- /dev/null +++ b/tests/Feature/Mcp/ShowCompanyToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowCompanyTool)->handle(new Request($args)); + } + + public function test_returns_company_by_id() + { + $company = Company::factory()->create(['name' => 'Lookup By ID Corp']); + + $content = $this->handle(['id' => $company->id])->getStructuredContent(); + + $this->assertEquals($company->id, $content['id']); + $this->assertEquals('Lookup By ID Corp', $content['name']); + } + + public function test_returns_company_by_name() + { + $company = Company::factory()->create(['name' => 'Lookup By Name Corp']); + + $content = $this->handle(['name' => 'Lookup By Name Corp'])->getStructuredContent(); + + $this->assertEquals($company->id, $content['id']); + $this->assertEquals('Lookup By Name Corp', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $company = Company::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $company->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowConsumableToolTest.php b/tests/Feature/Mcp/ShowConsumableToolTest.php new file mode 100644 index 000000000000..00c5b8868a17 --- /dev/null +++ b/tests/Feature/Mcp/ShowConsumableToolTest.php @@ -0,0 +1,72 @@ +actingAs(User::factory()->viewConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowConsumableTool)->handle(new Request($args)); + } + + public function test_returns_consumable_by_id() + { + $consumable = Consumable::factory()->create(['name' => 'Find By ID Consumable']); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertEquals($consumable->id, $content['id']); + $this->assertEquals('Find By ID Consumable', $content['name']); + } + + public function test_returns_consumable_by_name() + { + Consumable::factory()->create(['name' => 'Find By Name Consumable']); + + $content = $this->handle(['name' => 'Find By Name Consumable'])->getStructuredContent(); + + $this->assertEquals('Find By Name Consumable', $content['name']); + } + + public function test_response_includes_qty_and_users_count() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + $content = $this->handle(['id' => $consumable->id])->getStructuredContent(); + + $this->assertEquals(5, $content['qty']); + $this->assertArrayHasKey('users_count', $content); + $this->assertEquals(0, $content['users_count']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(); + + $this->assertTrue($this->handle(['id' => $consumable->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowDepreciationToolTest.php b/tests/Feature/Mcp/ShowDepreciationToolTest.php new file mode 100644 index 000000000000..7aa4ac2a99f7 --- /dev/null +++ b/tests/Feature/Mcp/ShowDepreciationToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowDepreciationTool)->handle(new Request($args)); + } + + public function test_returns_depreciation_by_id() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['id' => $dep->id])->getStructuredContent(); + + $this->assertEquals($dep->id, $content['id']); + $this->assertEquals($dep->name, $content['name']); + } + + public function test_returns_depreciation_by_name() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle(['name' => $dep->name])->getStructuredContent(); + + $this->assertEquals($dep->id, $content['id']); + $this->assertEquals($dep->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $dep = Depreciation::factory()->create(); + + $this->assertTrue($this->handle(['id' => $dep->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowGroupToolTest.php b/tests/Feature/Mcp/ShowGroupToolTest.php new file mode 100644 index 000000000000..8c521abe47d9 --- /dev/null +++ b/tests/Feature/Mcp/ShowGroupToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowGroupTool)->handle(new Request($args)); + } + + public function test_returns_group_by_id() + { + $group = Group::factory()->create(); + + $content = $this->handle(['id' => $group->id])->getStructuredContent(); + + $this->assertEquals($group->id, $content['id']); + $this->assertEquals($group->name, $content['name']); + } + + public function test_returns_group_by_name() + { + $group = Group::factory()->create(); + + $content = $this->handle(['name' => $group->name])->getStructuredContent(); + + $this->assertEquals($group->id, $content['id']); + $this->assertEquals($group->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $group = Group::factory()->create(); + + $this->assertTrue($this->handle(['id' => $group->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowLicenseToolTest.php b/tests/Feature/Mcp/ShowLicenseToolTest.php new file mode 100644 index 000000000000..8f2e5ff6a965 --- /dev/null +++ b/tests/Feature/Mcp/ShowLicenseToolTest.php @@ -0,0 +1,101 @@ +actingAs(User::factory()->viewLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ShowLicenseTool)->handle(new Request($args)); + } + + public function test_returns_license_by_id() + { + $license = License::factory()->create(['name' => 'Test License', 'seats' => 5]); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertEquals($license->id, $content['id']); + $this->assertEquals('Test License', $content['name']); + $this->assertEquals(5, $content['seats']); + } + + public function test_returns_license_by_name() + { + $license = License::factory()->create(['name' => 'FindByName License']); + + $content = $this->handle(['name' => 'FindByName License'])->getStructuredContent(); + + $this->assertEquals($license->id, $content['id']); + $this->assertEquals('FindByName License', $content['name']); + } + + public function test_response_includes_seat_counts() + { + $license = License::factory()->create(['seats' => 3, 'reassignable' => true]); + $user = User::factory()->create(); + + $seat = $license->freeSeat(); + $seat->assigned_to = $user->id; + $seat->save(); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertEquals(3, $content['seats']); + $this->assertEquals(1, $content['assigned_seats']); + $this->assertEquals(2, $content['free_seats']); + } + + public function test_response_includes_key_fields() + { + $license = License::factory()->create([ + 'name' => 'Full Details License', + 'serial' => 'SN-12345', + 'license_name' => 'Acme Corp', + 'license_email' => 'legal@acme.com', + ]); + + $content = $this->handle(['id' => $license->id])->getStructuredContent(); + + $this->assertArrayHasKey('serial', $content); + $this->assertArrayHasKey('license_name', $content); + $this->assertArrayHasKey('license_email', $content); + $this->assertArrayHasKey('maintained', $content); + $this->assertArrayHasKey('reassignable', $content); + $this->assertArrayHasKey('category', $content); + $this->assertArrayHasKey('category_id', $content); + $this->assertEquals('SN-12345', $content['serial']); + $this->assertEquals('Acme Corp', $content['license_name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $license->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowLocationToolTest.php b/tests/Feature/Mcp/ShowLocationToolTest.php new file mode 100644 index 000000000000..01e9bde67994 --- /dev/null +++ b/tests/Feature/Mcp/ShowLocationToolTest.php @@ -0,0 +1,62 @@ +actingAs(User::factory()->viewLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowLocationTool)->handle(new Request($args)); + } + + public function test_returns_location_by_id() + { + $location = Location::factory()->create(); + + $content = $this->handle(['id' => $location->id])->getStructuredContent(); + + $this->assertEquals($location->id, $content['id']); + $this->assertEquals($location->name, $content['name']); + } + + public function test_returns_location_by_name() + { + $location = Location::factory()->create(); + + $content = $this->handle(['name' => $location->name])->getStructuredContent(); + + $this->assertEquals($location->id, $content['id']); + $this->assertEquals($location->name, $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $location = Location::factory()->create(); + + $this->assertTrue($this->handle(['id' => $location->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowManufacturerToolTest.php b/tests/Feature/Mcp/ShowManufacturerToolTest.php new file mode 100644 index 000000000000..f4d85ec32d6d --- /dev/null +++ b/tests/Feature/Mcp/ShowManufacturerToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->viewManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowManufacturerTool)->handle(new Request($args)); + } + + public function test_returns_manufacturer_by_id() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Show By ID Manufacturer']); + + $content = $this->handle(['id' => $manufacturer->id])->getStructuredContent(); + + $this->assertEquals($manufacturer->id, $content['id']); + $this->assertEquals('Show By ID Manufacturer', $content['name']); + } + + public function test_returns_manufacturer_by_name() + { + Manufacturer::factory()->create(['name' => 'Show By Name Manufacturer']); + + $content = $this->handle(['name' => 'Show By Name Manufacturer'])->getStructuredContent(); + + $this->assertEquals('Show By Name Manufacturer', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $manufacturer = Manufacturer::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $manufacturer->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowStatusLabelToolTest.php b/tests/Feature/Mcp/ShowStatusLabelToolTest.php new file mode 100644 index 000000000000..dc0cf6195809 --- /dev/null +++ b/tests/Feature/Mcp/ShowStatusLabelToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->viewStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowStatusLabelTool)->handle(new Request($args)); + } + + public function test_returns_status_label_by_id() + { + $label = Statuslabel::factory()->create(['name' => 'Show By ID Status Label']); + + $content = $this->handle(['id' => $label->id])->getStructuredContent(); + + $this->assertEquals($label->id, $content['id']); + $this->assertEquals('Show By ID Status Label', $content['name']); + } + + public function test_returns_status_label_by_name() + { + Statuslabel::factory()->create(['name' => 'Show By Name Status Label']); + + $content = $this->handle(['name' => 'Show By Name Status Label'])->getStructuredContent(); + + $this->assertEquals('Show By Name Status Label', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $label = Statuslabel::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $label->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowSupplierToolTest.php b/tests/Feature/Mcp/ShowSupplierToolTest.php new file mode 100644 index 000000000000..4ed49a700ef5 --- /dev/null +++ b/tests/Feature/Mcp/ShowSupplierToolTest.php @@ -0,0 +1,61 @@ +actingAs(User::factory()->viewSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new ShowSupplierTool)->handle(new Request($args)); + } + + public function test_returns_supplier_by_id() + { + $supplier = Supplier::factory()->create(['name' => 'Show By ID Supplier']); + + $content = $this->handle(['id' => $supplier->id])->getStructuredContent(); + + $this->assertEquals($supplier->id, $content['id']); + $this->assertEquals('Show By ID Supplier', $content['name']); + } + + public function test_returns_supplier_by_name() + { + Supplier::factory()->create(['name' => 'Show By Name Supplier']); + + $content = $this->handle(['name' => 'Show By Name Supplier'])->getStructuredContent(); + + $this->assertEquals('Show By Name Supplier', $content['name']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle()->responses()->first()->isError()); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $supplier = Supplier::factory()->create(); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle(['id' => $supplier->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/ShowUserToolTest.php b/tests/Feature/Mcp/ShowUserToolTest.php new file mode 100644 index 000000000000..6467dcb5d6d6 --- /dev/null +++ b/tests/Feature/Mcp/ShowUserToolTest.php @@ -0,0 +1,113 @@ +actingAs(User::factory()->viewUsers()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new ShowUserTool)->handle(new Request($args)); + } + + public function test_finds_user_by_id() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals($user->username, $content['username']); + } + + public function test_finds_user_by_username() + { + $user = User::factory()->create(['username' => 'test.user.mcp']); + + $content = $this->handle(['username' => 'test.user.mcp'])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals('test.user.mcp', $content['username']); + } + + public function test_finds_user_by_email() + { + $user = User::factory()->create(['email' => 'mcp.test@example.com']); + + $content = $this->handle(['email' => 'mcp.test@example.com'])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals('mcp.test@example.com', $content['email']); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found_by_id() + { + $this->assertTrue($this->handle(['id' => 999999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found_by_username() + { + $this->assertTrue($this->handle(['username' => 'no.such.user'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_not_found_by_email() + { + $this->assertTrue($this->handle(['email' => 'nobody@nowhere.invalid'])->responses()->first()->isError()); + } + + public function test_response_includes_expected_fields() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + foreach (['id', 'username', 'email', 'first_name', 'last_name', 'activated', 'assets_count', 'licenses_count'] as $field) { + $this->assertArrayHasKey($field, $content); + } + } + + public function test_response_includes_asset_and_license_counts() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertIsInt($content['assets_count']); + $this->assertIsInt($content['licenses_count']); + } + + public function test_response_includes_company_and_department() + { + $user = User::factory()->create(); + + $content = $this->handle(['id' => $user->id])->getStructuredContent(); + + $this->assertArrayHasKey('company', $content); + $this->assertArrayHasKey('department', $content); + $this->assertArrayHasKey('location', $content); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + + $this->assertTrue($this->handle(['id' => $user->id])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateAccessoryToolTest.php b/tests/Feature/Mcp/UpdateAccessoryToolTest.php new file mode 100644 index 000000000000..44e9307c8e63 --- /dev/null +++ b/tests/Feature/Mcp/UpdateAccessoryToolTest.php @@ -0,0 +1,129 @@ +actingAs(User::factory()->editAccessories()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateAccessoryTool)->handle(new Request($args)); + } + + public function test_updates_accessory_by_id() + { + $accessory = Accessory::factory()->create(['qty' => 5]); + + $content = $this->handle([ + 'id' => $accessory->id, + 'qty' => 10, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $accessory->id, 'qty' => 10]); + } + + public function test_updates_accessory_by_name() + { + $accessory = Accessory::factory()->create(['name' => 'Lookup By Name', 'qty' => 3]); + + $content = $this->handle([ + 'name' => 'Lookup By Name', + 'qty' => 8, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('accessories', ['id' => $accessory->id, 'qty' => 8]); + } + + public function test_renames_accessory_via_new_name() + { + $accessory = Accessory::factory()->create(['name' => 'Old Accessory Name']); + + $content = $this->handle([ + 'id' => $accessory->id, + 'new_name' => 'New Accessory Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Accessory Name', $content['name']); + $this->assertDatabaseHas('accessories', ['id' => $accessory->id, 'name' => 'New Accessory Name']); + } + + public function test_updates_multiple_fields_at_once() + { + $accessory = Accessory::factory()->create(); + $location = Location::factory()->create(); + $category = Category::factory()->forAccessories()->create(); + + $this->handle([ + 'id' => $accessory->id, + 'qty' => 20, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-999', + 'notes' => 'Updated note', + ]); + + $this->assertDatabaseHas('accessories', [ + 'id' => $accessory->id, + 'qty' => 20, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-999', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([ + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_response_includes_id_and_name() + { + $accessory = Accessory::factory()->create(['name' => 'Response Accessory']); + + $content = $this->handle([ + 'id' => $accessory->id, + 'qty' => 7, + ])->getStructuredContent(); + + $this->assertEquals($accessory->id, $content['id']); + $this->assertEquals('Response Accessory', $content['name']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $accessory = Accessory::factory()->create(['name' => 'Locked Accessory']); + + $this->assertTrue($this->handle([ + 'id' => $accessory->id, + 'qty' => 5, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateAssetModelToolTest.php b/tests/Feature/Mcp/UpdateAssetModelToolTest.php new file mode 100644 index 000000000000..fdb57832a1d0 --- /dev/null +++ b/tests/Feature/Mcp/UpdateAssetModelToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->editAssetModels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateAssetModelTool)->handle(new Request($args)); + } + + public function test_updates_model_by_id() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle([ + 'id' => $model->id, + 'model_number' => 'MN-001', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('models', [ + 'id' => $model->id, + 'model_number' => 'MN-001', + ]); + } + + public function test_renames_via_new_name() + { + $model = AssetModel::factory()->create(); + + $content = $this->handle([ + 'id' => $model->id, + 'new_name' => 'Renamed Model', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('Renamed Model', $content['name']); + $this->assertDatabaseHas('models', [ + 'id' => $model->id, + 'name' => 'Renamed Model', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $model = AssetModel::factory()->create(); + + $this->assertTrue($this->handle(['id' => $model->id, 'model_number' => 'HACKED'])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateAssetToolTest.php b/tests/Feature/Mcp/UpdateAssetToolTest.php new file mode 100644 index 000000000000..22fa47f5f580 --- /dev/null +++ b/tests/Feature/Mcp/UpdateAssetToolTest.php @@ -0,0 +1,144 @@ +actingAs(User::factory()->editAssets()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateAssetTool)->handle(new Request($args)); + } + + public function test_updates_name_by_asset_tag() + { + $asset = Asset::factory()->create(['name' => 'Old Name']); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'name' => 'New Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'name' => 'New Name']); + } + + public function test_updates_asset_by_numeric_id() + { + $asset = Asset::factory()->create(['name' => 'Old Name']); + + $content = $this->handle([ + 'id' => $asset->id, + 'name' => 'Updated by ID', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'name' => 'Updated by ID']); + } + + public function test_updates_asset_by_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-UPDATE-001', 'name' => 'Old Name']); + + $content = $this->handle([ + 'serial' => 'SN-UPDATE-001', + 'name' => 'Updated by Serial', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'name' => 'Updated by Serial']); + } + + public function test_updates_multiple_fields_at_once() + { + $asset = Asset::factory()->create(); + $location = Location::factory()->create(); + $status = Statuslabel::factory()->rtd()->create(); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'notes' => 'MCP note', + 'location_id' => $location->id, + 'status_id' => $status->id, + ]); + + $this->assertDatabaseHas('assets', [ + 'id' => $asset->id, + 'notes' => 'MCP note', + 'location_id' => $location->id, + 'status_id' => $status->id, + ]); + } + + public function test_renames_asset_tag_via_new_asset_tag() + { + $asset = Asset::factory()->create(['asset_tag' => 'TAG-OLD-001']); + + $content = $this->handle([ + 'asset_tag' => 'TAG-OLD-001', + 'new_asset_tag' => 'TAG-NEW-001', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('TAG-NEW-001', $content['asset_tag']); + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'asset_tag' => 'TAG-NEW-001']); + } + + public function test_updates_serial_via_new_serial() + { + $asset = Asset::factory()->create(['serial' => 'SN-OLD-001']); + + $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'new_serial' => 'SN-NEW-001', + ]); + + $this->assertDatabaseHas('assets', ['id' => $asset->id, 'serial' => 'SN-NEW-001']); + } + + public function test_returns_error_when_asset_not_found() + { + $this->assertTrue($this->handle([ + 'asset_tag' => 'DOES-NOT-EXIST', + 'name' => 'Anything', + ])->responses()->first()->isError()); + } + + public function test_response_includes_asset_tag_and_id() + { + $asset = Asset::factory()->create(); + + $content = $this->handle([ + 'asset_tag' => $asset->asset_tag, + 'name' => 'Response Check', + ])->getStructuredContent(); + + $this->assertEquals($asset->asset_tag, $content['asset_tag']); + $this->assertEquals($asset->id, $content['id']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $asset = Asset::factory()->create(); + + $this->assertTrue($this->handle([ + 'asset_tag' => $asset->asset_tag, + 'name' => 'Should Not Update', + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateCategoryToolTest.php b/tests/Feature/Mcp/UpdateCategoryToolTest.php new file mode 100644 index 000000000000..e779d554aaaf --- /dev/null +++ b/tests/Feature/Mcp/UpdateCategoryToolTest.php @@ -0,0 +1,68 @@ +actingAs(User::factory()->editCategories()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateCategoryTool)->handle(new Request($args)); + } + + public function test_updates_category_by_id() + { + $category = Category::factory()->create(['name' => 'Original Cat Name']); + + $content = $this->handle([ + 'id' => $category->id, + 'new_name' => 'Updated Cat Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('categories', ['id' => $category->id, 'name' => 'Updated Cat Name']); + } + + public function test_renames_via_new_name() + { + $category = Category::factory()->create(['name' => 'Before Cat Rename']); + + $content = $this->handle([ + 'id' => $category->id, + 'new_name' => 'After Cat Rename', + ])->getStructuredContent(); + + $this->assertEquals('After Cat Rename', $content['name']); + $this->assertDatabaseHas('categories', ['id' => $category->id, 'name' => 'After Cat Rename']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost Cat'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $category = Category::factory()->create(['name' => 'Unchanged Cat']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $category->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('categories', ['id' => $category->id, 'name' => 'Unchanged Cat']); + } +} diff --git a/tests/Feature/Mcp/UpdateCompanyToolTest.php b/tests/Feature/Mcp/UpdateCompanyToolTest.php new file mode 100644 index 000000000000..c8c2483743e7 --- /dev/null +++ b/tests/Feature/Mcp/UpdateCompanyToolTest.php @@ -0,0 +1,86 @@ +actingAs(User::factory()->editCompanies()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateCompanyTool)->handle(new Request($args)); + } + + public function test_updates_company_by_id() + { + $company = Company::factory()->create(['name' => 'Original Corp']); + + $content = $this->handle([ + 'id' => $company->id, + 'new_name' => 'Updated Corp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'Updated Corp']); + } + + public function test_updates_company_by_name() + { + $company = Company::factory()->create(['name' => 'Find By Name Corp']); + + $content = $this->handle([ + 'name' => 'Find By Name Corp', + 'new_name' => 'Renamed Corp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'Renamed Corp']); + } + + public function test_renames_via_new_name() + { + $company = Company::factory()->create(['name' => 'Before Rename']); + + $content = $this->handle([ + 'id' => $company->id, + 'new_name' => 'After Rename', + ])->getStructuredContent(); + + $this->assertEquals('After Rename', $content['name']); + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'After Rename']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost Corp'])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle(['new_name' => 'X'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $company = Company::factory()->create(['name' => 'Unchanged Corp']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $company->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('companies', ['id' => $company->id, 'name' => 'Unchanged Corp']); + } +} diff --git a/tests/Feature/Mcp/UpdateComponentToolTest.php b/tests/Feature/Mcp/UpdateComponentToolTest.php new file mode 100644 index 000000000000..82b7d6b64157 --- /dev/null +++ b/tests/Feature/Mcp/UpdateComponentToolTest.php @@ -0,0 +1,129 @@ +actingAs(User::factory()->editComponents()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateComponentTool)->handle(new Request($args)); + } + + public function test_updates_component_by_id() + { + $component = Component::factory()->create(['qty' => 5]); + + $content = $this->handle([ + 'id' => $component->id, + 'qty' => 15, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $component->id, 'qty' => 15]); + } + + public function test_updates_component_by_name() + { + $component = Component::factory()->create(['name' => 'Lookup By Name', 'qty' => 3]); + + $content = $this->handle([ + 'name' => 'Lookup By Name', + 'qty' => 9, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('components', ['id' => $component->id, 'qty' => 9]); + } + + public function test_renames_component_via_new_name() + { + $component = Component::factory()->create(['name' => 'Old Component Name']); + + $content = $this->handle([ + 'id' => $component->id, + 'new_name' => 'New Component Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Component Name', $content['name']); + $this->assertDatabaseHas('components', ['id' => $component->id, 'name' => 'New Component Name']); + } + + public function test_updates_multiple_fields_at_once() + { + $component = Component::factory()->create(); + $location = Location::factory()->create(); + $category = Category::factory()->forComponents()->create(); + + $this->handle([ + 'id' => $component->id, + 'qty' => 25, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-COMP-999', + 'notes' => 'Updated component note', + ]); + + $this->assertDatabaseHas('components', [ + 'id' => $component->id, + 'qty' => 25, + 'location_id' => $location->id, + 'category_id' => $category->id, + 'order_number' => 'ORD-COMP-999', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([ + 'qty' => 5, + ])->responses()->first()->isError()); + } + + public function test_response_includes_id_and_name() + { + $component = Component::factory()->create(['name' => 'Response Component']); + + $content = $this->handle([ + 'id' => $component->id, + 'qty' => 8, + ])->getStructuredContent(); + + $this->assertEquals($component->id, $content['id']); + $this->assertEquals('Response Component', $content['name']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $component = Component::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $component->id, + 'qty' => 5, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateConsumableToolTest.php b/tests/Feature/Mcp/UpdateConsumableToolTest.php new file mode 100644 index 000000000000..f59f811893e2 --- /dev/null +++ b/tests/Feature/Mcp/UpdateConsumableToolTest.php @@ -0,0 +1,81 @@ +actingAs(User::factory()->editConsumables()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateConsumableTool)->handle(new Request($args)); + } + + public function test_updates_consumable_by_id() + { + $consumable = Consumable::factory()->create(['qty' => 5]); + + $content = $this->handle([ + 'id' => $consumable->id, + 'qty' => 20, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(20, $content['qty']); + $this->assertDatabaseHas('consumables', ['id' => $consumable->id, 'qty' => 20]); + } + + public function test_updates_consumable_by_name() + { + $consumable = Consumable::factory()->create(['name' => 'Update By Name', 'qty' => 3]); + + $content = $this->handle([ + 'name' => 'Update By Name', + 'qty' => 15, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('consumables', ['id' => $consumable->id, 'qty' => 15]); + } + + public function test_renames_via_new_name() + { + $consumable = Consumable::factory()->create(['name' => 'Old Consumable Name']); + + $content = $this->handle([ + 'id' => $consumable->id, + 'new_name' => 'New Consumable Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Consumable Name', $content['name']); + $this->assertDatabaseHas('consumables', ['id' => $consumable->id, 'name' => 'New Consumable Name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'qty' => 5])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $consumable = Consumable::factory()->create(['qty' => 5]); + + $this->assertTrue($this->handle([ + 'id' => $consumable->id, + 'qty' => 99, + ])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateDepartmentToolTest.php b/tests/Feature/Mcp/UpdateDepartmentToolTest.php new file mode 100644 index 000000000000..c9dfca0b3234 --- /dev/null +++ b/tests/Feature/Mcp/UpdateDepartmentToolTest.php @@ -0,0 +1,110 @@ +actingAs(User::factory()->editDepartments()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateDepartmentTool)->handle(new Request($args)); + } + + public function test_updates_department_by_id() + { + $department = Department::factory()->create(['name' => 'Original Name']); + + $content = $this->handle([ + 'id' => $department->id, + 'new_name' => 'Updated Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('departments', ['id' => $department->id, 'name' => 'Updated Name']); + } + + public function test_updates_department_by_name() + { + $department = Department::factory()->create(['name' => 'Find By Name Dept']); + + $content = $this->handle([ + 'name' => 'Find By Name Dept', + 'new_name' => 'Renamed Dept', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('departments', ['id' => $department->id, 'name' => 'Renamed Dept']); + } + + public function test_response_includes_id_and_name() + { + $department = Department::factory()->create(); + + $content = $this->handle([ + 'id' => $department->id, + 'new_name' => 'Response Check Dept', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check Dept', $content['name']); + } + + public function test_updates_multiple_fields() + { + $department = Department::factory()->create(); + $location = Location::factory()->create(); + $manager = User::factory()->create(); + + $this->handle([ + 'id' => $department->id, + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-9999', + 'fax' => '555-8888', + 'notes' => 'Updated notes', + ]); + + $this->assertDatabaseHas('departments', [ + 'id' => $department->id, + 'location_id' => $location->id, + 'manager_id' => $manager->id, + 'phone' => '555-9999', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle(['new_name' => 'No ID'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $department = Department::factory()->create(['name' => 'Unchanged Dept']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $department->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('departments', ['id' => $department->id, 'name' => 'Unchanged Dept']); + } +} diff --git a/tests/Feature/Mcp/UpdateDepreciationToolTest.php b/tests/Feature/Mcp/UpdateDepreciationToolTest.php new file mode 100644 index 000000000000..7b7c2e023db1 --- /dev/null +++ b/tests/Feature/Mcp/UpdateDepreciationToolTest.php @@ -0,0 +1,83 @@ +actingAs(User::factory()->editDepreciations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateDepreciationTool)->handle(new Request($args)); + } + + public function test_updates_depreciation_by_id() + { + $dep = Depreciation::factory()->create(['months' => 12]); + + $content = $this->handle([ + 'id' => $dep->id, + 'months' => 24, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('depreciations', [ + 'id' => $dep->id, + 'months' => 24, + ]); + } + + public function test_updates_months() + { + $dep = Depreciation::factory()->create(['months' => 36]); + + $content = $this->handle([ + 'id' => $dep->id, + 'months' => 48, + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals(48, $content['months']); + } + + public function test_renames_via_new_name() + { + $dep = Depreciation::factory()->create(); + + $content = $this->handle([ + 'id' => $dep->id, + 'new_name' => 'Renamed Depreciation', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('Renamed Depreciation', $content['name']); + $this->assertDatabaseHas('depreciations', [ + 'id' => $dep->id, + 'name' => 'Renamed Depreciation', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $dep = Depreciation::factory()->create(); + + $this->assertTrue($this->handle(['id' => $dep->id, 'months' => 99])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateGroupToolTest.php b/tests/Feature/Mcp/UpdateGroupToolTest.php new file mode 100644 index 000000000000..51b9e6e8a159 --- /dev/null +++ b/tests/Feature/Mcp/UpdateGroupToolTest.php @@ -0,0 +1,94 @@ +actingAs(User::factory()->superuser()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateGroupTool)->handle(new Request($args)); + } + + public function test_updates_group_by_id() + { + $group = Group::factory()->create(); + $newNotes = 'Updated notes '.uniqid(); + + $this->handle(['id' => $group->id, 'notes' => $newNotes]); + + $this->assertDatabaseHas('permission_groups', ['id' => $group->id, 'notes' => $newNotes]); + } + + public function test_renames_via_new_name() + { + $group = Group::factory()->create(); + $newName = 'Renamed Group '.uniqid(); + + $this->handle(['id' => $group->id, 'new_name' => $newName]); + + $this->assertDatabaseHas('permission_groups', ['id' => $group->id, 'name' => $newName]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'notes' => 'test'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $group = Group::factory()->create(); + + $this->assertTrue($this->handle(['id' => $group->id, 'notes' => 'test'])->responses()->first()->isError()); + } + + public function test_updates_permissions() + { + $group = Group::factory()->create(['permissions' => json_encode(['assets.view' => 1])]); + + $content = $this->handle([ + 'id' => $group->id, + 'permissions' => json_encode(['assets.create' => 1, 'assets.edit' => -1]), + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $decoded = json_decode($group->fresh()->permissions, true); + $this->assertArrayHasKey('assets.create', $decoded); + $this->assertArrayNotHasKey('assets.view', $decoded); + } + + public function test_returns_error_for_invalid_permission_key_on_update() + { + $group = Group::factory()->create(); + + $response = $this->handle([ + 'id' => $group->id, + 'permissions' => json_encode(['completely.fake.key' => 1]), + ]); + + $this->assertTrue($response->responses()->first()->isError()); + } + + public function test_permissions_unchanged_when_not_provided() + { + $original = json_encode(['assets.view' => 1]); + $group = Group::factory()->create(['permissions' => $original]); + + $this->handle(['id' => $group->id, 'notes' => 'updated notes']); + + $this->assertEquals($original, $group->fresh()->permissions); + } +} diff --git a/tests/Feature/Mcp/UpdateLicenseToolTest.php b/tests/Feature/Mcp/UpdateLicenseToolTest.php new file mode 100644 index 000000000000..1152d402d61e --- /dev/null +++ b/tests/Feature/Mcp/UpdateLicenseToolTest.php @@ -0,0 +1,106 @@ +actingAs(User::factory()->editLicenses()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateLicenseTool)->handle(new Request($args)); + } + + public function test_updates_license_by_id() + { + $license = License::factory()->create(['name' => 'Original Name']); + + $content = $this->handle([ + 'id' => $license->id, + 'new_name' => 'Updated Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('licenses', ['id' => $license->id, 'name' => 'Updated Name']); + } + + public function test_updates_license_by_name() + { + $license = License::factory()->create(['name' => 'Find By Name License']); + + $content = $this->handle([ + 'name' => 'Find By Name License', + 'new_name' => 'Renamed License', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('licenses', ['id' => $license->id, 'name' => 'Renamed License']); + } + + public function test_response_includes_id_name_and_seats() + { + $license = License::factory()->create(['seats' => 5]); + + $content = $this->handle([ + 'id' => $license->id, + 'new_name' => 'Response Check License', + ])->getStructuredContent(); + + $this->assertArrayHasKey('id', $content); + $this->assertEquals('Response Check License', $content['name']); + $this->assertEquals(5, $content['seats']); + } + + public function test_updates_multiple_fields() + { + $license = License::factory()->create(); + $category = Category::factory()->create(['category_type' => 'license']); + + $this->handle([ + 'id' => $license->id, + 'category_id' => $category->id, + 'serial' => 'SN-NEW-001', + 'purchase_cost' => 199.99, + 'expiration_date' => '2026-12-31', + 'maintained' => true, + 'reassignable' => true, + 'notes' => 'Updated notes', + ]); + + $this->assertDatabaseHas('licenses', [ + 'id' => $license->id, + 'category_id' => $category->id, + 'serial' => 'SN-NEW-001', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $license = License::factory()->create(['name' => 'Unchanged License']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $license->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('licenses', ['id' => $license->id, 'name' => 'Unchanged License']); + } +} diff --git a/tests/Feature/Mcp/UpdateLocationToolTest.php b/tests/Feature/Mcp/UpdateLocationToolTest.php new file mode 100644 index 000000000000..c94dcfd30260 --- /dev/null +++ b/tests/Feature/Mcp/UpdateLocationToolTest.php @@ -0,0 +1,70 @@ +actingAs(User::factory()->editLocations()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateLocationTool)->handle(new Request($args)); + } + + public function test_updates_location_by_id() + { + $location = Location::factory()->create(); + + $content = $this->handle([ + 'id' => $location->id, + 'city' => 'New City', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('locations', [ + 'id' => $location->id, + 'city' => 'New City', + ]); + } + + public function test_renames_via_new_name() + { + $location = Location::factory()->create(); + + $content = $this->handle([ + 'id' => $location->id, + 'new_name' => 'Renamed Location', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('Renamed Location', $content['name']); + $this->assertDatabaseHas('locations', [ + 'id' => $location->id, + 'name' => 'Renamed Location', + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 99999])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $location = Location::factory()->create(); + + $this->assertTrue($this->handle(['id' => $location->id, 'city' => 'Hacked'])->responses()->first()->isError()); + } +} diff --git a/tests/Feature/Mcp/UpdateManufacturerToolTest.php b/tests/Feature/Mcp/UpdateManufacturerToolTest.php new file mode 100644 index 000000000000..21c390d27940 --- /dev/null +++ b/tests/Feature/Mcp/UpdateManufacturerToolTest.php @@ -0,0 +1,68 @@ +actingAs(User::factory()->editManufacturers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateManufacturerTool)->handle(new Request($args)); + } + + public function test_updates_manufacturer_by_id() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Original Manufacturer']); + + $content = $this->handle([ + 'id' => $manufacturer->id, + 'new_name' => 'Updated Manufacturer', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('manufacturers', ['id' => $manufacturer->id, 'name' => 'Updated Manufacturer']); + } + + public function test_renames_via_new_name() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Old Manufacturer Name']); + + $content = $this->handle([ + 'name' => 'Old Manufacturer Name', + 'new_name' => 'New Manufacturer Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Manufacturer Name', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $manufacturer = Manufacturer::factory()->create(['name' => 'Unchanged Manufacturer']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $manufacturer->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('manufacturers', ['id' => $manufacturer->id, 'name' => 'Unchanged Manufacturer']); + } +} diff --git a/tests/Feature/Mcp/UpdateProfileToolTest.php b/tests/Feature/Mcp/UpdateProfileToolTest.php new file mode 100644 index 000000000000..6ee01be36a38 --- /dev/null +++ b/tests/Feature/Mcp/UpdateProfileToolTest.php @@ -0,0 +1,87 @@ +user = User::factory()->create(); + $this->actingAs($this->user); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateProfileTool)->handle(new Request($args)); + } + + public function test_updates_profile_fields() + { + $content = $this->handle([ + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'phone' => '555-1234', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'phone' => '555-1234', + ]); + } + + public function test_updates_locale() + { + $content = $this->handle(['locale' => 'fr'])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'locale' => 'fr']); + } + + public function test_updates_location_with_permission() + { + $this->actingAs(User::factory()->canEditOwnLocation()->create()); + $location = Location::factory()->create(); + + $content = $this->handle(['location_id' => $location->id])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals($location->id, $content['location_id']); + } + + public function test_does_not_update_location_without_permission() + { + $location = Location::factory()->create(); + + $originalLocation = $this->user->location_id; + + $this->handle(['location_id' => $location->id]); + + $this->assertDatabaseHas('users', ['id' => $this->user->id, 'location_id' => $originalLocation]); + } + + public function test_only_updates_provided_fields() + { + $originalLastName = $this->user->last_name; + + $this->handle(['first_name' => 'Changed']); + + $this->assertDatabaseHas('users', [ + 'id' => $this->user->id, + 'first_name' => 'Changed', + 'last_name' => $originalLastName, + ]); + } +} diff --git a/tests/Feature/Mcp/UpdateStatusLabelToolTest.php b/tests/Feature/Mcp/UpdateStatusLabelToolTest.php new file mode 100644 index 000000000000..1aaffa19d13c --- /dev/null +++ b/tests/Feature/Mcp/UpdateStatusLabelToolTest.php @@ -0,0 +1,87 @@ +actingAs(User::factory()->editStatusLabels()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateStatusLabelTool)->handle(new Request($args)); + } + + public function test_updates_status_label_by_id() + { + $label = Statuslabel::factory()->create(['name' => 'Original Status Label']); + + $content = $this->handle([ + 'id' => $label->id, + 'new_name' => 'Updated Status Label', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('status_labels', ['id' => $label->id, 'name' => 'Updated Status Label']); + } + + public function test_renames_via_new_name() + { + $label = Statuslabel::factory()->create(['name' => 'Old Status Label Name']); + + $content = $this->handle([ + 'name' => 'Old Status Label Name', + 'new_name' => 'New Status Label Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Status Label Name', $content['name']); + } + + public function test_updates_type() + { + $label = Statuslabel::factory()->rtd()->create(['name' => 'Type Change Label']); + + $content = $this->handle([ + 'id' => $label->id, + 'type' => 'pending', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('pending', $content['type']); + $this->assertDatabaseHas('status_labels', [ + 'id' => $label->id, + 'pending' => 1, + 'deployable' => 0, + 'archived' => 0, + ]); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $label = Statuslabel::factory()->create(['name' => 'Unchanged Status Label']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $label->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('status_labels', ['id' => $label->id, 'name' => 'Unchanged Status Label']); + } +} diff --git a/tests/Feature/Mcp/UpdateSupplierToolTest.php b/tests/Feature/Mcp/UpdateSupplierToolTest.php new file mode 100644 index 000000000000..a06b1dea050e --- /dev/null +++ b/tests/Feature/Mcp/UpdateSupplierToolTest.php @@ -0,0 +1,68 @@ +actingAs(User::factory()->editSuppliers()->create()); + } + + private function handle(array $args = []): ResponseFactory + { + return (new UpdateSupplierTool)->handle(new Request($args)); + } + + public function test_updates_supplier_by_id() + { + $supplier = Supplier::factory()->create(['name' => 'Original Supplier']); + + $content = $this->handle([ + 'id' => $supplier->id, + 'new_name' => 'Updated Supplier', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('suppliers', ['id' => $supplier->id, 'name' => 'Updated Supplier']); + } + + public function test_renames_via_new_name() + { + $supplier = Supplier::factory()->create(['name' => 'Old Supplier Name']); + + $content = $this->handle([ + 'name' => 'Old Supplier Name', + 'new_name' => 'New Supplier Name', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('New Supplier Name', $content['name']); + } + + public function test_returns_error_when_not_found() + { + $this->assertTrue($this->handle(['id' => 999999, 'new_name' => 'Ghost'])->responses()->first()->isError()); + } + + public function test_returns_error_when_user_lacks_permission() + { + $supplier = Supplier::factory()->create(['name' => 'Unchanged Supplier']); + $this->actingAs(User::factory()->create()); + + $this->assertTrue($this->handle([ + 'id' => $supplier->id, + 'new_name' => 'Should Not Change', + ])->responses()->first()->isError()); + + $this->assertDatabaseHas('suppliers', ['id' => $supplier->id, 'name' => 'Unchanged Supplier']); + } +} diff --git a/tests/Feature/Mcp/UpdateUserToolTest.php b/tests/Feature/Mcp/UpdateUserToolTest.php new file mode 100644 index 000000000000..025d240ad00d --- /dev/null +++ b/tests/Feature/Mcp/UpdateUserToolTest.php @@ -0,0 +1,285 @@ +actingAs(User::factory()->editUsers()->create()); + } + + private function handle(array $args): ResponseFactory + { + return (new UpdateUserTool)->handle(new Request($args)); + } + + public function test_updates_user_by_id() + { + $user = User::factory()->create(['first_name' => 'Old']); + + $content = $this->handle([ + 'id' => $user->id, + 'first_name' => 'New', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'first_name' => 'New']); + } + + public function test_updates_user_by_username() + { + $user = User::factory()->create(['username' => 'lookup.by.username', 'jobtitle' => 'Old Title']); + + $content = $this->handle([ + 'username' => 'lookup.by.username', + 'jobtitle' => 'New Title', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'jobtitle' => 'New Title']); + } + + public function test_updates_user_by_email() + { + $user = User::factory()->create(['email' => 'update.by@example.com', 'jobtitle' => 'Old Title']); + + $content = $this->handle([ + 'email' => 'update.by@example.com', + 'jobtitle' => 'Senior Engineer', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'jobtitle' => 'Senior Engineer']); + } + + public function test_renames_username_via_new_username() + { + $user = User::factory()->create(['username' => 'old.username.mcp']); + + $content = $this->handle([ + 'username' => 'old.username.mcp', + 'new_username' => 'new.username.mcp', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertEquals('new.username.mcp', $content['username']); + $this->assertDatabaseHas('users', ['id' => $user->id, 'username' => 'new.username.mcp']); + } + + public function test_updates_email_via_new_email() + { + $user = User::factory()->create(['email' => 'before@example.com']); + + $this->handle([ + 'id' => $user->id, + 'new_email' => 'after@example.com', + ]); + + $this->assertDatabaseHas('users', ['id' => $user->id, 'email' => 'after@example.com']); + } + + public function test_updates_password() + { + $user = User::factory()->create(); + + $this->handle([ + 'id' => $user->id, + 'password' => 'newpassword99', + ]); + + $this->assertTrue(Hash::check('newpassword99', $user->fresh()->password)); + } + + public function test_updates_multiple_fields_at_once() + { + $user = User::factory()->create(); + $department = Department::factory()->create(); + $location = Location::factory()->create(); + + $this->handle([ + 'id' => $user->id, + 'jobtitle' => 'Team Lead', + 'phone' => '555-9999', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + + $this->assertDatabaseHas('users', [ + 'id' => $user->id, + 'jobtitle' => 'Team Lead', + 'phone' => '555-9999', + 'department_id' => $department->id, + 'location_id' => $location->id, + ]); + } + + public function test_returns_error_when_user_not_found() + { + $this->assertTrue($this->handle([ + 'id' => 999999, + 'first_name' => 'Ghost', + ])->responses()->first()->isError()); + } + + public function test_returns_error_when_no_identifier_provided() + { + $this->assertTrue($this->handle([ + 'first_name' => 'No Identifier', + ])->responses()->first()->isError()); + } + + public function test_response_includes_id_and_username() + { + $user = User::factory()->create(); + + $content = $this->handle([ + 'id' => $user->id, + 'first_name' => 'Updated', + ])->getStructuredContent(); + + $this->assertEquals($user->id, $content['id']); + $this->assertEquals($user->username, $content['username']); + } + + // --- canEditAuthFields permission gate --- + + public function test_non_admin_cannot_change_auth_fields_of_admin() + { + // Acting user is a plain user (set in setUp); target is an admin + $admin = User::factory()->admin()->create(); + + $response = $this->handle([ + 'id' => $admin->id, + 'password' => 'shouldnotwork1', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertFalse(Hash::check('shouldnotwork1', $admin->fresh()->password)); + } + + public function test_non_admin_cannot_change_auth_fields_of_superuser() + { + $superuser = User::factory()->superuser()->create(); + + $response = $this->handle([ + 'id' => $superuser->id, + 'new_email' => 'hacked@example.com', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('users', ['id' => $superuser->id, 'email' => 'hacked@example.com']); + } + + public function test_admin_cannot_change_auth_fields_of_superuser() + { + $this->actingAs(User::factory()->admin()->create()); + $superuser = User::factory()->superuser()->create(); + + $response = $this->handle([ + 'id' => $superuser->id, + 'new_username' => 'hijacked', + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertDatabaseMissing('users', ['id' => $superuser->id, 'username' => 'hijacked']); + } + + public function test_superuser_can_change_auth_fields_of_any_user() + { + $this->actingAs(User::factory()->superuser()->create()); + $target = User::factory()->admin()->create(); + + $content = $this->handle([ + 'id' => $target->id, + 'new_email' => 'superchanged@example.com', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $target->id, 'email' => 'superchanged@example.com']); + } + + public function test_non_auth_fields_are_still_updated_even_without_can_edit_auth_fields() + { + // A non-admin updating an admin's non-sensitive fields (jobtitle etc.) should still work + $admin = User::factory()->admin()->create(['jobtitle' => 'Old Title']); + + $content = $this->handle([ + 'id' => $admin->id, + 'jobtitle' => 'New Title', + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertDatabaseHas('users', ['id' => $admin->id, 'jobtitle' => 'New Title']); + } + + public function test_returns_error_when_user_lacks_permission() + { + $this->actingAs(User::factory()->create()); + $user = User::factory()->create(); + + $this->assertTrue($this->handle([ + 'id' => $user->id, + 'first_name' => 'Should Not Update', + ])->responses()->first()->isError()); + } + + public function test_superadmin_can_assign_group_ids() + { + $this->actingAs(User::factory()->superuser()->create()); + $user = User::factory()->create(); + $group = Group::factory()->create(); + + $content = $this->handle([ + 'id' => $user->id, + 'group_ids' => [$group->id], + ])->getStructuredContent(); + + $this->assertTrue($content['success']); + $this->assertContains($group->id, $content['group_ids']); + $this->assertTrue($user->fresh()->groups->contains($group->id)); + } + + public function test_non_superadmin_cannot_assign_group_ids() + { + $user = User::factory()->create(); + $group = Group::factory()->create(); + + $response = $this->handle([ + 'id' => $user->id, + 'group_ids' => [$group->id], + ]); + + $this->assertTrue($response->responses()->first()->isError()); + $this->assertFalse($user->fresh()->groups->contains($group->id)); + } + + public function test_group_ids_replaces_existing_groups() + { + $this->actingAs(User::factory()->superuser()->create()); + $user = User::factory()->create(); + $oldGroup = Group::factory()->create(); + $newGroup = Group::factory()->create(); + $user->groups()->sync([$oldGroup->id]); + + $this->handle([ + 'id' => $user->id, + 'group_ids' => [$newGroup->id], + ]); + + $freshGroups = $user->fresh()->groups->pluck('id'); + $this->assertContains($newGroup->id, $freshGroups); + $this->assertNotContains($oldGroup->id, $freshGroups); + } +}